diff --git a/.github/workflows/deploy-convex.yaml b/.github/workflows/deploy-convex.yaml index c921eaf..77533ed 100644 --- a/.github/workflows/deploy-convex.yaml +++ b/.github/workflows/deploy-convex.yaml @@ -5,6 +5,9 @@ on: branches: [main] paths: - 'convex/**' + - 'release/authentication-cutover.json' + - 'scripts/check-authentication-cutover.mjs' + - '.github/workflows/deploy-convex.yaml' workflow_dispatch: jobs: @@ -18,6 +21,9 @@ jobs: with: node-version: '20' + - name: Require authenticated-client rollout evidence + run: node scripts/check-authentication-cutover.mjs + - name: Install dependencies run: npm ci diff --git a/API.md b/API.md index 393264b..4e3f233 100644 --- a/API.md +++ b/API.md @@ -33,7 +33,13 @@ API keys are long-lived credentials scoped to specific actions. Mint one with a JWT session (see [Agent API v1](#agent-api-v1)). When an `X-API-Key` header is present it takes precedence over the `Authorization` header. Keys carry scopes (`lists:read`, `items:read`, `items:write`); a request missing the required -scope returns `401`. JWT sessions have full access. +scope returns `403`. JWT sessions have full access. + +The authenticated-boundary cutover distinguishes `401` (missing, invalid, expired, +or revoked credentials) from `403` (valid credentials without the required scope +or resource access). API-key consumers that previously treated every denial as +`401` must handle both. Missing and inaccessible resources behind the HTTP write boundary return the +same `403` response to avoid disclosing private resource existence. ## Endpoints @@ -334,3 +340,11 @@ await fetch(`${BASE_URL}/api/agent/items/${itemId}`, { body: JSON.stringify({ checked: true }) }); ``` + +### Direct Convex clients + +Authenticated direct operations require `authToken` (the JWT from login) or an appropriately scoped `apiKey`. Browser/native clients first call `actorSession.establish({ authToken })` so logout and expiry invalidate reactive subscriptions. HTTP clients continue sending Bearer/cookie JWT or `X-API-Key`; the HTTP adapter establishes existing valid sessions automatically. + +Do not supply acting DIDs. Ownership, attribution and legacy-account access are resolved from authenticated server records. Old optional identity fields are compatibility checks only and never grant access. Anonymous access is limited to explicitly public resources with active publications; shared writes require authentication. + +See [authentication rollout](docs/authentication-rollout.md) for the required deployed-version confirmation and coordinated client/backend cutover. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9c698ac..918c52c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as activity from "../activity.js"; import type * as activityHttp from "../activityHttp.js"; +import type * as actorSession from "../actorSession.js"; import type * as adminGrants from "../adminGrants.js"; import type * as agentReadHttp from "../agentReadHttp.js"; import type * as apiKeys from "../apiKeys.js"; @@ -44,8 +45,12 @@ import type * as lib_actor from "../lib/actor.js"; import type * as lib_analytics from "../lib/analytics.js"; import type * as lib_apiKeyHelpers from "../lib/apiKeyHelpers.js"; import type * as lib_auth from "../lib/auth.js"; +import type * as lib_authError from "../lib/authError.js"; import type * as lib_authUser from "../lib/authUser.js"; +import type * as lib_authenticated from "../lib/authenticated.js"; import type * as lib_bucket from "../lib/bucket.js"; +import type * as lib_bucketKeys from "../lib/bucketKeys.js"; +import type * as lib_clientAuth from "../lib/clientAuth.js"; import type * as lib_didLogAuth from "../lib/didLogAuth.js"; import type * as lib_httpResponses from "../lib/httpResponses.js"; import type * as lib_itemCategories from "../lib/itemCategories.js"; @@ -54,6 +59,7 @@ import type * as lib_legacyList from "../lib/legacyList.js"; import type * as lib_listEnvelope from "../lib/listEnvelope.js"; import type * as lib_observability from "../lib/observability.js"; import type * as lib_permissions from "../lib/permissions.js"; +import type * as lib_session from "../lib/session.js"; import type * as lib_turnkeyClient from "../lib/turnkeyClient.js"; import type * as lib_turnkeySigner from "../lib/turnkeySigner.js"; import type * as lists from "../lists.js"; @@ -93,6 +99,7 @@ import type { declare const fullApi: ApiFromModules<{ activity: typeof activity; activityHttp: typeof activityHttp; + actorSession: typeof actorSession; adminGrants: typeof adminGrants; agentReadHttp: typeof agentReadHttp; apiKeys: typeof apiKeys; @@ -127,8 +134,12 @@ declare const fullApi: ApiFromModules<{ "lib/analytics": typeof lib_analytics; "lib/apiKeyHelpers": typeof lib_apiKeyHelpers; "lib/auth": typeof lib_auth; + "lib/authError": typeof lib_authError; "lib/authUser": typeof lib_authUser; + "lib/authenticated": typeof lib_authenticated; "lib/bucket": typeof lib_bucket; + "lib/bucketKeys": typeof lib_bucketKeys; + "lib/clientAuth": typeof lib_clientAuth; "lib/didLogAuth": typeof lib_didLogAuth; "lib/httpResponses": typeof lib_httpResponses; "lib/itemCategories": typeof lib_itemCategories; @@ -137,6 +148,7 @@ declare const fullApi: ApiFromModules<{ "lib/listEnvelope": typeof lib_listEnvelope; "lib/observability": typeof lib_observability; "lib/permissions": typeof lib_permissions; + "lib/session": typeof lib_session; "lib/turnkeyClient": typeof lib_turnkeyClient; "lib/turnkeySigner": typeof lib_turnkeySigner; lists: typeof lists; diff --git a/convex/activity.ts b/convex/activity.ts index 4bea69d..8593405 100644 --- a/convex/activity.ts +++ b/convex/activity.ts @@ -1,13 +1,14 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; + import { canUserEditList } from "./lib/permissions"; -export const recordActivity = mutation({ +export const { public: recordActivity, internal: recordActivityInternal } = actorMutation({ + resources: args => ({ lists: [args.listId], items: [args.itemId] }), + scope: "items:write", args: { listId: v.id("lists"), itemId: v.optional(v.id("items")), - actorDid: v.string(), - legacyDid: v.optional(v.string()), type: v.union( v.literal("item_assigned"), v.literal("item_unassigned"), @@ -23,13 +24,13 @@ export const recordActivity = mutation({ })), }, handler: async (ctx, args) => { - const canEdit = await canUserEditList(ctx, args.listId, args.actorDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) throw new Error("Not authorized to write activity"); return await ctx.db.insert("activities", { listId: args.listId, itemId: args.itemId, - actorDid: args.actorDid, + actorDid: ctx.actor.did, type: args.type, metadata: args.metadata, createdAt: Date.now(), @@ -37,7 +38,9 @@ export const recordActivity = mutation({ }, }); -export const getListActivity = query({ +export const { public: getListActivity, internal: getListActivityInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists"), limit: v.optional(v.number()), diff --git a/convex/activityHttp.ts b/convex/activityHttp.ts index 20a22b7..d82fad2 100644 --- a/convex/activityHttp.ts +++ b/convex/activityHttp.ts @@ -1,26 +1,24 @@ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { requireAuthenticatedUser } from "./lib/authUser"; -import { jsonResponse, errorResponse } from "./lib/httpResponses"; +import { authenticatedRequest } from "./lib/actor"; +import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; export const getListActivity = httpAction(async (ctx, request) => { try { - await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { listId, limit } = body as { listId: string; limit?: number }; if (!listId) return errorResponse(request, "listId is required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const activities = await ctx.runQuery((api as any).activity.getListActivity, { + const activities = await ctx.runQuery(internal.activity.getListActivityInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, limit, }); return jsonResponse(request, { activities }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to get activity", 500); + return handlerErrorResponse(request, error, "Failed to get activity"); } }); diff --git a/convex/actorSession.ts b/convex/actorSession.ts new file mode 100644 index 0000000..82d4d09 --- /dev/null +++ b/convex/actorSession.ts @@ -0,0 +1,101 @@ +import { authorizeResources } from "./lib/permissions"; +import { requireSession } from "./lib/session"; +import { v } from "convex/values"; +import { internalQuery, internalMutation, mutation } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { authenticate, type ResolvedActor } from "./lib/actor"; +import { verifyAuthToken } from "./lib/jwt"; +import { hashApiKey } from "./lib/apiKeyHelpers"; +import { AuthError } from "./lib/auth"; + +export const resolve = internalQuery({ + args: { authToken: v.optional(v.string()), apiKey: v.optional(v.string()) }, + handler: (ctx, args): Promise => authenticate(ctx, args), +}); + +// Establishing a record proves possession of a signed session, never a DID. +const establishOperation = { + args: { authToken: v.string() }, + handler: async (ctx: import("./_generated/server").MutationCtx, args: { authToken: string }) => { + const session = await verifyAuthToken(args.authToken).catch(() => { + throw new AuthError("Invalid or expired token", "INVALID_TOKEN"); + }); + const tokenHash = await hashApiKey(args.authToken); + const existing = await ctx.db.query("accessSessions").withIndex("by_hash", q => q.eq("tokenHash", tokenHash)).first(); + if (existing) { + if (existing.revokedAt !== undefined) throw new AuthError("Invalid or expired token", "INVALID_TOKEN"); + return; + } + const user = await ctx.db.query("users").withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", session.turnkeySubOrgId)).first(); + if (!user) throw new AuthError("User not found", "UNAUTHORIZED"); + const id = await ctx.db.insert("accessSessions", { tokenHash, subject: session.turnkeySubOrgId, expiresAt: session.expiresAt }); + await ctx.scheduler.runAt(session.expiresAt, internal.actorSession.expire, { id }); + }, +}; +export const establish = mutation(establishOperation); +export const establishInternal = internalMutation(establishOperation); +export const expire = internalMutation({ + args: { id: v.id("accessSessions") }, + handler: async (ctx, { id }) => { + const record = await ctx.db.get(id); + if (record && record.expiresAt <= Date.now()) await ctx.db.delete(id); + }, +}); + +// Recover records whose individual expiry callback did not complete. Revoked +// records remain until token expiry so they cannot be established again. +export const cleanupExpiredSessions = internalMutation({ + args: {}, + handler: async (ctx) => { + const expired = await ctx.db + .query("accessSessions") + .withIndex("by_expires_at", q => q.lte("expiresAt", Date.now())) + .take(100); + for (const session of expired) await ctx.db.delete(session._id); + return expired.length; + }, +}); + +const revokeOperation = { + args: { authToken: v.string() }, + handler: async (ctx: import("./_generated/server").MutationCtx, args: { authToken: string }) => { + const tokenHash = await hashApiKey(args.authToken); + const record = await ctx.db.query("accessSessions").withIndex("by_hash", q => q.eq("tokenHash", tokenHash)).first(); + if (record) { + await ctx.db.patch(record._id, { revokedAt: Date.now() }); + } else { + // A pre-rollout JWT can be logged out before its first authenticated call. + const session = await verifyAuthToken(args.authToken); + const id = await ctx.db.insert("accessSessions", { tokenHash, subject: session.turnkeySubOrgId, expiresAt: session.expiresAt, revokedAt: Date.now() }); + await ctx.scheduler.runAt(session.expiresAt, internal.actorSession.expire, { id }); + } + }, +}; +export const revoke = mutation(revokeOperation); +export const revokeInternal = internalMutation(revokeOperation); + +export const identity = internalQuery({ + args: { authToken: v.string() }, + handler: (ctx, args) => requireSession(ctx, args.authToken), +}); + +export const authorize = internalQuery({ + args: { + authToken: v.optional(v.string()), apiKey: v.optional(v.string()), + resources: v.object({ + lists: v.optional(v.array(v.union(v.id("lists"), v.null()))), + items: v.optional(v.array(v.union(v.id("items"), v.null()))), + anchors: v.optional(v.array(v.union(v.id("bitcoinAnchors"), v.null()))), + accounts: v.optional(v.array(v.union(v.id("users"), v.null()))), + }), + }, + handler: async (ctx, args): Promise => { + const actor = await authenticate(ctx, args); + await authorizeResources(ctx, actor, { + lists: args.resources.lists?.filter(id => id !== null), + items: args.resources.items?.filter(id => id !== null), + anchors: args.resources.anchors?.filter(id => id !== null), + accounts: args.resources.accounts?.filter(id => id !== null), + }); + }, +}); diff --git a/convex/agentReadHttp.ts b/convex/agentReadHttp.ts index b4c77c1..573d895 100644 --- a/convex/agentReadHttp.ts +++ b/convex/agentReadHttp.ts @@ -1,16 +1,9 @@ -/** - * HTTP read endpoints for agents. - * - * These GET handlers authenticate via resolveActor() (JWT session or agent - * API key) and require read scopes. They wrap existing queries without changing - * their logic, and resolve to a single current DID (no legacy/wallet threading). - */ +/** HTTP adapter; authentication and authorization run in the shared operation. */ import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { resolveActor, requireScope } from "./lib/actor"; +import { authenticatedRequest } from "./lib/actor"; import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; /** @@ -21,18 +14,11 @@ import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpRes */ export const getLists = httpAction(async (ctx, request) => { try { - const actor = await resolveActor(ctx, request); - requireScope(actor, "lists:read"); - - const lists = await ctx.runQuery(api.lists.getUserLists, { - userDid: actor.did, - legacyDid: actor.legacyDid, + const lists = await ctx.runQuery(internal.lists.getUserListsInternal, { + ...await authenticatedRequest(ctx, request), }); return jsonResponse(request, { lists }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[agentReadHttp] getLists error:", error); return handlerErrorResponse( request, @@ -51,9 +37,6 @@ export const getLists = httpAction(async (ctx, request) => { */ export const getListWithItems = httpAction(async (ctx, request) => { try { - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:read"); - const listId = new URL(request.url).searchParams.get("listId"); if (!listId) { return errorResponse(request, "listId query parameter is required"); @@ -63,17 +46,13 @@ export const getListWithItems = httpAction(async (ctx, request) => { // caller may not view it, so an items:read key can't read arbitrary lists. const result = await ctx.runQuery(internal.lists.getListWithItemsForViewer, { listId: listId as Id<"lists">, - viewerDid: actor.did, - legacyDid: actor.legacyDid, + ...await authenticatedRequest(ctx, request), }); if (!result) { return errorResponse(request, "List not found", 404); } return jsonResponse(request, result); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[agentReadHttp] getListWithItems error:", error); return handlerErrorResponse( request, diff --git a/convex/apiKeysHttp.ts b/convex/apiKeysHttp.ts index ab98b57..724ad72 100644 --- a/convex/apiKeysHttp.ts +++ b/convex/apiKeysHttp.ts @@ -9,7 +9,7 @@ import { httpAction } from "./_generated/server"; import type { ActionCtx } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { requireAuth, @@ -35,8 +35,8 @@ async function requireUserDid( ctx: ActionCtx, request: Request ): Promise { - const auth = await requireAuth(request); - const user = (await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const auth = await requireAuth(ctx, request); + const user = (await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, })) as UserInfo; return user?.did ?? null; diff --git a/convex/assignees.ts b/convex/assignees.ts index 2bf74e8..6c75a37 100644 --- a/convex/assignees.ts +++ b/convex/assignees.ts @@ -1,19 +1,20 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; + import { canUserEditList } from "./lib/permissions"; -export const assignItem = mutation({ +export const { public: assignItem, internal: assignItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), assigneeDid: v.string(), - actorDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.actorDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) throw new Error("Not authorized to assign item"); const existing = await ctx.db @@ -27,14 +28,14 @@ export const assignItem = mutation({ itemId: args.itemId, listId: item.listId, assigneeDid: args.assigneeDid, - assignedByDid: args.actorDid, + assignedByDid: ctx.actor.did, assignedAt: now, }); await ctx.db.insert("activities", { listId: item.listId, itemId: args.itemId, - actorDid: args.actorDid, + actorDid: ctx.actor.did, type: "item_assigned", metadata: { assigneeDid: args.assigneeDid }, createdAt: now, @@ -45,18 +46,18 @@ export const assignItem = mutation({ }, }); -export const unassignItem = mutation({ +export const { public: unassignItem, internal: unassignItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), assigneeDid: v.string(), - actorDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.actorDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) throw new Error("Not authorized to unassign item"); const existing = await ctx.db @@ -70,7 +71,7 @@ export const unassignItem = mutation({ await ctx.db.insert("activities", { listId: item.listId, itemId: args.itemId, - actorDid: args.actorDid, + actorDid: ctx.actor.did, type: "item_unassigned", metadata: { assigneeDid: args.assigneeDid }, createdAt: now, @@ -81,7 +82,9 @@ export const unassignItem = mutation({ }, }); -export const getItemAssignees = query({ +export const { public: getItemAssignees, internal: getItemAssigneesInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, args) => { return await ctx.db diff --git a/convex/assigneesHttp.ts b/convex/assigneesHttp.ts index 30d5da1..9c2cdd9 100644 --- a/convex/assigneesHttp.ts +++ b/convex/assigneesHttp.ts @@ -1,71 +1,63 @@ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { requireAuthenticatedUser } from "./lib/authUser"; -import { jsonResponse, errorResponse } from "./lib/httpResponses"; +import { authenticatedRequest } from "./lib/actor"; +import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; export const assignItem = httpAction(async (ctx, request) => { try { - const user = await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { itemId, assigneeDid } = body as { itemId: string; assigneeDid: string }; if (!itemId || !assigneeDid) return errorResponse(request, "itemId and assigneeDid are required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await ctx.runMutation((api as any).assignees.assignItem, { + await ctx.runMutation(internal.assignees.assignItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, assigneeDid, - actorDid: user.did, - legacyDid: user.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to assign item", 500); + return handlerErrorResponse(request, error, "Failed to assign item"); } }); export const unassignItem = httpAction(async (ctx, request) => { try { - const user = await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { itemId, assigneeDid } = body as { itemId: string; assigneeDid: string }; if (!itemId || !assigneeDid) return errorResponse(request, "itemId and assigneeDid are required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await ctx.runMutation((api as any).assignees.unassignItem, { + await ctx.runMutation(internal.assignees.unassignItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, assigneeDid, - actorDid: user.did, - legacyDid: user.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to unassign item", 500); + return handlerErrorResponse(request, error, "Failed to unassign item"); } }); export const getItemAssignees = httpAction(async (ctx, request) => { try { - await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { itemId } = body as { itemId: string }; if (!itemId) return errorResponse(request, "itemId is required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const assignees = await ctx.runQuery((api as any).assignees.getItemAssignees, { + const assignees = await ctx.runQuery(internal.assignees.getItemAssigneesInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, }); return jsonResponse(request, { assignees }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to get assignees", 500); + return handlerErrorResponse(request, error, "Failed to get assignees"); } }); diff --git a/convex/attachments.ts b/convex/attachments.ts index ff62156..64b4b0a 100644 --- a/convex/attachments.ts +++ b/convex/attachments.ts @@ -1,18 +1,14 @@ +import { resourceUnavailable } from "./lib/authError"; +import { isDirectChildKey } from "./lib/bucketKeys"; +import { canUserEditList } from "./lib/permissions"; +import { actorAction, actorMutation, actorQuery } from "./lib/authenticated"; /** * File attachments for list items — stored in Railway Bucket. */ import { v } from "convex/values"; -import { - action, - internalMutation, - internalQuery, - mutation, - query, -} from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; import { internal } from "./_generated/api"; -import type { Id } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; import { bucketKey as makeBucketKey, deleteObject, @@ -37,33 +33,11 @@ function extensionFor(contentType: string): string { return EXT_BY_CONTENT_TYPE[contentType] ?? "bin"; } -async function canUserEditList( - ctx: MutationCtx | QueryCtx, - listId: Id<"lists">, - userDid: string, - legacyDid?: string -): Promise { - const list = await ctx.db.get(listId); - if (!list) return false; - - const dids = [userDid]; - if (legacyDid) dids.push(legacyDid); - - if (dids.includes(list.ownerDid)) return true; - - const pub = await ctx.db - .query("publications") - .withIndex("by_list", (q) => q.eq("listId", listId)) - .first(); - - return pub?.status === "active"; -} - -export const generateUploadUrl = action({ +export const { public: generateUploadUrl, internal: generateUploadUrlInternal } = actorAction({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), contentType: v.string(), byteLength: v.number(), }, @@ -80,11 +54,11 @@ export const generateUploadUrl = action({ const owned = await ctx.runQuery(internal.attachments.assertItemEditable, { itemId: args.itemId, - userDid: args.userDid, - legacyDid: args.legacyDid, + userDid: ctx.actor.did, + legacyDid: ctx.actor.legacyDid, }); if (!owned) { - throw new Error("Not authorized to add attachments to this item"); + throw resourceUnavailable(); } const key = makeBucketKey( @@ -100,11 +74,11 @@ export const generateUploadUrl = action({ }, }); -export const addAttachment = mutation({ +export const { public: addAttachment, internal: addAttachmentInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), bucketKey: v.string(), contentType: v.string(), size: v.number(), @@ -114,11 +88,12 @@ export const addAttachment = mutation({ const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { - throw new Error("Not authorized to add attachments to this item"); + throw resourceUnavailable(); } + if (!isDirectChildKey(args.bucketKey, `attachments/${args.itemId}`)) throw new Error("Invalid attachment key"); const current = item.attachments ?? []; await ctx.db.patch(args.itemId, { attachments: [ @@ -135,23 +110,24 @@ export const addAttachment = mutation({ }, }); -export const removeAttachment = action({ +export const { public: removeAttachment, internal: removeAttachmentInternal } = actorAction({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), bucketKey: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args): Promise => { const owned = await ctx.runQuery(internal.attachments.assertItemEditable, { itemId: args.itemId, - userDid: args.userDid, - legacyDid: args.legacyDid, + userDid: ctx.actor.did, + legacyDid: ctx.actor.legacyDid, }); if (!owned) { - throw new Error("Not authorized to remove attachments from this item"); + throw resourceUnavailable(); } + if (!owned.keys.includes(args.bucketKey)) throw new Error("Attachment not found on this item"); await deleteObject(args.bucketKey); await ctx.runMutation(internal.attachments.dropAttachment, { itemId: args.itemId, @@ -160,7 +136,9 @@ export const removeAttachment = action({ }, }); -export const getAttachmentUrls = query({ +export const { public: getAttachmentUrls, internal: getAttachmentUrlsInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -194,7 +172,7 @@ export const assertItemEditable = internalQuery({ const item = await ctx.db.get(args.itemId); if (!item) return null; const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); - return canEdit ? { itemId: item._id } : null; + return canEdit ? { itemId: item._id, keys: (item.attachments ?? []).filter(entry => typeof entry === "object").map(entry => entry.key) } : null; }, }); diff --git a/convex/auth.ts b/convex/auth.ts index 549c20f..12b3cbc 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -1,3 +1,5 @@ +import { resourceUnavailable } from "./lib/authError"; +import { requireSession } from "./lib/session"; /** * Auth-related Convex functions for Turnkey authentication. * @@ -16,11 +18,10 @@ import type { QueryCtx } from "./_generated/server"; * sub-organization ID is not found, otherwise updates the existing user's * last login timestamp. * - * Migration flow: When legacyDid is provided, it means the user is migrating - * from localStorage identity to Turnkey. We look up by legacyDid, update their - * primary DID to the new Turnkey DID, and store the old DID as legacyDid. + * Existing verified account links are preserved. New links cannot be established + * by asserting a current or legacy DID. */ -export const upsertUser = mutation({ +export const upsertUserInternal = internalMutation({ args: { turnkeySubOrgId: v.string(), email: v.string(), @@ -36,6 +37,13 @@ export const upsertUser = mutation({ .withIndex("by_turnkey_id", (q) => q.eq("turnkeySubOrgId", args.turnkeySubOrgId)) .first(); + // A supplied new DID must never take over another current or migrated account. + if (args.did) { + const claimed = await ctx.db.query("users").withIndex("by_did", q => q.eq("did", args.did)).first() + ?? await ctx.db.query("users").withIndex("by_legacy_did", q => q.eq("legacyDid", args.did)).first(); + if (claimed && claimed._id !== existingByTurnkey?._id) throw resourceUnavailable(); + } + if (args.legacyDid) throw new Error("Identity migration requires verified account linking"); if (existingByTurnkey) { if (existingByTurnkey.email !== args.email) { throw new Error("This identity is linked to a different email."); @@ -54,44 +62,6 @@ export const upsertUser = mutation({ return existingByTurnkey._id; } - // Migration case: If legacyDid is provided, find user by their old DID - const legacyDid = args.legacyDid; - if (legacyDid) { - const existingByLegacyDid = await ctx.db - .query("users") - .withIndex("by_did", (q) => q.eq("did", legacyDid)) - .first(); - - if (existingByLegacyDid) { - // Migrate user: update DID to new Turnkey DID, store old DID as legacy - await ctx.db.patch(existingByLegacyDid._id, { - did: args.did, // New Turnkey DID - legacyDid, // Store old DID for list lookup - turnkeySubOrgId: args.turnkeySubOrgId, - email: args.email, - lastLoginAt: Date.now(), - legacyIdentity: false, - }); - return existingByLegacyDid._id; - } - } - - // Check if user exists by the new Turnkey DID (edge case: same DID) - const existingByDid = args.did - ? await ctx.db.query("users").withIndex("by_did", (q) => q.eq("did", args.did)).first() - : null; - - if (existingByDid) { - // Link Turnkey to existing user - await ctx.db.patch(existingByDid._id, { - turnkeySubOrgId: args.turnkeySubOrgId, - email: args.email, - lastLoginAt: Date.now(), - legacyIdentity: false, - }); - return existingByDid._id; - } - // This index read and insert share a Convex transaction. If two signup // sessions provision different identities, only the first may create a user. const existingByEmail = await ctx.db.query("users") @@ -124,7 +94,7 @@ export const upsertUser = mutation({ /** * Get a user by their Turnkey sub-organization ID. */ -export const getUserByTurnkeyId = query({ +export const getUserByTurnkeyIdInternal = internalQuery({ args: { turnkeySubOrgId: v.string() }, handler: async (ctx, args) => { return await ctx.db @@ -137,7 +107,7 @@ export const getUserByTurnkeyId = query({ /** * Get a user by their email address. */ -export const getUserByEmail = query({ +export const getUserByEmailInternal = internalQuery({ args: { email: v.string() }, handler: async (ctx, args) => { return await ctx.db @@ -189,3 +159,32 @@ export const selectLoginAccount = internalMutation({ return { userId, turnkeySubOrgId: user.turnkeySubOrgId, previous }; }, }); + +// Preserve public names during rollout. Only a verified session may access its account. +export const getUserByTurnkeyId = query({ + args: { turnkeySubOrgId: v.string(), authToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const auth = await requireSession(ctx, args.authToken); + if (auth.turnkeySubOrgId !== args.turnkeySubOrgId) throw resourceUnavailable(); + return ctx.db.query("users").withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", auth.turnkeySubOrgId)).first(); + }, +}); +export const getUserByEmail = query({ + args: { email: v.string(), authToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const auth = await requireSession(ctx, args.authToken); + if (auth.email !== args.email) throw resourceUnavailable(); + return ctx.db.query("users").withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", auth.turnkeySubOrgId)).first(); + }, +}); +export const upsertUser = mutation({ + args: { turnkeySubOrgId: v.string(), email: v.string(), did: v.optional(v.string()), displayName: v.optional(v.string()), legacyDid: v.optional(v.string()), authToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const auth = await requireSession(ctx, args.authToken); + if (auth.turnkeySubOrgId !== args.turnkeySubOrgId || auth.email !== args.email) throw resourceUnavailable(); + const user = await ctx.db.query("users").withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", auth.turnkeySubOrgId)).first(); + if (!user || (args.did && args.did !== user.did) || (args.legacyDid && args.legacyDid !== user.legacyDid)) throw resourceUnavailable(); + await ctx.db.patch(user._id, { lastLoginAt: Date.now() }); + return user._id; + }, +}); diff --git a/convex/authSessions.ts b/convex/authSessions.ts index a1c6dd5..6b229bd 100644 --- a/convex/authSessions.ts +++ b/convex/authSessions.ts @@ -8,7 +8,7 @@ */ import { v } from "convex/values"; -import { mutation, query, internalMutation } from "./_generated/server"; +import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; // Session timeout (15 minutes in milliseconds) const SESSION_TIMEOUT = 15 * 60 * 1000; @@ -16,7 +16,7 @@ const SESSION_TIMEOUT = 15 * 60 * 1000; /** * Create a new auth session. */ -export const createSession = mutation({ +export const createSessionInternal = internalMutation({ args: { sessionId: v.string(), email: v.string(), @@ -41,7 +41,7 @@ export const createSession = mutation({ * Get an auth session by session ID. * Returns null if session doesn't exist or has expired. */ -export const getSession = query({ +export const getSessionInternal = internalQuery({ args: { sessionId: v.string() }, handler: async (ctx, args) => { const session = await ctx.db @@ -63,7 +63,7 @@ export const getSession = query({ /** * Update a session after successful OTP verification. */ -export const markSessionVerified = mutation({ +export const markSessionVerifiedInternal = internalMutation({ args: { sessionId: v.string(), subOrgId: v.string(), @@ -94,7 +94,7 @@ export const markSessionVerified = mutation({ /** * Delete a session (cleanup after login or logout). */ -export const deleteSession = mutation({ +export const deleteSessionInternal = internalMutation({ args: { sessionId: v.string() }, handler: async (ctx, args) => { const session = await ctx.db @@ -134,3 +134,23 @@ export const cleanupExpiredSessions = internalMutation({ return deletedCount; }, }); + +// Compatibility name: OTP state is managed exclusively by verified HTTP login. +export const createSession = mutation({ args: { + sessionId: v.string(), + email: v.string(), + subOrgId: v.optional(v.string()), + otpId: v.optional(v.string()), + }, handler: async () => { throw new Error("Authentication required: use the HTTP login endpoints"); } }); + +// Compatibility name: OTP state is managed exclusively by verified HTTP login. +export const getSession = query({ args: { sessionId: v.string() }, handler: async () => { throw new Error("Authentication required: use the HTTP login endpoints"); } }); + +// Compatibility name: OTP state is managed exclusively by verified HTTP login. +export const markSessionVerified = mutation({ args: { + sessionId: v.string(), + subOrgId: v.string(), + }, handler: async () => { throw new Error("Authentication required: use the HTTP login endpoints"); } }); + +// Compatibility name: OTP state is managed exclusively by verified HTTP login. +export const deleteSession = mutation({ args: { sessionId: v.string() }, handler: async () => { throw new Error("Authentication required: use the HTTP login endpoints"); } }); diff --git a/convex/billing.ts b/convex/billing.ts index eb47da1..bd51515 100644 --- a/convex/billing.ts +++ b/convex/billing.ts @@ -1,3 +1,4 @@ +import { actorQuery } from "./lib/authenticated"; /** * Billing module — Stripe subscription management. * @@ -8,7 +9,7 @@ */ import { v } from "convex/values"; -import { internalMutation, internalQuery, query } from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; // --------------------------------------------------------------------------- @@ -30,7 +31,9 @@ export type Plan = keyof typeof PLANS; /** * Get current subscription for a user by userId. Returns null for free tier. */ -export const getUserSubscription = query({ +export const { public: getUserSubscription, internal: getUserSubscriptionAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { return await ctx.db @@ -44,7 +47,9 @@ export const getUserSubscription = query({ * Get effective plan — defaults to "free" if no active subscription. * Also grants "pro" when a referral Pro credit is active (referralProUntil > now). */ -export const getUserPlan = query({ +export const { public: getUserPlan, internal: getUserPlanAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }): Promise => { const sub = await ctx.db diff --git a/convex/billingHttp.ts b/convex/billingHttp.ts index 9baf6e7..42722ff 100644 --- a/convex/billingHttp.ts +++ b/convex/billingHttp.ts @@ -11,7 +11,7 @@ */ import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import { requireAuth } from "./lib/auth"; import { jsonResponse, errorResponse } from "./lib/httpResponses"; import type { Id } from "./_generated/dataModel"; @@ -49,8 +49,8 @@ export const stripeWebhook = httpAction(async (ctx, request) => { */ export const createCheckout = httpAction(async (ctx, request) => { try { - const auth = await requireAuth(request); - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const auth = await requireAuth(ctx, request); + const user = await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, }) as { _id: Id<"users">; email?: string } | null; if (!user) return errorResponse(request, "User not found", 404); @@ -83,8 +83,8 @@ export const createCheckout = httpAction(async (ctx, request) => { */ export const createPortal = httpAction(async (ctx, request) => { try { - const auth = await requireAuth(request); - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const auth = await requireAuth(ctx, request); + const user = await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, }) as { _id: Id<"users"> } | null; if (!user) return errorResponse(request, "User not found", 404); @@ -109,8 +109,8 @@ export const createPortal = httpAction(async (ctx, request) => { */ export const getSubscription = httpAction(async (ctx, request) => { try { - const auth = await requireAuth(request); - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const auth = await requireAuth(ctx, request); + const user = await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, }) as { _id: Id<"users"> } | null; if (!user) return errorResponse(request, "User not found", 404); diff --git a/convex/bitcoinAnchors.ts b/convex/bitcoinAnchors.ts index 1b2760b..9265091 100644 --- a/convex/bitcoinAnchors.ts +++ b/convex/bitcoinAnchors.ts @@ -1,3 +1,5 @@ +import { authorizeResources } from "./lib/permissions"; +import { actorMutation, actorQuery, actorAction } from "./lib/authenticated"; /** * Bitcoin Anchoring for List State * @@ -14,8 +16,8 @@ */ import { v } from "convex/values"; -import { query, mutation, action } from "./_generated/server"; -import { api } from "./_generated/api"; + +import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; /** @@ -81,12 +83,13 @@ function buildCanonicalState( * Internal mutation to create an anchor record. * Called by the action after computing the hash. */ -export const createAnchorRecord = mutation({ +export const { public: createAnchorRecord, internal: createAnchorRecordInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), stateHash: v.string(), stateSnapshot: v.string(), - anchoredByDid: v.string(), }, handler: async (ctx, args) => { return await ctx.db.insert("bitcoinAnchors", { @@ -94,7 +97,7 @@ export const createAnchorRecord = mutation({ contentHash: args.stateHash, network: BITCOIN_NETWORK, status: "pending", - requestedByDid: args.anchoredByDid, + requestedByDid: ctx.actor.did, createdAt: Date.now(), stateSnapshot: args.stateSnapshot, }); @@ -104,7 +107,9 @@ export const createAnchorRecord = mutation({ /** * Update anchor status after Bitcoin inscription. */ -export const updateAnchorStatus = mutation({ +export const { public: updateAnchorStatus, internal: updateAnchorStatusInternal } = actorMutation({ + resources: args => ({ anchors: [args.anchorId] }), + scope: "items:write", args: { anchorId: v.id("bitcoinAnchors"), status: v.union( @@ -135,7 +140,9 @@ export const updateAnchorStatus = mutation({ /** * Get list data for anchoring (internal helper query). */ -export const getListDataForAnchor = query({ +export const { public: getListDataForAnchor, internal: getListDataForAnchorInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); @@ -164,14 +171,16 @@ export const getListDataForAnchor = query({ * @param userDid - DID of user requesting the anchor * @returns The anchor record ID */ -export const anchorListState = action({ +export const { public: anchorListState, internal: anchorListStateInternal } = actorAction({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args): Promise<{ anchorId: Id<"bitcoinAnchors">; stateHash: string; status: string }> => { // 1. Fetch list data - const data = await ctx.runQuery(api.bitcoinAnchors.getListDataForAnchor, { + const data = await ctx.runQuery(internal.bitcoinAnchors.getListDataForAnchorInternal, { + ...ctx.credentials, listId: args.listId, }); @@ -182,7 +191,7 @@ export const anchorListState = action({ const { list, items } = data; // 2. Verify user has access (owner) - if (list.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(list.ownerDid)) { throw new Error("Only the owner can anchor list state"); } @@ -191,11 +200,11 @@ export const anchorListState = action({ const stateHash = await computeSha256(canonicalState); // 4. Create anchor record - const anchorId = await ctx.runMutation(api.bitcoinAnchors.createAnchorRecord, { + const anchorId = await ctx.runMutation(internal.bitcoinAnchors.createAnchorRecordInternal, { + ...ctx.credentials, listId: args.listId, stateHash, stateSnapshot: canonicalState, - anchoredByDid: args.userDid, }); // 5. Attempt Bitcoin inscription @@ -219,7 +228,8 @@ export const anchorListState = action({ // 'application/json', // feeRate // ); - // await ctx.runMutation(api.bitcoinAnchors.updateAnchorStatus, { + // await ctx.runMutation(internal.bitcoinAnchors.updateAnchorStatusInternal, { + // ...ctx.credentials, // anchorId, // status: 'inscribed', // txid: inscription.txid, @@ -233,7 +243,8 @@ export const anchorListState = action({ const simulatedTxid = `signet:${stateHash.substring(0, 16)}:${Date.now()}`; const simulatedInscriptionId = `${simulatedTxid}i0`; - await ctx.runMutation(api.bitcoinAnchors.updateAnchorStatus, { + await ctx.runMutation(internal.bitcoinAnchors.updateAnchorStatusInternal, { + ...ctx.credentials, anchorId, status: "inscribed", txid: simulatedTxid, @@ -250,7 +261,9 @@ export const anchorListState = action({ /** * Get all anchors for a list. */ -export const getListAnchors = query({ +export const { public: getListAnchors, internal: getListAnchorsInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { return await ctx.db @@ -264,7 +277,9 @@ export const getListAnchors = query({ /** * Get all Bitcoin anchors for a specific item */ -export const getItemAnchors = query({ +export const { public: getItemAnchors, internal: getItemAnchorsInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, { itemId }) => { const anchors = await ctx.db @@ -272,14 +287,21 @@ export const getItemAnchors = query({ .withIndex("by_item", (q) => q.eq("itemId", itemId)) .collect(); - return anchors; + const accessible = []; + for (const anchor of anchors) { + try { await authorizeResources(ctx, ctx.actor, { anchors: [anchor._id] }); accessible.push(anchor); } + catch { /* Private anchors are omitted from collections. */ } + } + return accessible; }, }); /** * Get anchor by transaction ID */ -export const getAnchorByTxid = query({ +export const { public: getAnchorByTxid, internal: getAnchorByTxidInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", args: { txid: v.string() }, handler: async (ctx, { txid }) => { const anchor = await ctx.db @@ -287,6 +309,7 @@ export const getAnchorByTxid = query({ .withIndex("by_txid", (q) => q.eq("txid", txid)) .first(); + if (anchor) await authorizeResources(ctx, ctx.actor, { anchors: [anchor._id] }); return anchor; }, }); @@ -294,7 +317,9 @@ export const getAnchorByTxid = query({ /** * Get the latest anchor for a list. */ -export const getLatestAnchor = query({ +export const { public: getLatestAnchor, internal: getLatestAnchorInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { return await ctx.db @@ -308,7 +333,9 @@ export const getLatestAnchor = query({ /** * Get all pending anchors (for background processing) */ -export const getPendingAnchors = query({ +export const { public: getPendingAnchors, internal: getPendingAnchorsInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", args: {}, handler: async (ctx) => { const anchors = await ctx.db @@ -316,14 +343,21 @@ export const getPendingAnchors = query({ .withIndex("by_status", (q) => q.eq("status", "pending")) .collect(); - return anchors; + const accessible = []; + for (const anchor of anchors) { + try { await authorizeResources(ctx, ctx.actor, { anchors: [anchor._id] }); accessible.push(anchor); } + catch { /* Private anchors are omitted from collections. */ } + } + return accessible; }, }); /** * Get anchor by ID. */ -export const getAnchor = query({ +export const { public: getAnchor, internal: getAnchorInternal } = actorQuery({ + resources: args => ({ anchors: [args.anchorId] }), + scope: "items:read", args: { anchorId: v.id("bitcoinAnchors") }, handler: async (ctx, args) => { return await ctx.db.get(args.anchorId); @@ -333,13 +367,16 @@ export const getAnchor = query({ /** * Verify anchor against current list state. */ -export const verifyAnchorState = action({ +export const { public: verifyAnchorState, internal: verifyAnchorStateInternal } = actorAction({ + resources: args => ({ anchors: [args.anchorId] }), + scope: "items:write", args: { anchorId: v.id("bitcoinAnchors"), }, handler: async (ctx, args): Promise<{ valid: boolean; currentHash: string; anchoredHash: string; stateChanged: boolean }> => { // Get the anchor - const anchor = await ctx.runQuery(api.bitcoinAnchors.getAnchor, { + const anchor = await ctx.runQuery(internal.bitcoinAnchors.getAnchorInternal, { + ...ctx.credentials, anchorId: args.anchorId, }); @@ -352,7 +389,8 @@ export const verifyAnchorState = action({ } // Get current list state - const data = await ctx.runQuery(api.bitcoinAnchors.getListDataForAnchor, { + const data = await ctx.runQuery(internal.bitcoinAnchors.getListDataForAnchorInternal, { + ...ctx.credentials, listId: anchor.listId, }); diff --git a/convex/categories.ts b/convex/categories.ts index 5ded0f0..7016f88 100644 --- a/convex/categories.ts +++ b/convex/categories.ts @@ -1,3 +1,4 @@ +import { actorQuery, actorMutation } from "./lib/authenticated"; /** * Convex functions for category management. * @@ -7,18 +8,24 @@ */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import type { QueryCtx } from "./_generated/server"; +import type { ResolvedActor } from "./lib/actor"; + +async function ownedCategories(ctx: QueryCtx, actor: ResolvedActor) { + const dids = [actor.did, actor.legacyDid].filter((did): did is string => !!did); + return (await Promise.all(dids.map(did => ctx.db.query("categories") + .withIndex("by_owner", q => q.eq("ownerDid", did)).collect()))).flat(); +} /** * Get all categories for a user, sorted by order. */ -export const getUserCategories = query({ - args: { userDid: v.string() }, - handler: async (ctx, args) => { - const categories = await ctx.db - .query("categories") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.userDid)) - .collect(); +export const { public: getUserCategories, internal: getUserCategoriesInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx) => { + const categories = await ownedCategories(ctx, ctx.actor); // Sort by order return categories.sort((a, b) => a.order - b.order); @@ -30,9 +37,10 @@ export const getUserCategories = query({ * * Validates that name is unique for the user. */ -export const createCategory = mutation({ +export const { public: createCategory, internal: createCategoryInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { - userDid: v.string(), name: v.string(), createdAt: v.number(), }, @@ -48,27 +56,19 @@ export const createCategory = mutation({ } // Check for duplicate name - const existing = await ctx.db - .query("categories") - .withIndex("by_owner_name", (q) => - q.eq("ownerDid", args.userDid).eq("name", trimmedName) - ) - .first(); + const existing = (await ownedCategories(ctx, ctx.actor)).find(category => category.name === trimmedName); if (existing) { throw new Error("Category with this name already exists"); } // Get max order to place new category at end - const categories = await ctx.db - .query("categories") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.userDid)) - .collect(); + const categories = await ownedCategories(ctx, ctx.actor); const maxOrder = categories.reduce((max, c) => Math.max(max, c.order), 0); return await ctx.db.insert("categories", { - ownerDid: args.userDid, + ownerDid: ctx.actor.did, name: trimmedName, order: maxOrder + 1, createdAt: args.createdAt, @@ -81,10 +81,11 @@ export const createCategory = mutation({ * * Validates ownership and unique name. */ -export const renameCategory = mutation({ +export const { public: renameCategory, internal: renameCategoryInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { categoryId: v.id("categories"), - userDid: v.string(), name: v.string(), }, handler: async (ctx, args) => { @@ -93,7 +94,7 @@ export const renameCategory = mutation({ throw new Error("Category not found"); } - if (category.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(category.ownerDid)) { throw new Error("Not authorized to modify this category"); } @@ -108,12 +109,7 @@ export const renameCategory = mutation({ } // Check for duplicate name (excluding current category) - const existing = await ctx.db - .query("categories") - .withIndex("by_owner_name", (q) => - q.eq("ownerDid", args.userDid).eq("name", trimmedName) - ) - .first(); + const existing = (await ownedCategories(ctx, ctx.actor)).find(category => category.name === trimmedName); if (existing && existing._id !== args.categoryId) { throw new Error("Category with this name already exists"); @@ -128,10 +124,11 @@ export const renameCategory = mutation({ * * Lists in this category are moved to uncategorized (categoryId = undefined). */ -export const deleteCategory = mutation({ +export const { public: deleteCategory, internal: deleteCategoryInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { categoryId: v.id("categories"), - userDid: v.string(), }, handler: async (ctx, args) => { const category = await ctx.db.get(args.categoryId); @@ -139,7 +136,7 @@ export const deleteCategory = mutation({ throw new Error("Category not found"); } - if (category.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(category.ownerDid)) { throw new Error("Not authorized to delete this category"); } @@ -162,10 +159,11 @@ export const deleteCategory = mutation({ * * Updates the order field to move category to new position. */ -export const reorderCategory = mutation({ +export const { public: reorderCategory, internal: reorderCategoryInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { categoryId: v.id("categories"), - userDid: v.string(), newOrder: v.number(), }, handler: async (ctx, args) => { @@ -174,7 +172,7 @@ export const reorderCategory = mutation({ throw new Error("Category not found"); } - if (category.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(category.ownerDid)) { throw new Error("Not authorized to modify this category"); } @@ -188,12 +186,12 @@ export const reorderCategory = mutation({ * Pass undefined for categoryId to move to uncategorized. * Validates that user has access to the list (owner or collaborator via collaborators table). */ -export const setListCategory = mutation({ +export const { public: setListCategory, internal: setListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), categoryId: v.optional(v.id("categories")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); @@ -202,9 +200,9 @@ export const setListCategory = mutation({ } // DIDs to check: current DID and optionally legacy DID - const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); + const didsToCheck = [ctx.actor.did]; + if (ctx.actor.legacyDid) { + didsToCheck.push(ctx.actor.legacyDid); } // Check user has access (owner or published list) @@ -225,7 +223,7 @@ export const setListCategory = mutation({ // If setting a category, verify ownership if (args.categoryId) { const category = await ctx.db.get(args.categoryId); - if (!category || category.ownerDid !== args.userDid) { + if (!category || ![ctx.actor.did, ctx.actor.legacyDid].includes(category.ownerDid)) { throw new Error("Category not found or not owned by user"); } } diff --git a/convex/categoriesHttp.ts b/convex/categoriesHttp.ts index 09d51d6..8a07ea9 100644 --- a/convex/categoriesHttp.ts +++ b/convex/categoriesHttp.ts @@ -1,3 +1,4 @@ +import { authenticatedRequest } from "./lib/actor"; /** * HTTP action handlers for protected category mutations. * @@ -6,20 +7,10 @@ */ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { - requireAuth, - AuthError, - unauthorizedResponseWithCors, -} from "./lib/auth"; import { jsonResponse, errorResponse } from "./lib/httpResponses"; -/** - * Helper type for user info. - */ -type UserInfo = { did: string; legacyDid?: string } | null; - /** * POST /api/categories/create * @@ -31,15 +22,8 @@ type UserInfo = { did: string; legacyDid?: string } | null; export const createCategory = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } // Parse request body const body = await request.json(); @@ -50,17 +34,15 @@ export const createCategory = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - const categoryId = await ctx.runMutation(api.categories.createCategory, { - userDid: user.did, + const categoryId = await ctx.runMutation(internal.categories.createCategoryInternal, { + ...await authenticatedRequest(ctx, request), + name, createdAt: Date.now(), }); return jsonResponse(request, { categoryId }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[categoriesHttp] createCategory error:", error); return errorResponse( request, @@ -81,15 +63,8 @@ export const createCategory = httpAction(async (ctx, request) => { export const renameCategory = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } // Parse request body const body = await request.json(); @@ -100,17 +75,14 @@ export const renameCategory = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - await ctx.runMutation(api.categories.renameCategory, { + await ctx.runMutation(internal.categories.renameCategoryInternal, { + ...await authenticatedRequest(ctx, request), categoryId: categoryId as Id<"categories">, - userDid: user.did, name, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[categoriesHttp] renameCategory error:", error); return errorResponse( request, @@ -131,15 +103,8 @@ export const renameCategory = httpAction(async (ctx, request) => { export const deleteCategory = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } // Parse request body const body = await request.json(); @@ -150,16 +115,13 @@ export const deleteCategory = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - await ctx.runMutation(api.categories.deleteCategory, { + await ctx.runMutation(internal.categories.deleteCategoryInternal, { + ...await authenticatedRequest(ctx, request), categoryId: categoryId as Id<"categories">, - userDid: user.did, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[categoriesHttp] deleteCategory error:", error); return errorResponse( request, @@ -180,15 +142,8 @@ export const deleteCategory = httpAction(async (ctx, request) => { export const setListCategory = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } // Parse request body const body = await request.json(); @@ -199,18 +154,14 @@ export const setListCategory = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - await ctx.runMutation(api.categories.setListCategory, { + await ctx.runMutation(internal.categories.setListCategoryInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, categoryId: categoryId as Id<"categories">, - userDid: user.did, - legacyDid: user.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[categoriesHttp] setListCategory error:", error); return errorResponse( request, diff --git a/convex/comments.ts b/convex/comments.ts index f8a1655..44115b3 100644 --- a/convex/comments.ts +++ b/convex/comments.ts @@ -1,61 +1,31 @@ +import { canUserEditList, canUserViewList } from "./lib/permissions"; +import { actorQuery, actorMutation } from "./lib/authenticated"; +import { resourceUnavailable } from "./lib/authError"; /** * Comments API - Threaded discussions on items for shared lists. * Enables collaboration through item-level comments. */ import { v } from "convex/values"; -import type { Id } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; -import { mutation, query } from "./_generated/server"; /** * Helper to check if a user can view a list. * Owner can always view. Published lists are viewable by anyone. */ -async function canUserViewList( - ctx: MutationCtx | QueryCtx, - listId: Id<"lists">, - userDid: string, - legacyDid?: string -): Promise { - const list = await ctx.db.get(listId); - if (!list) return false; - - const dids = [userDid]; - if (legacyDid) dids.push(legacyDid); - - if (dids.includes(list.ownerDid)) return true; - - // Published lists are viewable by anyone - const pub = await ctx.db - .query("publications") - .withIndex("by_list", (q) => q.eq("listId", listId)) - .first(); - - return pub?.status === "active"; -} /** * Helper to check if a user can edit a list. * Owner can always edit. Published lists are editable by anyone. */ -async function canUserEditList( - ctx: MutationCtx | QueryCtx, - listId: Id<"lists">, - userDid: string, - legacyDid?: string -): Promise { - return canUserViewList(ctx, listId, userDid, legacyDid); -} /** * Get all comments for an item, ordered by creation time. */ -export const getItemComments = query({ +export const { public: getItemComments, internal: getItemCommentsInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -67,8 +37,8 @@ export const getItemComments = query({ const canView = await canUserViewList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canView) { throw new Error("Not authorized to view comments on this item"); @@ -88,11 +58,11 @@ export const getItemComments = query({ * Add a comment to an item. * Any collaborator (owner, editor, or viewer) can comment. */ -export const addComment = mutation({ +export const { public: addComment, internal: addCommentInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), text: v.string(), }, handler: async (ctx, args) => { @@ -109,8 +79,8 @@ export const addComment = mutation({ const canView = await canUserViewList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canView) { throw new Error("Not authorized to comment on this item"); @@ -118,7 +88,7 @@ export const addComment = mutation({ return await ctx.db.insert("comments", { itemId: args.itemId, - userDid: args.userDid, + userDid: ctx.actor.did, text: args.text.trim(), createdAt: Date.now(), }); @@ -129,26 +99,26 @@ export const addComment = mutation({ * Delete a comment. * Only the comment author or list owner/editor can delete. */ -export const deleteComment = mutation({ +export const { public: deleteComment, internal: deleteCommentInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { commentId: v.id("comments"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const comment = await ctx.db.get(args.commentId); if (!comment) { - throw new Error("Comment not found"); + throw resourceUnavailable(); } const item = await ctx.db.get(comment.itemId); if (!item) { - throw new Error("Item not found"); + throw resourceUnavailable(); } - const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); + const didsToCheck = [ctx.actor.did]; + if (ctx.actor.legacyDid) { + didsToCheck.push(ctx.actor.legacyDid); } // Check if user is the comment author @@ -158,12 +128,12 @@ export const deleteComment = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!isAuthor && !canEdit) { - throw new Error("Not authorized to delete this comment"); + throw resourceUnavailable(); } await ctx.db.delete(args.commentId); @@ -173,7 +143,9 @@ export const deleteComment = mutation({ /** * Get comment count for an item (useful for showing badge on item). */ -export const getCommentCount = query({ +export const { public: getCommentCount, internal: getCommentCountInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, args) => { const comments = await ctx.db diff --git a/convex/crons.ts b/convex/crons.ts index fbe5387..fcad098 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -3,6 +3,12 @@ import { internal } from "./_generated/api"; const crons = cronJobs(); +crons.interval( + "clean up expired access sessions", + { minutes: 1 }, + internal.actorSession.cleanupExpiredSessions +); + crons.interval( "poll custom hostnames", { seconds: 60 }, diff --git a/convex/didCreation.ts b/convex/didCreation.ts index 78b81df..6b38d27 100644 --- a/convex/didCreation.ts +++ b/convex/didCreation.ts @@ -1,4 +1,5 @@ "use node"; +import { actorAction } from "./lib/authenticated"; /** * Server-side DID creation using Turnkey and OriginalsSDK. @@ -8,7 +9,7 @@ * - Public action for list publication (list DID) */ -import { action, internalAction } from "./_generated/server"; +import { internalAction } from "./_generated/server"; import { v } from "convex/values"; import { TurnkeyWebVHSigner } from "./lib/turnkeySigner"; import { getEd25519Account } from "./turnkeyHelpers"; @@ -111,17 +112,20 @@ export const createDIDKey = internalAction({ * Create a did:webvh DID for list publication. * Public action - called from client when publishing a list. */ -export const createListDID = action({ +export const { public: createListDID, internal: createListDIDInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { subOrgId: v.string(), domain: v.string(), slug: v.string(), }, - handler: async (_ctx, args): Promise<{ + handler: async (ctx, args): Promise<{ did: string; didDocument: unknown; didLog: unknown; }> => { + if (args.subOrgId !== ctx.actor.turnkeySubOrgId) throw new Error("Not authorized to use this signing identity"); console.log( `[didCreation] Creating list did:webvh for slug: ${args.slug} (subOrg: ${args.subOrgId})` ); diff --git a/convex/didLogsHttp.ts b/convex/didLogsHttp.ts index 91a63f3..bd54177 100644 --- a/convex/didLogsHttp.ts +++ b/convex/didLogsHttp.ts @@ -31,7 +31,7 @@ export const storeDidLog = httpAction(async (ctx, request) => { const corsHeaders = getCorsHeaders(request); try { - const auth = await requireAuth(request); + const auth = await requireAuth(ctx, request); const body = await request.json(); const { userDid, path, log } = body as { userDid: string; path: string; log: string }; diff --git a/convex/didResources.ts b/convex/didResources.ts index f26592a..479ee32 100644 --- a/convex/didResources.ts +++ b/convex/didResources.ts @@ -1,3 +1,4 @@ +import { actorMutation } from "./lib/authenticated"; /** * Queries for serving list resources publicly. * @@ -6,7 +7,7 @@ */ import { v } from "convex/values"; -import { query, mutation } from "./_generated/server"; +import { query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; /** @@ -32,7 +33,8 @@ export const getPublicList = query({ return null; } - return list; + const pub = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", list._id)).first(); + return pub?.status === "active" ? list : null; }, }); @@ -45,6 +47,8 @@ export const getPublicListItems = query({ listId: v.id("lists"), }, handler: async (ctx, args) => { + const pub = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", args.listId)).first(); + if (pub?.status !== "active") return []; const items = await ctx.db .query("items") .withIndex("by_list", (q) => q.eq("listId", args.listId)) @@ -83,7 +87,10 @@ export const getListById = query({ args: { listId: v.string() }, handler: async (ctx, args) => { try { - return await ctx.db.get(args.listId as Id<"lists">); + const list = await ctx.db.get(args.listId as Id<"lists">); + if (!list) return null; + const pub = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", list._id)).first(); + return pub?.status === "active" ? list : null; } catch { return null; } @@ -109,7 +116,9 @@ export const getActivePublicationByListId = query({ /** * Mark a shared-list item as checked (public link access). */ -export const checkSharedItem = mutation({ +export const { public: checkSharedItem, internal: checkSharedItemInternal } = actorMutation({ + resources: args => ({ lists: [args.listId], items: [args.itemId] }), + scope: "items:write", args: { listId: v.id("lists"), itemId: v.id("items"), @@ -122,6 +131,7 @@ export const checkSharedItem = mutation({ await ctx.db.patch(args.itemId, { checked: true, + checkedByDid: ctx.actor.did, checkedAt: Date.now(), updatedAt: Date.now(), }); @@ -133,7 +143,9 @@ export const checkSharedItem = mutation({ /** * Mark a shared-list item as unchecked (public link access). */ -export const uncheckSharedItem = mutation({ +export const { public: uncheckSharedItem, internal: uncheckSharedItemInternal } = actorMutation({ + resources: args => ({ lists: [args.listId], items: [args.itemId] }), + scope: "items:write", args: { listId: v.id("lists"), itemId: v.id("items"), @@ -146,6 +158,7 @@ export const uncheckSharedItem = mutation({ await ctx.db.patch(args.itemId, { checked: false, + checkedByDid: undefined, checkedAt: undefined, updatedAt: Date.now(), }); diff --git a/convex/didResourcesHttp.ts b/convex/didResourcesHttp.ts index 2a7ac0d..7a1a343 100644 --- a/convex/didResourcesHttp.ts +++ b/convex/didResourcesHttp.ts @@ -1,3 +1,5 @@ +import { handlerErrorResponse } from "./lib/httpResponses"; +import { authenticatedRequest } from "./lib/actor"; /** * HTTP actions for serving DID logs and list resources at canonical paths. * @@ -7,7 +9,7 @@ */ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { api, internal } from "./_generated/api"; function corsHeaders(request: Request): Record { const origin = request.headers.get("Origin") || "*"; @@ -69,7 +71,7 @@ export const didResourceHandler = httpAction(async (ctx, request) => { ) { const listId = parts[2].slice("list-".length); const itemId = parts[4]; - return await toggleItem(ctx, userPath, listId, itemId, true, headers); + return await toggleItem(ctx, userPath, listId, itemId, true, headers, request); } // POST /{userPath}/resources/list-{listId}/items/{itemId}/uncheck @@ -83,7 +85,7 @@ export const didResourceHandler = httpAction(async (ctx, request) => { ) { const listId = parts[2].slice("list-".length); const itemId = parts[4]; - return await toggleItem(ctx, userPath, listId, itemId, false, headers); + return await toggleItem(ctx, userPath, listId, itemId, false, headers, request); } return new Response("Not found", { status: 404, headers }); @@ -232,9 +234,11 @@ async function toggleItem( listId: string, itemId: string, checked: boolean, - headers: Record + headers: Record, + request: Request ): Promise { try { + const credentials = await authenticatedRequest(ctx as import("./_generated/server").ActionCtx, request); // Resolve list the same way as serveListResource (didLogs primary, publication fallback) const fullRecord = await ctx.runQuery(api.didLogs.getDidLogRecordByPath, { path: userPath }); let userDid: string | null = fullRecord?.userDid ?? null; @@ -261,12 +265,14 @@ async function toggleItem( } if (checked) { - await ctx.runMutation(api.didResources.checkSharedItem, { + await ctx.runMutation(internal.didResources.checkSharedItemInternal, { + ...credentials, listId: list._id, itemId, }); } else { - await ctx.runMutation(api.didResources.uncheckSharedItem, { + await ctx.runMutation(internal.didResources.uncheckSharedItemInternal, { + ...credentials, listId: list._id, itemId, }); @@ -278,6 +284,6 @@ async function toggleItem( }); } catch (error) { console.error("[didResources] Error toggling item:", error); - return new Response("Internal server error", { status: 500, headers }); + return handlerErrorResponse(request, error, "Failed to update item"); } } diff --git a/convex/feedback.ts b/convex/feedback.ts index d87f719..0a28c4e 100644 --- a/convex/feedback.ts +++ b/convex/feedback.ts @@ -1,3 +1,4 @@ +import { actorMutation } from "./lib/authenticated"; /** * Feedback module — in-app user feedback collection. * @@ -7,17 +8,19 @@ */ import { v } from "convex/values"; -import { internalAction, internalMutation, mutation } from "./_generated/server"; +import { internalAction, internalMutation } from "./_generated/server"; // --------------------------------------------------------------------------- // Public mutations // --------------------------------------------------------------------------- /** - * Submit in-app feedback. Authenticated via userId passed from frontend. + * Submit in-app feedback. Authenticated through the shared session boundary. * Takes body and category; source is always "in_app", status starts as "new". */ -export const submit = mutation({ +export const { public: submit, internal: submitAuthenticatedInternal } = actorMutation({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users"), body: v.string(), diff --git a/convex/http.ts b/convex/http.ts index 9cb99d1..c5a646d 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -1,3 +1,4 @@ +import { extractTokenFromRequest } from "./lib/jwt"; /** * Convex HTTP router for server-side endpoints. * @@ -151,7 +152,7 @@ const initiate = httpAction(async (ctx, request) => { const result = await ctx.runAction(internal.authInternal.initiateAuth, { email }); // Persist session to Convex database - await ctx.runMutation(api.authSessions.createSession, { + await ctx.runMutation(internal.authSessions.createSessionInternal, { sessionId: result.sessionId, email: result.session.email, subOrgId: result.session.subOrgId, @@ -203,7 +204,7 @@ const verify = httpAction(async (ctx, request) => { console.log(`[authHttp] Verifying OTP for session: ${sessionId}`); // Get session from database - const dbSession = await ctx.runQuery(api.authSessions.getSession, { sessionId }); + const dbSession = await ctx.runQuery(internal.authSessions.getSessionInternal, { sessionId }); if (!dbSession) { return jsonResponse({ error: "Invalid or expired session" }, 400, {}, request); } @@ -233,7 +234,7 @@ const verify = httpAction(async (ctx, request) => { console.log(`[authHttp] OTP verified for: ${result.email}`); // Mark session as verified - await ctx.runMutation(api.authSessions.markSessionVerified, { + await ctx.runMutation(internal.authSessions.markSessionVerifiedInternal, { sessionId, subOrgId: result.subOrgId, }); @@ -241,7 +242,7 @@ const verify = httpAction(async (ctx, request) => { // DID creation happens client-side after auth completes: for a NEW account // the client mints a did:webvh with BrowserWebVHSigner and posts it to // /api/user/updateDID. - await ctx.runMutation(api.auth.upsertUser, { + await ctx.runMutation(internal.auth.upsertUserInternal, { turnkeySubOrgId: result.subOrgId, email: result.email, did: undefined, @@ -254,7 +255,7 @@ const verify = httpAction(async (ctx, request) => { // the client then treated the discarded DID as its identity. That burned a // keypair per login and, worse, left the client's DID disagreeing with the // database — which is why the stale-domain re-mint never fired. - const storedUser = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const storedUser = await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: result.subOrgId, }); @@ -264,8 +265,10 @@ const verify = httpAction(async (ctx, request) => { email: result.email, }); + await ctx.runMutation(internal.actorSession.establishInternal, { authToken: authResult.token }); + // Clean up session - await ctx.runMutation(api.authSessions.deleteSession, { sessionId }); + await ctx.runMutation(internal.authSessions.deleteSessionInternal, { sessionId }); console.log(`[authHttp] Auth complete, JWT issued for: ${result.email}`); @@ -300,6 +303,8 @@ const verify = httpAction(async (ctx, request) => { const logout = httpAction(async (ctx, request) => { console.log("[authHttp] Logging out"); + const token = extractTokenFromRequest(request); + if (token) await ctx.runMutation(internal.actorSession.revokeInternal, { authToken: token }); const result = await ctx.runAction(internal.authInternal.getLogoutCookie, {}); return jsonResponse({ success: true }, 200, { "Set-Cookie": result.cookieValue }, request); @@ -420,7 +425,6 @@ http.route({ path: "/api/sites/resolve-host", method: "OPTIONS", handler: resolv http.route({ path: "/api/sites/resolve-asset", method: "GET", handler: resolveSiteAsset }); http.route({ path: "/api/sites/resolve-asset", method: "OPTIONS", handler: resolveSiteAsset }); - // ============================================================================ // Agent API v1 (Plan 001) // API-key management (JWT-only) + agent read/write over HTTP (JWT or X-API-Key). diff --git a/convex/itemCategories.ts b/convex/itemCategories.ts index 4d50717..c46066a 100644 --- a/convex/itemCategories.ts +++ b/convex/itemCategories.ts @@ -1,3 +1,4 @@ +import { actorMutation } from "./lib/authenticated"; /** * Mutations for a list's item categories. * @@ -8,7 +9,7 @@ */ import { v } from "convex/values"; -import { mutation } from "./_generated/server"; + import type { MutationCtx } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import { canUserEditList } from "./lib/permissions"; @@ -25,7 +26,6 @@ import { const editorArgs = { listId: v.id("lists"), - userDid: v.string(), }; /** @@ -36,11 +36,12 @@ const editorArgs = { async function loadEditableSet( ctx: MutationCtx, listId: Id<"lists">, - userDid: string + userDid: string, + legacyDid?: string ): Promise { const list = await ctx.db.get(listId); if (!list) throw new Error("List not found"); - if (!(await canUserEditList(ctx, listId, userDid))) { + if (!(await canUserEditList(ctx, listId, userDid, legacyDid))) { throw new Error("You do not have permission to edit this list"); } return materialiseCategories(list.itemCategories, list.customAisles); @@ -54,46 +55,56 @@ async function persist( await ctx.db.patch(listId, { itemCategories: categories }); } -export const addListCategory = mutation({ +export const { public: addListCategory, internal: addListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, name: v.string(), emoji: v.string() }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); await persist(ctx, args.listId, addCategory(set, args.name, args.emoji)); }, }); -export const renameListCategory = mutation({ +export const { public: renameListCategory, internal: renameListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, categoryId: v.string(), name: v.string() }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); await persist(ctx, args.listId, renameCategory(set, args.categoryId, args.name)); }, }); -export const setListCategoryEmoji = mutation({ +export const { public: setListCategoryEmoji, internal: setListCategoryEmojiInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, categoryId: v.string(), emoji: v.string() }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); await persist(ctx, args.listId, setCategoryEmoji(set, args.categoryId, args.emoji)); }, }); -export const moveListCategory = mutation({ +export const { public: moveListCategory, internal: moveListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, categoryId: v.string(), direction: v.union(v.literal("up"), v.literal("down")), }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); await persist(ctx, args.listId, moveCategory(set, args.categoryId, args.direction)); }, }); -export const deleteListCategory = mutation({ +export const { public: deleteListCategory, internal: deleteListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, categoryId: v.string() }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); const next = deleteCategory(set, args.categoryId); // Items explicitly filed here would otherwise point at a category that no diff --git a/convex/items.ts b/convex/items.ts index 0d61a08..a298547 100644 --- a/convex/items.ts +++ b/convex/items.ts @@ -1,6 +1,8 @@ +import { resourceUnavailable } from "./lib/authError"; +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; -import { mutation, query } from "./_generated/server"; + import { internal } from "./_generated/api"; import { withMutationObservability } from "./lib/observability"; import { canUserEditList } from "./lib/permissions"; @@ -111,17 +113,16 @@ function createItemCompletionVC( }; } - /** * Add an item to a list. * Supports legacy DID for migrated users. */ -export const addItem = mutation({ +export const { public: addItem, internal: addItemInternal } = actorMutation({ + resources: args => ({ lists: [args.listId], items: [args.parentId] }), + scope: "items:write", args: { listId: v.id("lists"), name: v.string(), - createdByDid: v.string(), - legacyDid: v.optional(v.string()), createdAt: v.number(), // Optional enhanced fields description: v.optional(v.string()), @@ -156,11 +157,11 @@ export const addItem = mutation({ const canEdit = await canUserEditList( ctx, args.listId, - args.createdByDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canEdit) { - throw new Error("Not authorized to add items to this list"); + throw resourceUnavailable(); } // If it's a sub-item, verify parent exists and belongs to same list @@ -187,7 +188,7 @@ export const addItem = mutation({ listId: args.listId, name: args.name, checked: false, - createdByDid: args.createdByDid, + createdByDid: ctx.actor.did, checkedByDid: undefined, createdAt: args.createdAt, checkedAt: undefined, @@ -207,7 +208,7 @@ export const addItem = mutation({ const authorshipVC = createItemAuthorshipVC( itemId, args.listId, - args.createdByDid, + ctx.actor.did, args.name, args.createdAt ); @@ -218,7 +219,7 @@ export const addItem = mutation({ // Notify other list members (fire-and-forget via scheduler) await ctx.scheduler.runAfter(0, internal.notificationActions.sendListNotificationInternal, { listId: args.listId, - excludeDid: args.createdByDid, + excludeDid: ctx.actor.did, title: list.name, body: `"${args.name}" was added`, data: { listId: args.listId }, @@ -232,11 +233,11 @@ export const addItem = mutation({ * Update an item's details (name, description, due date, url, recurrence, priority). * Supports legacy DID for migrated users. */ -export const updateItem = mutation({ +export const { public: updateItem, internal: updateItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), // Fields that can be updated name: v.optional(v.string()), description: v.optional(v.string()), @@ -267,9 +268,9 @@ export const updateItem = mutation({ throw new Error("Item not found"); } - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { - throw new Error("Not authorized to update this item"); + throw resourceUnavailable(); } const updates: Record = { @@ -329,11 +330,11 @@ function calculateNextDueDate( * Supports legacy DID for migrated users. * If the item has recurrence settings, creates a new unchecked copy with the next due date. */ -export const checkItem = mutation({ +export const { public: checkItem, internal: checkItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - checkedByDid: v.string(), - legacyDid: v.optional(v.string()), checkedAt: v.number(), }, handler: async (ctx, args) => withMutationObservability("items.checkItem", async () => { @@ -346,11 +347,11 @@ export const checkItem = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.checkedByDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canEdit) { - throw new Error("Not authorized to check items in this list"); + throw resourceUnavailable(); } const now = Date.now(); @@ -359,7 +360,7 @@ export const checkItem = mutation({ const completionVC = createItemCompletionVC( args.itemId, item.listId, - args.checkedByDid, + ctx.actor.did, item.name, args.checkedAt ); @@ -373,7 +374,7 @@ export const checkItem = mutation({ // Mark the current item as checked and add completion VC await ctx.db.patch(args.itemId, { checked: true, - checkedByDid: args.checkedByDid, + checkedByDid: ctx.actor.did, checkedAt: args.checkedAt, updatedAt: now, vcProofs: updatedProofs, @@ -383,7 +384,7 @@ export const checkItem = mutation({ const list = await ctx.db.get(item.listId); await ctx.scheduler.runAfter(0, internal.notificationActions.sendListNotificationInternal, { listId: item.listId, - excludeDid: args.checkedByDid, + excludeDid: ctx.actor.did, title: list?.name ?? "Your list", body: `"${item.name}" was completed`, data: { listId: item.listId }, @@ -416,7 +417,7 @@ export const checkItem = mutation({ listId: item.listId, name: item.name, checked: false, - createdByDid: args.checkedByDid, + createdByDid: ctx.actor.did, createdAt: now, order: minOrder - 1, updatedAt: now, @@ -438,11 +439,11 @@ export const checkItem = mutation({ * Uncheck an item. * Supports legacy DID for migrated users. */ -export const uncheckItem = mutation({ +export const { public: uncheckItem, internal: uncheckItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -454,11 +455,11 @@ export const uncheckItem = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canEdit) { - throw new Error("Not authorized to uncheck items in this list"); + throw resourceUnavailable(); } await ctx.db.patch(args.itemId, { @@ -474,11 +475,11 @@ export const uncheckItem = mutation({ * Remove an item from a list. * Supports legacy DID for migrated users. */ -export const removeItem = mutation({ +export const { public: removeItem, internal: removeItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -490,11 +491,11 @@ export const removeItem = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canEdit) { - throw new Error("Not authorized to remove items from this list"); + throw resourceUnavailable(); } await ctx.db.delete(args.itemId); @@ -504,7 +505,9 @@ export const removeItem = mutation({ /** * Get all items for a list, ordered by position. */ -export const getListItems = query({ +export const { public: getListItems, internal: getListItemsInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { const items = await ctx.db @@ -526,31 +529,14 @@ export const getListItems = query({ * Takes the full ordered list of item IDs and updates their order values. * Supports legacy DID for migrated users. */ -export const reorderItems = mutation({ +export const { public: reorderItems, internal: reorderItemsInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), itemIds: v.array(v.id("items")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { - // Verify the list exists - const list = await ctx.db.get(args.listId); - if (!list) { - throw new Error("List not found"); - } - - // Verify user is authorized (owner or editor) - const canEdit = await canUserEditList( - ctx, - args.listId, - args.userDid, - args.legacyDid - ); - if (!canEdit) { - throw new Error("Not authorized to reorder items in this list"); - } - // Update order for each item for (let i = 0; i < args.itemIds.length; i++) { const itemId = args.itemIds[i]; @@ -569,12 +555,12 @@ export const reorderItems = mutation({ * Allows users to manually classify items into a different aisle. * Pass null/undefined aisleId to clear the override. */ -export const setAisleOverride = mutation({ +export const { public: setAisleOverride, internal: setAisleOverrideInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), aisleId: v.optional(v.string()), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -583,10 +569,10 @@ export const setAisleOverride = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); - if (!canEdit) throw new Error("Not authorized to edit this item"); + if (!canEdit) throw resourceUnavailable(); await ctx.db.patch(args.itemId, { groceryAisle: args.aisleId ?? undefined, @@ -597,12 +583,16 @@ export const setAisleOverride = mutation({ /** * Get an item by ID for sync conflict checking. - * Returns null if item doesn't exist (was deleted). + * Missing and inaccessible items have the same response. */ -export const getItemForSync = query({ +export const { public: getItemForSync, internal: getItemForSyncInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, args) => { - return await ctx.db.get(args.itemId); + const item = await ctx.db.get(args.itemId); + if (!item || !await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid)) throw resourceUnavailable(); + return item; }, }); @@ -611,17 +601,17 @@ export const getItemForSync = query({ * Returns null when the item does not exist OR the user cannot edit it * (in the current permission model, no edit access == no access). */ -export const getItemForEditor = query({ +export const { public: getItemForEditor, internal: getItemForEditorInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) return null; - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) return null; return { @@ -636,7 +626,9 @@ export const getItemForEditor = query({ /** * Get sub-items for a parent item. */ -export const getSubItems = query({ +export const { public: getSubItems, internal: getSubItemsInternal } = actorQuery({ + resources: args => ({ items: [args.parentId] }), + scope: "items:read", args: { parentId: v.id("items") }, handler: async (ctx, args) => { return await ctx.db @@ -650,11 +642,11 @@ export const getSubItems = query({ * Batch check multiple items at once. * Handles recurring items by creating new copies with next due dates. */ -export const batchCheckItems = mutation({ +export const { public: batchCheckItems, internal: batchCheckItemsInternal } = actorMutation({ + resources: args => ({ items: [...args.itemIds] }), + scope: "items:write", args: { itemIds: v.array(v.id("items")), - checkedByDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const checkedAt = Date.now(); @@ -667,15 +659,15 @@ export const batchCheckItems = mutation({ // Verify authorization once per list if (listId !== item.listId) { listId = item.listId; - const canEdit = await canUserEditList(ctx, item.listId, args.checkedByDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { - throw new Error("Not authorized to check items in this list"); + throw resourceUnavailable(); } } await ctx.db.patch(itemId, { checked: true, - checkedByDid: args.checkedByDid, + checkedByDid: ctx.actor.did, checkedAt, updatedAt: checkedAt, }); @@ -707,7 +699,7 @@ export const batchCheckItems = mutation({ listId: item.listId, name: item.name, checked: false, - createdByDid: args.checkedByDid, + createdByDid: ctx.actor.did, createdAt: checkedAt, order: minOrder - 1, updatedAt: checkedAt, @@ -729,11 +721,11 @@ export const batchCheckItems = mutation({ /** * Batch uncheck multiple items at once. */ -export const batchUncheckItems = mutation({ +export const { public: batchUncheckItems, internal: batchUncheckItemsInternal } = actorMutation({ + resources: args => ({ items: [...args.itemIds] }), + scope: "items:write", args: { itemIds: v.array(v.id("items")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const now = Date.now(); @@ -745,9 +737,9 @@ export const batchUncheckItems = mutation({ if (listId !== item.listId) { listId = item.listId; - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { - throw new Error("Not authorized to uncheck items in this list"); + throw resourceUnavailable(); } } @@ -764,11 +756,11 @@ export const batchUncheckItems = mutation({ /** * Batch delete multiple items at once. */ -export const batchDeleteItems = mutation({ +export const { public: batchDeleteItems, internal: batchDeleteItemsInternal } = actorMutation({ + resources: args => ({ items: [...args.itemIds] }), + scope: "items:write", args: { itemIds: v.array(v.id("items")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { let listId: Id<"lists"> | null = null; @@ -779,9 +771,9 @@ export const batchDeleteItems = mutation({ if (listId !== item.listId) { listId = item.listId; - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { - throw new Error("Not authorized to delete items in this list"); + throw resourceUnavailable(); } } @@ -803,7 +795,9 @@ export const batchDeleteItems = mutation({ /** * Get items with due dates for calendar view. */ -export const getItemsWithDueDates = query({ +export const { public: getItemsWithDueDates, internal: getItemsWithDueDatesInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists"), startDate: v.optional(v.number()), @@ -834,16 +828,15 @@ export const getItemsWithDueDates = query({ * Get all high-priority items across all lists the user has access to. * Used for Priority Focus mode. */ -export const getHighPriorityItems = query({ - args: { - userDid: v.string(), - legacyDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { +export const { public: getHighPriorityItems, internal: getHighPriorityItemsInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", + args: {}, + handler: async (ctx) => { // DIDs to check: current DID and optionally legacy DID - const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); + const didsToCheck = [ctx.actor.did]; + if (ctx.actor.legacyDid) { + didsToCheck.push(ctx.actor.legacyDid); } // Get all list IDs the user has access to (owned + bookmarked) @@ -878,7 +871,7 @@ export const getHighPriorityItems = query({ for (const listId of listIds) { const list = await ctx.db.get(listId); - if (!list) continue; + if (!list || !(await canUserEditList(ctx, listId, ctx.actor.did, ctx.actor.legacyDid))) continue; const items = await ctx.db .query("items") @@ -916,11 +909,11 @@ export const getHighPriorityItems = query({ /** * Promote an item to a top-level item (remove parent). */ -export const promoteItem = mutation({ +export const { public: promoteItem, internal: promoteItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -928,9 +921,9 @@ export const promoteItem = mutation({ throw new Error("Item not found"); } - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { - throw new Error("Not authorized to edit this item"); + throw resourceUnavailable(); } // Remove parent to make it top-level @@ -945,12 +938,12 @@ export const promoteItem = mutation({ * Demote an item to become a subtask of another item. * Ensures we don't exceed max nesting depth (2 levels). */ -export const demoteItem = mutation({ +export const { public: demoteItem, internal: demoteItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId, args.newParentId] }), + scope: "items:write", args: { itemId: v.id("items"), newParentId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -968,9 +961,9 @@ export const demoteItem = mutation({ throw new Error("Items must be in the same list"); } - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { - throw new Error("Not authorized to edit this item"); + throw resourceUnavailable(); } // Check nesting depth: new parent can't already have a parent (max 2 levels) diff --git a/convex/itemsHttp.ts b/convex/itemsHttp.ts index f31bfed..bf7e00a 100644 --- a/convex/itemsHttp.ts +++ b/convex/itemsHttp.ts @@ -1,16 +1,9 @@ -/** - * HTTP action handlers for protected item mutations. - * - * These endpoints authenticate via resolveActor(), which accepts either a JWT - * session or an agent API key (X-API-Key). Writes require the "items:write" scope. - * The acting DID is resolved server-side and passed to the mutations. - */ +/** HTTP adapter; authentication and authorization run in the shared operation. */ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { resolveActor, requireScope } from "./lib/actor"; +import { authenticatedRequest } from "./lib/actor"; import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; /** @@ -24,8 +17,6 @@ import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpRes export const addItem = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -36,19 +27,15 @@ export const addItem = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - const itemId = await ctx.runMutation(api.items.addItem, { + const itemId = await ctx.runMutation(internal.items.addItemInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, name, - createdByDid: actor.did, - legacyDid: actor.legacyDid, createdAt: Date.now(), }); return jsonResponse(request, { itemId }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] addItem error:", error); return handlerErrorResponse( request, @@ -69,8 +56,6 @@ export const addItem = httpAction(async (ctx, request) => { export const checkItem = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -81,18 +66,14 @@ export const checkItem = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - await ctx.runMutation(api.items.checkItem, { + await ctx.runMutation(internal.items.checkItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, - checkedByDid: actor.did, - legacyDid: actor.legacyDid, checkedAt: Date.now(), }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] checkItem error:", error); return handlerErrorResponse( request, @@ -113,8 +94,6 @@ export const checkItem = httpAction(async (ctx, request) => { export const uncheckItem = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -125,17 +104,13 @@ export const uncheckItem = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - await ctx.runMutation(api.items.uncheckItem, { + await ctx.runMutation(internal.items.uncheckItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, - userDid: actor.did, - legacyDid: actor.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] uncheckItem error:", error); return handlerErrorResponse( request, @@ -156,8 +131,6 @@ export const uncheckItem = httpAction(async (ctx, request) => { export const removeItem = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -168,17 +141,13 @@ export const removeItem = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - await ctx.runMutation(api.items.removeItem, { + await ctx.runMutation(internal.items.removeItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, - userDid: actor.did, - legacyDid: actor.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] removeItem error:", error); return handlerErrorResponse( request, @@ -199,8 +168,6 @@ export const removeItem = httpAction(async (ctx, request) => { export const reorderItems = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -211,18 +178,14 @@ export const reorderItems = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - await ctx.runMutation(api.items.reorderItems, { + await ctx.runMutation(internal.items.reorderItemsInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, itemIds: itemIds as Id<"items">[], - userDid: actor.did, - legacyDid: actor.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] reorderItems error:", error); return handlerErrorResponse( request, diff --git a/convex/lib/actor.ts b/convex/lib/actor.ts index ff6bb25..40cd230 100644 --- a/convex/lib/actor.ts +++ b/convex/lib/actor.ts @@ -1,72 +1,63 @@ -/** - * Shared actor resolver for HTTP handlers. - * - * Accepts either an API key (X-API-Key header) or the existing JWT session, - * and resolves both to a single current DID plus a scope set. This is the only - * new auth surface: the JWT path is unchanged and always resolves to scopes ["*"]. - */ - -import type { ActionCtx } from "../_generated/server"; -import { api, internal } from "../_generated/api"; -import { requireAuth, AuthError } from "./auth"; -import { hasScope, hashApiKey } from "./apiKeyHelpers"; +import { requireSession } from "./session"; +/** The credential boundary shared by reactive browser calls and HTTP actions. */ +import type { ActionCtx, QueryCtx, MutationCtx } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { AuthError } from "./auth"; +import { extractTokenFromRequest } from "./jwt"; +import { hasScope, hashApiKey, type Scope } from "./apiKeyHelpers"; +export type Credentials = { authToken?: string; apiKey?: string }; export type ResolvedActor = { - // Authorization identity. Everything downstream (ownership, canUserEditList) - // keys off this. For an API key it's the key owner's DID, not the agent's — - // so a key always acts *as its owner*. Distinct agent attribution is Step 5 - // and requires splitting authz-vs-attribution in the mutations first. did: string; - // Legacy DID of a migrated Turnkey user, forwarded so the existing - // canUserEditList/ownerDid checks still match lists owned under the old DID. - // Only ever set on the JWT path; API keys are a clean, legacy-free surface. + userId: import("../_generated/dataModel").Id<"users">; legacyDid?: string; - scopes: string[]; // ["*"] for JWT sessions; the key's scopes otherwise + turnkeySubOrgId?: string; + scopes: string[]; viaApiKey: boolean; }; -type UserInfo = { did?: string; legacyDid?: string } | null; +export function requestCredentials(request: Request): Credentials { + return { + authToken: extractTokenFromRequest(request) ?? undefined, + apiKey: request.headers.get("X-API-Key") ?? undefined, + }; +} -/** - * Resolve the actor behind a request. Prefers an API key; falls back to JWT. - * @throws AuthError if neither credential is valid. - */ -export async function resolveActor( - ctx: ActionCtx, - request: Request +export async function authenticate( + ctx: QueryCtx | MutationCtx, + credentials: Credentials, ): Promise { - const apiKey = request.headers.get("X-API-Key"); - if (apiKey) { - const keyHash = await hashApiKey(apiKey); - const rec = await ctx.runQuery(internal.apiKeys.getByHash, { keyHash }); - if (!rec || rec.revokedAt) { - throw new AuthError("Invalid API key", "INVALID_TOKEN"); - } - return { - did: rec.ownerDid, - scopes: rec.scopes, - viaApiKey: true, - }; + if (credentials.apiKey !== undefined) { + const keyHash = await hashApiKey(credentials.apiKey); + const key = await ctx.db.query("agentApiKeys") + .withIndex("by_hash", q => q.eq("keyHash", keyHash)).first(); + if (!key || key.revokedAt !== undefined) throw new AuthError("Invalid API key", "INVALID_TOKEN"); + // Resolve the account on every operation, including keys minted before a DID migration. + const user = await ctx.db.query("users").withIndex("by_did", q => q.eq("did", key.ownerDid)).first() + ?? await ctx.db.query("users").withIndex("by_legacy_did", q => q.eq("legacyDid", key.ownerDid)).first(); + if (!user?.did) throw new AuthError("User not found", "UNAUTHORIZED"); + return { userId: user._id, did: user.did, legacyDid: user.legacyDid, scopes: key.scopes, viaApiKey: true }; } + const session = await requireSession(ctx, credentials.authToken); + const user = await ctx.db.query("users") + .withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", session.turnkeySubOrgId)).first(); + if (!user?.did) throw new AuthError("User not found", "UNAUTHORIZED"); + return { userId: user._id, did: user.did, legacyDid: user.legacyDid, turnkeySubOrgId: session.turnkeySubOrgId, scopes: ["*"], viaApiKey: false }; +} - const auth = await requireAuth(request); - const user = (await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - })) as UserInfo; - if (!user?.did) { - throw new AuthError("User not found", "UNAUTHORIZED"); - } - return { - did: user.did, - legacyDid: user.legacyDid, - scopes: ["*"], - viaApiKey: false, - }; +export async function resolveActor(ctx: ActionCtx, request: Request): Promise { + return ctx.runQuery(internal.actorSession.resolve, await authenticatedRequest(ctx, request)); +} + +export function requireScope(actor: ResolvedActor, scope: Scope): void { + if (!hasScope(actor.scopes, scope)) throw new AuthError(`Missing scope: ${scope}`, "FORBIDDEN"); } -/** Throw AuthError if the actor lacks the required scope. */ -export function requireScope(actor: ResolvedActor, scope: string): void { - if (!hasScope(actor.scopes, scope)) { - throw new AuthError(`Missing scope: ${scope}`, "UNAUTHORIZED"); +/** Compatibility for existing HTTP JWT clients: register verified sessions lazily. */ +export async function authenticatedRequest(ctx: ActionCtx, request: Request): Promise { + const credentials = requestCredentials(request); + if (credentials.apiKey === undefined && credentials.authToken) { + await ctx.runMutation(internal.actorSession.establishInternal, { authToken: credentials.authToken }); } + return credentials; } diff --git a/convex/lib/apiKeyHelpers.ts b/convex/lib/apiKeyHelpers.ts index eb37abb..66304a8 100644 --- a/convex/lib/apiKeyHelpers.ts +++ b/convex/lib/apiKeyHelpers.ts @@ -14,6 +14,7 @@ const KEY_BODY_LENGTH = 40; /** Scopes granted to agent API keys by default. */ export const AGENT_SCOPES = ["lists:read", "items:read", "items:write"] as const; +export type Scope = (typeof AGENT_SCOPES)[number] | "*"; /** * Generate a fresh raw API key: "pa_live_" + 40 chars of [A-Za-z0-9]. diff --git a/convex/lib/auth.ts b/convex/lib/auth.ts index dec4c8a..4b26ba3 100644 --- a/convex/lib/auth.ts +++ b/convex/lib/auth.ts @@ -1,3 +1,5 @@ +import type { ActionCtx } from "../_generated/server"; +import { internal } from "../_generated/api"; /** * Authentication helper for protecting Convex HTTP actions. * @@ -5,7 +7,6 @@ */ import { - verifyAuthToken, extractTokenFromRequest, type AuthTokenPayload, } from "./jwt"; @@ -13,21 +14,8 @@ import { getCorsHeaders } from "./httpResponses"; export type { AuthTokenPayload }; -/** - * Error thrown when authentication fails. - */ -export class AuthError extends Error { - readonly code: "UNAUTHORIZED" | "INVALID_TOKEN" | "EXPIRED_TOKEN"; - - constructor( - message: string, - code: "UNAUTHORIZED" | "INVALID_TOKEN" | "EXPIRED_TOKEN" - ) { - super(message); - this.name = "AuthError"; - this.code = code; - } -} +import { AuthError } from "./authError"; +export { AuthError } from "./authError"; /** * Require authentication for an HTTP action. @@ -42,12 +30,12 @@ export class AuthError extends Error { * @example * ```typescript * export const protectedAction = httpAction(async (ctx, request) => { - * const auth = await requireAuth(request); + * const auth = await requireAuth(ctx, request); * // auth.turnkeySubOrgId and auth.email are now available * }); * ``` */ -export async function requireAuth(request: Request): Promise { +export async function requireAuth(ctx: ActionCtx, request: Request): Promise { // Extract token from request const token = extractTokenFromRequest(request); @@ -59,8 +47,8 @@ export async function requireAuth(request: Request): Promise { } try { - // Verify and decode the token - return await verifyAuthToken(token); + await ctx.runMutation(internal.actorSession.establishInternal, { authToken: token }); + return await ctx.runQuery(internal.actorSession.identity, { authToken: token }); } catch (error) { // Map specific error messages to error codes const message = error instanceof Error ? error.message : "Invalid token"; @@ -81,9 +69,9 @@ export async function requireAuth(request: Request): Promise { * @param request - HTTP request object * @returns Authenticated user payload or null if not authenticated */ -export async function tryAuth(request: Request): Promise { +export async function tryAuth(ctx: ActionCtx, request: Request): Promise { try { - return await requireAuth(request); + return await requireAuth(ctx, request); } catch { return null; } diff --git a/convex/lib/authError.ts b/convex/lib/authError.ts new file mode 100644 index 0000000..bc99437 --- /dev/null +++ b/convex/lib/authError.ts @@ -0,0 +1,29 @@ +import { ConvexError } from "convex/values"; + +export type AuthErrorCode = "UNAUTHORIZED" | "INVALID_TOKEN" | "EXPIRED_TOKEN" | "FORBIDDEN"; +type AuthErrorData = { kind: "auth"; code: AuthErrorCode; message: string }; + +/** Convex preserves data across RPC; ordinary Error properties are stripped. */ +export class AuthError extends ConvexError { + readonly code: AuthErrorCode; + constructor(message: string, code: AuthErrorCode) { + super({ kind: "auth", code, message }); + this.name = "AuthError"; + this.message = message; + this.code = code; + } +} + +export function authErrorData(error: unknown): AuthErrorData | null { + if (!error || typeof error !== "object" || !("data" in error)) return null; + const data = error.data; + if (!data || typeof data !== "object" || !("kind" in data) || data.kind !== "auth" + || !("code" in data) || !("message" in data) || typeof data.message !== "string") return null; + if (data.code !== "UNAUTHORIZED" && data.code !== "INVALID_TOKEN" && data.code !== "EXPIRED_TOKEN" && data.code !== "FORBIDDEN") return null; + return { kind: "auth", code: data.code, message: data.message }; +} + +/** Identical for missing and inaccessible resources: never disclose existence. */ +export function resourceUnavailable(): AuthError { + return new AuthError("Resource unavailable", "FORBIDDEN"); +} diff --git a/convex/lib/authUser.ts b/convex/lib/authUser.ts index 7f6c817..c097c04 100644 --- a/convex/lib/authUser.ts +++ b/convex/lib/authUser.ts @@ -1,32 +1,8 @@ import type { ActionCtx } from "../_generated/server"; -import { api } from "../_generated/api"; -import { AuthError, requireAuth } from "./auth"; - -export type AuthenticatedUser = { - did: string; - legacyDid?: string; - turnkeySubOrgId?: string; -}; - -/** - * Resolve authenticated user from JWT and users table. - */ -export async function requireAuthenticatedUser( - ctx: ActionCtx, - request: Request -): Promise { - const auth = await requireAuth(request); - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as { did: string; legacyDid?: string } | null; - - if (!user) { - throw new AuthError("User not found", "UNAUTHORIZED"); - } - - return { - did: user.did, - legacyDid: user.legacyDid, - turnkeySubOrgId: auth.turnkeySubOrgId, - }; +import { resolveActor, requireScope, type ResolvedActor } from "./actor"; +export type AuthenticatedUser = ResolvedActor; +export async function requireAuthenticatedUser(ctx: ActionCtx, request: Request): Promise { + const actor = await resolveActor(ctx, request); + requireScope(actor, "*"); + return actor; } diff --git a/convex/lib/authenticated.ts b/convex/lib/authenticated.ts new file mode 100644 index 0000000..d4315f9 --- /dev/null +++ b/convex/lib/authenticated.ts @@ -0,0 +1,75 @@ +import { identityAssertionFields } from "./clientAuth"; +/** Public adapters authenticate before invoking private business handlers. + * HTTP adapters use the internal registration of the same operation. Credentials + * are checked inside the transaction, so key revocation and scopes cannot race a write. + */ +import { v, type PropertyValidators, type ObjectType, type VObject } from "convex/values"; +import { mutation, query, action, internalMutation, internalQuery, internalAction } from "../_generated/server"; +import type { MutationCtx, QueryCtx, ActionCtx } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { authenticate, requireScope, type ResolvedActor, type Credentials } from "./actor"; +import { authorizeResources, type ListResources } from "./permissions"; +import { AuthError } from "./auth"; + +const credentials = { authToken: v.optional(v.string()), apiKey: v.optional(v.string()) }; +// Temporary wire compatibility only. These assertions never grant access. Keep +// endpoint names and these fields until deployed browser/mobile versions are confirmed. +const optionalDid = v.optional(v.string()); +const assertions = Object.fromEntries(identityAssertionFields.map(field => [field, optionalDid])) as Record<(typeof identityAssertionFields)[number], typeof optionalDid>; + +type Assertions = ObjectType; +export type ActorCtx = C & { actor: ResolvedActor; credentials: Credentials }; +type Definition = { + args: A; + scope: import("./apiKeyHelpers").Scope; + resources: (args: ObjectType) => ListResources; + handler: (ctx: ActorCtx, args: ObjectType) => R | Promise; +}; +function checkAssertions(actor: ResolvedActor, args: Assertions) { + for (const key of identityAssertionFields) { + const did = args[key]; + if (did !== undefined && did !== actor.did && did !== actor.legacyDid) { + throw new AuthError("Identity assertion does not match authenticated account", "UNAUTHORIZED"); + } + } +} +function prepare(definition: Definition, resolve: (ctx: C, args: Credentials) => Promise) { + return { + args: v.object({ ...assertions, ...definition.args, ...credentials }) as VObject & Assertions & Credentials, A & typeof assertions & typeof credentials>, + handler: async (ctx: C, args: ObjectType & Assertions & Credentials): Promise => { + const actor = await resolve(ctx, args); + requireScope(actor, definition.scope); + checkAssertions(actor, args); + if (ctx && typeof ctx === "object" && "db" in ctx) { + await authorizeResources(ctx as unknown as QueryCtx | MutationCtx, actor, definition.resources(args)); + } else { + const resources = definition.resources(args); + await (ctx as ActionCtx).runQuery(internal.actorSession.authorize, { + authToken: args.authToken, apiKey: args.apiKey, + resources: { + lists: resources.lists?.filter(id => id !== undefined), + items: resources.items?.filter(id => id !== undefined), + anchors: resources.anchors?.filter(id => id !== undefined), + accounts: resources.accounts?.filter(id => id !== undefined), + }, + }); + } + // Only declared business arguments reach operations; credentials and old + // identity assertions cannot be accidentally persisted via an args spread. + const businessArgs = Object.fromEntries(Object.keys(definition.args).map(key => [key, (args as Record)[key]])) as ObjectType; + return definition.handler(Object.assign({}, ctx, { actor, credentials: { authToken: args.authToken, apiKey: args.apiKey } }), businessArgs); + }, + }; +} +export function actorMutation(definition: Definition) { + const config = prepare(definition, authenticate); + return { public: mutation(config), internal: internalMutation(config) }; +} +export function actorQuery(definition: Definition) { + const config = prepare(definition, authenticate); + return { public: query(config), internal: internalQuery(config) }; +} +export function actorAction(definition: Definition) { + const config = prepare(definition, (ctx, args): Promise => ctx.runQuery(internal.actorSession.resolve, { authToken: args.authToken, apiKey: args.apiKey })); + return { public: action(config), internal: internalAction(config) }; +} diff --git a/convex/lib/bucket.ts b/convex/lib/bucket.ts index a86eb82..0f856ac 100644 --- a/convex/lib/bucket.ts +++ b/convex/lib/bucket.ts @@ -33,6 +33,7 @@ function client(cfg: BucketConfig = readConfig()) { } function objectUrl(cfg: BucketConfig, key: string): string { + if (key.split("/").some(segment => segment === "." || segment === "..")) throw new Error("Invalid bucket key"); const host = new URL(cfg.endpoint).host; const encoded = key.split("/").map(encodeURIComponent).join("/"); return `https://${cfg.name}.${host}/${encoded}`; diff --git a/convex/lib/bucketKeys.ts b/convex/lib/bucketKeys.ts new file mode 100644 index 0000000..18c2378 --- /dev/null +++ b/convex/lib/bucketKeys.ts @@ -0,0 +1,6 @@ +/** Uploaded objects must be a direct child of the authorized resource prefix. */ +export function isDirectChildKey(key: string, prefix: string): boolean { + if (!key.startsWith(`${prefix}/`)) return false; + const name = key.slice(prefix.length + 1); + return name.length > 0 && name !== "." && name !== ".." && !/[\\/]/.test(name); +} diff --git a/convex/lib/clientAuth.ts b/convex/lib/clientAuth.ts new file mode 100644 index 0000000..b6d5b79 --- /dev/null +++ b/convex/lib/clientAuth.ts @@ -0,0 +1,5 @@ +/** Legacy wire fields retained until deployed client versions are confirmed. */ +export const identityAssertionFields = [ + "userDid", "ownerDid", "createdByDid", "checkedByDid", "actorDid", + "publisherDid", "viewerDid", "legacyDid", "walletDid", "anchoredByDid", +] as const; diff --git a/convex/lib/httpResponses.ts b/convex/lib/httpResponses.ts index 7e372ab..da68b85 100644 --- a/convex/lib/httpResponses.ts +++ b/convex/lib/httpResponses.ts @@ -1,3 +1,4 @@ +import { authErrorData } from "./authError"; /** * Shared HTTP response helpers for Convex HTTP actions. * @@ -65,15 +66,20 @@ export function errorResponse( * * Convex wraps handler throws with a stack trace naming internal source files, * so never echo the raw message to an API client: authorization failures become - * a clean 403, everything else a generic 500 (details go to the server log). + * a clean 401 or 403, everything else a generic 500 (details go to the server log). */ export function handlerErrorResponse( request: Request, error: unknown, fallbackMessage: string ): Response { + const auth = authErrorData(error); + if (auth) return auth.code === "FORBIDDEN" + ? errorResponse(request, "Not authorized", 403) + : errorResponse(request, "Authentication required", 401); const message = error instanceof Error ? error.message : ""; - if (/not authorized/i.test(message)) { + if (/Authentication required|Invalid or expired token|Invalid API key|User not found/i.test(message)) return errorResponse(request, "Authentication required", 401); + if (/not authorized|Only the list|Missing scope|Identity assertion/i.test(message)) { return errorResponse(request, "Not authorized", 403); } return errorResponse(request, fallbackMessage, 500); diff --git a/convex/lib/jwt.ts b/convex/lib/jwt.ts index 26a40eb..7f81481 100644 --- a/convex/lib/jwt.ts +++ b/convex/lib/jwt.ts @@ -10,6 +10,7 @@ import * as jose from "jose"; * Result of a successful token verification. */ export interface AuthTokenPayload { + expiresAt: number; /** Turnkey sub-organization ID (stable user identifier) */ turnkeySubOrgId: string; /** User's email address */ @@ -71,6 +72,7 @@ export async function verifyAuthToken(token: string): Promise } return { + expiresAt: jwtPayload.exp! * 1000, turnkeySubOrgId: jwtPayload.sub, email: jwtPayload.email, sessionToken: jwtPayload.sessionToken, diff --git a/convex/lib/permissions.ts b/convex/lib/permissions.ts index 0f1022a..2300d06 100644 --- a/convex/lib/permissions.ts +++ b/convex/lib/permissions.ts @@ -1,3 +1,4 @@ +import { resourceUnavailable } from "./authError"; import type { Id } from "../_generated/dataModel"; import type { MutationCtx, QueryCtx } from "../_generated/server"; @@ -38,3 +39,53 @@ export async function canUserViewList( ): Promise { return canUserEditList(ctx, listId, userDid, legacyDid); } + +export type ListResources = { + accounts?: Array | undefined>; + lists?: Array | undefined>; + items?: Array | undefined>; + anchors?: Array | undefined>; +}; + +/** Each operation explicitly selects its resources; argument names confer no access. */ +export async function authorizeResources( + ctx: MutationCtx | QueryCtx, + actor: import("./actor").ResolvedActor, + resources: ListResources, +): Promise { + for (const accountId of resources.accounts ?? []) { + if (accountId !== actor.userId) throw resourceUnavailable(); + } + const listIds = new Set((resources.lists ?? []).filter((id): id is Id<"lists"> => id !== undefined)); + for (const id of new Set(resources.items ?? [])) { + if (!id) continue; + const item = await ctx.db.get(id); + if (!item) throw resourceUnavailable(); + listIds.add(item.listId); + } + for (const id of new Set(resources.anchors ?? [])) { + if (!id) continue; + const anchor = await ctx.db.get(id); + if (!anchor) throw resourceUnavailable(); + if (anchor.listId) listIds.add(anchor.listId); + if (anchor?.itemId) { + const item = await ctx.db.get(anchor.itemId); + if (!item) throw resourceUnavailable(); + listIds.add(item.listId); + } + } + for (const listId of listIds) { + const list = await ctx.db.get(listId); + if (!list) throw resourceUnavailable(); + if ([actor.did, actor.legacyDid].includes(list.ownerDid)) continue; + const publication = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", listId)).first(); + if (publication?.status !== "active") throw resourceUnavailable(); + } +} + +/** actorDid is resolved by the authenticated boundary, never supplied by a public caller. */ +export async function isResourceOwner(ctx: QueryCtx | MutationCtx, ownerDid: string, actorDid: string): Promise { + if (ownerDid === actorDid) return true; + const account = await ctx.db.query("users").withIndex("by_did", q => q.eq("did", actorDid)).first(); + return account?.legacyDid === ownerDid; +} diff --git a/convex/lib/session.ts b/convex/lib/session.ts new file mode 100644 index 0000000..7e4e671 --- /dev/null +++ b/convex/lib/session.ts @@ -0,0 +1,18 @@ +import type { QueryCtx, MutationCtx } from "../_generated/server"; +import { hashApiKey } from "./apiKeyHelpers"; +import { verifyAuthToken } from "./jwt"; +import { AuthError } from "./auth"; + +/** Read the expiring DB record so logout/expiry invalidates reactive query caches. */ +export async function requireSession(ctx: QueryCtx | MutationCtx, token?: string) { + if (!token) throw new AuthError("Authentication required", "UNAUTHORIZED"); + let session; + try { session = await verifyAuthToken(token); } + catch { throw new AuthError("Invalid or expired token", "INVALID_TOKEN"); } + const tokenHash = await hashApiKey(token); + const record = await ctx.db.query("accessSessions").withIndex("by_hash", q => q.eq("tokenHash", tokenHash)).first(); + if (!record || record.revokedAt !== undefined || record.expiresAt <= Date.now() || record.subject !== session.turnkeySubOrgId) { + throw new AuthError("Authentication required: restore your session", "UNAUTHORIZED"); + } + return session; +} diff --git a/convex/lists.ts b/convex/lists.ts index d451dd7..ca618a7 100644 --- a/convex/lists.ts +++ b/convex/lists.ts @@ -1,5 +1,7 @@ +import { resourceUnavailable } from "./lib/authError"; +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { mutation, query, internalQuery } from "./_generated/server"; + import type { MutationCtx } from "./_generated/server"; import type { Doc, Id } from "./_generated/dataModel"; import { withMutationObservability } from "./lib/observability"; @@ -101,11 +103,12 @@ async function assertListQuota( return { owner, isFirstList: existingLists.length === 0 }; } -export const createList = mutation({ +export const { public: createList, internal: createListInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { assetDid: v.string(), name: v.string(), - ownerDid: v.string(), categoryId: v.optional(v.id("categories")), createdAt: v.number(), // Serialized AssetEnvelope from createListAsset. Optional so older clients @@ -118,12 +121,12 @@ export const createList = mutation({ if (args.name.trim().length === 0) throw new Error("List name cannot be empty"); if (args.name.length > 200) throw new Error("List name cannot exceed 200 characters"); - const { owner, isFirstList } = await assertListQuota(ctx, args.ownerDid); + const { owner, isFirstList } = await assertListQuota(ctx, ctx.actor.did); const listId = await ctx.db.insert("lists", { assetDid: args.assetDid, name: args.name, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, categoryId: args.categoryId, createdAt: args.createdAt, }); @@ -131,7 +134,7 @@ export const createList = mutation({ const vcProof = createListOwnershipVC( listId, args.assetDid, - args.ownerDid, + ctx.actor.did, args.name, args.createdAt ); @@ -173,7 +176,9 @@ export const createList = mutation({ * The copy is honestly new: it gets today's genesis and its own DID, and makes * no claim to the original's history. The source list is left untouched. */ -export const copyList = mutation({ +export const { public: copyList, internal: copyListInternal } = actorMutation({ + resources: args => ({ lists: [args.sourceListId] }), + scope: "items:write", args: { sourceListId: v.id("lists"), // Minted client-side by createListAsset — that is the whole point, so both @@ -181,7 +186,6 @@ export const copyList = mutation({ assetDid: v.string(), celEnvelope: v.string(), name: v.string(), - ownerDid: v.string(), createdAt: v.number(), }, handler: async (ctx, args) => withMutationObservability("lists.copyList", async () => { @@ -192,16 +196,16 @@ export const copyList = mutation({ if (!source) throw new Error("List not found"); // Copying mints a new identity naming this owner, so viewers who can merely // read a shared list must not be able to do it. - if (source.ownerDid !== args.ownerDid) { - throw new Error("Only the list's owner can copy it"); + if (![ctx.actor.did, ctx.actor.legacyDid].includes(source.ownerDid)) { + throw resourceUnavailable(); } - const { owner, isFirstList } = await assertListQuota(ctx, args.ownerDid); + const { owner, isFirstList } = await assertListQuota(ctx, ctx.actor.did); const listId = await ctx.db.insert("lists", { assetDid: args.assetDid, name: args.name, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, categoryId: source.categoryId, createdAt: args.createdAt, // Presentation settings belong to the list, so the copy should look like @@ -212,7 +216,7 @@ export const copyList = mutation({ }); await ctx.db.patch(listId, { - vcProof: createListOwnershipVC(listId, args.assetDid, args.ownerDid, args.name, args.createdAt), + vcProof: createListOwnershipVC(listId, args.assetDid, ctx.actor.did, args.name, args.createdAt), }); await upsertListEnvelope(ctx, listId, args.assetDid, args.celEnvelope); @@ -272,22 +276,22 @@ export const copyList = mutation({ /** * Rename a list. Only the owner can rename. */ -export const renameList = mutation({ +export const { public: renameList, internal: renameListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), name: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { - throw new Error("Only the list owner can rename this list"); + throw resourceUnavailable(); } const vcProof = createListOwnershipVC( @@ -305,22 +309,22 @@ export const renameList = mutation({ /** * Update the category of a list. Only owner can change. */ -export const updateListCategory = mutation({ +export const { public: updateListCategory, internal: updateListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), categoryId: v.optional(v.id("categories")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { - throw new Error("Only the list owner can change the category"); + throw resourceUnavailable(); } if (args.categoryId) { @@ -335,10 +339,14 @@ export const updateListCategory = mutation({ /** * Get a list by its ID. */ -export const getList = query({ +export const { public: getList, internal: getListInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { - return await ctx.db.get(args.listId); + const list = await ctx.db.get(args.listId); + if (!list || !await canUserViewList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid)) return null; + return list; }, }); @@ -347,11 +355,11 @@ export const getList = query({ * getList so the hot list subscriptions don't carry it. Returns null when the * list predates envelope persistence. * - * Unauthenticated, matching getList above: the envelope holds the DID document, - * the signed CEL log and the list's own name/owner — the same surface getList - * already returns to any caller with the id. + * Protected by the same authenticated list access check as getList. */ -export const getListEnvelope = query({ +export const { public: getListEnvelope, internal: getListEnvelopeInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "lists:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { const row = await ctx.db @@ -370,11 +378,12 @@ export const getListEnvelope = query({ * Internal: only the server-side agent read handler may call it, so the viewer * DID it trusts always comes from an authenticated actor, never a raw client. */ -export const getListWithItemsForViewer = internalQuery({ +const listWithItemsOperation = actorQuery({ + // This read returns null for both missing and inaccessible lists (HTTP 404). + resources: () => ({}), + scope: "items:read", args: { listId: v.id("lists"), - viewerDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); @@ -383,8 +392,8 @@ export const getListWithItemsForViewer = internalQuery({ const canView = await canUserViewList( ctx, args.listId, - args.viewerDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canView) return null; @@ -403,16 +412,13 @@ export const getListWithItemsForViewer = internalQuery({ /** * Get all lists where user is the owner, plus any bookmarked published lists. */ -export const getUserLists = query({ - args: { - userDid: v.string(), - legacyDid: v.optional(v.string()), - walletDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const didsToCheck = [args.userDid]; - if (args.legacyDid) didsToCheck.push(args.legacyDid); - if (args.walletDid) didsToCheck.push(args.walletDid); +export const { public: getUserLists, internal: getUserListsInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx) => { + const didsToCheck = [ctx.actor.did]; + if (ctx.actor.legacyDid) didsToCheck.push(ctx.actor.legacyDid); const listMap = new Map>(); @@ -440,7 +446,7 @@ export const getUserLists = query({ for (const bookmark of bookmarks) { if (!listMap.has(bookmark.listId.toString())) { const list = await ctx.db.get(bookmark.listId); - if (list) { + if (list && await canUserViewList(ctx, list._id, ctx.actor.did, ctx.actor.legacyDid)) { listMap.set(bookmark.listId.toString(), list); } } @@ -462,7 +468,9 @@ export const getUserLists = query({ * indexed row per list and compares two numbers; the parsing happened once, at * write time. */ -export const getLegacyListIds = query({ +export const { public: getLegacyListIds, internal: getLegacyListIdsInternal } = actorQuery({ + resources: args => ({ lists: [...args.listIds] }), + scope: "lists:read", args: { listIds: v.array(v.id("lists")) }, handler: async (ctx, args) => { const legacy: Id<"lists">[] = []; @@ -492,21 +500,21 @@ export const getLegacyListIds = query({ * Delete a list and all its items. * Only the owner can delete a list. */ -export const deleteList = mutation({ +export const { public: deleteList, internal: deleteListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { - throw new Error("Only the list owner can delete this list"); + throw resourceUnavailable(); } // Delete all items @@ -543,22 +551,22 @@ export const deleteList = mutation({ * Add a custom grocery aisle to a list. * Only the list owner can add custom aisles. */ -export const addCustomAisle = mutation({ +export const { public: addCustomAisle, internal: addCustomAisleInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), name: v.string(), emoji: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { - throw new Error("Only the list owner can add custom aisles"); + throw resourceUnavailable(); } const existing = list.customAisles ?? []; @@ -577,21 +585,21 @@ export const addCustomAisle = mutation({ * Update the item view mode for a list. * Only the list owner can change view mode. */ -export const updateItemViewMode = mutation({ +export const { public: updateItemViewMode, internal: updateItemViewModeInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), itemViewMode: v.union(v.literal("alphabetical"), v.literal("categorized")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { - throw new Error("Only the list owner can change view mode"); + throw resourceUnavailable(); } await ctx.db.patch(args.listId, { itemViewMode: args.itemViewMode }); @@ -602,21 +610,21 @@ export const updateItemViewMode = mutation({ * Remove a custom grocery aisle from a list. * Only the list owner can remove custom aisles. */ -export const removeCustomAisle = mutation({ +export const { public: removeCustomAisle, internal: removeCustomAisleInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), aisleId: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { - throw new Error("Only the list owner can remove custom aisles"); + throw resourceUnavailable(); } const existing = list.customAisles ?? []; @@ -625,3 +633,5 @@ export const removeCustomAisle = mutation({ }); }, }); + +export const getListWithItemsForViewer = listWithItemsOperation.internal; diff --git a/convex/listsHttp.ts b/convex/listsHttp.ts index 84ce62f..c24b58f 100644 --- a/convex/listsHttp.ts +++ b/convex/listsHttp.ts @@ -1,16 +1,9 @@ -/** - * HTTP action handlers for protected list mutations. - * - * These endpoints authenticate via resolveActor(), which accepts either a JWT - * session or an agent API key (X-API-Key). Writes require the "items:write" scope - * (there is no lists:write scope yet). - */ +/** HTTP adapter; authentication and authorization run in the shared operation. */ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { resolveActor, requireScope } from "./lib/actor"; +import { authenticatedRequest } from "./lib/actor"; import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; /** @@ -24,8 +17,6 @@ import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpRes export const createList = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -40,19 +31,16 @@ export const createList = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - const listId = await ctx.runMutation(api.lists.createList, { + const listId = await ctx.runMutation(internal.lists.createListInternal, { + ...await authenticatedRequest(ctx, request), assetDid, name, - ownerDid: actor.did, categoryId: categoryId as unknown as undefined, // Optional category ID createdAt: Date.now(), }); return jsonResponse(request, { listId }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[listsHttp] createList error:", error); return handlerErrorResponse( request, @@ -73,8 +61,6 @@ export const createList = httpAction(async (ctx, request) => { export const deleteList = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -85,17 +71,13 @@ export const deleteList = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - await ctx.runMutation(api.lists.deleteList, { + await ctx.runMutation(internal.lists.deleteListInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, - userDid: actor.did, - legacyDid: actor.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[listsHttp] deleteList error:", error); return handlerErrorResponse( request, diff --git a/convex/notificationActions.ts b/convex/notificationActions.ts index 5bf2696..ab85baf 100644 --- a/convex/notificationActions.ts +++ b/convex/notificationActions.ts @@ -1,25 +1,27 @@ "use node"; +import { actorAction } from "./lib/authenticated"; /** * Push notification actions (Node.js) — APNs + Web Push. * Queries/mutations are in notifications.ts. */ import { v } from "convex/values"; -import { action, internalAction } from "./_generated/server"; +import { internalAction } from "./_generated/server"; import { internal } from "./_generated/api"; // ─── Send push notification (action) ──────────────────────────────── -export const sendPushNotification = action({ +export const { public: sendPushNotification, internal: sendPushNotificationAuthenticatedInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - userDid: v.string(), title: v.string(), body: v.string(), data: v.optional(v.any()), }, handler: async (ctx, args): Promise> => { const tokens: Array<{ token: string; platform: string; webPushKeys?: { p256dh: string; auth: string } }> = await ctx.runQuery(internal.notifications.getTokensForUser, { - userDid: args.userDid, + userDid: ctx.actor.did, }); const results: PromiseSettledResult[] = await Promise.allSettled( @@ -41,7 +43,9 @@ export const sendPushNotification = action({ }, }); -export const sendListNotification = action({ +export const { public: sendListNotification, internal: sendListNotificationAuthenticatedInternal } = actorAction({ + resources: args => ({ lists: [args.listId] }), + scope: "*", args: { listId: v.id("lists"), excludeDid: v.optional(v.string()), diff --git a/convex/notifications.ts b/convex/notifications.ts index 840ef12..07bc2d9 100644 --- a/convex/notifications.ts +++ b/convex/notifications.ts @@ -1,16 +1,18 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * Push notification management — queries & mutations (non-Node.js). * Actions that need Node.js are in notificationActions.ts. */ import { v } from "convex/values"; -import { mutation, query, internalQuery } from "./_generated/server"; +import { internalQuery } from "./_generated/server"; // ─── Token registration ───────────────────────────────────────────── -export const registerPushToken = mutation({ +export const { public: registerPushToken, internal: registerPushTokenInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { - userDid: v.string(), token: v.string(), platform: v.union(v.literal("ios"), v.literal("android"), v.literal("web")), webPushKeys: v.optional( @@ -25,7 +27,7 @@ export const registerPushToken = mutation({ if (existing) { await ctx.db.patch(existing._id, { - userDid: args.userDid, + userDid: ctx.actor.did, platform: args.platform, webPushKeys: args.webPushKeys, }); @@ -33,7 +35,7 @@ export const registerPushToken = mutation({ } return await ctx.db.insert("pushTokens", { - userDid: args.userDid, + userDid: ctx.actor.did, token: args.token, platform: args.platform, webPushKeys: args.webPushKeys, @@ -42,17 +44,18 @@ export const registerPushToken = mutation({ }, }); -export const unregisterPushToken = mutation({ +export const { public: unregisterPushToken, internal: unregisterPushTokenInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { token: v.string(), - userDid: v.string(), }, handler: async (ctx, args) => { const tok = await ctx.db .query("pushTokens") .withIndex("by_token", (q) => q.eq("token", args.token)) .first(); - if (tok && tok.userDid === args.userDid) { + if (tok && [ctx.actor.did, ctx.actor.legacyDid].includes(tok.userDid)) { await ctx.db.delete(tok._id); } }, @@ -60,30 +63,30 @@ export const unregisterPushToken = mutation({ // ─── Queries ───────────────────────────────────────────────────────── -export const hasSubscription = query({ - args: { userDid: v.string() }, - handler: async (ctx, args) => { - const legacySub = await ctx.db - .query("pushSubscriptions") - .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) - .first(); - if (legacySub) return true; - - const token = await ctx.db - .query("pushTokens") - .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) - .first(); - return token !== null; +export const { public: hasSubscription, internal: hasSubscriptionInternal } = actorQuery({ + resources: () => ({}), + scope: "*", + args: {}, + handler: async (ctx) => { + for (const did of [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did)) { + const [subscription, token] = await Promise.all([ + ctx.db.query("pushSubscriptions").withIndex("by_user", q => q.eq("userDid", did)).first(), + ctx.db.query("pushTokens").withIndex("by_user", q => q.eq("userDid", did)).first(), + ]); + if (subscription || token) return true; + } + return false; }, }); -export const getUserSubscriptions = query({ - args: { userDid: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("pushTokens") - .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) - .collect(); +export const { public: getUserSubscriptions, internal: getUserSubscriptionsInternal } = actorQuery({ + resources: () => ({}), + scope: "*", + args: {}, + handler: async (ctx) => { + const dids = [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did); + return (await Promise.all(dids.map(did => ctx.db.query("pushTokens") + .withIndex("by_user", q => q.eq("userDid", did)).collect()))).flat(); }, }); @@ -129,9 +132,10 @@ export const getTokensForList = internalQuery({ // ─── Legacy compatibility ──────────────────────────────────────────── -export const saveSubscription = mutation({ +export const { public: saveSubscription, internal: saveSubscriptionInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { - userDid: v.string(), endpoint: v.string(), keys: v.object({ p256dh: v.string(), auth: v.string() }), }, @@ -141,11 +145,11 @@ export const saveSubscription = mutation({ .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint)) .first(); if (existing) { - await ctx.db.patch(existing._id, { userDid: args.userDid, keys: args.keys }); + await ctx.db.patch(existing._id, { userDid: ctx.actor.did, keys: args.keys }); return existing._id; } return await ctx.db.insert("pushSubscriptions", { - userDid: args.userDid, + userDid: ctx.actor.did, endpoint: args.endpoint, keys: args.keys, createdAt: Date.now(), @@ -153,14 +157,16 @@ export const saveSubscription = mutation({ }, }); -export const removeSubscription = mutation({ - args: { endpoint: v.string(), userDid: v.string() }, +export const { public: removeSubscription, internal: removeSubscriptionInternal } = actorMutation({ + resources: () => ({}), + scope: "*", + args: { endpoint: v.string() }, handler: async (ctx, args) => { const sub = await ctx.db .query("pushSubscriptions") .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint)) .first(); - if (sub && sub.userDid === args.userDid) { + if (sub && [ctx.actor.did, ctx.actor.legacyDid].includes(sub.userDid)) { await ctx.db.delete(sub._id); } }, diff --git a/convex/originals.ts b/convex/originals.ts index 65feabf..4cf0096 100644 --- a/convex/originals.ts +++ b/convex/originals.ts @@ -1,13 +1,5 @@ -/** - * Originals Explorer — unified read-only query. - * - * Joins lists, sites, siteHostnames, publications, bitcoinAnchors, activities, - * itemAssignees into a single ExplorerRow[] sorted by updatedAt desc with - * id-ascending tiebreaker. Pure derivation logic lives in src/lib/explorer.ts. - */ +import { actorQuery } from "./lib/authenticated"; -import { v } from "convex/values"; -import { query } from "./_generated/server"; import type { Doc } from "./_generated/dataModel"; import { deriveExplorerRows, @@ -15,18 +7,17 @@ import { type ExplorerRow, } from "../src/lib/explorer"; -export const listOwnedOriginals = query({ - args: { ownerDid: v.string() }, - handler: async (ctx, args): Promise => { +export const { public: listOwnedOriginals, internal: listOwnedOriginalsInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx): Promise => { + const dids = [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did); const [lists, sites] = await Promise.all([ - ctx.db - .query("lists") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) - .collect(), - ctx.db - .query("sites") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) - .collect(), + Promise.all(dids.map(did => ctx.db.query("lists") + .withIndex("by_owner", q => q.eq("ownerDid", did)).collect())).then(rows => rows.flat()), + Promise.all(dids.map(did => ctx.db.query("sites") + .withIndex("by_owner", q => q.eq("ownerDid", did)).collect())).then(rows => rows.flat()), ]); // Per-site joins: hostnames. diff --git a/convex/presence.ts b/convex/presence.ts index 7b1a2bb..f191e73 100644 --- a/convex/presence.ts +++ b/convex/presence.ts @@ -1,18 +1,19 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; + import { canUserEditList } from "./lib/permissions"; const ACTIVE_WINDOW_MS = 60_000; -export const heartbeat = mutation({ +export const { public: heartbeat, internal: heartbeatInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), - userDid: v.string(), - legacyDid: v.optional(v.string()), status: v.optional(v.union(v.literal("active"), v.literal("idle"), v.literal("offline"))), }, handler: async (ctx, args) => { - const canAccess = await canUserEditList(ctx, args.listId, args.userDid, args.legacyDid); + const canAccess = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canAccess) throw new Error("Not authorized to update presence"); const now = Date.now(); @@ -20,7 +21,7 @@ export const heartbeat = mutation({ const existing = await ctx.db .query("presence") - .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", args.userDid)) + .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", ctx.actor.did)) .first(); if (existing) { @@ -28,7 +29,7 @@ export const heartbeat = mutation({ } else { await ctx.db.insert("presence", { listId: args.listId, - userDid: args.userDid, + userDid: ctx.actor.did, status, lastSeenAt: now, updatedAt: now, @@ -37,7 +38,7 @@ export const heartbeat = mutation({ await ctx.db.insert("activities", { listId: args.listId, - actorDid: args.userDid, + actorDid: ctx.actor.did, type: "presence_heartbeat", metadata: { status }, createdAt: now, @@ -47,19 +48,19 @@ export const heartbeat = mutation({ }, }); -export const markOffline = mutation({ +export const { public: markOffline, internal: markOfflineInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { - const canAccess = await canUserEditList(ctx, args.listId, args.userDid, args.legacyDid); + const canAccess = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canAccess) throw new Error("Not authorized to update presence"); const existing = await ctx.db .query("presence") - .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", args.userDid)) + .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", ctx.actor.did)) .first(); const now = Date.now(); @@ -69,7 +70,7 @@ export const markOffline = mutation({ await ctx.db.insert("activities", { listId: args.listId, - actorDid: args.userDid, + actorDid: ctx.actor.did, type: "presence_offline", metadata: { status: "offline" }, createdAt: now, @@ -79,7 +80,9 @@ export const markOffline = mutation({ }, }); -export const getListPresence = query({ +export const { public: getListPresence, internal: getListPresenceInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { const now = Date.now(); diff --git a/convex/presenceHttp.ts b/convex/presenceHttp.ts index cbdc636..5c53d0b 100644 --- a/convex/presenceHttp.ts +++ b/convex/presenceHttp.ts @@ -1,47 +1,42 @@ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { requireAuthenticatedUser } from "./lib/authUser"; -import { jsonResponse, errorResponse } from "./lib/httpResponses"; +import { authenticatedRequest } from "./lib/actor"; +import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; export const heartbeat = httpAction(async (ctx, request) => { try { - const user = await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { listId, status } = body as { listId: string; status?: "active" | "idle" | "offline" }; if (!listId) return errorResponse(request, "listId is required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await ctx.runMutation((api as any).presence.heartbeat, { + const result = await ctx.runMutation(internal.presence.heartbeatInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, - userDid: user.did, - legacyDid: user.legacyDid, status, }); return jsonResponse(request, result); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to update presence", 500); + return handlerErrorResponse(request, error, "Failed to update presence"); } }); export const listPresence = httpAction(async (ctx, request) => { try { - await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { listId } = body as { listId: string }; if (!listId) return errorResponse(request, "listId is required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const presence = await ctx.runQuery((api as any).presence.getListPresence, { + const presence = await ctx.runQuery(internal.presence.getListPresenceInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, }); return jsonResponse(request, { presence }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to read presence", 500); + return handlerErrorResponse(request, error, "Failed to read presence"); } }); diff --git a/convex/publication.ts b/convex/publication.ts index c1a5a22..419b0aa 100644 --- a/convex/publication.ts +++ b/convex/publication.ts @@ -1,3 +1,4 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * Publication functions for did:webvh public list publishing. * @@ -6,21 +7,23 @@ */ import { v } from "convex/values"; -import { query, mutation } from "./_generated/server"; +import { query } from "./_generated/server"; import { upsertListEnvelope } from "./lib/listEnvelope"; import { internal } from "./_generated/api"; +import { canUserViewList } from "./lib/permissions"; /** * Record a publication for a list. * Only the owner can publish a list. */ -export const publishList = mutation({ +export const { public: publishList, internal: publishListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "*", args: { listId: v.id("lists"), webvhDid: v.string(), didDocument: v.optional(v.string()), didLog: v.optional(v.string()), - publisherDid: v.string(), // The asset envelope after appending the published-version event. Optional: // a list whose signing key was lost to the celAssetDids migration can still // be published, it just cannot record the fact in its own log. @@ -32,7 +35,7 @@ export const publishList = mutation({ if (!list) { throw new Error("List not found"); } - if (list.ownerDid !== args.publisherDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(list.ownerDid)) { throw new Error("Only the owner can publish a list"); } @@ -72,7 +75,7 @@ export const publishList = mutation({ didDocument: args.didDocument, didLog: args.didLog, publishedAt: Date.now(), - publishedByDid: args.publisherDid, + publishedByDid: ctx.actor.did, status: "active", }); }, @@ -82,17 +85,18 @@ export const publishList = mutation({ * Unpublish a list. * Only the owner can unpublish. */ -export const unpublishList = mutation({ +export const { public: unpublishList, internal: unpublishListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "*", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) { throw new Error("List not found"); } - if (list.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(list.ownerDid)) { throw new Error("Only the owner can unpublish a list"); } @@ -205,10 +209,11 @@ export const getPublicList = query({ /** * Bookmark a published list so it shows in the user's list view. */ -export const bookmarkList = mutation({ +export const { public: bookmarkList, internal: bookmarkListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "*", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args) => { // Verify list exists and is published @@ -225,7 +230,7 @@ export const bookmarkList = mutation({ const existing = await ctx.db .query("bookmarks") .withIndex("by_user_list", (q) => - q.eq("userDid", args.userDid).eq("listId", args.listId) + q.eq("userDid", ctx.actor.did).eq("listId", args.listId) ) .first(); @@ -257,12 +262,12 @@ export const bookmarkList = mutation({ } const bookmarkId = await ctx.db.insert("bookmarks", { - userDid: args.userDid, + userDid: ctx.actor.did, listId: args.listId, bookmarkedAt: Date.now(), }); - if (list && list.ownerDid !== args.userDid) { + if (list && ![ctx.actor.did, ctx.actor.legacyDid].includes(list.ownerDid)) { // Notify the list owner: a new collaborator joined await ctx.scheduler.runAfter(0, internal.notificationActions.sendPushNotificationInternal, { userDid: list.ownerDid, @@ -272,7 +277,7 @@ export const bookmarkList = mutation({ }); // Notify the joiner: the list was shared with them (delivers to their other devices) await ctx.scheduler.runAfter(0, internal.notificationActions.sendPushNotificationInternal, { - userDid: args.userDid, + userDid: ctx.actor.did, title: list.name, body: "Added to your lists", data: { listId: args.listId }, @@ -286,21 +291,21 @@ export const bookmarkList = mutation({ /** * Remove a bookmark. */ -export const unbookmarkList = mutation({ +export const { public: unbookmarkList, internal: unbookmarkListInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args) => { - const existing = await ctx.db - .query("bookmarks") - .withIndex("by_user_list", (q) => - q.eq("userDid", args.userDid).eq("listId", args.listId) - ) - .first(); - - if (existing) { - await ctx.db.delete(existing._id); + for (const did of new Set([ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did))) { + const bookmarks = await ctx.db + .query("bookmarks") + .withIndex("by_user_list", (q) => + q.eq("userDid", did).eq("listId", args.listId) + ) + .collect(); + for (const bookmark of bookmarks) await ctx.db.delete(bookmark._id); } }, }); @@ -308,20 +313,23 @@ export const unbookmarkList = mutation({ /** * Check if a list is bookmarked by the user. */ -export const isBookmarked = query({ +export const { public: isBookmarked, internal: isBookmarkedInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args) => { - const existing = await ctx.db - .query("bookmarks") - .withIndex("by_user_list", (q) => - q.eq("userDid", args.userDid).eq("listId", args.listId) - ) - .first(); - - return !!existing; + for (const did of new Set([ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did))) { + const existing = await ctx.db + .query("bookmarks") + .withIndex("by_user_list", (q) => + q.eq("userDid", did).eq("listId", args.listId) + ) + .first(); + if (existing) return true; + } + return false; }, }); @@ -329,9 +337,12 @@ export const isBookmarked = query({ * Get publication status for a list. * Returns publication info if the list is published, null otherwise. */ -export const getPublicationStatus = query({ +export const { public: getPublicationStatus, internal: getPublicationStatusInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { + if (!await canUserViewList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid)) return null; const pub = await ctx.db .query("publications") .withIndex("by_list", (q) => q.eq("listId", args.listId)) @@ -356,15 +367,16 @@ export const getPublicationStatus = query({ /** * Get all bookmarked list IDs for a user. */ -export const getUserBookmarkIds = query({ - args: { - userDid: v.string(), - }, - handler: async (ctx, args) => { - const bookmarks = await ctx.db +export const { public: getUserBookmarkIds, internal: getUserBookmarkIdsInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx) => { + const dids = [...new Set([ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did))]; + const bookmarks = await Promise.all(dids.map(did => ctx.db .query("bookmarks") - .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) - .collect(); - return bookmarks.map((b) => b.listId); + .withIndex("by_user", (q) => q.eq("userDid", did)) + .collect())); + return [...new Set(bookmarks.flatMap(rows => rows.map(bookmark => bookmark.listId)))]; }, }); diff --git a/convex/referrals.ts b/convex/referrals.ts index 9f264da..6bab57c 100644 --- a/convex/referrals.ts +++ b/convex/referrals.ts @@ -1,3 +1,4 @@ +import { actorQuery, actorMutation } from "./lib/authenticated"; /** * Referral growth loop — invite a friend, unlock +1 list. * @@ -10,7 +11,6 @@ */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; // --------------------------------------------------------------------------- // Helpers @@ -35,7 +35,9 @@ function generateCode(): string { /** * Get the referral code for a user (by userId). Returns null if none exists yet. */ -export const getReferralCode = query({ +export const { public: getReferralCode, internal: getReferralCodeAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { return await ctx.db @@ -48,7 +50,9 @@ export const getReferralCode = query({ /** * Get referral stats for a user: total successful referrals and Pro credit status. */ -export const getReferralStats = query({ +export const { public: getReferralStats, internal: getReferralStatsAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { const referrals = await ctx.db @@ -67,7 +71,9 @@ export const getReferralStats = query({ * Get referral Pro credit status for the current user (as referee). * Returns whether they came via referral and when their Pro expires. */ -export const getReferralProStatus = query({ +export const { public: getReferralProStatus, internal: getReferralProStatusAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { const user = await ctx.db.get(userId); @@ -90,7 +96,9 @@ export const getReferralProStatus = query({ * Get or create a referral code for the current user. * Idempotent — always returns the same code for the same user. */ -export const getOrCreateReferralCode = mutation({ +export const { public: getOrCreateReferralCode, internal: getOrCreateReferralCodeAuthenticatedInternal } = actorMutation({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { // Return existing code if one exists @@ -135,7 +143,9 @@ export const getOrCreateReferralCode = mutation({ * * Safe to call multiple times — idempotent via the referee uniqueness check. */ -export const redeemReferral = mutation({ +export const { public: redeemReferral, internal: redeemReferralAuthenticatedInternal } = actorMutation({ + resources: args => ({ accounts: [args.refereeUserId] }), + scope: "*", args: { code: v.string(), refereeUserId: v.id("users"), diff --git a/convex/schema.ts b/convex/schema.ts index f4d4c6a..8a1fbb7 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -9,6 +9,14 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ + accessSessions: defineTable({ + tokenHash: v.string(), + subject: v.string(), + expiresAt: v.number(), + revokedAt: v.optional(v.number()), + }) + .index("by_hash", ["tokenHash"]) + .index("by_expires_at", ["expiresAt"]), // DID logs table - stores did:webvh logs for resolution didLogs: defineTable({ userDid: v.string(), // The user's did:webvh diff --git a/convex/siteActions.ts b/convex/siteActions.ts index 2bb1810..2c1b48a 100644 --- a/convex/siteActions.ts +++ b/convex/siteActions.ts @@ -1,7 +1,10 @@ "use node"; +import { isDirectChildKey } from "./lib/bucketKeys"; -import { action, internalAction } from "./_generated/server"; -import { internal, api } from "./_generated/api"; +import { actorAction } from "./lib/authenticated"; + +import { internalAction } from "./_generated/server"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { v } from "convex/values"; import { createCustomHostname, getCustomHostname } from "./cloudflare"; @@ -207,12 +210,14 @@ function normalizeCustomHostname(hostname: string): string { return normalized; } -export const createSiteFromUpload = action({ +export const { public: createSiteFromUpload, internal: createSiteFromUploadInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), bucketKey: v.string(), }, handler: async (ctx, args): Promise<{ siteId: string; hostname: string; url: string; did: string; scid: string }> => { + if (!isDirectChildKey(args.bucketKey, `siteFiles/${encodeURIComponent(ctx.actor.did)}`)) throw new Error("Invalid site upload key"); configureEd25519Sha512(); const baseDomain = process.env.SITE_BASE_DOMAIN || process.env.WEBVH_DOMAIN || "boop.ad"; @@ -276,7 +281,7 @@ export const createSiteFromUpload = action({ const createdAt = Date.now(); const record = await ctx.runMutation(internal.siteInternals.createSiteRecord, { - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, bucketKey: args.bucketKey, contentType: "text/html; charset=utf-8", sha256, @@ -300,9 +305,10 @@ export const createSiteFromUpload = action({ }, }); -export const createSite = action({ +export const { public: createSite, internal: createSiteInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), html: v.string(), }, handler: async (): Promise => { @@ -310,7 +316,7 @@ export const createSite = action({ }, }); -export const migrateVerifiedCustomDomain = action({ +export const migrateCustomDomainInternal = internalAction({ args: { ownerDid: v.string(), siteId: v.id("sites"), @@ -403,9 +409,10 @@ function normalizeRequestedHostname(input: string): string { return lowered; } -export const requestCustomHostname = action({ +export const { public: requestCustomHostname, internal: requestCustomHostnameInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), siteId: v.id("sites"), hostname: v.string(), }, @@ -414,7 +421,7 @@ export const requestCustomHostname = action({ const record = await ctx.runQuery( internal.siteInternals.getSiteIdentityForUpdate, - { siteId: args.siteId, ownerDid: args.ownerDid } + { siteId: args.siteId, ownerDid: ctx.actor.did } ); if (!record) { throw new Error("Site not found"); @@ -494,7 +501,7 @@ export const pollCustomHostname = internalAction({ }); if (!owner) return; try { - await ctx.runAction(api.siteActions.migrateVerifiedCustomDomain, { + await ctx.runAction(internal.siteActions.migrateCustomDomainInternal, { ownerDid: owner.ownerDid, siteId: row.siteId, hostname: row.hostname, @@ -530,9 +537,10 @@ export const pollPendingCustomHostnames = internalAction({ const MAX_REPLACE_HTML_BYTES = 2 * 1024 * 1024; // 2 MB -export const replaceSiteFile = action({ +export const { public: replaceSiteFile, internal: replaceSiteFileInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), siteId: v.id("sites"), bucketKey: v.string(), }, @@ -540,10 +548,11 @@ export const replaceSiteFile = action({ // Ownership check via the existing query. const owned = await ctx.runQuery(internal.siteInternals.getSiteIdentityForUpdate, { siteId: args.siteId, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, }); if (!owned) throw new Error("Site not found"); + if (!isDirectChildKey(args.bucketKey, `siteFiles/${encodeURIComponent(ctx.actor.did)}`)) throw new Error("Invalid site upload key"); const head = await headObject(args.bucketKey); if (!head.exists) throw new Error("Uploaded file not found."); if (!head.contentLength || head.contentLength === 0) { @@ -578,9 +587,10 @@ export const replaceSiteFile = action({ }, }); -export const retryCustomHostname = action({ +export const { public: retryCustomHostname, internal: retryCustomHostnameInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), hostnameId: v.id("siteHostnames"), }, handler: async (ctx, args): Promise => { @@ -593,7 +603,7 @@ export const retryCustomHostname = action({ const owner = await ctx.runQuery(internal.siteInternals.getSiteOwner, { siteId: row.siteId, }); - if (!owner || owner.ownerDid !== args.ownerDid) { + if (!owner || ![ctx.actor.did, ctx.actor.legacyDid].includes(owner.ownerDid)) { throw new Error("Not authorized"); } @@ -607,3 +617,18 @@ export const retryCustomHostname = action({ }); }, }); + +// Preserve the public name while requiring an authenticated owner and verified DNS. +export const { public: migrateVerifiedCustomDomain, internal: migrateVerifiedCustomDomainAuthenticatedInternal } = actorAction({ + scope: "*", + resources: () => ({}), + args: { siteId: v.id("sites"), hostname: v.string() }, + handler: async (ctx, args): Promise<{ siteId: string; hostname: string; did: string; scid: string }> => { + const hostname = normalizeCustomHostname(args.hostname); + const record = await ctx.runQuery(internal.siteInternals.getSiteIdentityForUpdate, { siteId: args.siteId, ownerDid: ctx.actor.did }); + if (!record) throw new Error("Not authorized"); + const verified = await ctx.runQuery(internal.siteInternals.isVerifiedCustomHostname, { siteId: args.siteId, hostname }); + if (!verified) throw new Error("Custom hostname is not verified"); + return ctx.runAction(internal.siteActions.migrateCustomDomainInternal, { siteId: args.siteId, hostname, ownerDid: ctx.actor.did }); + }, +}); diff --git a/convex/siteAssets.ts b/convex/siteAssets.ts index 672fd6f..9d1db56 100644 --- a/convex/siteAssets.ts +++ b/convex/siteAssets.ts @@ -1,11 +1,7 @@ +import { isResourceOwner } from "./lib/permissions"; +import { actorAction, actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { - action, - internalMutation, - internalQuery, - mutation, - query, -} from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { @@ -59,9 +55,10 @@ function sanitizeFileName(fileName: string): string { return cleaned; } -export const generateSiteAssetUploadUrl = action({ +export const { public: generateSiteAssetUploadUrl, internal: generateSiteAssetUploadUrlInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), siteId: v.id("sites"), fileName: v.string(), contentType: v.string(), @@ -80,7 +77,7 @@ export const generateSiteAssetUploadUrl = action({ const owned = await ctx.runQuery(internal.siteAssets.assertOwnsSite, { siteId: args.siteId, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, }); if (!owned) throw new Error("Site not found"); @@ -94,9 +91,10 @@ export const generateSiteAssetUploadUrl = action({ }, }); -export const addSiteAsset = mutation({ +export const { public: addSiteAsset, internal: addSiteAssetInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), siteId: v.id("sites"), fileName: v.string(), bucketKey: v.string(), @@ -106,11 +104,12 @@ export const addSiteAsset = mutation({ }, handler: async (ctx, args): Promise<{ assetId: Id<"siteAssets"> }> => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) { + if (!site || !await isResourceOwner(ctx, site.ownerDid, ctx.actor.did)) { throw new Error("Site not found"); } const fileName = sanitizeFileName(args.fileName); + if (args.bucketKey !== makeBucketKey("site-assets", args.siteId, fileName)) throw new Error("Invalid site asset key"); const now = Date.now(); const existing = await ctx.db .query("siteAssets") @@ -143,11 +142,13 @@ export const addSiteAsset = mutation({ }, }); -export const listSiteAssets = query({ - args: { ownerDid: v.string(), siteId: v.id("sites") }, +export const { public: listSiteAssets, internal: listSiteAssetsInternal } = actorQuery({ + resources: () => ({}), + scope: "*", + args: { siteId: v.id("sites") }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return []; + if (!site || !await isResourceOwner(ctx, site.ownerDid, ctx.actor.did)) return []; return await ctx.db .query("siteAssets") @@ -157,15 +158,16 @@ export const listSiteAssets = query({ }, }); -export const removeSiteAsset = action({ +export const { public: removeSiteAsset, internal: removeSiteAssetInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), assetId: v.id("siteAssets"), }, handler: async (ctx, args): Promise => { const asset = await ctx.runQuery(internal.siteAssets.getOwnedAsset, { assetId: args.assetId, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, }); if (!asset) throw new Error("Asset not found"); @@ -180,7 +182,7 @@ export const assertOwnsSite = internalQuery({ args: { siteId: v.id("sites"), ownerDid: v.string() }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, args.ownerDid)) return null; return { siteId: site._id }; }, }); @@ -191,7 +193,7 @@ export const getOwnedAsset = internalQuery({ const asset = await ctx.db.get(args.assetId); if (!asset) return null; const site = await ctx.db.get(asset.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, args.ownerDid)) return null; return asset; }, }); diff --git a/convex/siteInternals.ts b/convex/siteInternals.ts index 75c1f56..a88fc0d 100644 --- a/convex/siteInternals.ts +++ b/convex/siteInternals.ts @@ -1,3 +1,4 @@ +import { isResourceOwner } from "./lib/permissions"; import { v } from "convex/values"; import { internalMutation, internalQuery } from "./_generated/server"; @@ -100,7 +101,7 @@ export const getSiteIdentityForUpdate = internalQuery({ }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, args.ownerDid)) return null; const [key, hostnames, didLogEntries] = await Promise.all([ ctx.db @@ -356,3 +357,11 @@ export const replaceSiteFileRecord = internalMutation({ return { fileId: newFileId }; }, }); + +export const isVerifiedCustomHostname = internalQuery({ + args: { siteId: v.id("sites"), hostname: v.string() }, + handler: async (ctx, args) => { + const row = await ctx.db.query("siteHostnames").withIndex("by_hostname", q => q.eq("hostname", args.hostname)).first(); + return !!row && row.siteId === args.siteId && row.kind === "custom" && row.cfStatus === "active" && row.cfSslStatus === "active"; + }, +}); diff --git a/convex/sites.ts b/convex/sites.ts index 7904b11..9c11012 100644 --- a/convex/sites.ts +++ b/convex/sites.ts @@ -1,5 +1,7 @@ +import { isResourceOwner } from "./lib/permissions"; +import { actorAction, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { action, internalQuery, query } from "./_generated/server"; +import { internalQuery, query } from "./_generated/server"; import { internal } from "./_generated/api"; import { bucketKey as makeBucketKey, @@ -11,17 +13,18 @@ const UPLOAD_EXPIRY_SEC = 600; const PREVIEW_EXPIRY_SEC = 300; const SITE_HTML_CONTENT_TYPE = "text/html; charset=utf-8"; -function newSiteBucketKey(): string { - return makeBucketKey("siteFiles", `${crypto.randomUUID()}.html`); +function newSiteBucketKey(ownerDid: string): string { + return makeBucketKey("siteFiles", encodeURIComponent(ownerDid), `${crypto.randomUUID()}.html`); } -export const generateSiteUploadUrl = action({ - args: { ownerDid: v.string() }, +export const { public: generateSiteUploadUrl, internal: generateSiteUploadUrlInternal } = actorAction({ + resources: () => ({}), + scope: "*", + args: {}, handler: async ( - _ctx, - _args + ctx ): Promise<{ uploadUrl: string; bucketKey: string }> => { - const key = newSiteBucketKey(); + const key = newSiteBucketKey(ctx.actor.did); const uploadUrl = await presignPut(key, { contentType: SITE_HTML_CONTENT_TYPE, expiresSec: UPLOAD_EXPIRY_SEC, @@ -30,14 +33,14 @@ export const generateSiteUploadUrl = action({ }, }); -export const listSites = query({ - args: { ownerDid: v.string() }, - handler: async (ctx, args) => { - const sites = await ctx.db - .query("sites") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) - .order("desc") - .collect(); +export const { public: listSites, internal: listSitesInternal } = actorQuery({ + resources: () => ({}), + scope: "*", + args: {}, + handler: async (ctx) => { + const identities = [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did); + const sites = (await Promise.all(identities.map(did => ctx.db.query("sites") + .withIndex("by_owner", q => q.eq("ownerDid", did)).order("desc").collect()))).flat(); return Promise.all( sites.map(async (site) => { @@ -50,14 +53,15 @@ export const listSites = query({ }, }); -export const getSite = query({ +export const { public: getSite, internal: getSiteInternal } = actorQuery({ + resources: () => ({}), + scope: "*", args: { siteId: v.id("sites"), - ownerDid: v.string(), }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, ctx.actor.did)) return null; const [file, key, hostnames, didLogEntries] = await Promise.all([ ctx.db.get(site.fileId), @@ -101,12 +105,14 @@ export const getSite = query({ }, }); -export const getSitePreviewUrl = action({ - args: { siteId: v.id("sites"), ownerDid: v.string() }, +export const { public: getSitePreviewUrl, internal: getSitePreviewUrlInternal } = actorAction({ + resources: () => ({}), + scope: "*", + args: { siteId: v.id("sites") }, handler: async (ctx, args): Promise => { const site = await ctx.runQuery(internal.sites.getSiteFileBucketKey, { siteId: args.siteId, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, }); if (!site?.bucketKey) return null; return await presignGet(site.bucketKey, { expiresSec: PREVIEW_EXPIRY_SEC }); @@ -117,7 +123,7 @@ export const getSiteFileBucketKey = internalQuery({ args: { siteId: v.id("sites"), ownerDid: v.string() }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, args.ownerDid)) return null; const file = await ctx.db.get(site.fileId); if (!file) return null; return { bucketKey: file.bucketKey ?? null }; diff --git a/convex/tags.ts b/convex/tags.ts index 67d73a2..38c9fce 100644 --- a/convex/tags.ts +++ b/convex/tags.ts @@ -1,36 +1,16 @@ +import { canUserEditList } from "./lib/permissions"; +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * Tag management for categorizing items. */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; -import type { Id, Doc } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; + +import type { Doc } from "./_generated/dataModel"; /** * Helper to check if a user can edit a list (owner or editor). */ -async function canUserEditList( - ctx: MutationCtx | QueryCtx, - listId: Id<"lists">, - userDid: string, - legacyDid?: string -): Promise { - const list = await ctx.db.get(listId); - if (!list) return false; - - const dids = [userDid]; - if (legacyDid) dids.push(legacyDid); - - if (dids.includes(list.ownerDid)) return true; - - const pub = await ctx.db - .query("publications") - .withIndex("by_list", (q) => q.eq("listId", listId)) - .first(); - - return pub?.status === "active"; -} // Predefined tag colors export const TAG_COLORS = [ @@ -48,16 +28,16 @@ export const TAG_COLORS = [ /** * Create a new tag for a list. */ -export const createTag = mutation({ +export const { public: createTag, internal: createTagInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), name: v.string(), color: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { - const canEdit = await canUserEditList(ctx, args.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to create tags for this list"); } @@ -76,7 +56,7 @@ export const createTag = mutation({ listId: args.listId, name: args.name, color: args.color, - createdByDid: args.userDid, + createdByDid: ctx.actor.did, createdAt: Date.now(), }); }, @@ -85,19 +65,19 @@ export const createTag = mutation({ /** * Update a tag's name or color. */ -export const updateTag = mutation({ +export const { public: updateTag, internal: updateTagInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { tagId: v.id("tags"), name: v.optional(v.string()), color: v.optional(v.string()), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const tag = await ctx.db.get(args.tagId); if (!tag) throw new Error("Tag not found"); - const canEdit = await canUserEditList(ctx, tag.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, tag.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to update this tag"); } @@ -114,17 +94,17 @@ export const updateTag = mutation({ /** * Delete a tag and remove it from all items. */ -export const deleteTag = mutation({ +export const { public: deleteTag, internal: deleteTagInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { tagId: v.id("tags"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const tag = await ctx.db.get(args.tagId); if (!tag) throw new Error("Tag not found"); - const canEdit = await canUserEditList(ctx, tag.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, tag.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to delete this tag"); } @@ -150,7 +130,9 @@ export const deleteTag = mutation({ /** * Get all tags for a list. */ -export const getListTags = query({ +export const { public: getListTags, internal: getListTagsInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { return await ctx.db @@ -163,18 +145,18 @@ export const getListTags = query({ /** * Add a tag to an item. */ -export const addTagToItem = mutation({ +export const { public: addTagToItem, internal: addTagToItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), tagId: v.id("tags"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to update this item"); } @@ -197,18 +179,18 @@ export const addTagToItem = mutation({ /** * Remove a tag from an item. */ -export const removeTagFromItem = mutation({ +export const { public: removeTagFromItem, internal: removeTagFromItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), tagId: v.id("tags"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to update this item"); } diff --git a/convex/templates.ts b/convex/templates.ts index bd28b3d..f1fd439 100644 --- a/convex/templates.ts +++ b/convex/templates.ts @@ -1,9 +1,10 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * List templates - save and reuse list structures. */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { query } from "./_generated/server"; import { upsertListEnvelope } from "./lib/listEnvelope"; // Id type used in function arguments via v.id() @@ -17,13 +18,14 @@ const templateItemValidator = v.object({ /** * Create a template from an existing list. */ -export const createFromList = mutation({ +export const { public: createFromList, internal: createFromListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), templateName: v.string(), description: v.optional(v.string()), isPublic: v.optional(v.boolean()), - userDid: v.string(), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); @@ -49,7 +51,7 @@ export const createFromList = mutation({ return await ctx.db.insert("listTemplates", { name: args.templateName, description: args.description, - ownerDid: args.userDid, + ownerDid: ctx.actor.did, items: templateItems, createdAt: Date.now(), isPublic: args.isPublic ?? false, @@ -60,19 +62,20 @@ export const createFromList = mutation({ /** * Create a new template manually. */ -export const createTemplate = mutation({ +export const { public: createTemplate, internal: createTemplateInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { name: v.string(), description: v.optional(v.string()), items: v.array(templateItemValidator), isPublic: v.optional(v.boolean()), - userDid: v.string(), }, handler: async (ctx, args) => { return await ctx.db.insert("listTemplates", { name: args.name, description: args.description, - ownerDid: args.userDid, + ownerDid: ctx.actor.did, items: args.items, createdAt: Date.now(), isPublic: args.isPublic ?? false, @@ -83,19 +86,20 @@ export const createTemplate = mutation({ /** * Update a template. */ -export const updateTemplate = mutation({ +export const { public: updateTemplate, internal: updateTemplateInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { templateId: v.id("listTemplates"), name: v.optional(v.string()), description: v.optional(v.string()), items: v.optional(v.array(templateItemValidator)), isPublic: v.optional(v.boolean()), - userDid: v.string(), }, handler: async (ctx, args) => { const template = await ctx.db.get(args.templateId); if (!template) throw new Error("Template not found"); - if (template.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) { throw new Error("Not authorized to update this template"); } @@ -113,15 +117,16 @@ export const updateTemplate = mutation({ /** * Delete a template. */ -export const deleteTemplate = mutation({ +export const { public: deleteTemplate, internal: deleteTemplateInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { templateId: v.id("listTemplates"), - userDid: v.string(), }, handler: async (ctx, args) => { const template = await ctx.db.get(args.templateId); if (!template) throw new Error("Template not found"); - if (template.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) { throw new Error("Not authorized to delete this template"); } @@ -132,13 +137,14 @@ export const deleteTemplate = mutation({ /** * Get user's templates. */ -export const getUserTemplates = query({ - args: { userDid: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("listTemplates") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.userDid)) - .collect(); +export const { public: getUserTemplates, internal: getUserTemplatesInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx) => { + const dids = [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did); + return (await Promise.all(dids.map(did => ctx.db.query("listTemplates") + .withIndex("by_owner", q => q.eq("ownerDid", did)).collect()))).flat(); }, }); @@ -158,21 +164,26 @@ export const getPublicTemplates = query({ /** * Get a single template. */ -export const getTemplate = query({ +export const { public: getTemplate, internal: getTemplateInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", args: { templateId: v.id("listTemplates") }, handler: async (ctx, args) => { - return await ctx.db.get(args.templateId); + const template = await ctx.db.get(args.templateId); + if (template && !template.isPublic && ![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) throw new Error("Not authorized to read this template"); + return template; }, }); /** * Create a new list from a template. */ -export const createListFromTemplate = mutation({ +export const { public: createListFromTemplate, internal: createListFromTemplateInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { templateId: v.id("listTemplates"), listName: v.string(), - userDid: v.string(), // Genesis happens client-side (only the client holds the key), same as lists.createList. assetDid: v.string(), celEnvelope: v.optional(v.string()), @@ -182,7 +193,7 @@ export const createListFromTemplate = mutation({ if (!template) throw new Error("Template not found"); // Check if template is accessible - if (!template.isPublic && template.ownerDid !== args.userDid) { + if (!template.isPublic && ![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) { throw new Error("Not authorized to use this template"); } @@ -192,7 +203,7 @@ export const createListFromTemplate = mutation({ const listId = await ctx.db.insert("lists", { assetDid: args.assetDid, name: args.listName, - ownerDid: args.userDid, + ownerDid: ctx.actor.did, createdAt: now, }); @@ -208,7 +219,7 @@ export const createListFromTemplate = mutation({ description: templateItem.description, priority: templateItem.priority, checked: false, - createdByDid: args.userDid, + createdByDid: ctx.actor.did, createdAt: now, order: templateItem.order, }); diff --git a/convex/userHttp.ts b/convex/userHttp.ts index 33c5acf..04264dd 100644 --- a/convex/userHttp.ts +++ b/convex/userHttp.ts @@ -5,7 +5,7 @@ */ import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import { requireAuth, AuthError, @@ -37,7 +37,7 @@ function didWebvhDomain(did: string): string | null { export const updateUserDID = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); + const auth = await requireAuth(ctx, request); // Parse request body const body = await request.json(); @@ -59,7 +59,7 @@ export const updateUserDID = httpAction(async (ctx, request) => { console.log(`[userHttp] Updating DID for ${auth.email} to ${did}`); // Call upsertUser which handles the DID upgrade logic - await ctx.runMutation(api.auth.upsertUser, { + await ctx.runMutation(internal.auth.upsertUserInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, email: auth.email, did, @@ -103,7 +103,7 @@ export const updateUserDID = httpAction(async (ctx, request) => { */ export const remintUserDID = httpAction(async (ctx, request) => { try { - const auth = await requireAuth(request); + const auth = await requireAuth(ctx, request); const body = await request.json(); const { did: newDid, didLog, path } = body as { did: string; diff --git a/convex/users.ts b/convex/users.ts index 48536fa..c1405ef 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,10 +1,12 @@ +import { canUserViewList } from "./lib/permissions"; +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * User-related queries and mutations. * Provides user statistics and profile information. */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; /** @@ -13,13 +15,16 @@ import type { Id } from "./_generated/dataModel"; * categories, bookmarks, push tokens, referrals, feedback, subscriptions, * and the user record itself. */ -export const deleteUserData = mutation({ +export const { public: deleteUserData, internal: deleteUserDataInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { userId: v.id("users"), }, handler: async (ctx, { userId }) => { const user = await ctx.db.get(userId); if (!user) return; + if (user.turnkeySubOrgId !== ctx.actor.turnkeySubOrgId || ctx.actor.viaApiKey) throw new Error("Not authorized to delete this account"); const dids = [user.did, user.legacyDid].filter(Boolean) as string[]; @@ -194,13 +199,12 @@ export const getUsersByDids = query({ /** * Get aggregate statistics for a user across all their lists. */ -export const getUserStats = query({ - args: { - userDid: v.string(), - legacyDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const { userDid, legacyDid } = args; +export const { public: getUserStats, internal: getUserStatsInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", + args: {}, + handler: async (ctx) => { + const { did: userDid, legacyDid } = ctx.actor; // Get all lists where user is owner const ownedLists = await ctx.db @@ -227,7 +231,10 @@ export const getUserStats = query({ } const ownedListIds = new Set(ownedLists.map((l) => l._id)); - const sharedListIds = bookmarkedListIds.filter((id) => !ownedListIds.has(id)); + const sharedListIds: Id<"lists">[] = []; + for (const id of new Set(bookmarkedListIds)) { + if (!ownedListIds.has(id) && await canUserViewList(ctx, id, userDid, legacyDid)) sharedListIds.push(id); + } const allListIds = [...ownedListIds, ...sharedListIds]; diff --git a/docs/authentication-rollout.md b/docs/authentication-rollout.md new file mode 100644 index 0000000..e3794cb --- /dev/null +++ b/docs/authentication-rollout.md @@ -0,0 +1,80 @@ +# Authenticated operations rollout + +This change is under review in PR #241; Railway has built a PR preview. Production cutover remains unapproved. Do not retire compatibility names or deploy the authorization cutover until the deployed-client inventory below is confirmed. Merely retaining a function name does not make an old unauthenticated client compatible. + +## Client evidence required + +| Client | Evidence in this checkout | Deployed evidence still required | +|---|---|---| +| Browser | package version 1.0.0; this branch adds the session adapter | Railway release commit, cached service-worker versions, and confirmation that authenticated direct calls are in use | +| iOS | marketing version 1.0, build 3 | App Store/TestFlight versions, supported installed versions, and minimum-version/update policy | +| Android | versionName 1.0, versionCode 1 | Play/internal-track releases, supported installed versions, and minimum-version/update policy | +| HTTP/agents | JWT and X-API-Key adapters retained | Active integrations and confirmation whether any bypass HTTP and call Convex directly | + +Local version strings are not evidence of deployed or active versions. No release inventory or production verification was available in this task. The release owner must record confirmation before production deployment. Unauthenticated direct callers must upgrade; there is no safe fallback that accepts an asserted identity. + +## Boundary and compatibility + +- Browser/Capacitor establish the existing signed JWT with `actorSession.establish` before subscribing. Reactive calls carry `authToken`; HTTP calls continue using Bearer/cookie JWT or X-API-Key. HTTP registers valid pre-rollout JWTs automatically. +- `accessSessions` stores only token hashes, subjects, expiry and revocation. Scheduled expiry, a bounded indexed cleanup sweep, and logout change database state, invalidating private query caches. A revoked token cannot establish another session. Existing verified account records and legacy DID associations remain intact. +- Every protected operation resolves current/legacy identity from server records. Each operation declares its resources and required scope. Internal HTTP registrations use the same authenticated boundary and recheck key revocation in the data transaction. +- Public operation names remain, with optional legacy assertion fields accepted only when matching the authenticated account. These fields confer no authority. Shared business handlers receive authenticated context and declared business arguments only. +- OTP/session storage helpers retain rejecting public compatibility names; the verified login HTTP flow uses internal registrations. The public account lookup is limited to the signed-in account. New identity links cannot be established by asserting another current or legacy DID. +- Public list/resource reads require an active publication. Published lists remain readable without login; editing requires a logged-in human or an appropriately scoped agent. Bookmarks stop exposing a list when it is unpublished. +- Site uploads now bind pending object keys to the authenticated account. Finish or restart any pre-cutover pending site uploads; existing stored site files remain readable through authenticated ownership checks. Attachment and site upload references reject path traversal. +- The generated browser operation registry uses typed Convex references. After adding/renaming an authenticated operation, run `node scripts/generate-auth-client.mjs`; verify with `--check`. + +## Coordinated rollout + +1. Confirm the inventory above, including old mobile builds and stale browser tabs. Choose the supported client floor and user update/reauthentication messaging. +2. In staging, deploy the schema/functions together, then the matching browser/native clients. Verify scheduling is operational for session expiry. A frontend deployed against the previous backend will not find `actorSession.establish`; an old frontend against the secured backend cannot make unauthenticated direct calls. Coordinate the cutover or enforce an update window. +3. Validate OTP login/new-account DID setup, session restore, logout/expired-session invalidation, private list reads, shared edit/unpublish, attachment upload/read/removal, publishing and migrated accounts on web/iOS/Android. Test old valid JWT HTTP clients and current/legacy-owner API keys, then revoke keys and test insufficient scopes. Use designated test accounts and lists. +4. Approve the production cutover only after those checks and version confirmation. Remove old public names/assertion validators only in a later release after usage and supported versions demonstrate they are unused. + +## Enforced production gate + +`release/authentication-cutover.json` is the durable approval record. It is intentionally pending: fill in the deployed/supported versions and evidence for browser, iOS, Android and integrations, link the staging results, and have the named release owner record approval and its date in a reviewed commit. Evidence must identify actual releases and exercised behavior; source version strings alone are insufficient. If a platform has no supported deployments, record the inventory evidence establishing that fact. + +The Convex production workflow runs `scripts/check-authentication-cutover.mjs` before dependencies or deployment secrets are used. Railway's configured build runs the same check before the production frontend build. Missing or unknown environment names require approval; only `boop-pr-`, `staging`, and `development` bypass the production approval check. Railway provides the environment name during builds ([reference variables](https://docs.railway.com/variables/reference)). Local builds remain available for verification. + +The record is an auditable release attestation, not proof of who approved it. On 2026-09-09, GitHub reported `main` as unprotected, no repository rulesets, and no `CODEOWNERS` file. Required owner review is therefore not enforced. The repository owner must configure required review/protected release settings before treating this as an owner-only approval mechanism; this task did not change live repository policy. + +These guards cover the repository's configured deployment paths; operators must apply the same check before any manual production deploy. + +Until the record is approved, merging this PR intentionally blocks every subsequent production Convex deployment, including unrelated hotfixes, and every production Railway build. A later full deployment includes this authentication change even when its latest commit is unrelated, so filtering by the latest changed files would bypass the cutover gate. Complete the inventory and staging approval before merging; if an urgent unrelated hotfix is needed first, ship it before this PR. Use staging/PR previews to collect the required evidence. No production settings or live client inventory were changed by this review fix. + +## Compatibility retirement follow-up: AUTH-COMPAT-RETIREMENT + +Owner: Brian (`brianorwhatever`). Review date: 2026-09-16. Status: pending deployed-client inventory. + +Retain the old operation names and matching-identity argument validators during this cutover. Retire them in a separate PR only when the owner has confirmed the supported-version inventory and recorded 14 consecutive days with no compatibility calls or legacy assertion fields from supported clients. Record the observation window and evidence alongside the inventory; absent telemetry means the retirement condition is unmet. This follow-up covers rejecting OTP helper names and the self-authenticated `upsertUser` alias as well as the assertion validators. + +The unused client `startOtp(..., legacyDid)` parameter has been removed. Existing server-owned legacy associations continue to resolve; new account linking requires a separately verified ownership flow and cannot use an asserted DID. + +## Post-Deploy Monitoring & Validation + +Release owner: Brian (`brianorwhatever`, PR author). Observe continuously for the first 30 minutes and review again at 24 hours. + +- Convex logs: search `Authentication required`, `Invalid or expired token`, `Invalid API key`, `Missing scope`, `Not authorized`, and unknown-function/argument-validation errors. Check `actorSession.establish`, `actorSession.expire` and scheduled-function failures. +- Watch login success, private query failures, offline sync retries and HTTP 401/403 rates, broken down by release/platform where available. Healthy behavior: test-account login/restore works; revoked/forged requests fail; no anonymous private reads; expiry removes access; supported releases produce no new unknown-function errors. +- Failure trigger: legitimate supported clients cannot login/read/write, scheduling fails, or any private bypass succeeds. Pause rollout, keep access restricted, and repair/update clients or provide a maintenance response. Do not restore DID-only authorization as an automatic rollback. + +## Evidence limits + +Regression tests exercise real signed JWT verification, database-owned identities, handler business behavior and HTTP-to-internal dispatch against in-memory fixtures. React provider tests exercise restore acceptance, rejection cleanup, and logout/login serialization; adapter tests cover token changes and long-session expiry. Convex code generation/type analysis, TypeScript and the application build validate integration statically. The repository lint baseline and any new diagnostics are checked separately. These do not prove live OTP delivery, WebSocket cache invalidation timing, bucket upload completion or deployed native behavior. Those checks remain staging/production release gates above. + +## Local validation + +- `node --test scripts/*.test.mjs`: 228 passed. +- `bun test`: 252 passed, including browser component and authentication lifecycle tests. +- `npx convex codegen --typecheck enable`, `npx tsc -b`, and `npx vite build`: passed. Code generation performs deployment analysis without completing a deployment. The build retains existing large-chunk warnings. +- `npm run lint` does not pass: it includes existing source errors, nested checkouts and generated test bundles. A comparison restricted to `src` and `convex` reports 49 errors versus 51 on the pre-change baseline, with no new error diagnostics. This change does not claim a clean repository lint baseline. +- The completed `ce-code-review` run (`20260908-223043-d36023ea`) reports no actionable findings after fixes. Ten local review passes and independent validation completed; the external cross-model pass timed out, so no cross-model corroboration is claimed. + +The originally reported anonymous handler reproduction is now a regression test. Actual React provider tests additionally cover expiry of a mounted 30-day session, logout/login ordering and credential cleanup; server tests cover idempotent establishment and premature expiry callbacks. + +PR preparation replayed the authorization change on `024144f` from `main`, preserving the canonical account-selection and duplicate-email signup protections from PR #235. The combined login and authorization tests pass; an independent conflict review found no issues. + +Review follow-up adds regression coverage for bounded restore/OTP verification, authenticated shared-list writes and visible failures, bookmark removal after unpublishing, expiry sweep limits and revoked tombstones, production-serialized access errors preserving offline edits, and deployment approval enforcement. The five review findings are addressed in code; deployed client evidence and live staging checks remain pending in the release record. + +The follow-up review distinguishes session failures from resource denials. Missing and inaccessible resources have the same structured `FORBIDDEN` response (single-list reads return `null` for either). Offline resource denials do not stop later edits; each denied edit uses the existing five-retry budget, then is discarded with a warning. Session failures preserve all remaining edits and retry counts. Regression tests cover permanent denial, restored access, production RPC response equality, and migrated bookmark-ID enumeration. diff --git a/railway.json b/railway.json index 493e5de..ff15eb2 100644 --- a/railway.json +++ b/railway.json @@ -1,7 +1,8 @@ { "$schema": "https://railway.app/railway.schema.json", "build": { - "builder": "RAILPACK" + "builder": "RAILPACK", + "buildCommand": "node scripts/check-authentication-cutover.mjs --railway && npm run build" }, "deploy": { "startCommand": "bun server.ts", diff --git a/release/authentication-cutover.json b/release/authentication-cutover.json new file mode 100644 index 0000000..4c8a084 --- /dev/null +++ b/release/authentication-cutover.json @@ -0,0 +1,12 @@ +{ + "owner": "brianorwhatever", + "approvedBy": null, + "approvedAt": null, + "stagingEvidence": null, + "clients": { + "browser": { "supportedVersions": null, "evidence": null }, + "ios": { "supportedVersions": null, "evidence": null }, + "android": { "supportedVersions": null, "evidence": null }, + "integrations": { "supportedVersions": null, "evidence": null } + } +} diff --git a/scripts/auth-boundary.test.mjs b/scripts/auth-boundary.test.mjs new file mode 100644 index 0000000..15ed9ef --- /dev/null +++ b/scripts/auth-boundary.test.mjs @@ -0,0 +1,410 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { SignJWT } from 'jose'; +import { getFunctionName } from 'convex/server'; +import { ConvexError, convexToJson, jsonToConvex } from 'convex/values'; +import { createHash } from 'node:crypto'; + +process.env.JWT_SECRET = 'boundary-test-secret-not-a-deployed-credential'; +const names = ['items','lists','publication','attachments','activity','assignees','presence','comments','tags','itemCategories','auth','authSessions','actorSession','didResources','itemsHttp','listsHttp','agentReadHttp','users','bitcoinAnchors','siteActions','siteInternals','sites','siteAssets','didCreation','billing','referrals','feedback','notificationActions','categories','templates','notifications','lib/httpResponses']; +await build({ entryPoints: names.map(n => `convex/${n}.ts`), outdir: 'tmp/auth-boundary-test', bundle: true, platform: 'node', format: 'esm', outExtension: { '.js': '.mjs' }, external: ['convex/*','@originals/*','@turnkey/*','didwebvh-ts','@noble/*'] }); +const modules = Object.fromEntries(await Promise.all(names.map(async n => [n, await import(pathToFileURL(`${process.cwd()}/tmp/auth-boundary-test/${n}.mjs`))]))); +const call = (module, name, ctx, args) => modules[module][name]._handler(ctx, args); +async function token(subject = 'owner', options = {}) { + return new SignJWT({ email: `${subject}@example.test` }).setProtectedHeader({ alg: 'HS256' }) + .setSubject(subject).setIssuer('originals-auth').setAudience('originals-api') + .setExpirationTime(options.exp ?? '1h').sign(new TextEncoder().encode(options.secret ?? process.env.JWT_SECRET)); +} +const ownerToken = await token(), strangerToken = await token('stranger'); +function fixture({ published = false, migrated = false, keyScopes = ['lists:read','items:read','items:write'], revokedAt } = {}) { + const rows = { + users: [{ _id: 'U1', turnkeySubOrgId: 'owner', did: 'did:owner', legacyDid: migrated ? 'did:legacy' : undefined, email: 'owner@example.test' }, { _id: 'U2', turnkeySubOrgId: 'stranger', did: 'did:stranger', email: 'stranger@example.test' }], + lists: [{ _id: 'L1', ownerDid: migrated ? 'did:legacy' : 'did:owner', name: 'Private', createdAt: 1, assetDid: 'did:list' }, { _id: 'L2', ownerDid: 'did:other', name: 'Other private', createdAt: 2 }], + items: [{ _id: 'I1', listId: 'L1', name: 'Secret', checked: false, createdAt: 1, createdByDid: 'did:owner', vcProofs: [], priority: 'high' }, { _id: 'I2', listId: 'L2', name: 'Other secret', checked: false, createdAt: 1, vcProofs: [] }], + publications: published ? [{ _id: 'P1', listId: 'L1', webvhDid: 'did:webvh:public', status: 'active' }] : [], + agentApiKeys: [{ _id: 'K1', ownerDid: migrated ? 'did:legacy' : 'did:owner', keyHash: createHash('sha256').update('valid-key').digest('hex'), scopes: keyScopes, revokedAt }], + accessSessions: [ownerToken,strangerToken].map((t,i) => ({_id:`S${i}`,tokenHash:createHash("sha256").update(t).digest("hex"),subject:i ? "stranger" : "owner",expiresAt:Date.now()+3600000})), + bookmarks: [], listEnvelopes: [], subscriptions: [], referrals: [], categories: [], bitcoinAnchors: [], + }; + let next = 1; + const find = id => Object.values(rows).flat().find(row => row._id === id) ?? null; + const ctx = { rows, db: { + get: async id => find(id), + patch: async (id, patch) => { const row = find(id); assert.ok(row, `missing ${id}`); Object.assign(row, patch); }, + insert: async (table, values) => { const row = { ...values, _id: `new${next++}` }; (rows[table] ??= []).push(row); return row._id; }, + delete: async id => { for (const table of Object.values(rows)) { const at = table.findIndex(row => row._id === id); if(at >= 0) table.splice(at,1); } }, + query: table => { + let predicates = []; + const q = { + withIndex: (_index, fn) => { const b = { eq: (key,value) => { predicates.push(row => row[key] === value); return b; }, lte: (key,value) => { predicates.push(row => row[key] <= value); return b; } }; fn?.(b); return q; }, + order: () => q, + filter: fn => { const b = { field: key => row => row[key], eq: (left,right) => row => (typeof left === 'function' ? left(row) : left) === right, or: (...ps) => row => ps.some(p => p(row)), and: (...ps) => row => ps.every(p => p(row)) }; predicates.push(fn(b)); return q; }, + collect: async () => (rows[table] ?? []).filter(row => predicates.every(p => p(row))), + take: async count => (await q.collect()).slice(0,count), + first: async () => (await q.collect())[0] ?? null, + unique: async () => (await q.collect())[0] ?? null, + }; return q; + }, + }, scheduler: { runAfter: async () => {}, runAt: async () => {} } }; + ctx.runQuery = ctx.runMutation = async (ref,args) => { const [mod,fn] = getFunctionName(ref).split(':'); return call(mod,fn,ctx,args); }; + return ctx; +} + +test('asserted owner without credentials cannot complete an item', async () => { + const ctx = fixture(); + await assert.rejects(() => call('items','checkItem',ctx,{ itemId:'I1', checkedByDid:'did:owner', checkedAt:10 }), /auth|token/i); + assert.equal(ctx.rows.items[0].checked,false); +}); +test('forged current, legacy, and wallet identities never confer access', async () => { + for (const field of ['checkedByDid','legacyDid','walletDid']) { + const ctx = fixture({ migrated:true }); + await assert.rejects(() => call('items','checkItem',ctx,{ authToken:strangerToken, itemId:'I1', checkedAt:10, [field]: field === 'checkedByDid' ? 'did:owner' : 'did:legacy' }), /assertion|authorized/i); + assert.equal(ctx.rows.items[0].checked,false); + } +}); +test('anonymous and unrelated authenticated callers cannot read private list surfaces', async () => { + const reads = [ ['lists','getList',{listId:'L1'}], ['lists','getListEnvelope',{listId:'L1'}], ['lists','getLegacyListIds',{listIds:['L1']}], ['items','getListItems',{listId:'L1'}], ['items','getItemForSync',{itemId:'I1'}], ['items','getSubItems',{parentId:'I1'}], ['attachments','getAttachmentUrls',{itemId:'I1'}], ['activity','getListActivity',{listId:'L1'}], ['presence','getListPresence',{listId:'L1'}], ['assignees','getItemAssignees',{itemId:'I1'}], ['comments','getCommentCount',{itemId:'I1'}], ['bitcoinAnchors','getListDataForAnchor',{listId:'L1'}] ]; + for (const [mod,fn,args] of reads) for (const authToken of [undefined,strangerToken]) { + if(fn==='getList' && authToken){assert.equal(await call(mod,fn,fixture(),{...args,authToken}),null);continue;} + await assert.rejects(() => call(mod,fn,fixture(),{...args,authToken}), /auth|token/i, `${mod}.${fn}`); + } +}); +test('invalid signatures and expired sessions are rejected', async () => { + for (const authToken of [await token('owner',{secret:'wrong'}), await token('owner',{exp:1})]) + await assert.rejects(() => call('lists','getUserLists',fixture(),{authToken}), /token/i); +}); +test('authenticated browser and agent editing use server attribution, including migrated accounts', async () => { + for (const migrated of [false,true]) for (const credentials of [{authToken:ownerToken},{apiKey:'valid-key'}]) { + const ctx=fixture({migrated}); + const lists=await call('lists','getUserLists',ctx,credentials); assert.equal(lists.length,1); + await call('items','checkItem',ctx,{...credentials,itemId:'I1',checkedAt:10}); + assert.equal(ctx.rows.items[0].checked,true); assert.equal(ctx.rows.items[0].checkedByDid,'did:owner'); + await call('items','uncheckItem',ctx,{...credentials,itemId:'I1'}); assert.equal(ctx.rows.items[0].checked,false); + } +}); +test('revoked and unknown keys fail even alongside a valid JWT', async () => { + for(const [apiKey,revokedAt] of [['valid-key',0],['unknown',undefined]]) + await assert.rejects(() => call('items','checkItem',fixture({revokedAt}),{apiKey,authToken:ownerToken,itemId:'I1',checkedAt:10}),/API key/); +}); +test('scopes apply equally to direct and internal operations, and revocation is rechecked', async () => { + const ctx=fixture({keyScopes:['lists:read']}); + await call('lists','getUserListsInternal',ctx,{apiKey:'valid-key'}); + for(const fn of ['checkItem','checkItemInternal']) await assert.rejects(() => call('items',fn,ctx,{apiKey:'valid-key',itemId:'I1',checkedAt:10}),/Missing scope/); + await assert.rejects(() => call('items','getListItems',ctx,{apiKey:'valid-key',listId:'L1'}),/Missing scope/); + ctx.rows.agentApiKeys[0].revokedAt=Date.now(); + await assert.rejects(() => call('lists','getUserListsInternal',ctx,{apiKey:'valid-key'}),/API key/); +}); +test('published reads remain public, shared editing requires login, and unpublishing removes access', async () => { + const ctx=fixture({published:true}); + assert.ok(await call('publication','getPublicList',ctx,{webvhDid:'did:webvh:public'})); + await assert.rejects(() => call('didResources','checkSharedItem',ctx,{listId:'L1',itemId:'I1'}),/Authentication/); + await call('items','checkItem',ctx,{authToken:strangerToken,itemId:'I1',checkedAt:10}); + assert.equal(ctx.rows.items[0].checkedByDid,'did:stranger'); + await call('publication','bookmarkList',ctx,{authToken:strangerToken,listId:'L1'}); + await call('publication','unpublishList',ctx,{authToken:ownerToken,listId:'L1'}); + assert.equal(await call('publication','getPublicList',ctx,{webvhDid:'did:webvh:public'}),null); + assert.deepEqual(await call('lists','getUserLists',ctx,{authToken:strangerToken}),[]); + assert.deepEqual(await call('items','getHighPriorityItems',ctx,{authToken:strangerToken}),[]); + await assert.rejects(() => call('items','uncheckItem',ctx,{authToken:strangerToken,itemId:'I1'}),/Resource unavailable/); +}); +test('private resource aliases cannot bypass publication protection', async () => { + const ctx=fixture(); + assert.equal(await call('didResources','getPublicList',ctx,{listId:'L1',ownerDid:'did:owner'}),null); + assert.equal(await call('didResources','getListById',ctx,{listId:'L1'}),null); + assert.deepEqual(await call('didResources','getPublicListItems',ctx,{listId:'L1'}),[]); +}); +test('bookmarks remain actor-owned across unpublishing, migration, and republication', async () => { + const ctx=fixture({published:true}); + ctx.rows.users[1].legacyDid='did:old-stranger'; + ctx.rows.bookmarks=[ + {_id:'owner-bookmark',userDid:'did:owner',listId:'L1'}, + {_id:'legacy-bookmark',userDid:'did:old-stranger',listId:'L1'}, + ]; + assert.equal(await call('publication','isBookmarked',ctx,{authToken:strangerToken,listId:'L1'}),true); + assert.equal((await call('publication','getPublicationStatus',ctx,{authToken:strangerToken,listId:'L1'})).status,'active'); + await call('publication','unpublishList',ctx,{authToken:ownerToken,listId:'L1'}); + assert.equal(await call('publication','isBookmarked',ctx,{authToken:strangerToken,listId:'L1'}),true); + assert.equal(await call('publication','getPublicationStatus',ctx,{authToken:strangerToken,listId:'L1'}),null); + assert.equal(await call('publication','getPublicationStatus',ctx,{authToken:strangerToken,listId:'missing'}),null); + assert.equal((await call('publication','getPublicationStatus',ctx,{authToken:ownerToken,listId:'L1'})).status,'unpublished'); + await assert.rejects(()=>call('items','getListItems',ctx,{authToken:strangerToken,listId:'L1'}),/Resource unavailable/); + + ctx.rows.bookmarks.push({_id:'current-bookmark',userDid:'did:stranger',listId:'L1'}); + await call('publication','unbookmarkList',ctx,{authToken:strangerToken,listId:'L1'}); + assert.deepEqual(ctx.rows.bookmarks.map(b=>b._id),['owner-bookmark']); + assert.equal(await call('publication','isBookmarked',ctx,{authToken:strangerToken,listId:'L1'}),false); + await call('publication','publishList',ctx,{authToken:ownerToken,listId:'L1',webvhDid:'did:webvh:public'}); + assert.equal(await call('publication','isBookmarked',ctx,{authToken:strangerToken,listId:'L1'}),false); + assert.deepEqual(await call('lists','getUserLists',ctx,{authToken:strangerToken}),[]); + assert.ok(await call('publication','getPublicList',ctx,{webvhDid:'did:webvh:public'})); + await call('publication','bookmarkList',ctx,{authToken:strangerToken,listId:'L1'}); + assert.equal(await call('publication','isBookmarked',ctx,{authToken:strangerToken,listId:'L1'}),true); + + ctx.rows.lists=[]; + assert.equal(await call('publication','getPublicationStatus',ctx,{authToken:ownerToken,listId:'L1'}),null); + await call('publication','unbookmarkList',ctx,{authToken:strangerToken,listId:'L1'}); + assert.deepEqual(ctx.rows.bookmarks.map(b=>b._id),['owner-bookmark']); +}); +test('bookmark state and publication status still require authentication and scopes', async () => { + for (const fn of ['isBookmarked','unbookmarkList','getPublicationStatus']) { + await assert.rejects(()=>call('publication',fn,fixture(),{listId:'L1'}),/Authentication/); + await assert.rejects(()=>call('publication',fn,fixture({keyScopes:[]}),{apiKey:'valid-key',listId:'L1'}),/Missing scope/); + await assert.rejects(()=>call('publication',fn,fixture(),{authToken:strangerToken,userDid:'did:owner',listId:'L1'}),/assertion/); + } +}); +test('migrated owners can publish and edit categories, strangers cannot publish', async () => { + const ctx=fixture({migrated:true}); + await assert.rejects(() => call('publication','publishList',ctx,{authToken:strangerToken,listId:'L1',webvhDid:'did:pub',publisherDid:'did:legacy'}),/assertion/); + await call('itemCategories','addListCategory',ctx,{authToken:ownerToken,listId:'L1',name:'Travel',emoji:'🧳'}); + await call('publication','publishList',ctx,{authToken:ownerToken,listId:'L1',webvhDid:'did:pub'}); + assert.equal(ctx.rows.publications[0].publishedByDid,'did:owner'); +}); +test('attachment registration is authorized and bound to the target item', async () => { + const ctx=fixture(); const args={itemId:'I1',bucketKey:'attachments/I1/file.png',contentType:'image/png',size:10,sha256:'abc'}; + await assert.rejects(() => call('attachments','addAttachment',ctx,{...args,userDid:'did:owner'}),/Authentication/); + await assert.rejects(() => call('attachments','addAttachment',ctx,{...args,authToken:ownerToken,bucketKey:'attachments/I2/file.png'}),/key/); + await call('attachments','addAttachment',ctx,{...args,authToken:ownerToken}); assert.equal(ctx.rows.items[0].attachments.length,1); +}); +test('batch operations refuse a mixed unauthorized list before any write', async () => { + const ctx=fixture(); + await assert.rejects(() => call('items','batchCheckItems',ctx,{authToken:ownerToken,itemIds:['I1','I2'],checkedAt:10}),/Resource unavailable/); + assert.equal(ctx.rows.items[0].checked,false); +}); +test('account and OTP storage cannot be used to forge a login or legacy link', async () => { + const ctx=fixture(); + await assert.rejects(() => call('auth','upsertUser',ctx,{turnkeySubOrgId:'attacker',email:'attacker@example.test',legacyDid:'did:owner'}),/Authentication/); + await assert.rejects(() => call('auth','upsertUser',ctx,{authToken:strangerToken,turnkeySubOrgId:'stranger',email:'stranger@example.test',legacyDid:'did:owner'}),/Resource unavailable/); + await assert.rejects(() => call('authSessions','markSessionVerified',ctx,{sessionId:'stolen',subOrgId:'owner'}),/Authentication/); + await assert.rejects(() => call('auth','getUserByTurnkeyId',ctx,{authToken:strangerToken,turnkeySubOrgId:'owner'}),/Resource unavailable/); + assert.equal((await call('auth','getUserByTurnkeyId',ctx,{authToken:ownerToken,turnkeySubOrgId:'owner'})).did,'did:owner'); +}); +test('HTTP writes and reads execute the same authenticated internal boundary', async () => { + for(const credential of [{Authorization:`Bearer ${ownerToken}`},{'X-API-Key':'valid-key'}]) { + const ctx=fixture({migrated:true}); + const response=await modules.itemsHttp.checkItem._handler(ctx,new Request('https://test/api/items/check',{method:'POST',headers:{'Content-Type':'application/json',...credential},body:JSON.stringify({itemId:'I1',checkedByDid:'did:forged'})})); + assert.equal(response.status,200);assert.equal(ctx.rows.items[0].checkedByDid,'did:owner'); + const read=await modules.agentReadHttp.getLists._handler(ctx,new Request('https://test/api/v1/lists',{headers:credential}));assert.equal(read.status,200); + } + for(const keyScopes of [[],['items:read']]) { + const ctx=fixture({keyScopes}); const response=await modules.itemsHttp.checkItem._handler(ctx,new Request('https://test/api/items/check',{method:'POST',headers:{'X-API-Key':'valid-key'},body:JSON.stringify({itemId:'I1'})})); + assert.equal(response.status,403);assert.equal(ctx.rows.items[0].checked,false); + } +}); + +test('session logout and scheduled expiry invalidate private reads and prevent re-establishment', async () => { + const ctx=fixture(); + await call('lists','getList',ctx,{authToken:ownerToken,listId:'L1'}); + await call('actorSession','revoke',ctx,{authToken:ownerToken}); + await assert.rejects(() => call('lists','getList',ctx,{authToken:ownerToken,listId:'L1'}),/Authentication/); + await assert.rejects(() => call('actorSession','establish',ctx,{authToken:ownerToken}),/token/); + ctx.rows.accessSessions[1].expiresAt=1; + await call('actorSession','expire',ctx,{id:'S1'}); + assert.equal(ctx.rows.accessSessions.some(s=>s._id==='S1'),false); + await assert.rejects(() => call('lists','getUserLists',ctx,{authToken:strangerToken}),/Authentication/); +}); +test('existing valid JWT clients can establish a cache-aware session without asserting identity', async () => { + const ctx=fixture();ctx.rows.accessSessions=[]; + await assert.rejects(() => call('lists','getUserLists',ctx,{authToken:ownerToken}),/restore your session/); + await call('actorSession','establish',ctx,{authToken:ownerToken}); + assert.equal((await call('lists','getUserLists',ctx,{authToken:ownerToken})).length,1); + assert.equal(ctx.rows.accessSessions[0].subject,'owner'); +}); + +test('attachment capabilities reject forged callers and cross-item removal before storage access', async () => { + const ctx=fixture();ctx.rows.items[0].attachments=[{key:'attachments/I1/file.png',contentType:'image/png',size:10,sha256:'abc'}]; + const actionCtx={runQuery:ctx.runQuery,runMutation:ctx.runMutation}; + await assert.rejects(() => call('attachments','generateUploadUrl',actionCtx,{itemId:'I1',userDid:'did:owner',contentType:'image/png',byteLength:10}),/Authentication/); + await assert.rejects(() => call('attachments','generateUploadUrl',actionCtx,{authToken:strangerToken,itemId:'I1',contentType:'image/png',byteLength:10}),/Resource unavailable/); + await assert.rejects(() => call('attachments','removeAttachment',actionCtx,{authToken:ownerToken,itemId:'I1',bucketKey:'attachments/I2/file.png'}),/Attachment not found/); +}); +test('agent combined read preserves indistinguishable missing/private responses', async () => { + const ctx=fixture(); + for (const listId of ['L2','missing']) { + const response=await modules.agentReadHttp.getListWithItems._handler(ctx,new Request(`https://test/api/v1/lists/items?listId=${listId}`,{headers:{'X-API-Key':'valid-key'}})); + assert.equal(response.status,404); + } +}); + +test('missing parents and deleted lists never authorize retained private data', async()=>{ + const ctx=fixture();ctx.rows.items.push({_id:'child',listId:'L1',parentId:'I1',name:'Private child'}); + await call('items','removeItem',ctx,{authToken:ownerToken,itemId:'I1'}); + await assert.rejects(()=>call('items','getSubItems',ctx,{authToken:strangerToken,parentId:'I1'}),/Resource unavailable/); + ctx.rows.activities=[{_id:'A1',listId:'L1',metadata:{note:'Private note'}}]; + ctx.rows.bitcoinAnchors=[{_id:'B1',listId:'L1',status:'pending',stateSnapshot:'Private snapshot'}]; + await call('lists','deleteList',ctx,{authToken:ownerToken,listId:'L1'}); + await assert.rejects(()=>call('activity','getListActivity',ctx,{authToken:strangerToken,listId:'L1'}),/Resource unavailable/); + assert.deepEqual(await call('bitcoinAnchors','getPendingAnchors',ctx,{authToken:strangerToken}),[]); + assert.equal(await call('lists','getList',ctx,{authToken:ownerToken,listId:'L1'}),null); + await assert.rejects(()=>call('items','getItemForSync',ctx,{authToken:ownerToken,itemId:'I1'}),/Resource unavailable/); +}); + +test('site publication and signing actions reject forged owner and signing identities', async()=>{ + const ctx=fixture();const actionCtx={runQuery:ctx.runQuery,runMutation:ctx.runMutation}; + ctx.rows.sites=[{_id:'site1',ownerDid:'did:owner'}]; + for (const name of ['replaceSiteFile','migrateVerifiedCustomDomain','requestCustomHostname']) { + await assert.rejects(()=>call('siteActions',name,actionCtx,{authToken:strangerToken,ownerDid:'did:owner',siteId:'site1',bucketKey:'known',hostname:'example.test'}),/assertion/); + } + await assert.rejects(()=>call('siteActions','replaceSiteFile',actionCtx,{authToken:strangerToken,siteId:'site1',bucketKey:'known'}),/not found/i); + await assert.rejects(()=>call('siteActions','createSiteFromUpload',actionCtx,{authToken:ownerToken,bucketKey:'attachments/I2/private.html'}),/upload key/i); + await assert.rejects(()=>call('didCreation','createListDID',actionCtx,{authToken:strangerToken,subOrgId:'owner',domain:'example.test',slug:'list'}),/signing identity/i); +}); +test('account-id callers cannot bypass the authenticated boundary through billing, referrals or feedback', async()=>{ + for(const [mod,fn] of [['billing','getUserPlan'],['referrals','getReferralCode'],['feedback','submit']]) { + await assert.rejects(()=>call(mod,fn,fixture(),{authToken:strangerToken,userId:'U1',body:'Forged',category:'bug'}),/Resource unavailable/); + await assert.rejects(()=>call(mod,fn,fixture(),{userId:'U1',body:'Forged',category:'bug'}),/Authentication/); + } +}); + +test('upload references cannot traverse from an authorized prefix into private objects', async()=>{ + const ctx=fixture(), actionCtx={runQuery:ctx.runQuery,runMutation:ctx.runMutation}; + for (const suffix of ['../../attachments/I2/private.html','../victim.html','x/../../../private.html']) { + await assert.rejects(()=>call('siteActions','createSiteFromUpload',actionCtx,{authToken:ownerToken,bucketKey:`siteFiles/${encodeURIComponent('did:owner')}/${suffix}`}),/upload key/); + await assert.rejects(()=>call('attachments','addAttachment',ctx,{authToken:ownerToken,itemId:'I1',bucketKey:`attachments/I1/${suffix}`,contentType:'image/png',size:10,sha256:'abc'}),/attachment key/); + } + assert.equal(ctx.rows.items[0].attachments,undefined); +}); +test('referral redemption binds the referee account to the authenticated user',async()=>{ + await assert.rejects(()=>call('referrals','redeemReferral',fixture(),{authToken:strangerToken,refereeUserId:'U1',code:'known'}),/Resource unavailable/); + assert.deepEqual(await call('referrals','redeemReferral',fixture(),{authToken:ownerToken,refereeUserId:'U1',code:'invalid'}),{success:false,reason:'invalid_code'}); +}); + +test('migrated accounts retain their saved categories, templates, sites, and push subscriptions', async()=>{ + const ctx=fixture({migrated:true}); + ctx.rows.categories=[{_id:'C1',ownerDid:'did:legacy',name:'Saved',order:1}]; + ctx.rows.listTemplates=[{_id:'T1',ownerDid:'did:legacy',name:'Saved',items:[]}]; + ctx.rows.sites=[{_id:'site1',ownerDid:'did:legacy'}]; + ctx.rows.pushTokens=[{_id:'push1',userDid:'did:legacy',token:'old-device'}]; + for(const [mod,fn] of [['categories','getUserCategories'],['templates','getUserTemplates'],['sites','listSites'],['notifications','getUserSubscriptions']]) { + assert.equal((await call(mod,fn,ctx,{authToken:ownerToken})).length,1); + assert.equal((await call(mod,fn,ctx,{authToken:strangerToken})).length,0); + } + await call('categories','renameCategory',ctx,{authToken:ownerToken,categoryId:'C1',name:'Renamed'}); + assert.equal(ctx.rows.categories[0].name,'Renamed'); + await call('notifications','unregisterPushToken',ctx,{authToken:ownerToken,token:'old-device'}); + assert.equal(ctx.rows.pushTokens.length,0); +}); + +test('session establishment is idempotent and premature or missing expiry callbacks are harmless',async()=>{ + const ctx=fixture(),count=ctx.rows.accessSessions.length; + await call('actorSession','establish',ctx,{authToken:ownerToken}); + await call('actorSession','establish',ctx,{authToken:ownerToken}); + assert.equal(ctx.rows.accessSessions.length,count); + await call('actorSession','expire',ctx,{id:'S0'}); + await call('actorSession','expire',ctx,{id:'missing'}); + assert.equal(ctx.rows.accessSessions.length,count); + assert.equal((await call('lists','getUserLists',ctx,{authToken:ownerToken})).length,1); +}); +test('session cleanup is bounded and preserves live sessions and unexpired revocation tombstones',async()=>{ + const ctx=fixture(); + await call('actorSession','revoke',ctx,{authToken:strangerToken}); + const expiresAt=Date.now()-1000; + ctx.rows.accessSessions.push(...Array.from({length:105},(_,i)=>({ + _id:`expired${i}`,tokenHash:`expired-hash-${i}`,subject:'owner',expiresAt, + ...(i%2===0?{revokedAt:expiresAt-1000}:{}), + }))); + assert.equal(await call('actorSession','cleanupExpiredSessions',ctx,{}),100); + assert.equal(ctx.rows.accessSessions.filter(s=>s.expiresAt===expiresAt).length,5); + assert.equal(ctx.rows.accessSessions.some(s=>s._id==='S0'),true); + assert.equal(ctx.rows.accessSessions.some(s=>s._id==='S1'&&s.revokedAt!==undefined),true); + await assert.rejects(()=>call('actorSession','establish',ctx,{authToken:strangerToken}),/token/); + assert.equal((await call('lists','getUserLists',ctx,{authToken:ownerToken})).length,1); + assert.equal(await call('actorSession','cleanupExpiredSessions',ctx,{}),5); + assert.equal(await call('actorSession','cleanupExpiredSessions',ctx,{}),0); + assert.deepEqual(ctx.rows.accessSessions.map(s=>s._id),['S0','S1']); +}); + +// Production strips ordinary Error messages; authorization data must survive RPC. +test('private resource denials preserve structured authorization data across RPC', async () => { + for (const [module, name, args] of [ + ['items','getItemForSync',{itemId:'I1'}], + ['items','checkItem',{itemId:'I1',checkedAt:10}], + ['lists','renameList',{listId:'L1',name:'Renamed'}], + ['lists','deleteList',{listId:'L1'}], + ]) { + const ctx=fixture({published:name==='renameList'||name==='deleteList'}); + await assert.rejects(() => call(module,name,ctx,{...args,authToken:strangerToken}), error => { + assert.ok(error instanceof ConvexError); + const received=new ConvexError(jsonToConvex(convexToJson(error.data))); + assert.equal(received.data.kind,'auth');assert.equal(received.data.code,'FORBIDDEN'); + assert.equal(received.data.message,"Resource unavailable");return true; + }); + } +}); + +test('missing and inaccessible resources have identical production RPC responses', async () => { + for (const [module,name,field,existing,extra] of [ + ['items','getItemForSync','itemId','I1',{}], + ['items','checkItem','itemId','I1',{checkedAt:10}], + ['items','getListItems','listId','L1',{}], + ]) { + const data=[]; + for (const id of [existing,'missing']) { + await assert.rejects(()=>call(module,name,fixture(),{authToken:strangerToken,[field]:id,...extra}),error=>{ + assert.ok(error instanceof ConvexError); + data.push(jsonToConvex(convexToJson(error.data)));return true; + }); + } + assert.deepEqual(data[0],data[1]); + assert.deepEqual(data[0],{kind:'auth',code:'FORBIDDEN',message:'Resource unavailable'}); + } +}); + +test('single-list reads return the same empty result for missing and inaccessible lists',async()=>{ + for(const listId of ['L1','missing'])assert.equal(await call('lists','getList',fixture(),{authToken:strangerToken,listId}),null); +}); + +test('demotion conceals unknown and inaccessible parent IDs before changing an owned item',async()=>{ + for(const fn of ['demoteItem','demoteItemInternal']) { + const errors=[]; + for(const newParentId of ['missing','I2']) { + const ctx=fixture();const before=structuredClone(ctx.rows.items); + await assert.rejects(()=>call('items',fn,ctx,{authToken:ownerToken,itemId:'I1',newParentId}),error=>{ + assert.ok(error instanceof ConvexError);errors.push(jsonToConvex(convexToJson(error.data)));return true; + }); + assert.deepEqual(ctx.rows.items,before); + } + assert.deepEqual(errors[0],errors[1]); + assert.deepEqual(errors[0],{kind:'auth',code:'FORBIDDEN',message:'Resource unavailable'}); + const ctx=fixture({migrated:true}); + ctx.rows.items.push({_id:'parent',listId:'L1',name:'Parent',createdAt:1}); + await call('items',fn,ctx,{authToken:ownerToken,itemId:'I1',newParentId:'parent'}); + assert.equal(ctx.rows.items[0].parentId,'parent'); + } +}); + +test('comment deletion conceals missing, orphaned, and inaccessible comments while retaining author and editor access',async()=>{ + const errors=[]; + for(const commentId of ['missing','private','orphan']) { + const ctx=fixture(); + ctx.rows.comments=[{_id:'private',itemId:'I2',userDid:'did:other',text:'Private'}, + {_id:'orphan',itemId:'missing-item',userDid:'did:other',text:'Orphan'}]; + const before=structuredClone(ctx.rows.comments); + await assert.rejects(()=>call('comments','deleteComment',ctx,{authToken:ownerToken,commentId}),error=>{ + assert.ok(error instanceof ConvexError);errors.push(jsonToConvex(convexToJson(error.data)));return true; + }); + assert.deepEqual(ctx.rows.comments,before); + } + assert.ok(errors.every(e=>JSON.stringify(e)===JSON.stringify(errors[0]))); + assert.deepEqual(errors[0],{kind:'auth',code:'FORBIDDEN',message:'Resource unavailable'}); + for(const [author,itemId] of [['did:owner','I2'],['did:legacy','I2'],['did:other','I1']]) { + const ctx=fixture({migrated:true});ctx.rows.comments=[{_id:'comment',itemId,userDid:author,text:'Comment'}]; + await call('comments','deleteComment',ctx,{authToken:ownerToken,commentId:'comment'}); + assert.deepEqual(ctx.rows.comments,[]); + } + const shared=fixture({published:true});shared.rows.comments=[{_id:'comment',itemId:'I1',userDid:'did:owner',text:'Shared'}]; + await call('comments','deleteComment',shared,{authToken:strangerToken,commentId:'comment'}); + assert.deepEqual(shared.rows.comments,[]); +}); + +test('identity assertion RPC errors retain their authentication status over HTTP',async()=>{ + const ctx=fixture();let received; + await assert.rejects(()=>call('items','checkItem',ctx,{authToken:ownerToken,itemId:'I1',checkedAt:10,checkedByDid:'did:stranger'}),error=>{ + assert.ok(error instanceof ConvexError);received=new ConvexError(jsonToConvex(convexToJson(error.data)));return true; + }); + assert.equal(received.data.code,'UNAUTHORIZED'); + const response=modules['lib/httpResponses'].handlerErrorResponse(new Request('https://test/api/items/check'),received,'Failed'); + assert.equal(response.status,401);assert.deepEqual(await response.json(),{error:'Authentication required'}); + assert.equal(ctx.rows.items[0].checked,false); +}); diff --git a/scripts/auth-provider.test.mjs b/scripts/auth-provider.test.mjs new file mode 100644 index 0000000..6785eb7 --- /dev/null +++ b/scripts/auth-provider.test.mjs @@ -0,0 +1,149 @@ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { GlobalRegistrator } from '@happy-dom/global-registrator'; +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +const { renderHook, cleanup, act } = await import('@testing-library/react'); +const state = globalThis.__authProviderTest = { storage: new Map(), establish: async () => {} }; +state.convex = { mutation: (...args) => state.establish(...args) }; +await build({entryPoints:['src/hooks/useAuth.tsx'],outfile:'tmp/auth-provider-test.mjs',bundle:true,jsx:'automatic',platform:'node',format:'esm',external:['react','react/jsx-runtime','convex/server'],plugins:[{ + name:'auth-provider-fixtures', setup(b) { + b.onResolve({filter:/^(convex\/react|\.\.?\/.*(storageAdapter|webvh|useDidDomainRemint|convexUrls|analytics))$/}, args=>({path:args.path.split('/').at(-1),namespace:'fixture'})); + b.onLoad({filter:/.*/,namespace:'fixture'},({path})=>({contents:({ + react:'export function useConvex(){return globalThis.__authProviderTest.convex;}', + storageAdapter:'export const storageAdapter={get:async k=>globalThis.__authProviderTest.storage.get(k)??null,set:async(k,v)=>globalThis.__authProviderTest.storage.set(k,v),remove:async k=>globalThis.__authProviderTest.storage.delete(k)};', + webvh:'export const createUserWebVHDid=async()=>{throw new Error("Unexpected DID creation")};', + useDidDomainRemint:'export const useDidDomainRemint=()=>{};', + convexUrls:'export const getConvexHttpUrl=()=>"https://auth.example.test";', + analytics:'export const identifyUser=()=>{};export const resetAnalytics=()=>{};', + })[path]})); + } +}]}); +const {AuthProvider,useAuth}=await import(pathToFileURL(`${process.cwd()}/tmp/auth-provider-test.mjs`)); +const user={turnkeySubOrgId:'owner',email:'owner@example.test',did:'did:webvh:owner',displayName:'Owner'}; +const token=`header.${btoa(JSON.stringify({exp:Math.floor(Date.now()/1000)+30*86400}))}.signature`; +const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};}; +const flush=()=>act(async()=>{await Promise.resolve();}); +const fetchBefore=globalThis.fetch; +afterEach(()=>{cleanup();globalThis.fetch=fetchBefore;state.storage.clear();state.establish=async()=>{};}); +function seed(){state.storage.set('lisa-auth-state',JSON.stringify({user,token}));state.storage.set('lisa-jwt-token',token);} + +async function withEstablishClock(run) { + const originalSet=globalThis.setTimeout,originalClear=globalThis.clearTimeout; + const timers=new Map();let next=0; + globalThis.setTimeout=(fn,delay,...args)=>{ + if(delay===15_000){const id={authTimer:++next};timers.set(id,fn);return id;} + return originalSet(fn,delay,...args); + }; + globalThis.clearTimeout=id=>{if(!timers.delete(id))originalClear(id);}; + try {await run({timers,expire:()=>{ + assert.equal(timers.size,1,'one bounded session verification is pending'); + const [id,fn]=timers.entries().next().value;timers.delete(id);fn(); + }});} finally {globalThis.setTimeout=originalSet;globalThis.clearTimeout=originalClear;} +} + +for(const flow of ['restore','OTP']){ + test(`${flow} timeout releases authentication, permits logout and login, and ignores late completion`,async()=>{ + await withEstablishClock(async({timers,expire})=>{ + const pending=deferred();state.establish=()=>pending.promise; + const nextUser={...user,did:'did:webvh:next-owner',email:'next@example.test'}; + const nextToken=`header.${btoa(JSON.stringify({exp:Math.floor(Date.now()/1000)+86400}))}.next-signature`; + let loginResult={user,token}; + globalThis.fetch=async url=>Response.json(url.endsWith('/auth/initiate')?{sessionId:'otp-session'}:loginResult); + if(flow==='restore')seed(); + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + let verificationRejected; + if(flow==='OTP'){ + await act(async()=>result.current.startOtp(user.email)); + await act(async()=>{ + verificationRejected=assert.rejects(result.current.verifyOtp('123456'),/Session verification timed out/); + await Promise.resolve(); + }); + } + assert.equal(result.current.isLoading,true);assert.equal(result.current.token,null); + await assert.rejects(()=>result.current.startOtp(user.email),/already in progress/); + await act(async()=>{expire();await verificationRejected;}); + assert.equal(result.current.isLoading,false);assert.equal(result.current.isAuthenticated,false); + assert.equal(result.current.token,null);assert.equal(state.storage.size,0);assert.equal(timers.size,0); + + await act(async()=>result.current.logout()); + assert.equal(result.current.isLoading,false); + state.establish=async()=>{};loginResult={user:nextUser,token:nextToken}; + await act(async()=>result.current.startOtp(nextUser.email)); + await act(async()=>result.current.verifyOtp('654321')); + assert.equal(result.current.token,nextToken);assert.equal(result.current.user.did,nextUser.did); + assert.equal(result.current.isLoading,false);assert.equal(timers.size,0); + await act(async()=>pending.resolve()); + assert.equal(result.current.token,nextToken);assert.equal(result.current.user.did,nextUser.did); + assert.equal(state.storage.get('lisa-jwt-token'),nextToken); + assert.equal(JSON.parse(state.storage.get('lisa-auth-state')).user.did,nextUser.did); + }); + }); +} + +for(const outcome of ['accepted','rejected']){ + test(`session verification cancels its timeout when ${outcome}`,async()=>{ + await withEstablishClock(async({timers})=>{ + seed();const pending=deferred(); + state.establish=async()=>{await pending.promise;if(outcome==='rejected')throw new Error('revoked session');}; + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + assert.equal(timers.size,1); + await act(async()=>pending.resolve()); + assert.equal(result.current.isLoading,false);assert.equal(timers.size,0); + assert.equal(result.current.isAuthenticated,outcome==='accepted'); + }); + }); +} + +test('restore exposes credentials only after the server accepts the session',async()=>{ + seed();const pending=deferred();state.establish=()=>pending.promise; + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + assert.equal(result.current.token,null);assert.equal(result.current.isLoading,true); + await act(async()=>pending.resolve());assert.equal(result.current.token,token);assert.equal(result.current.isAuthenticated,true);assert.equal(result.current.isLoading,false); +}); +test('failed restore removes persisted credentials and leaves private queries signed out',async()=>{ + seed();state.establish=async()=>{throw new Error('revoked session')}; + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + assert.equal(result.current.token,null);assert.equal(result.current.isAuthenticated,false);assert.equal(result.current.isLoading,false);assert.equal(state.storage.size,0); +}); +test('OTP cannot persist or expose a token when session establishment fails',async()=>{ + globalThis.fetch=async url=>Response.json(url.endsWith('/auth/initiate')?{sessionId:'otp-session'}:{user,token}); + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + await act(async()=>result.current.startOtp(user.email)); + state.establish=async()=>{throw new Error('session rejected')}; + await act(async()=>assert.rejects(()=>result.current.verifyOtp('123456'),/session rejected/)); + assert.equal(result.current.token,null);assert.equal(result.current.isAuthenticated,false);assert.equal(state.storage.size,0); +}); +test('logout drops credentials immediately and serializes the next login until its response settles',async()=>{ + seed();const pending=deferred();let logoutRequest; + globalThis.fetch=async(url,options)=>{logoutRequest={url,options};return pending.promise;}; + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + let logout;await act(async()=>{logout=result.current.logout();await Promise.resolve();}); + assert.equal(result.current.token,null);assert.equal(result.current.isAuthenticated,false);assert.equal(result.current.isLoading,true);assert.equal(state.storage.size,0); + assert.equal(logoutRequest.options.headers.Authorization,`Bearer ${token}`); + await assert.rejects(()=>result.current.startOtp(user.email),/already in progress/); + await act(async()=>{pending.resolve(new Response(null,{status:204}));await logout;});assert.equal(result.current.isLoading,false); + globalThis.fetch=async()=>Response.json({sessionId:'new-otp'}); + await act(async()=>result.current.startOtp(user.email)); +}); + +test('the mounted provider expires a long session at its deadline and clears persisted credentials',async()=>{ + seed(); + const originalNow=Date.now, originalSet=globalThis.setTimeout, originalClear=globalThis.clearTimeout; + let now=Date.now(),next=0;const timers=new Map(); + Date.now=()=>now; + globalThis.setTimeout=(fn,delay,...args)=>{ + if(delay>100000){const id={expiryTimer:++next};timers.set(id,{fn,at:now+delay});return id;} + return originalSet(fn,delay,...args); + }; + globalThis.clearTimeout=id=>{if(!timers.delete(id))originalClear(id);}; + try { + const {result,unmount}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + assert.equal(result.current.token,token); + const fireNext=async()=>{const [id,timer]=timers.entries().next().value;timers.delete(id);now=timer.at;await act(async()=>timer.fn());}; + await fireNext();assert.equal(result.current.token,token); + await fireNext();assert.equal(result.current.token,null);assert.equal(result.current.isAuthenticated,false);assert.equal(state.storage.size,0); + unmount(); + } finally {Date.now=originalNow;globalThis.setTimeout=originalSet;globalThis.clearTimeout=originalClear;} +}); diff --git a/scripts/authenticated-client.test.mjs b/scripts/authenticated-client.test.mjs new file mode 100644 index 0000000..2497aa5 --- /dev/null +++ b/scripts/authenticated-client.test.mjs @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { GlobalRegistrator } from '@happy-dom/global-registrator'; +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +const { renderHook, cleanup } = await import('@testing-library/react'); +const { makeFunctionReference } = await import('convex/server'); +const state = globalThis.__authAdapterTest = { token: null, calls: [] }; +await build({entryPoints:['src/lib/authenticatedConvex.ts'],outfile:'tmp/authenticated-client-test.mjs',bundle:true,platform:'node',format:'esm',external:['react','convex/server'],plugins:[{ + name:'hook-fixtures', setup(b) { + b.onResolve({filter:/convex\/react$/},()=>({path:'convex-react',namespace:'fixture'})); + b.onResolve({filter:/hooks\/useAuth$/},()=>({path:'auth',namespace:'fixture'})); + b.onLoad({filter:/.*/,namespace:'fixture'},({path})=>({contents:path==='auth' ? `export function useAuth(){return globalThis.__authAdapterTest}` : ` + export function useQuery(ref,args){globalThis.__authAdapterTest.calls.push({ref,args});return args==='skip'?undefined:args;} + function mutation(ref){const fn=async args=>{globalThis.__authAdapterTest.calls.push({ref,args});return args;};fn.withOptimisticUpdate=()=>mutation(ref);return fn;} + export const useMutation=mutation;export const useAction=mutation; + `})); + } +}]}); +const hooks=await import(pathToFileURL(`${process.cwd()}/tmp/authenticated-client-test.mjs`)); +const privateQuery=makeFunctionReference('lists:getList'),publicQuery=makeFunctionReference('publication:getPublicList'),write=makeFunctionReference('items:checkItem'); + +test('login, account changes and logout update query credentials and skip private subscriptions',()=>{ + state.token=null;state.calls=[]; + const {result,rerender,unmount}=renderHook(()=>hooks.useQuery(privateQuery,{listId:'L1',userDid:'did:forged',legacyDid:'did:forged'})); + assert.equal(result.current,undefined); + state.token='session-A';rerender();assert.deepEqual(result.current,{listId:'L1',authToken:'session-A'}); + state.token='session-B';rerender();assert.deepEqual(result.current,{listId:'L1',authToken:'session-B'}); + state.token=null;rerender();assert.equal(result.current,undefined);assert.equal(state.calls.at(-1).args,'skip');unmount();cleanup(); +}); +test('public reads remain available without a session',()=>{ + state.token=null; + const {result,unmount}=renderHook(()=>hooks.useQuery(publicQuery,{webvhDid:'did:public'})); + assert.deepEqual(result.current,{webvhDid:'did:public'});unmount();cleanup(); +}); +test('writes and actions carry session credentials and omit identity assertions',async()=>{ + state.token='session-A'; + const {result,rerender,unmount}=renderHook(()=>({write:hooks.useMutation(write),upload:hooks.useAction(makeFunctionReference('attachments:generateUploadUrl'))})); + assert.deepEqual(await result.current.write({itemId:'I1',checkedByDid:'did:forged'}),{itemId:'I1',authToken:'session-A'}); + assert.deepEqual(await result.current.upload({itemId:'I1',contentType:'image/png',byteLength:10,userDid:'did:forged'}),{itemId:'I1',contentType:'image/png',byteLength:10,authToken:'session-A'}); + assert.deepEqual(await result.current.write.withOptimisticUpdate(()=>{})({itemId:'I1'}),{itemId:'I1',authToken:'session-A'}); + state.token=null;rerender();await assert.rejects(async()=>result.current.write({itemId:'I1'}),/Sign in/);unmount();cleanup(); +}); +test('generated client registry matches authenticated server registrations',()=>{ + execFileSync(process.execPath,['scripts/generate-auth-client.mjs','--check']); +}); + +await build({entryPoints:['src/lib/sessionExpiry.ts'],outfile:'tmp/session-expiry-test.mjs',bundle:true,platform:'node',format:'esm'}); +const {onSessionExpiry}=await import(pathToFileURL(`${process.cwd()}/tmp/session-expiry-test.mjs`)); +test('30-day sessions expire at their deadline, not on timer overflow, and cancelled timers stay cancelled', () => { + const originalNow = Date.now, originalSet = globalThis.setTimeout, originalClear = globalThis.clearTimeout; + let now = 0, next = 0, expired = 0; + const timers = new Map(); + Date.now = () => now; + globalThis.setTimeout = (fn, delay) => { const id = ++next; timers.set(id, {fn, at: now + delay}); return id; }; + globalThis.clearTimeout = id => timers.delete(id); + const tick = ms => { + const end = now + ms; + for (;;) { + const entry = [...timers.entries()].filter(([, t]) => t.at <= end).sort((a,b) => a[1].at-b[1].at)[0]; + if (!entry) break; + now = entry[1].at; timers.delete(entry[0]); entry[1].fn(); + } + now = end; + }; + try { + const lifetime = 30*24*60*60*1000; + const cancel = onSessionExpiry(lifetime, () => expired++); + tick(2_147_483_647); assert.equal(expired, 0); + tick(lifetime-2_147_483_647-1); assert.equal(expired, 0); + tick(1); assert.equal(expired, 1); cancel(); + const stop = onSessionExpiry(Date.now()+100, () => expired++); stop(); + tick(100); assert.equal(expired, 1); + } finally { + Date.now = originalNow; globalThis.setTimeout = originalSet; globalThis.clearTimeout = originalClear; + } +}); diff --git a/scripts/authentication-cutover.test.mjs b/scripts/authentication-cutover.test.mjs new file mode 100644 index 0000000..b36e291 --- /dev/null +++ b/scripts/authentication-cutover.test.mjs @@ -0,0 +1,25 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { checkApproval, needsApproval } from './check-authentication-cutover.mjs'; +const record = () => ({owner:'release-owner',approvedBy:'release-owner',approvedAt:'2026-09-09T00:00:00Z',stagingEvidence:'staging run URL',clients:Object.fromEntries(['browser','ios','android','integrations'].map(k=>[k,{supportedVersions:'verified build IDs',evidence:'inventory URL'}]))}); +test('pending cutover evidence blocks production',()=>{ + const pending=record();pending.approvedBy=null;pending.approvedAt=null; + assert.throws(()=>checkApproval(pending)); +}); +test('approval requires the owner, staging run, and every supported client inventory',()=>{ + assert.doesNotThrow(()=>checkApproval(record())); + for(const field of ['approvedBy','approvedAt','stagingEvidence']){const r=record();r[field]=null;assert.throws(()=>checkApproval(r));} + for(const client of ['browser','ios','android','integrations']) for(const field of ['supportedVersions','evidence']){const r=record();r.clients[client][field]=null;assert.throws(()=>checkApproval(r));} +}); +test('production and unknown Railway environments fail closed; previews can validate',()=>{ + for(const env of ['production',undefined,'renamed-production','']) assert.equal(needsApproval(true,env),true); + for(const env of ['boop-pr-241','staging','development'])assert.equal(needsApproval(true,env),false); + assert.equal(needsApproval(false,'boop-pr-241'),true); +}); +test('both repository deployment paths enforce approval before production build or deploy',()=>{ + const workflow=readFileSync('.github/workflows/deploy-convex.yaml','utf8'); + assert.ok(workflow.indexOf('run: node scripts/check-authentication-cutover.mjs') row[field] === value); return index; } }; + select(index); + return query; + }, + first: async () => matches[0] ?? null, + collect: async () => matches, + }; + return query; + } } }; +} + +test("bookmark IDs include server-resolved legacy identities and deduplicate lists", async () => { + for (const operation of [getUserBookmarkIds, getUserBookmarkIdsInternal]) { + const result = await operation._handler(fixture(), { authToken: owner.authToken }); + assert.deepEqual(result, ["current-list", "shared-list", "legacy-list"]); + } +}); + +test("bookmark IDs stay isolated to the authenticated account", async () => { + assert.deepEqual(await getUserBookmarkIds._handler(fixture(), { authToken: stranger.authToken }), ["stranger-list"]); + await assert.rejects(() => getUserBookmarkIds._handler(fixture(), {}), /Authentication/); + await assert.rejects(() => getUserBookmarkIds._handler(fixture(), { + authToken: stranger.authToken, legacyDid: "did:legacy", + }), /assertion/); +}); diff --git a/scripts/check-authentication-cutover.mjs b/scripts/check-authentication-cutover.mjs new file mode 100644 index 0000000..77a638a --- /dev/null +++ b/scripts/check-authentication-cutover.mjs @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; + +const evidenceUrl = new URL('../release/authentication-cutover.json', import.meta.url); +const nonempty = value => typeof value === 'string' && value.trim().length > 0; + +export function checkApproval(record) { + if (!nonempty(record?.owner) || record.approvedBy !== record.owner + || !nonempty(record.approvedAt) || !Number.isFinite(Date.parse(record.approvedAt)) + || !nonempty(record.stagingEvidence)) throw new Error('Release owner approval and staging evidence are required'); + for (const client of ['browser', 'ios', 'android', 'integrations']) { + const inventory = record.clients?.[client]; + if (!nonempty(inventory?.supportedVersions) || !nonempty(inventory?.evidence)) { + throw new Error(`Deployed ${client} versions and compatibility evidence are required`); + } + } +} + +export function needsApproval(railway, environment) { + // Railway gives preview environments the repository PR name. Unknown names fail closed. + return !railway || !/^(boop-pr-\d+|staging|development)$/i.test(environment ?? ''); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + if (needsApproval(process.argv.includes('--railway'), process.env.RAILWAY_ENVIRONMENT_NAME)) { + checkApproval(JSON.parse(readFileSync(evidenceUrl, 'utf8'))); + } + } catch (error) { + console.error(`Authentication cutover blocked: ${error.message}. See docs/authentication-rollout.md.`); + process.exitCode = 1; + } +} diff --git a/scripts/copy-list.test.mjs b/scripts/copy-list.test.mjs index 9a1cef6..75164a8 100644 --- a/scripts/copy-list.test.mjs +++ b/scripts/copy-list.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, rm } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { build } from "esbuild"; +import { createAuthFixture } from "./helpers/auth-fixture.mjs"; const outdir = "tmp/copy-list-test"; @@ -28,6 +29,8 @@ const unwrap = (fn) => fn._handler ?? fn.handler; const OWNER = "did:webvh:QmS:boop.ad:user-owner"; const STRANGER = "did:webvh:QmS:boop.ad:user-stranger"; +const ownerSession = await createAuthFixture(OWNER); +const strangerSession = await createAuthFixture(STRANGER); /** * The source list is a migrated one: its envelope exists but nobody holds the @@ -40,7 +43,8 @@ function makeCtx({ items = [], lists: extraLists = [], user = null, subscription ...extraLists, ], items: items.map((i, n) => ({ _id: `I${n}`, listId: "L1", ...i })), - users: user ? [user] : [], + users: [{ ...ownerSession.user, ...user }, strangerSession.user], + accessSessions: [ownerSession.accessSession, strangerSession.accessSession], subscriptions: subscription ? [subscription] : [], referrals: [], listEnvelopes: [], @@ -87,7 +91,7 @@ const MINTED = { assetDid: "did:cel:fresh", celEnvelope: '{"format":"originals/asset"}', name: "Camping (copy)", - ownerDid: OWNER, + authToken: ownerSession.authToken, createdAt: 5000, }; @@ -171,8 +175,8 @@ test("drops vcProofs — they attest actions against the source asset", async () test("only the owner can copy a list", async () => { const ctx = makeCtx({ items: [] }); await assert.rejects( - () => unwrap(mod.copyList)(ctx, { sourceListId: "L1", ...MINTED, ownerDid: STRANGER }), - /owner/i + () => unwrap(mod.copyList)(ctx, { sourceListId: "L1", ...MINTED, authToken: strangerSession.authToken }), + {data:{kind:'auth',code:'FORBIDDEN',message:'Resource unavailable'}} ); }); diff --git a/scripts/generate-auth-client.mjs b/scripts/generate-auth-client.mjs new file mode 100644 index 0000000..4a1fa26 --- /dev/null +++ b/scripts/generate-auth-client.mjs @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import ts from 'typescript'; + +const refs = ['api.auth.getUserByTurnkeyId', 'api.auth.getUserByEmail', 'api.auth.upsertUser']; +for (const file of fs.readdirSync('convex').filter(f => f.endsWith('.ts')).sort()) { + const source = ts.createSourceFile(file, fs.readFileSync(`convex/${file}`, 'utf8'), ts.ScriptTarget.Latest, true); + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (!declaration.initializer || !ts.isCallExpression(declaration.initializer) + || !['actorQuery','actorMutation','actorAction'].includes(declaration.initializer.expression.getText(source)) + || !ts.isObjectBindingPattern(declaration.name)) continue; + const name = declaration.name.elements.find(element => element.propertyName?.getText(source) === 'public')?.name.getText(source); + if (name) refs.push(`api.${file.slice(0,-3)}.${name}`); + } + } +} +const content = `// Generated by scripts/generate-auth-client.mjs. Function references are type checked.\nimport { api } from "../../convex/_generated/api";\nimport { getFunctionName } from "convex/server";\nexport const authenticatedOperations = new Set([\n${refs.sort().map(ref => ` ${ref},`).join('\n')}\n].map(getFunctionName));\n`; +const target = 'src/lib/authenticatedOperations.ts'; +if (process.argv.includes('--check')) { + if (fs.readFileSync(target,'utf8') !== content) throw new Error('Authenticated client registry is stale: run node scripts/generate-auth-client.mjs'); +} else fs.writeFileSync(target,content); diff --git a/scripts/helpers/auth-fixture.mjs b/scripts/helpers/auth-fixture.mjs new file mode 100644 index 0000000..fe338b9 --- /dev/null +++ b/scripts/helpers/auth-fixture.mjs @@ -0,0 +1,31 @@ +import { SignJWT } from "jose"; +import { createHash } from "node:crypto"; + +// Exercise the production verifier with a test-only signing secret. The DID is +// deliberately absent from the JWT: the database account owns that association. +process.env.JWT_SECRET = "boop-handler-regression-test-secret-only"; + +export async function createAuthFixture(did, overrides = {}) { + const user = { + _id: `user-${did}`, + did, + turnkeySubOrgId: `org-${did}`, + ...overrides, + }; + const expiresAt = (Math.floor(Date.now() / 1000) + 3600) * 1000; + const authToken = await new SignJWT({ email: "fixture@example.test" }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(user.turnkeySubOrgId) + .setIssuer("originals-auth") + .setAudience("originals-api") + .setIssuedAt() + .setExpirationTime(expiresAt / 1000) + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); + const accessSession = { + _id: `session-${did}`, + tokenHash: createHash("sha256").update(authToken).digest("hex"), + subject: user.turnkeySubOrgId, + expiresAt, + }; + return { user, authToken, accessSession }; +} diff --git a/scripts/item-categories-mutations.test.mjs b/scripts/item-categories-mutations.test.mjs index fbc8942..f0a19df 100644 --- a/scripts/item-categories-mutations.test.mjs +++ b/scripts/item-categories-mutations.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, rm } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { build } from "esbuild"; +import { createAuthFixture } from "./helpers/auth-fixture.mjs"; const outdir = "tmp/item-categories-mutations-test"; @@ -28,10 +29,17 @@ const unwrap = (fn) => fn._handler ?? fn.handler; const OWNER = "did:webvh:QmS:boop.ad:user-owner"; const STRANGER = "did:webvh:QmS:boop.ad:user-stranger"; +const ownerSession = await createAuthFixture(OWNER); +const strangerSession = await createAuthFixture(STRANGER); function makeCtx({ list, items = [] } = {}) { const lists = [{ _id: "L1", ownerDid: OWNER, name: "Rachel's 40th", ...list }]; - const rows = { lists, items: items.map((i) => ({ listId: "L1", ...i })) }; + const rows = { + lists, + items: items.map((i) => ({ listId: "L1", ...i })), + users: [ownerSession.user, strangerSession.user], + accessSessions: [ownerSession.accessSession, strangerSession.accessSession], + }; const byId = new Map(); rows.lists.forEach((l) => byId.set(l._id, l)); rows.items.forEach((i) => byId.set(i._id, i)); @@ -42,11 +50,20 @@ function makeCtx({ list, items = [] } = {}) { get: async (id) => byId.get(id) ?? null, patch: async (id, fields) => Object.assign(byId.get(id), fields), query: (table) => { + let working = rows[table] ?? []; const result = { - // Index filtering is irrelevant here: each fixture holds one list. - withIndex: () => result, - collect: async () => rows[table] ?? [], - first: async () => (rows[table] ?? [])[0] ?? null, + withIndex: (_name, fn) => { + const builder = { + eq: (field, value) => { + working = working.filter((row) => row[field] === value); + return builder; + }, + }; + fn?.(builder); + return result; + }, + collect: async () => working, + first: async () => working[0] ?? null, }; return result; }, @@ -54,7 +71,7 @@ function makeCtx({ list, items = [] } = {}) { }; } -const call = (fn, ctx, args) => unwrap(mod[fn])(ctx, { listId: "L1", userDid: OWNER, ...args }); +const call = (fn, ctx, args) => unwrap(mod[fn])(ctx, { listId: "L1", authToken: ownerSession.authToken, ...args }); test("the first edit materialises the grocery set onto the list", async () => { const ctx = makeCtx(); @@ -145,8 +162,8 @@ test("the Other bucket is protected at the mutation layer too", async () => { test("a non-editor cannot change categories", async () => { const ctx = makeCtx(); await assert.rejects( - () => unwrap(mod.addListCategory)(ctx, { listId: "L1", userDid: STRANGER, name: "X", emoji: "🏷️" }), - /permission/ + () => unwrap(mod.addListCategory)(ctx, { listId: "L1", authToken: strangerSession.authToken, name: "X", emoji: "🏷️" }), + {data:{kind:'auth',code:'FORBIDDEN',message:'Resource unavailable'}} ); assert.equal(ctx.rows.lists[0].itemCategories, undefined, "nothing persisted on refusal"); }); @@ -171,7 +188,7 @@ test("a rejected edit leaves the stored set untouched", async () => { test("a missing list is an error, not a silent no-op", async () => { const ctx = makeCtx(); await assert.rejects( - () => unwrap(mod.addListCategory)(ctx, { listId: "nope", userDid: OWNER, name: "X", emoji: "🏷️" }), - /List not found/ + () => call("addListCategory", ctx, { listId: "nope", name: "X", emoji: "🏷️" }), + {data:{kind:'auth',code:'FORBIDDEN',message:'Resource unavailable'}} ); }); diff --git a/scripts/login-account.test.mjs b/scripts/login-account.test.mjs index 5553012..f0628e0 100644 --- a/scripts/login-account.test.mjs +++ b/scripts/login-account.test.mjs @@ -149,7 +149,7 @@ test("verification rejects an old session pointing at a different account", asyn test("competing signup sessions cannot insert a second user for an existing email", async () => { const ctx = context(); - const upsert = handler(auth.upsertUser); + const upsert = handler(auth.upsertUserInternal); await upsert(ctx, { email: EMAIL, turnkeySubOrgId: "first-signup" }); await assert.rejects(upsert(ctx, { email: EMAIL, turnkeySubOrgId: "second-signup" }), /request a new code/i); assert.equal(ctx.users.length, 1); @@ -158,23 +158,23 @@ test("competing signup sessions cannot insert a second user for an existing emai test("unfinished signup for another email cannot be linked through an undefined DID", async () => { const ctx = context([{ ...original, did: undefined }]); - await handler(auth.upsertUser)(ctx, { email: "new@example.com", turnkeySubOrgId: "new-identity" }); + await handler(auth.upsertUserInternal)(ctx, { email: "new@example.com", turnkeySubOrgId: "new-identity" }); assert.equal(ctx.users.length, 2); assert.equal(ctx.users[0].turnkeySubOrgId, original.turnkeySubOrgId); }); test("upsert rechecks operator selection after OTP verification", async () => { const ctx = context([{ ...original, isCanonicalLogin: true }, { ...duplicate, isCanonicalLogin: false }]); - await assert.rejects(handler(auth.upsertUser)(ctx, { email: EMAIL, turnkeySubOrgId: duplicate.turnkeySubOrgId }), /request a new code/i); + await assert.rejects(handler(auth.upsertUserInternal)(ctx, { email: EMAIL, turnkeySubOrgId: duplicate.turnkeySubOrgId }), /request a new code/i); assert.equal(ctx.users.length, 2); assert.equal(ctx.users[1].lastLoginAt, undefined); - await handler(auth.upsertUser)(ctx, { email: EMAIL, turnkeySubOrgId: original.turnkeySubOrgId }); + await handler(auth.upsertUserInternal)(ctx, { email: EMAIL, turnkeySubOrgId: original.turnkeySubOrgId }); assert.equal(ctx.users.length, 2); assert.ok(ctx.users[0].lastLoginAt); }); test("an existing identity cannot be reused under a different email", async () => { const ctx = context([original]); - await assert.rejects(handler(auth.upsertUser)(ctx, { email: "stranger@example.com", turnkeySubOrgId: original.turnkeySubOrgId }), /different email/i); + await assert.rejects(handler(auth.upsertUserInternal)(ctx, { email: "stranger@example.com", turnkeySubOrgId: original.turnkeySubOrgId }), /different email/i); assert.equal(ctx.users[0].lastLoginAt, undefined); }); diff --git a/scripts/originals-query.test.mjs b/scripts/originals-query.test.mjs index 1a77bec..96ceb15 100644 --- a/scripts/originals-query.test.mjs +++ b/scripts/originals-query.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, rm } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { build } from "esbuild"; +import { createAuthFixture } from "./helpers/auth-fixture.mjs"; const outdir = "tmp/originals-query-test"; @@ -25,9 +26,11 @@ async function loadModule() { const mod = await loadModule(); const handler = mod.listOwnedOriginals._handler ?? mod.listOwnedOriginals.handler; +const ownerSession = await createAuthFixture("me"); // In-memory ctx.db mock matching Convex's surface. function makeDb(tables) { + tables = { users: [ownerSession.user], accessSessions: [ownerSession.accessSession], ...tables }; const all = new Map(); for (const [name, rows] of Object.entries(tables)) { for (const row of rows) all.set(row._id, row); @@ -69,7 +72,7 @@ function makeQuery(rows) { }; } -test("ownerDid filter applied", async () => { +test("authenticated owner filter applied", async () => { const ctx = { db: makeDb({ lists: [ @@ -84,7 +87,7 @@ test("ownerDid filter applied", async () => { itemAssignees: [], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.equal(rows.length, 1); assert.equal(rows[0].title, "Mine"); }); @@ -105,7 +108,7 @@ test("joins all source tables and produces rows in updatedAt desc order", async ], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.equal(rows.length, 2); assert.equal(rows[0].source, "site"); assert.equal(rows[1].source, "list"); @@ -128,7 +131,7 @@ test("missing optional joins do not crash", async () => { itemAssignees: [], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.equal(rows[0].layer, "did:cel"); assert.equal(rows[0].verification, "none"); assert.equal(rows[0].collaborators, undefined); @@ -146,7 +149,7 @@ test("a site never reports anchored", async () => { itemAssignees: [], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.notEqual(rows[0].verification, "anchored"); }); @@ -165,7 +168,7 @@ test("multiple confirmed anchors → most recent confirmedAt wins", async () => itemAssignees: [], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.equal(rows[0].anchorTxId, "newer"); }); diff --git a/scripts/shared-list-auth.test.mjs b/scripts/shared-list-auth.test.mjs new file mode 100644 index 0000000..fccc80e --- /dev/null +++ b/scripts/shared-list-auth.test.mjs @@ -0,0 +1,91 @@ +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { build } from "esbuild"; +import { pathToFileURL } from "node:url"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +const { render, screen, fireEvent, waitFor, cleanup } = await import("@testing-library/react"); +const React = await import("react"); + +const state = globalThis.__sharedListAuthTest = { token: null, did: null }; +await build({ + entryPoints: ["src/components/SharedListResource.tsx"], + outfile: "tmp/shared-list-auth-test.mjs", + bundle: true, + jsx: "automatic", + platform: "node", + format: "esm", + define: { "import.meta.env.VITE_CONVEX_URL": JSON.stringify("https://test.convex.cloud") }, + external: ["react", "react/jsx-runtime"], + plugins: [{ + name: "shared-list-fixtures", + setup(builder) { + builder.onResolve({ filter: /^(react-router-dom|\.\.\/lib\/authenticatedConvex|\.\.\/hooks\/useCurrentUser|\.\.\/hooks\/useAuth)$/ }, ({ path }) => ({ path, namespace: "fixture" })); + builder.onLoad({ filter: /.*/, namespace: "fixture" }, ({ path }) => ({ contents: ({ + "react-router-dom": `import React from "react";export const useParams=()=>({userPath:"owner",resourceId:"list-list1"});export const Link=({children,to,...props})=>React.createElement("a",{...props,href:to},children);export const useNavigate=()=>()=>{};`, + "../lib/authenticatedConvex": `export const useMutation=()=>async()=>{};export const useQuery=()=>false;`, + "../hooks/useCurrentUser": `export const useCurrentUser=()=>({did:globalThis.__sharedListAuthTest.did});`, + "../hooks/useAuth": `export const useAuth=()=>({token:globalThis.__sharedListAuthTest.token});`, + })[path] })); + }, + }], +}); + +const { SharedListResource } = await import(pathToFileURL(`${process.cwd()}/tmp/shared-list-auth-test.mjs`)); +const originalFetch = globalThis.fetch; +const resource = { + "@context": [], id: "resource", type: "List", controller: "owner", name: "Groceries", + items: [{ _id: "item1", name: "Milk", checked: false, createdAt: 1 }], + createdAt: 1, itemCount: 1, checkedCount: 0, +}; + +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; + state.token = null; + state.did = null; +}); + +async function renderLoaded(postResponse = new Response(null, { status: 200 })) { + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url: String(url), options }); + return options?.method === "POST" ? postResponse : Response.json(resource); + }; + render(React.createElement(SharedListResource)); + await screen.findByRole("button", { name: /Milk/ }); + return calls; +} + +test("anonymous viewers see a sign-in affordance and cannot send item writes", async () => { + const calls = await renderLoaded(); + const item = screen.getByRole("button", { name: /Milk/ }); + assert.equal(item.disabled, true); + assert.match(screen.getByRole("link", { name: "Sign in" }).parentElement.textContent, /check off items/); + fireEvent.click(item); + assert.equal(calls.filter(({ options }) => options?.method === "POST").length, 0); +}); + +test("signed-in item writes send the session token and cross-origin credentials", async () => { + state.token = "session-token"; + state.did = "did:webvh:owner"; + const calls = await renderLoaded(); + fireEvent.click(screen.getByRole("button", { name: /Milk/ })); + await waitFor(() => assert.equal(calls.filter(({ options }) => options?.method === "POST").length, 1)); + const request = calls.find(({ options }) => options?.method === "POST"); + assert.equal(request.options.headers.Authorization, "Bearer session-token"); + assert.equal(request.options.credentials, "include"); +}); + +test("a 401 rolls back the optimistic check and shows a visible error", async () => { + state.token = "expired-token"; + state.did = "did:webvh:owner"; + await renderLoaded(new Response(null, { status: 401 })); + const item = screen.getByRole("button", { name: /Milk/ }); + fireEvent.click(item); + await screen.findByRole("alert"); + assert.match(screen.getByRole("alert").textContent, /Sign in again/); + assert.equal(item.querySelector("svg"), null); + assert.equal(item.querySelector("p").className.includes("line-through"), false); +}); diff --git a/scripts/sync-auth.test.mjs b/scripts/sync-auth.test.mjs new file mode 100644 index 0000000..63ec349 --- /dev/null +++ b/scripts/sync-auth.test.mjs @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { ConvexError, convexToJson, jsonToConvex } from 'convex/values'; +const state = globalThis.__syncAuthTest = { queue: [], toasts: [] }; +await build({ entryPoints: ['src/lib/sync.ts', 'convex/lib/authError.ts', 'convex/lib/httpResponses.ts'], outdir: 'tmp/sync-auth', outbase: '.', bundle: true, platform: 'node', format: 'esm', outExtension: {'.js': '.mjs'}, external: ['convex/values', 'convex/server'], plugins: [{name: 'sync-fixtures', setup(b) { + b.onResolve({filter: /^\.\/(offline|storageAdapter|toast)$/}, a => ({path: a.path.slice(2), namespace: 'fixture'})); + b.onLoad({filter: /.*/, namespace: 'fixture'}, ({path}) => ({contents: { + offline: 'const s=globalThis.__syncAuthTest; export const getQueuedMutations=async()=>structuredClone(s.queue); export const clearMutation=async id=>{s.queue=s.queue.filter(m=>m.id!==id)}; export const updateMutationRetry=async(id,retryCount)=>{s.queue.find(m=>m.id===id).retryCount=retryCount};', + storageAdapter: 'export const storageAdapter={get:async()=>"session-token"};', + toast: 'export const showGlobalToast=(...args)=>globalThis.__syncAuthTest.toasts.push(args);', + }[path]})); +}}]}); +const load = p => import(pathToFileURL(`${process.cwd()}/tmp/sync-auth/${p}.mjs`)); +const {SyncManager} = await load('src/lib/sync'); +const {AuthError} = await load('convex/lib/authError'); +const {handlerErrorResponse} = await load('convex/lib/httpResponses'); +const wireError = code => new ConvexError(jsonToConvex(convexToJson(new AuthError('Account unavailable', code).data))); +function seed(type='checkItem') { + state.queue = [1,2].map(id=>({id,type,payload:{itemId:`item-${id}`,checkedByDid:'did:owner'},timestamp:10,retryCount:5})); + state.toasts=[]; +} +for (const phase of ['query','mutation']) for (const code of ['UNAUTHORIZED','INVALID_TOKEN','EXPIRED_TOKEN']) { + test(`${phase} ${code} across RPC preserves queued edits and resumes after login`, async()=>{ + seed(); const manager=new SyncManager(); const statuses=[];manager.subscribe(s=>statuses.push(s)); + let reject=true; + const client={query:async()=>{if(reject&&phase==='query')throw wireError(code);return {updatedAt:1}},mutation:async()=>{if(reject&&phase==='mutation')throw wireError(code)}}; + await manager.sync(client); + assert.equal(state.queue.length,2);assert.ok(state.queue.every(m=>m.retryCount===5)); + assert.equal(statuses.at(-1).status,'error');assert.equal(manager.syncing,false); + assert.match(state.toasts[0][0],/changes are still saved/);assert.ok(!state.toasts.some(([m])=>m.includes('deleted'))); + reject=false;await manager.sync(client);assert.equal(state.queue.length,0);assert.equal(statuses.at(-1).status,'synced'); + }); +} +test('legacy missing-user errors pause sync without discarding queued edits',async()=>{ + seed('removeItem');const manager=new SyncManager(); + await manager.sync({mutation:async()=>{throw new Error('User not found')}}); + assert.equal(state.queue.length,2);assert.ok(!state.toasts.some(([m])=>m.includes('deleted'))); + assert.ok(state.queue.every(m=>m.retryCount===5)); +}); +test('HTTP recognizes serialized authentication errors without matching their prose',()=>{ + const request=new Request('https://example.test'); + for(const code of ['UNAUTHORIZED','INVALID_TOKEN','EXPIRED_TOKEN'])assert.equal(handlerErrorResponse(request,wireError(code),'Failed').status,401); + assert.equal(handlerErrorResponse(request,new Error('Missing scope: write'),'Failed').status,403); + const denied=new ConvexError(jsonToConvex(convexToJson(new AuthError('Resource unavailable','FORBIDDEN').data))); + assert.equal(handlerErrorResponse(request,denied,'Failed').status,403); +}); + +for(const phase of ['query','mutation']) { + test(`structured ${phase} missing-or-denied error backs off, progresses later edits, and exhausts only its own retry budget`,async()=>{ + seed();state.queue.forEach(m=>m.retryCount=0); + const manager=new SyncManager();const statuses=[];manager.subscribe(s=>statuses.push(s)); + const delays=[];manager.delay=async ms=>{delays.push(ms)}; + const applied=[]; + const client={ + query:async(_ref,args)=>{if(phase==='query'&&args.itemId==='item-1')throw wireError('FORBIDDEN');return {updatedAt:1}}, + mutation:async(_ref,args)=>{if(args.itemId==='item-1')throw wireError('FORBIDDEN');applied.push(args.itemId)}, + }; + await manager.sync(client); + assert.deepEqual(applied,['item-2']);assert.equal(state.queue.length,1);assert.equal(state.queue[0].retryCount,1); + assert.equal(statuses.at(-1).status,'error'); + for(let attempt=1;attempt<=5;attempt++) { + // New authorized edits must progress even when the denied operation never recovers. + state.queue.push({id:attempt+2,type:'checkItem',payload:{itemId:`healthy-${attempt}`},timestamp:10,retryCount:0}); + await manager.sync(client); + assert.ok(applied.includes(`healthy-${attempt}`)); + } + assert.equal(state.queue.length,0);assert.equal(manager.syncing,false); + assert.deepEqual(delays,[1000,2000,4000,8000,16000]); + assert.equal(statuses.at(-1).status,'error');assert.match(statuses.at(-1).message,/discarded/); + assert.ok(state.toasts.every(([m])=>m.includes('may have been removed or access is unavailable'))); + assert.ok(!state.toasts.some(([m])=>/Sign in|deleted by another/.test(m))); + }); +} +test('temporary resource denial can recover before exhausting its retry budget',async()=>{ + seed();state.queue.forEach(m=>m.retryCount=0);const manager=new SyncManager(); + const delays=[];manager.delay=async ms=>{delays.push(ms)}; + await manager.sync({query:async()=>{throw wireError('FORBIDDEN')}}); + assert.equal(state.queue.length,2);assert.ok(state.queue.every(m=>m.retryCount===1)); + assert.deepEqual(delays,[1000,1000]); + await manager.sync({query:async()=>({updatedAt:1}),mutation:async()=>{}}); + assert.equal(state.queue.length,0); +}); + +test('rapid sync attempts do not consume retries while a denial is backing off',async()=>{ + seed();state.queue.forEach(m=>m.retryCount=0); + const manager=new SyncManager();const applied=[];const delays=[]; + let releaseDelay;let delayStarted; + const waiting=new Promise(resolve=>{delayStarted=resolve}); + manager.delay=ms=>{delays.push(ms);delayStarted();return new Promise(resolve=>{releaseDelay=resolve})}; + const client={ + query:async(_ref,args)=>{if(args.itemId==='item-1')throw wireError('FORBIDDEN');return {updatedAt:1}}, + mutation:async(_ref,args)=>{applied.push(args.itemId)}, + }; + const firstSync=manager.sync(client); + await waiting; + await manager.sync(client);await manager.sync(client); + assert.equal(state.queue[0].retryCount,1);assert.deepEqual(delays,[1000]); + assert.equal(manager.syncing,true); + releaseDelay();await firstSync; + assert.deepEqual(applied,['item-2']);assert.equal(state.queue.length,1); + assert.equal(manager.syncing,false); +}); diff --git a/src/components/Attachments.tsx b/src/components/Attachments.tsx index c097fe0..81fb3c6 100644 --- a/src/components/Attachments.tsx +++ b/src/components/Attachments.tsx @@ -4,7 +4,7 @@ */ import { useState, useRef } from "react"; -import { useAction, useMutation, useQuery } from "convex/react"; +import { useAction, useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; @@ -40,7 +40,7 @@ const ALLOWED_TYPES = [ "application/json", ]; -export function Attachments({ itemId, userDid, legacyDid, canEdit }: AttachmentsProps) { +export function Attachments({ itemId, canEdit }: AttachmentsProps) { const { haptic } = useSettings(); const fileInputRef = useRef(null); const [uploadingCount, setUploadingCount] = useState(0); @@ -68,8 +68,6 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments const { uploadUrl, bucketKey } = await generateUploadUrl({ itemId, - userDid, - legacyDid, contentType, byteLength: file.size, }); @@ -86,8 +84,6 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments await addAttachment({ itemId, - userDid, - legacyDid, bucketKey, contentType, size: file.size, @@ -164,8 +160,6 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments await removeAttachment({ itemId, bucketKey, - userDid, - legacyDid, }); setFailedPreviewKeys((prev) => { if (!prev[bucketKey]) return prev; diff --git a/src/components/BatchOperations.tsx b/src/components/BatchOperations.tsx index 1c75fed..551a9a0 100644 --- a/src/components/BatchOperations.tsx +++ b/src/components/BatchOperations.tsx @@ -3,7 +3,7 @@ */ import { useState } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; diff --git a/src/components/CalendarView.tsx b/src/components/CalendarView.tsx index 7cee4ca..45b0c9e 100644 --- a/src/components/CalendarView.tsx +++ b/src/components/CalendarView.tsx @@ -3,7 +3,7 @@ */ import { useState, useMemo } from "react"; -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id, Doc } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; diff --git a/src/components/ChangeCategoryDialog.tsx b/src/components/ChangeCategoryDialog.tsx index c5a6e92..86239c7 100644 --- a/src/components/ChangeCategoryDialog.tsx +++ b/src/components/ChangeCategoryDialog.tsx @@ -3,7 +3,7 @@ */ import { useState } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -20,7 +20,7 @@ export function ChangeCategoryDialog({ currentCategoryId, onClose, }: ChangeCategoryDialogProps) { - const { did, legacyDid } = useCurrentUser(); + const { did } = useCurrentUser(); const updateCategory = useMutation(api.lists.updateListCategory); const [categoryId, setCategoryId] = useState | undefined>(currentCategoryId); const [saving, setSaving] = useState(false); @@ -34,8 +34,6 @@ export function ChangeCategoryDialog({ await updateCategory({ listId, categoryId, - userDid: did, - legacyDid: legacyDid ?? undefined, }); onClose(); } catch (err) { diff --git a/src/components/Comments.tsx b/src/components/Comments.tsx index 09b17b3..26340ff 100644 --- a/src/components/Comments.tsx +++ b/src/components/Comments.tsx @@ -4,7 +4,7 @@ */ import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; @@ -43,8 +43,7 @@ export function Comments({ itemId, userDid, legacyDid, canEdit }: CommentsProps) const comments = useQuery(api.comments.getItemComments, { itemId, - userDid, - legacyDid, + }); const addComment = useMutation(api.comments.addComment); @@ -60,8 +59,7 @@ export function Comments({ itemId, userDid, legacyDid, canEdit }: CommentsProps) try { await addComment({ itemId, - userDid, - legacyDid, + text: newComment.trim(), }); setNewComment(""); @@ -83,8 +81,7 @@ export function Comments({ itemId, userDid, legacyDid, canEdit }: CommentsProps) try { await deleteComment({ commentId, - userDid, - legacyDid, + }); haptic("success"); } catch (err) { diff --git a/src/components/CreateListModal.tsx b/src/components/CreateListModal.tsx index e167d0a..22519ff 100644 --- a/src/components/CreateListModal.tsx +++ b/src/components/CreateListModal.tsx @@ -5,7 +5,7 @@ */ import { useState, type FormEvent } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { useNavigate, Link } from "react-router-dom"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; @@ -28,7 +28,7 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps const navigate = useNavigate(); const { haptic } = useSettings(); const createList = useMutation(api.lists.createList); - const existingLists = useQuery(api.lists.getUserLists, did ? { userDid: did } : "skip"); + const existingLists = useQuery(api.lists.getUserLists, did ? {} : "skip"); const [name, setName] = useState(""); const [categoryId, setCategoryId] = useState | undefined>(undefined); @@ -64,7 +64,6 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, name: trimmedName, - ownerDid: did, categoryId, createdAt: Date.now(), }); diff --git a/src/components/DeleteListDialog.tsx b/src/components/DeleteListDialog.tsx index 8567f2e..de51513 100644 --- a/src/components/DeleteListDialog.tsx +++ b/src/components/DeleteListDialog.tsx @@ -3,7 +3,7 @@ */ import { useState } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useFocusTrap } from "../hooks/useFocusTrap"; @@ -16,7 +16,7 @@ interface DeleteListDialogProps { } export function DeleteListDialog({ list, onClose, onDeleted }: DeleteListDialogProps) { - const { did, legacyDid } = useCurrentUser(); + const { did } = useCurrentUser(); const deleteList = useMutation(api.lists.deleteList); const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(null); @@ -32,8 +32,6 @@ export function DeleteListDialog({ list, onClose, onDeleted }: DeleteListDialogP // Pass both current and legacy DID for migrated users await deleteList({ listId: list._id, - userDid: did, - legacyDid: legacyDid ?? undefined, }); onDeleted(); } catch (err) { diff --git a/src/components/ItemAttribution.tsx b/src/components/ItemAttribution.tsx index 31cbd0f..801aa68 100644 --- a/src/components/ItemAttribution.tsx +++ b/src/components/ItemAttribution.tsx @@ -2,7 +2,7 @@ * Component showing who added/checked an item and when. */ -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { formatRelativeTime } from "../lib/time"; diff --git a/src/components/ItemDetailsModal.tsx b/src/components/ItemDetailsModal.tsx index b8429ef..c3103a4 100644 --- a/src/components/ItemDetailsModal.tsx +++ b/src/components/ItemDetailsModal.tsx @@ -5,7 +5,7 @@ */ import { useState, useEffect, useMemo } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { useNavigate } from "react-router-dom"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -93,8 +93,7 @@ export function ItemDetailsModal({ const comments = useQuery(api.comments.getItemComments, { itemId: item._id, - userDid, - legacyDid, + }); const participantDids = useMemo(() => { diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx index 27d7140..a3165dd 100644 --- a/src/components/ListCard.tsx +++ b/src/components/ListCard.tsx @@ -6,7 +6,7 @@ */ import { memo } from "react"; -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { Link } from "react-router-dom"; import type { Doc } from "../../convex/_generated/dataModel"; import { api } from "../../convex/_generated/api"; diff --git a/src/components/ListItem.tsx b/src/components/ListItem.tsx index 949c7e3..2dc9f48 100644 --- a/src/components/ListItem.tsx +++ b/src/components/ListItem.tsx @@ -7,7 +7,7 @@ */ import { useState, useRef, lazy, Suspense, memo } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { ItemAttribution } from "./ItemAttribution"; @@ -119,7 +119,7 @@ export const ListItem = memo(function ListItem({ if (onUncheck) { await onUncheck(item._id, userDid, legacyDid); } else { - await uncheckItemMutation({ itemId: item._id, userDid, legacyDid }); + await uncheckItemMutation({ itemId: item._id, }); } } else { if (onCheck) { @@ -127,8 +127,7 @@ export const ListItem = memo(function ListItem({ } else { await checkItemMutation({ itemId: item._id, - checkedByDid: userDid, - legacyDid, + checkedAt: Date.now(), }); } diff --git a/src/components/NativePushRegistrar.tsx b/src/components/NativePushRegistrar.tsx index 3bb3c20..f11a6ba 100644 --- a/src/components/NativePushRegistrar.tsx +++ b/src/components/NativePushRegistrar.tsx @@ -10,7 +10,7 @@ */ import { useEffect, useRef } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { Capacitor } from "@capacitor/core"; @@ -33,7 +33,7 @@ export function NativePushRegistrar() { clearInterval(interval); registered.current = true; try { - await registerPushToken({ userDid: did, token, platform: "ios" }); + await registerPushToken({ token, platform: "ios" }); } catch (err) { console.error("[NativePushRegistrar] Failed to register token:", err); } diff --git a/src/components/NestedListItem.tsx b/src/components/NestedListItem.tsx index 87e12df..4a94f9f 100644 --- a/src/components/NestedListItem.tsx +++ b/src/components/NestedListItem.tsx @@ -4,7 +4,7 @@ */ import { useState, useCallback, useMemo } from "react"; -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { ListItem } from "./ListItem"; diff --git a/src/components/OnboardingFlow.tsx b/src/components/OnboardingFlow.tsx index f02b03c..8a7da27 100644 --- a/src/components/OnboardingFlow.tsx +++ b/src/components/OnboardingFlow.tsx @@ -6,7 +6,7 @@ import { useState, type FormEvent, type KeyboardEvent } from "react"; import { createPortal } from "react-dom"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { useNavigate } from "react-router-dom"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; @@ -41,7 +41,7 @@ type Step = "welcome" | "create-list" | "add-items" | "share"; const STEPS: Step[] = ["welcome", "create-list", "add-items", "share"]; export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { - const { did, legacyDid } = useCurrentUser(); + const { did } = useCurrentUser(); const navigate = useNavigate(); const { haptic } = useSettings(); @@ -100,7 +100,6 @@ export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, name, - ownerDid: did, createdAt: Date.now(), }); setListId(id); @@ -127,8 +126,6 @@ export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { await addItem({ listId, name, - createdByDid: did, - legacyDid: legacyDid ?? undefined, createdAt: Date.now(), }); setAddedItems((prev) => [...prev, name]); @@ -157,7 +154,7 @@ export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { try { const webvhDid = buildListResourceDid(did, listId); - await publishList({ listId, webvhDid, publisherDid: did }); + await publishList({ listId, webvhDid }); const url = buildListResourceUrl(did, listId); setShareUrl(url); haptic("success"); diff --git a/src/components/ProvenanceInfo.tsx b/src/components/ProvenanceInfo.tsx index c53eddd..30222df 100644 --- a/src/components/ProvenanceInfo.tsx +++ b/src/components/ProvenanceInfo.tsx @@ -11,7 +11,7 @@ */ import { useEffect, useState } from "react"; -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { useNavigate } from "react-router-dom"; @@ -272,7 +272,6 @@ function CopyForProvenance({ list }: { list: Doc<"lists"> }) { assetDid: asset.assetDid, celEnvelope: asset.envelope, name, - ownerDid: did, createdAt: Date.now(), }); navigate(`/list/${listId}`); diff --git a/src/components/ReferralInvite.tsx b/src/components/ReferralInvite.tsx index fb63796..552e3b8 100644 --- a/src/components/ReferralInvite.tsx +++ b/src/components/ReferralInvite.tsx @@ -5,7 +5,7 @@ */ import { useState } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import type { Id } from "../../convex/_generated/dataModel"; diff --git a/src/components/ReferralRedeemer.tsx b/src/components/ReferralRedeemer.tsx index 03b893e..8143669 100644 --- a/src/components/ReferralRedeemer.tsx +++ b/src/components/ReferralRedeemer.tsx @@ -9,7 +9,7 @@ */ import { useEffect, useRef } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { REFERRAL_CODE_KEY } from "../pages/InviteLanding"; diff --git a/src/components/RenameListDialog.tsx b/src/components/RenameListDialog.tsx index fefa16a..1818e84 100644 --- a/src/components/RenameListDialog.tsx +++ b/src/components/RenameListDialog.tsx @@ -3,7 +3,7 @@ */ import { useState } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useFocusTrap } from "../hooks/useFocusTrap"; @@ -15,7 +15,7 @@ interface RenameListDialogProps { } export function RenameListDialog({ list, onClose }: RenameListDialogProps) { - const { did, legacyDid } = useCurrentUser(); + const { did } = useCurrentUser(); const renameList = useMutation(api.lists.renameList); const [name, setName] = useState(list.name); const [isRenaming, setIsRenaming] = useState(false); @@ -35,8 +35,6 @@ export function RenameListDialog({ list, onClose }: RenameListDialogProps) { await renameList({ listId: list._id, name: name.trim(), - userDid: did, - legacyDid: legacyDid ?? undefined, }); onClose(); } catch (err) { diff --git a/src/components/SaveAsTemplateModal.tsx b/src/components/SaveAsTemplateModal.tsx index 9020227..40e41b9 100644 --- a/src/components/SaveAsTemplateModal.tsx +++ b/src/components/SaveAsTemplateModal.tsx @@ -4,7 +4,7 @@ */ import { useState, type FormEvent } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -57,7 +57,6 @@ export function SaveAsTemplateModal({ listId, listName, onClose, onSuccess }: Sa templateName: trimmedName, description: description.trim() || undefined, isPublic, - userDid: did, }); haptic('success'); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 0f8b258..785daa4 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -14,7 +14,7 @@ import { supportsPushNotifications } from '../lib/notifications'; import { biometrics } from '../lib/biometrics'; import { checkForUpdateAndApply, clearAllCachesAndReload } from '../lib/sw-registration'; import { Panel } from './ui/Panel'; -import { useMutation, useQuery } from 'convex/react'; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from '../../convex/_generated/api'; import { useToast } from '../hooks/useToast'; diff --git a/src/components/ShareModal.tsx b/src/components/ShareModal.tsx index 040e3d7..166346e 100644 --- a/src/components/ShareModal.tsx +++ b/src/components/ShareModal.tsx @@ -4,7 +4,7 @@ */ import { useState, useEffect } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -66,7 +66,6 @@ export function ShareModal({ list, onClose }: ShareModalProps) { await publishListMutation({ listId: list._id, webvhDid: listResourceDid, - publisherDid: did, }); trackListShared('webvh'); @@ -84,7 +83,7 @@ export function ShareModal({ list, onClose }: ShareModalProps) { if (!did) return; setError(null); try { - await unpublishListMutation({ listId: list._id, userDid: did }); + await unpublishListMutation({ listId: list._id }); haptic('success'); } catch (err) { setError(err instanceof Error ? err.message : "Failed to unpublish"); diff --git a/src/components/SharedListResource.tsx b/src/components/SharedListResource.tsx index eacf0ff..057f3fc 100644 --- a/src/components/SharedListResource.tsx +++ b/src/components/SharedListResource.tsx @@ -8,9 +8,10 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { useParams, Link, useNavigate } from "react-router-dom"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; +import { useAuth } from "../hooks/useAuth"; import type { Id } from "../../convex/_generated/dataModel"; interface ListItem { @@ -65,11 +66,13 @@ export function SharedListResource() { const listId = resourceId?.startsWith("list-") ? resourceId.slice(5) : resourceId; const [resource, setResource] = useState(null); const [error, setError] = useState(null); + const [toggleError, setToggleError] = useState(null); const [loading, setLoading] = useState(true); const pollRef = useRef | null>(null); // Auth for favouriting const { did } = useCurrentUser(); + const { token } = useAuth(); const bookmarkMutation = useMutation(api.publication.bookmarkList); const unbookmarkMutation = useMutation(api.publication.unbookmarkList); @@ -79,7 +82,7 @@ export function SharedListResource() { const isBookmarked = useQuery( api.publication.isBookmarked, - did && convexListId ? { listId: convexListId, userDid: did } : "skip" + did && convexListId ? { listId: convexListId } : "skip" ); const [favouritePending, setFavouritePending] = useState(false); @@ -113,10 +116,10 @@ export function SharedListResource() { setFavouritePending(true); try { if (isBookmarked) { - await unbookmarkMutation({ listId: convexListId, userDid: did }); + await unbookmarkMutation({ listId: convexListId }); setBookmarkPlanLimit(false); } else { - await bookmarkMutation({ listId: convexListId, userDid: did }); + await bookmarkMutation({ listId: convexListId }); } } catch (err) { const msg = err instanceof Error ? err.message : ""; @@ -131,7 +134,9 @@ export function SharedListResource() { }; const handleToggleItem = async (itemId: string, currentChecked: boolean) => { - if (!userPath || !listId || !resource) return; + if (!userPath || !listId || !resource || !token) return; + + setToggleError(null); // Optimistic update setResource((prev) => { @@ -152,15 +157,23 @@ export function SharedListResource() { const action = currentChecked ? "uncheck" : "check"; const resp = await fetch(`${siteUrl}/d/${userPath}/resources/list-${listId}/items/${itemId}/${action}`, { method: "POST", + headers: { Authorization: `Bearer ${token}` }, + credentials: "include", }); if (!resp.ok) { - throw new Error(`Failed to toggle item (${resp.status})`); + throw new Error(resp.status === 401 ? "Sign in again to update this list." : "Couldn't update this item. Please try again."); } } catch (err) { console.error("Failed to toggle shared item:", err); - // Rollback by refetching - await fetchResource(); + setResource((prev) => { + if (!prev) return prev; + const items = prev.items.map((item) => + item._id === itemId ? { ...item, checked: currentChecked } : item + ); + return { ...prev, items, checkedCount: items.filter((item) => item.checked).length }; + }); + setToggleError(err instanceof Error ? err.message : "Couldn't update this item. Please try again."); } }; @@ -260,16 +273,22 @@ export function SharedListResource() { )} {/* Not logged in — nudge to sign up */} - {!did && ( + {!token && (
- + Sign in - {" "}to save this list to your favourites + {" "}to check off items in this shared list
)} + {toggleError && ( +
+ {toggleError} +
+ )} + {/* Logged-in shortcut to full app list view */} {did && convexListId && (
@@ -289,11 +308,13 @@ export function SharedListResource() {
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 569f765..783e415 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -4,7 +4,7 @@ */ import { Link } from "react-router-dom"; -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; @@ -18,7 +18,7 @@ export function Profile() { // Fetch user's lists const lists = useQuery( api.lists.getUserLists, - did ? { userDid: did, legacyDid: legacyDid ?? undefined } : "skip" + did ? {} : "skip" ); // Fetch user stats diff --git a/src/pages/PublicList.tsx b/src/pages/PublicList.tsx index 81d4eae..df027ed 100644 --- a/src/pages/PublicList.tsx +++ b/src/pages/PublicList.tsx @@ -7,7 +7,7 @@ import { useEffect } from "react"; import { useParams, Link } from "react-router-dom"; -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { VerificationBadge } from "../components/publish/VerificationBadge"; import { formatRelativeTime } from "../lib/time"; diff --git a/src/pages/SiteDetail.tsx b/src/pages/SiteDetail.tsx index c797488..face58b 100644 --- a/src/pages/SiteDetail.tsx +++ b/src/pages/SiteDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; -import { useAction, useQuery } from "convex/react"; +import { useAction, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -25,7 +25,7 @@ export function SiteDetail() { const site = useQuery( api.sites.getSite, - did && siteId ? { ownerDid: did, siteId: siteId as Id<"sites"> } : "skip" + did && siteId ? { siteId: siteId as Id<"sites"> } : "skip" ); const hostname = site?.primaryHostname?.hostname ?? ""; @@ -39,7 +39,7 @@ export function SiteDetail() { } (async () => { try { - const url = await getPreviewUrl({ siteId: site._id, ownerDid: did }); + const url = await getPreviewUrl({ siteId: site._id }); if (!cancelled) setPreviewSrc(url ?? ""); } catch { if (!cancelled) setPreviewSrc(""); @@ -66,7 +66,7 @@ export function SiteDetail() { setReplacing(true); haptic("medium"); try { - const { uploadUrl, bucketKey } = await generateUploadUrl({ ownerDid: did }); + const { uploadUrl, bucketKey } = await generateUploadUrl({}); const uploadResponse = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": "text/html; charset=utf-8" }, diff --git a/src/pages/Sites.tsx b/src/pages/Sites.tsx index 833d8bb..09736f0 100644 --- a/src/pages/Sites.tsx +++ b/src/pages/Sites.tsx @@ -1,6 +1,6 @@ import { useState, type ChangeEvent, type FormEvent } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { useAction, useQuery } from "convex/react"; +import { useAction, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useSettings } from "../hooks/useSettings"; @@ -13,7 +13,7 @@ export function Sites() { const { addToast } = useToast(); const generateUploadUrl = useAction(api.sites.generateSiteUploadUrl); const createSiteFromUpload = useAction(api.siteActions.createSiteFromUpload); - const sites = useQuery(api.sites.listSites, did ? { ownerDid: did } : "skip"); + const sites = useQuery(api.sites.listSites, did ? {} : "skip"); const [html, setHtml] = useState(""); const [selectedFileName, setSelectedFileName] = useState(null); @@ -46,7 +46,7 @@ export function Sites() { haptic("medium"); try { - const { uploadUrl, bucketKey } = await generateUploadUrl({ ownerDid: did }); + const { uploadUrl, bucketKey } = await generateUploadUrl({}); const uploadResponse = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": "text/html; charset=utf-8" }, diff --git a/src/pages/Templates.tsx b/src/pages/Templates.tsx index 633fae8..23979ed 100644 --- a/src/pages/Templates.tsx +++ b/src/pages/Templates.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id, Doc } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -26,7 +26,7 @@ export function Templates() { // Fetch templates from API const userTemplates = useQuery( api.templates.getUserTemplates, - did ? { userDid: did } : "skip" + did ? {} : "skip" ) ?? []; const publicTemplates = useQuery(api.templates.getPublicTemplates) ?? []; @@ -49,7 +49,6 @@ export function Templates() { assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, name: template.name, - ownerDid: did, createdAt: Date.now(), }); @@ -58,7 +57,6 @@ export function Templates() { await addItem({ listId, name: item.name, - createdByDid: did, createdAt: now, priority: item.priority, description: item.description, @@ -87,7 +85,6 @@ export function Templates() { const listId = await createListFromTemplate({ templateId: template._id, listName: template.name, - userDid: did, assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, }); @@ -107,7 +104,7 @@ export function Templates() { haptic('medium'); try { - await deleteTemplate({ templateId, userDid: did }); + await deleteTemplate({ templateId }); haptic('success'); } catch (err) { console.error("Failed to delete template:", err);