diff --git a/convex/activity.ts b/convex/activity.ts index 9aba704..e334b9f 100644 --- a/convex/activity.ts +++ b/convex/activity.ts @@ -1,45 +1,9 @@ -import { resourceUnavailable } from "./lib/authError"; -import { actorMutation, actorQuery } from "./lib/authenticated"; +import { actorQuery } from "./lib/authenticated"; +import { withoutCredential } from "./lib/actor"; import { v } from "convex/values"; -import { canUserEditList } from "./lib/permissions"; - -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")), - type: v.union( - v.literal("item_assigned"), - v.literal("item_unassigned"), - v.literal("presence_heartbeat"), - v.literal("presence_offline"), - v.literal("item_updated"), - v.literal("list_updated") - ), - metadata: v.optional(v.object({ - assigneeDid: v.optional(v.string()), - status: v.optional(v.union(v.literal("active"), v.literal("idle"), v.literal("offline"))), - note: v.optional(v.string()), - })), - }, - handler: async (ctx, args) => { - const canEdit = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); - if (!canEdit) throw new Error("Not authorized to write activity"); - - if (args.itemId && (await ctx.db.get(args.itemId))?.listId !== args.listId) throw resourceUnavailable(); - - return await ctx.db.insert("activities", { - listId: args.listId, - itemId: args.itemId, - actorDid: ctx.actor.did, - type: args.type, - metadata: args.metadata, - createdAt: Date.now(), - }); - }, -}); +// Activity rows are written only by the server operations they describe; a public +// writer let editors fabricate assignment/reconciliation history. export const { public: getListActivity, internal: getListActivityInternal } = actorQuery({ resources: args => ({ lists: [args.listId] }), @@ -55,6 +19,6 @@ export const { public: getListActivity, internal: getListActivityInternal } = ac .collect(); const sorted = events.sort((a, b) => b.createdAt - a.createdAt); - return sorted.slice(0, Math.max(1, Math.min(args.limit ?? 50, 200))); + return sorted.slice(0, Math.max(1, Math.min(args.limit ?? 50, 200))).map(withoutCredential); }, }); diff --git a/convex/billingHttp.ts b/convex/billingHttp.ts index 42722ff..4d0f8b4 100644 --- a/convex/billingHttp.ts +++ b/convex/billingHttp.ts @@ -13,7 +13,8 @@ import { httpAction } from "./_generated/server"; import { internal } from "./_generated/api"; import { requireAuth } from "./lib/auth"; -import { jsonResponse, errorResponse } from "./lib/httpResponses"; +import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; +import { authErrorData } from "./lib/authError"; import type { Id } from "./_generated/dataModel"; /** @@ -72,6 +73,8 @@ export const createCheckout = httpAction(async (ctx, request) => { return jsonResponse(request, { url }); } catch (err) { + // Authentication failures keep the shared 401/403 contract. + if (authErrorData(err)) return handlerErrorResponse(request, err, "Failed"); console.error("[createCheckout] error:", err); return errorResponse(request, err instanceof Error ? err.message : "Failed", 500); } @@ -98,6 +101,8 @@ export const createPortal = httpAction(async (ctx, request) => { return jsonResponse(request, { url }); } catch (err) { + // Authentication failures keep the shared 401/403 contract. + if (authErrorData(err)) return handlerErrorResponse(request, err, "Failed"); console.error("[createPortal] error:", err); return errorResponse(request, err instanceof Error ? err.message : "Failed", 500); } @@ -121,6 +126,8 @@ export const getSubscription = httpAction(async (ctx, request) => { return jsonResponse(request, { subscription: sub, plan: sub?.plan ?? "free" }); } catch (err) { + // Authentication failures keep the shared 401/403 contract. + if (authErrorData(err)) return handlerErrorResponse(request, err, "Failed"); console.error("[getSubscription] error:", err); return errorResponse(request, err instanceof Error ? err.message : "Failed", 500); } diff --git a/convex/bitcoinAnchors.ts b/convex/bitcoinAnchors.ts index e7e7d34..f9a2367 100644 --- a/convex/bitcoinAnchors.ts +++ b/convex/bitcoinAnchors.ts @@ -81,9 +81,10 @@ function buildCanonicalState( /** * Internal mutation to create an anchor record. - * Called by the action after computing the hash. + * Called by the action after computing the hash. Not public: a direct caller could + * record an arbitrary hash/snapshot instead of the server-computed state. */ -export const { public: createAnchorRecord, internal: createAnchorRecordInternal } = actorMutation({ +export const { internal: createAnchorRecordInternal } = actorMutation({ authority: "owner", resources: args => ({ lists: [args.listId] }), scope: "items:write", @@ -107,8 +108,10 @@ export const { public: createAnchorRecord, internal: createAnchorRecordInternal /** * Update anchor status after Bitcoin inscription. + * Internal only: inscription status, txid and confirmations come from the inscription + * path, never from a list owner or agent key asserting them. */ -export const { public: updateAnchorStatus, internal: updateAnchorStatusInternal } = actorMutation({ +export const { internal: updateAnchorStatusInternal } = actorMutation({ authority: "owner", resources: args => ({ anchors: [args.anchorId] }), scope: "items:write", diff --git a/convex/categoriesHttp.ts b/convex/categoriesHttp.ts index 8a07ea9..fe4b9a6 100644 --- a/convex/categoriesHttp.ts +++ b/convex/categoriesHttp.ts @@ -9,7 +9,8 @@ import { authenticatedRequest } from "./lib/actor"; import { httpAction } from "./_generated/server"; import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { jsonResponse, errorResponse } from "./lib/httpResponses"; +import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; +import { authErrorData } from "./lib/authError"; /** * POST /api/categories/create @@ -43,6 +44,8 @@ export const createCategory = httpAction(async (ctx, request) => { return jsonResponse(request, { categoryId }); } catch (error) { + // Authentication failures keep the shared 401/403 contract. + if (authErrorData(error)) return handlerErrorResponse(request, error, "Failed"); console.error("[categoriesHttp] createCategory error:", error); return errorResponse( request, @@ -83,6 +86,8 @@ export const renameCategory = httpAction(async (ctx, request) => { return jsonResponse(request, { success: true }); } catch (error) { + // Authentication failures keep the shared 401/403 contract. + if (authErrorData(error)) return handlerErrorResponse(request, error, "Failed"); console.error("[categoriesHttp] renameCategory error:", error); return errorResponse( request, @@ -122,6 +127,8 @@ export const deleteCategory = httpAction(async (ctx, request) => { return jsonResponse(request, { success: true }); } catch (error) { + // Authentication failures keep the shared 401/403 contract. + if (authErrorData(error)) return handlerErrorResponse(request, error, "Failed"); console.error("[categoriesHttp] deleteCategory error:", error); return errorResponse( request, @@ -162,6 +169,8 @@ export const setListCategory = httpAction(async (ctx, request) => { return jsonResponse(request, { success: true }); } catch (error) { + // Authentication failures keep the shared 401/403 contract. + if (authErrorData(error)) return handlerErrorResponse(request, error, "Failed"); console.error("[categoriesHttp] setListCategory error:", error); return errorResponse( request, diff --git a/convex/comments.ts b/convex/comments.ts index 609ae3f..cad02be 100644 --- a/convex/comments.ts +++ b/convex/comments.ts @@ -1,6 +1,7 @@ import { canUserEditList, canUserViewList } from "./lib/permissions"; import { actorQuery, actorMutation } from "./lib/authenticated"; import { resourceUnavailable } from "./lib/authError"; +import { withoutCredential } from "./lib/actor"; /** * Comments API - Threaded discussions on items for shared lists. * Enables collaboration through item-level comments. @@ -50,7 +51,7 @@ export const { public: getItemComments, internal: getItemCommentsInternal } = ac .collect(); // Sort by createdAt ascending (oldest first for a thread) - return comments.sort((a, b) => a.createdAt - b.createdAt); + return comments.sort((a, b) => a.createdAt - b.createdAt).map(withoutCredential); }, }); @@ -89,6 +90,7 @@ export const { public: addComment, internal: addCommentInternal } = actorMutatio return await ctx.db.insert("comments", { itemId: args.itemId, userDid: ctx.actor.did, + credential: ctx.actor.credential, text: args.text.trim(), createdAt: Date.now(), }); @@ -129,6 +131,18 @@ export const { public: deleteComment, internal: deleteCommentInternal } = actorM } await ctx.db.delete(args.commentId); + // Deletion leaves an audit row naming who removed it with which credential. Only + // the comment ID is kept: not its text or author, which readers of a later + // published list must not learn. + await ctx.db.insert("activities", { + listId: item.listId, + itemId: item._id, + actorDid: ctx.actor.did, + credential: ctx.actor.credential, + type: "comment_deleted", + metadata: { note: JSON.stringify({ commentId: comment._id }) }, + createdAt: Date.now(), + }); }, }); diff --git a/convex/didLogs.ts b/convex/didLogs.ts index 113ae66..8fefa8d 100644 --- a/convex/didLogs.ts +++ b/convex/didLogs.ts @@ -6,7 +6,7 @@ */ import { v } from "convex/values"; -import { internalMutation, query } from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; /** * Store or update a user's DID log. @@ -46,9 +46,10 @@ export const upsertDidLog = internalMutation({ }); /** - * Get a DID log by path (for resolution). + * Get a DID log by path (for resolution). Served publicly by the HTTP resolver; + * the lookups themselves are internal so only that projection is exposed. */ -export const getDidLogByPath = query({ +export const getDidLogByPath = internalQuery({ args: { path: v.string() }, handler: async (ctx, args) => { const record = await ctx.db @@ -62,7 +63,7 @@ export const getDidLogByPath = query({ /** * Get the full DID log record by path (includes userDid). */ -export const getDidLogRecordByPath = query({ +export const getDidLogRecordByPath = internalQuery({ args: { path: v.string() }, handler: async (ctx, args) => { return await ctx.db @@ -75,7 +76,7 @@ export const getDidLogRecordByPath = query({ /** * Get a DID log by user DID. */ -export const getDidLogByUserDid = query({ +export const getDidLogByUserDid = internalQuery({ args: { userDid: v.string() }, handler: async (ctx, args) => { const record = await ctx.db diff --git a/convex/didLogsHttp.ts b/convex/didLogsHttp.ts index bd54177..f20aa04 100644 --- a/convex/didLogsHttp.ts +++ b/convex/didLogsHttp.ts @@ -6,7 +6,7 @@ */ import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import { requireAuth, AuthError } from "./lib/auth"; import { assertDidLogOwnership, DidLogOwnershipError } from "./lib/didLogAuth"; @@ -89,7 +89,7 @@ export const getDidLog = httpAction(async (ctx, request) => { }); } - const log = await ctx.runQuery(api.didLogs.getDidLogByPath, { path }); + const log = await ctx.runQuery(internal.didLogs.getDidLogByPath, { path }); if (!log) { return new Response("Not found", { diff --git a/convex/didResources.ts b/convex/didResources.ts index bbfd614..53feb31 100644 --- a/convex/didResources.ts +++ b/convex/didResources.ts @@ -4,18 +4,19 @@ import { actorMutation } from "./lib/authenticated"; * Queries for serving list resources publicly. * * These are used by the HTTP handlers to serve lists as Originals resources - * at /{userPath}/resources/list-{id}. + * at /{userPath}/resources/list-{id}. They are internal: the HTTP handlers + * project the public fields, while these return whole list documents. */ import { v } from "convex/values"; -import { query } from "./_generated/server"; +import { internalQuery } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; /** * Get a list by its Convex ID, verifying ownership by DID. * Returns null if not found or owner doesn't match. */ -export const getPublicList = query({ +export const getPublicList = internalQuery({ args: { listId: v.string(), ownerDid: v.string(), @@ -43,7 +44,7 @@ export const getPublicList = query({ * Get all items for a list (public view — no auth required). * Only returns non-sensitive fields. */ -export const getPublicListItems = query({ +export const getPublicListItems = internalQuery({ args: { listId: v.id("lists"), }, @@ -81,36 +82,32 @@ export const getPublicListItems = query({ }); /** - * Get a list by ID without owner check (used as fallback for legacy users - * who don't have didLogs rows yet). + * Fallback for owners without a didLogs row at this path: a published list whose + * publication DID names this path. The controller derived from that DID must be the + * list owner's current or legacy DID, so one account's publication can never be + * served under another account's path. Returns null for every failure. */ -export const getListById = query({ - args: { listId: v.string() }, +export const getPublishedListForPath = internalQuery({ + args: { listId: v.string(), userPath: v.string() }, handler: async (ctx, args) => { + let list; try { - 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; + list = await ctx.db.get(args.listId as Id<"lists">); } catch { - return null; + return null; // Invalid ID format } - }, -}); - -/** - * Get active publication for a list. - */ -export const getActivePublicationByListId = query({ - args: { 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 || pub.status !== "active") return null; - return pub; + if (!list) return null; + const pub = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", list._id)).first(); + // Expected: did:webvh:{scid}:{domain}:{userPath}/resources/list-{listId} + const suffix = `/resources/list-${list._id}`; + if (pub?.status !== "active" || !pub.webvhDid.endsWith(`:${args.userPath}${suffix}`)) return null; + const controllerDid = pub.webvhDid.slice(0, -suffix.length); + if (list.ownerDid !== controllerDid) { + const owner = await ctx.db.query("users").withIndex("by_did", q => q.eq("did", list.ownerDid)).first() + ?? await ctx.db.query("users").withIndex("by_legacy_did", q => q.eq("legacyDid", list.ownerDid)).first(); + if (!owner || ![owner.did, owner.legacyDid].includes(controllerDid)) return null; + } + return { list, controllerDid }; }, }); diff --git a/convex/didResourcesHttp.ts b/convex/didResourcesHttp.ts index 7a1a343..145d91f 100644 --- a/convex/didResourcesHttp.ts +++ b/convex/didResourcesHttp.ts @@ -9,7 +9,7 @@ import { authenticatedRequest } from "./lib/actor"; */ import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; function corsHeaders(request: Request): Record { const origin = request.headers.get("Origin") || "*"; @@ -97,7 +97,7 @@ async function serveDidLog( headers: Record ): Promise { try { - const log = await ctx.runQuery(api.didLogs.getDidLogByPath, { path: userPath }); + const log = await ctx.runQuery(internal.didLogs.getDidLogByPath, { path: userPath }); if (!log) { return new Response("DID not found", { @@ -128,55 +128,34 @@ async function serveListResource( ): Promise { try { // Primary path: resolve owner DID via didLogs - const fullRecord = await ctx.runQuery(api.didLogs.getDidLogRecordByPath, { path: userPath }); + const fullRecord = await ctx.runQuery(internal.didLogs.getDidLogRecordByPath, { path: userPath }); let userDid: string | null = fullRecord?.userDid ?? null; let list = null; if (userDid) { - list = await ctx.runQuery(api.didResources.getPublicList, { + list = await ctx.runQuery(internal.didResources.getPublicList, { listId, ownerDid: userDid, }); } - // Fallback path for legacy users without didLogs rows yet: - // verify the list is actively published and the publication DID matches this URL path. + // Fallback path for legacy users without didLogs rows yet: the publication + // DID must name this path and be controlled by the list owner. if (!list) { - const candidate = await ctx.runQuery(api.didResources.getListById, { listId }); - if (!candidate) { + const published = await ctx.runQuery(internal.didResources.getPublishedListForPath, { listId, userPath }); + if (!published) { return new Response("List not found", { status: 404, headers: { "Content-Type": "text/plain", ...headers }, }); } - - const publication = await ctx.runQuery(api.didResources.getActivePublicationByListId, { - listId: candidate._id, - }); - if (!publication) { - return new Response("List not found", { - status: 404, - headers: { "Content-Type": "text/plain", ...headers }, - }); - } - - // Expected: did:webvh:{scid}:{domain}:{userPath}/resources/list-{listId} - const expectedSuffix = `:${userPath}/resources/list-${listId}`; - if (!publication.webvhDid.endsWith(expectedSuffix)) { - return new Response("List not found", { - status: 404, - headers: { "Content-Type": "text/plain", ...headers }, - }); - } - - // Derive controller DID from resource DID by stripping /resources/... suffix - userDid = publication.webvhDid.replace(/\/resources\/list-.+$/, ""); - list = candidate; + userDid = published.controllerDid; + list = published.list; } // Get list items - const items = await ctx.runQuery(api.didResources.getPublicListItems, { + const items = await ctx.runQuery(internal.didResources.getPublicListItems, { listId: list._id, }); @@ -240,28 +219,18 @@ async function toggleItem( 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; + const fullRecord = await ctx.runQuery(internal.didLogs.getDidLogRecordByPath, { path: userPath }); + const userDid: string | null = fullRecord?.userDid ?? null; let list = null; if (userDid) { - list = await ctx.runQuery(api.didResources.getPublicList, { listId, ownerDid: userDid }); + list = await ctx.runQuery(internal.didResources.getPublicList, { listId, ownerDid: userDid }); } if (!list) { - const candidate = await ctx.runQuery(api.didResources.getListById, { listId }); - if (!candidate) return new Response("List not found", { status: 404, headers }); - - const publication = await ctx.runQuery(api.didResources.getActivePublicationByListId, { - listId: candidate._id, - }); - if (!publication) return new Response("List not found", { status: 404, headers }); - - const expectedSuffix = `:${userPath}/resources/list-${listId}`; - if (!publication.webvhDid.endsWith(expectedSuffix)) { - return new Response("List not found", { status: 404, headers }); - } - list = candidate; + const published = await ctx.runQuery(internal.didResources.getPublishedListForPath, { listId, userPath }); + if (!published) return new Response("List not found", { status: 404, headers }); + list = published.list; } if (checked) { diff --git a/convex/lib/actor.ts b/convex/lib/actor.ts index aa48ad8..cd50b2a 100644 --- a/convex/lib/actor.ts +++ b/convex/lib/actor.ts @@ -18,6 +18,20 @@ export type ResolvedActor = { credential: { kind: "session" | "apiKey"; id: string }; }; +/** The credential behind an actor-wrapped operation, for audit rows written by shared + * helpers. Server-originated work (crons, migrations) has none. */ +export function actingCredential(ctx: object): ResolvedActor["credential"] | undefined { + return "actor" in ctx ? (ctx.actor as ResolvedActor).credential : undefined; +} + +/** Read projection: credential row IDs are stored for audit, never returned to readers + * (published lists are readable by any signed-in account). */ +export function withoutCredential(row: T): Omit { + const { credential: _credential, ...rest } = row; + void _credential; + return rest; +} + export function requestCredentials(request: Request): Credentials { return { authToken: extractTokenFromRequest(request) ?? undefined, diff --git a/convex/lib/assignments.ts b/convex/lib/assignments.ts index 272de9f..a2f0864 100644 --- a/convex/lib/assignments.ts +++ b/convex/lib/assignments.ts @@ -1,5 +1,6 @@ import type { Doc, Id } from '../_generated/dataModel'; import type { MutationCtx, QueryCtx } from '../_generated/server'; +import { actingCredential } from './actor'; export async function assignmentRows(ctx: QueryCtx, item: Doc<'items'>) { const storedRows = await ctx.db.query('itemAssignees').withIndex('by_item', q => q.eq('itemId', item._id)).collect(); @@ -82,12 +83,12 @@ export async function changeAssignments(ctx: MutationCtx, source: Doc<'items'>, for (const did of before) if (!after.has(did)) { // Remove every duplicate, while retaining all prior activities. for (const row of rows.filter(r => r.assigneeDid === did)) await ctx.db.delete(row._id); - await ctx.db.insert('activities', { listId: item.listId, itemId: item._id, actorDid, + await ctx.db.insert('activities', { listId: item.listId, itemId: item._id, actorDid, credential: actingCredential(ctx), type: 'item_unassigned', metadata: { assigneeDid: did, note: JSON.stringify({ reason: note ?? 'unassigned', priorAssignments: rows.filter(r => r.assigneeDid === did).map(r => ({ assignedByDid: r.assignedByDid, assignedAt: r.assignedAt, inferredFromLegacyScalar: r.inferredFromLegacyScalar ?? false })) }) }, createdAt: now }); } for (const did of after) if (!before.has(did)) { await ctx.db.insert('itemAssignees', { itemId: item._id, listId: item.listId, assigneeDid: did, assignedByDid: actorDid, assignedAt: now }); - await ctx.db.insert('activities', { listId: item.listId, itemId: item._id, actorDid, + await ctx.db.insert('activities', { listId: item.listId, itemId: item._id, actorDid, credential: actingCredential(ctx), type: 'item_assigned', metadata: { assigneeDid: did, ...(note ? { note } : {}) }, createdAt: now }); } const primary = change.legacy && after.has(change.legacy) ? change.legacy @@ -113,7 +114,7 @@ export async function insertInheritedAssignments(ctx: MutationCtx, args: { for (const assigneeDid of args.assigneeDids) { await ctx.db.insert('itemAssignees', { itemId: args.targetId, listId: args.listId, assigneeDid, assignedByDid: args.actorDid, assignedAt: args.assignedAt }); - await ctx.db.insert('activities', { listId: args.listId, itemId: args.targetId, actorDid: args.actorDid, + await ctx.db.insert('activities', { listId: args.listId, itemId: args.targetId, actorDid: args.actorDid, credential: actingCredential(ctx), type: 'item_assigned', metadata: { assigneeDid, note: `${args.reason} from item ${args.sourceId}; source assignment history remains on that item` }, createdAt: args.assignedAt }); } diff --git a/convex/lib/jwt.ts b/convex/lib/jwt.ts index 500351d..e025f0b 100644 --- a/convex/lib/jwt.ts +++ b/convex/lib/jwt.ts @@ -39,10 +39,29 @@ interface JWTPayload { * @returns Decoded token payload with turnkeySubOrgId and email * @throws Error if token is invalid, expired, or missing required fields */ +/** + * jose decodes base64url leniently: the unused low bits of a segment's final + * character may vary, so one signature has several valid token strings. Sessions and + * revocation tombstones are keyed by the token string's hash, so a re-encoded copy of a + * logged-out token would otherwise establish a fresh session. Accept only the single + * canonical encoding, which is what jose's signer produces. + */ +function isCanonicalJwt(token: string): boolean { + const parts = token.split("."); + return parts.length === 3 && parts.every(part => { + if (!/^[A-Za-z0-9_-]+$/.test(part)) return false; + try { return jose.base64url.encode(jose.base64url.decode(part)) === part; } + catch { return false; } + }); +} + export async function verifyAuthToken(token: string): Promise { if (!token) { throw new Error("Token is required"); } + if (!isCanonicalJwt(token)) { + throw new Error("Invalid token"); + } const jwtSecret = process.env.JWT_SECRET; if (!jwtSecret) { diff --git a/convex/lists.ts b/convex/lists.ts index ea43ecd..0983c0e 100644 --- a/convex/lists.ts +++ b/convex/lists.ts @@ -107,6 +107,18 @@ export async function assertListQuota( return { owner, isFirstList: existingLists.length === 0 }; } +/** Lists may only be filed in the actor's own categories; another account's + * category ID would let that account's later category writes touch this list. + * Returns the category to store: on create, a category deleted meanwhile (e.g. on + * another device) leaves the new list uncategorized instead of failing. */ +async function ownCategory(ctx: MutationCtx, actor: { did: string; legacyDid?: string }, categoryId: Id<"categories"> | undefined, { dropMissing = false } = {}) { + if (!categoryId) return undefined; + const category = await ctx.db.get(categoryId); + if (!category && dropMissing) return undefined; + if (!category || ![actor.did, actor.legacyDid].includes(category.ownerDid)) throw resourceUnavailable(); + return categoryId; +} + /** Shared transaction-local first-list benefit for every template/list entry point. */ export async function grantFirstListReferral(ctx: MutationCtx, owner: Doc<"users"> | null, isFirstList: boolean) { // Award 30-day referral Pro to both referee and referrer on first list creation @@ -145,6 +157,8 @@ export const { public: createList, internal: createListInternal, replay: createL 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 categoryId = await ownCategory(ctx, ctx.actor, args.categoryId, { dropMissing: true }); + // Notes are uncapped and are not a "first list" for the referral grant. const { owner, isFirstList } = args.kind === "note" ? { owner: null, isFirstList: false } @@ -154,7 +168,7 @@ export const { public: createList, internal: createListInternal, replay: createL assetDid: args.assetDid, name: args.name, ownerDid: ctx.actor.did, - categoryId: args.categoryId, + categoryId, createdAt: args.createdAt, kind: args.kind, noteSummary: args.kind === "note" @@ -366,10 +380,7 @@ export const { public: updateListCategory, internal: updateListCategoryInternal throw resourceUnavailable(); } - if (args.categoryId) { - const category = await ctx.db.get(args.categoryId); - if (!category) throw new Error("Category not found"); - } + await ownCategory(ctx, ctx.actor, args.categoryId); await ctx.db.patch(args.listId, { categoryId: args.categoryId }); }, diff --git a/convex/migrations/remintUserDidDb.ts b/convex/migrations/remintUserDidDb.ts index 210ab32..0ef6e05 100644 --- a/convex/migrations/remintUserDidDb.ts +++ b/convex/migrations/remintUserDidDb.ts @@ -235,6 +235,11 @@ export const applyRemint = internalMutation({ // Someone already re-minted this user; don't rewrite a second time. return { rewritten: 0, skipped: true }; } + // Never move an account onto an identity another account holds: ownership is + // DID-based, so that would hand over the other account's lists and keys. + const holder = await ctx.db.query("users").withIndex("by_did", (q) => q.eq("did", args.newDid)).first() + ?? await ctx.db.query("users").withIndex("by_legacy_did", (q) => q.eq("legacyDid", args.newDid)).first(); + if (holder && holder._id !== user._id) throw new Error("DID is already in use by another account"); let rewritten = 0; @@ -266,7 +271,8 @@ export const applyRemint = internalMutation({ const patch: Record = {}; for (const field of fields) { const value = row[field]; - if (typeof value === "string" && value.startsWith(args.oldDid)) { + // Whole-DID prefix only: `{oldDid}/...`, never a longer DID sharing its characters. + if (typeof value === "string" && value.startsWith(`${args.oldDid}/`)) { patch[field] = args.newDid + value.slice(args.oldDid.length); } } diff --git a/convex/notificationActions.ts b/convex/notificationActions.ts index c40c407..9f777c7 100644 --- a/convex/notificationActions.ts +++ b/convex/notificationActions.ts @@ -43,42 +43,8 @@ export const { public: sendPushNotification, internal: sendPushNotificationAuthe }, }); -export const { public: sendListNotification, internal: sendListNotificationAuthenticatedInternal } = actorAction({ - resources: args => ({ lists: [args.listId] }), - scope: "*", - args: { - listId: v.id("lists"), - excludeDid: v.optional(v.string()), - title: v.string(), - body: v.string(), - data: v.optional(v.any()), - }, - handler: async (ctx, args): Promise> => { - type TokenRecord = { userDid: string; token: string; platform: string; webPushKeys?: { p256dh: string; auth: string } }; - const tokens: TokenRecord[] = await ctx.runQuery(internal.notifications.getTokensForList, { - listId: args.listId, - }); - - const filtered: TokenRecord[] = args.excludeDid - ? tokens.filter((t: TokenRecord) => t.userDid !== args.excludeDid) - : tokens; - - const results: PromiseSettledResult[] = await Promise.allSettled( - filtered.map((tok: TokenRecord) => { - if (tok.platform === "ios") { - return sendAPNs(args.title, args.body, args.data, tok.token); - } else { - return sendWebPush(args.title, args.body, args.data, tok.token, tok.webPushKeys!); - } - }) - ); - - return results.map((r: PromiseSettledResult, i: number) => ({ - platform: filtered[i].platform, - status: r.status, - })); - }, -}); +// List-wide pushes are server-originated only (sendListNotificationInternal): a +// public variant let any editor push arbitrary text to every member and bookmarker. // ─── Internal variants (for scheduling from mutations) ────────────── diff --git a/convex/presence.ts b/convex/presence.ts index f191e73..c029681 100644 --- a/convex/presence.ts +++ b/convex/presence.ts @@ -39,6 +39,7 @@ export const { public: heartbeat, internal: heartbeatInternal } = actorMutation( await ctx.db.insert("activities", { listId: args.listId, actorDid: ctx.actor.did, + credential: ctx.actor.credential, type: "presence_heartbeat", metadata: { status }, createdAt: now, @@ -71,6 +72,7 @@ export const { public: markOffline, internal: markOfflineInternal } = actorMutat await ctx.db.insert("activities", { listId: args.listId, actorDid: ctx.actor.did, + credential: ctx.actor.credential, type: "presence_offline", metadata: { status: "offline" }, createdAt: now, diff --git a/convex/publication.ts b/convex/publication.ts index d432d8e..de17342 100644 --- a/convex/publication.ts +++ b/convex/publication.ts @@ -43,6 +43,10 @@ export const { public: publishList, internal: publishListInternal } = actorMutat } // Publication grants public read only. New note publishing is a separate release. if (isNote(list)) throw new Error("Notes cannot be published"); + // The resource DID is served under its controller's path, so it must name the + // publisher's own identity, never one asserted for another account. + const resourceDids = [ctx.actor.did, ctx.actor.legacyDid].filter(Boolean).map(did => `${did}/resources/list-${list._id}`); + if (!resourceDids.includes(args.webvhDid)) throw new Error("Publication DID must be your own resource DID for this list"); // Check if already published const existing = await ctx.db diff --git a/convex/schema.ts b/convex/schema.ts index 9421cf3..00e9a8d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -8,6 +8,13 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +// The authenticated session or specific API key that acted (#236). Absent on system +// rows and on rows written before credential attribution existed. +const actingCredential = v.optional(v.object({ + kind: v.union(v.literal("session"), v.literal("apiKey")), + id: v.string(), +})); + export default defineSchema({ listInvitations: defineTable({ listId: v.id("lists"), ownerId: v.id("users"), email: v.string(), @@ -311,13 +318,15 @@ export default defineSchema({ v.literal("presence_heartbeat"), v.literal("presence_offline"), v.literal("item_updated"), - v.literal("list_updated") + v.literal("list_updated"), + v.literal("comment_deleted") ), metadata: v.optional(v.object({ assigneeDid: v.optional(v.string()), status: v.optional(v.union(v.literal("active"), v.literal("idle"), v.literal("offline"))), note: v.optional(v.string()), })), + credential: actingCredential, createdAt: v.number(), }) .index("by_list", ["listId"]) @@ -517,6 +526,7 @@ export default defineSchema({ itemId: v.id("items"), userDid: v.string(), // Author of the comment text: v.string(), + credential: actingCredential, createdAt: v.number(), }) .index("by_item", ["itemId"]) diff --git a/convex/sites.ts b/convex/sites.ts index d50b7d3..9b0de68 100644 --- a/convex/sites.ts +++ b/convex/sites.ts @@ -2,7 +2,7 @@ import { getSiteAllowance, requireSiteCapacity, requireCustomDomains } from "./l import { isResourceOwner } from "./lib/permissions"; import { actorAction, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { internalQuery, query } from "./_generated/server"; +import { internalQuery } from "./_generated/server"; import { internal } from "./_generated/api"; import { bucketKey as makeBucketKey, @@ -131,7 +131,8 @@ export const getSiteFileBucketKey = internalQuery({ }, }); -export const getPublicSiteByHostname = query({ +// Internal: the Sites HTTP resolver projects only public fields and active hostnames. +export const getPublicSiteByHostname = internalQuery({ args: { hostname: v.string() }, handler: async (ctx, args) => { const normalizedHostname = args.hostname.toLowerCase(); diff --git a/convex/sitesHttp.ts b/convex/sitesHttp.ts index dcc0133..4278d76 100644 --- a/convex/sitesHttp.ts +++ b/convex/sitesHttp.ts @@ -1,5 +1,5 @@ import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import { presignGet } from "./lib/bucket"; function json(data: unknown, status = 200): Response { @@ -37,7 +37,7 @@ export const resolveSiteHost = httpAction(async (ctx, request) => { return json({ status: "error", error: "hostname is required" }, 400); } - const record = await ctx.runQuery(api.sites.getPublicSiteByHostname, { + const record = await ctx.runQuery(internal.sites.getPublicSiteByHostname, { hostname, }); @@ -99,7 +99,7 @@ export const resolveSiteAsset = httpAction(async (ctx, request) => { return json({ status: "error", error: "hostname and fileName are required" }, 400); } - const record = await ctx.runQuery(api.sites.getPublicSiteByHostname, { hostname }); + const record = await ctx.runQuery(internal.sites.getPublicSiteByHostname, { hostname }); if (!record) return json({ status: "missing" }, 404); if (record.hostname.status !== "active") { return json({ status: "pending" }, 404); diff --git a/convex/userHttp.ts b/convex/userHttp.ts index 04264dd..02c5de2 100644 --- a/convex/userHttp.ts +++ b/convex/userHttp.ts @@ -12,7 +12,7 @@ import { unauthorizedResponseWithCors, } from "./lib/auth"; import { jsonResponse, errorResponse } from "./lib/httpResponses"; -import { assertDidLogOwnership, DidLogOwnershipError } from "./lib/didLogAuth"; +import { assertDidLogOwnership, didLogPathForSubOrg, DidLogOwnershipError } from "./lib/didLogAuth"; /** The domain encoded in a did:webvh, percent-decoded. Null if not a did:webvh. */ function didWebvhDomain(did: string): string | null { @@ -52,9 +52,12 @@ export const updateUserDID = httpAction(async (ctx, request) => { return errorResponse(request, "Cannot update to a temporary DID"); } - if (!did.startsWith("did:webvh:") && !did.startsWith("did:key:")) { - return errorResponse(request, "Invalid DID format. Expected did:webvh or did:key"); + if (!did.startsWith("did:webvh:")) { + return errorResponse(request, "Invalid DID format. Expected did:webvh"); } + // The account identity comes from the token; the DID must be minted at this + // sub-org's own path. did:key is derived server-side at login, never accepted here. + assertDidLogOwnership({ subOrgId: auth.turnkeySubOrgId, userDid: did, path: didLogPathForSubOrg(auth.turnkeySubOrgId) }); console.log(`[userHttp] Updating DID for ${auth.email} to ${did}`); @@ -72,6 +75,9 @@ export const updateUserDID = httpAction(async (ctx, request) => { if (err instanceof AuthError) { return unauthorizedResponseWithCors(request, err.message); } + if (err instanceof DidLogOwnershipError) { + return errorResponse(request, err.message, 403); + } console.error("[userHttp] Update DID error:", err); return errorResponse( request, @@ -140,12 +146,12 @@ export const remintUserDID = httpAction(async (ctx, request) => { }); } - // Checked before applyRemint: storeDidLog patches the didLogs row matching - // `path`, so an unchecked body could point any other account's serving path - // at this caller's log. Rejecting afterwards would leave rows already moved. - if (didLog && path) { - assertDidLogOwnership({ subOrgId: auth.turnkeySubOrgId, userDid: newDid, path }); - } + // Checked before applyRemint, whether or not a log is supplied: the new DID + // must be minted at this sub-org's own path, so it cannot name another + // account. storeDidLog patches the didLogs row matching `path`, so an + // unchecked path could also point another account's serving path at this + // caller's log. Rejecting afterwards would leave rows already moved. + assertDidLogOwnership({ subOrgId: auth.turnkeySubOrgId, userDid: newDid, path: path ?? didLogPathForSubOrg(auth.turnkeySubOrgId) }); const { rewritten } = await ctx.runMutation( internal.migrations.remintUserDidDb.applyRemint, diff --git a/convex/users.ts b/convex/users.ts index 477bb77..557820b 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -20,7 +20,7 @@ import type { Doc, Id, TableNames } from "./_generated/dataModel"; * and the user record itself. */ export const { public: deleteUserData, internal: deleteUserDataInternal } = actorMutation({ - resources: () => ({}), + resources: args => ({ accounts: [args.userId] }), scope: "*", allowDeletingAccount: true, args: { diff --git a/docs/authenticated-function-inventory.md b/docs/authenticated-function-inventory.md new file mode 100644 index 0000000..28d2b01 --- /dev/null +++ b/docs/authenticated-function-inventory.md @@ -0,0 +1,91 @@ +# Public Convex function inventory (#236) + +Every function a browser, native app or direct Convex client can call, classified by +how it establishes who is acting. `scripts/public-function-boundary.test.mjs` loads the +real registrations and fails when: +- a new public function is not covered by one of these classes; +- a protected function, called with validator-shaped business arguments naming real + fixture rows, accepts an anonymous, forged-key or asserted-identity call; +- such a call reads anything beyond the credential tables, or writes, before rejecting; +- a non-public HTTP route answers such a request with anything other than 401/403, + writes, or reads beyond the credential tables (`/d/*` may also read public resolution + tables). + +Snapshot: 172 public registrations (156 actor-wrapped, 5 self-authenticating, +7 rejecting compatibility names, 4 intentionally public). HTTP routes are listed +separately below. + +## 1. Actor-wrapped (156) + +Defined with `actorQuery` / `actorMutation` / `actorAction` (`convex/lib/authenticated.ts`). +The wrapper resolves the actor from `authToken` (a signed JWT whose hash has a live +`accessSessions` row) or `apiKey` (an unrevoked `agentApiKeys` row), checks the declared +API scope, rejects identity-assertion fields (`userDid`, `ownerDid`, `checkedByDid`, +`legacyDid`, …) that do not match that actor, authorizes declared resources (lists, +items, anchors, accounts) and passes only declared business arguments to the handler. +Handlers attribute writes to `ctx.actor`. `ctx.actor.credential` identifies the session +row or the specific API-key row that acted. Activity rows (assignment, presence) persist it +as `credential` on activity rows (assignment, presence, comment deletion) and comments, +and strips it from read responses. Resources a handler loads beyond the declared +ones (tags, comments, categories, templates, sites, grants, invitations) are checked +against the actor inside the handler. + +`activity`, `assignees`, `attachments`, `billing`, `bitcoinAnchors` (reads, `anchorListState`, +`verifyAnchorState`), `categories`, `comments`, `didCreation`, `didResources` +(`checkSharedItem`, `uncheckSharedItem`), `feedback`, `invitations`, `itemCategories`, +`items` (including `*Replay`), `listGrants`, `lists` (including `*Replay`), `notes`, +`notificationActions.sendPushNotification`, `notifications`, `originals`, `presence`, +`publication` (except `getPublicList`), `referrals`, `siteActions`, `siteAssets`, `sites`, +`tags`, `templates` (except `getPublicTemplates`), `users` (except `getUsersByDids`). + +## 2. Self-authenticating (5) + +| Function | Identity source | +|---|---| +| `actorSession.establish` | Verifies the JWT signature; records only its hash. Proves possession of a token, never a DID. | +| `actorSession.revoke` | Requires the raw token being revoked. | +| `auth.getUserByTurnkeyId`, `auth.getUserByEmail`, `auth.upsertUser` | `requireSession`; arguments must equal the session's sub-org/email; `upsertUser` cannot link a new current or legacy DID. Compatibility names (AUTH-COMPAT-RETIREMENT). | + +## 3. Rejecting compatibility names (7) + +`authSessions.createSession|getSession|markSessionVerified|deleteSession` and +`rateLimits.checkAndIncrement|checkStatus|cleanupExpired` throw for every caller; the +HTTP login flow uses their internal registrations (#241, #251). + +## 4. Intentionally public (4) + +| Function | What an anonymous caller gets | +|---|---| +| `publication.getPublicList` | Lists with an active publication only; attribution names masked (#251). | +| `templates.getPublicTemplates` | Templates their owners marked public. | +| `users.getUsersByDids` | Public display names for attribution; never emails (#251). | +| `waitlist.joinWaitlist` | Inserts the submitted email; no account or identity. | + +## Made internal or removed in this change + +| Was public | Now | Why | +|---|---|---| +| `bitcoinAnchors.createAnchorRecord`, `updateAnchorStatus` | internal only | A list owner or `items:write` key could record an arbitrary hash or mark an anchor `confirmed` with any txid; `verifyAnchorState` would then report it valid. | +| `activity.recordActivity` | removed | Editors could fabricate assignment/reconciliation history. No client used it. | +| `notificationActions.sendListNotification` | removed | Any editor could push arbitrary text to every member and bookmarker. Server code uses `sendListNotificationInternal`. | +| `sites.getPublicSiteByHostname` | internal | Returned the whole site and hostname rows (owner DID, Cloudflare state) for any hostname; the HTTP resolver already projects public fields for active hostnames. | +| `didResources.getPublicList`, `getPublicListItems` | internal | HTTP-only; returned whole list documents. | +| `didResources.getListById`, `getActivePublicationByListId` | replaced by internal `getPublishedListForPath` | The fallback trusted a publication's `webvhDid` to name the controller. | +| `didLogs.getDidLogByPath`, `getDidLogRecordByPath`, `getDidLogByUserDid` | internal | HTTP-only resolver lookups; `did.jsonl` stays public through the HTTP route. | + +None of these names were called by any browser or native client (they appeared only in the +generated session registry), so no deployed client depends on them. + +## HTTP routes + +All authenticated routes call `authenticatedRequest` / `requireAuth` and then the +`.internal` registration of the same actor-wrapped operation with only the request's +credentials, so the actor is re-resolved in the transaction. API keys cannot create API +keys. Category and billing routes now return 401/403 for credential failures instead of +500. Unauthenticated routes: OTP `/auth/initiate|verify`, Stripe webhook (signature), +`/api/sites/resolve-*`, `GET /api/did/log`, `/d/*` (active publications), public-list +attachment downloads, `/health`. + +`POST /api/user/updateDID` and `POST /api/user/remintDid` take the account from the JWT and +now also require the new DID to be a `did:webvh` minted at that account's own path +(`user-`); `applyRemint` refuses a DID held by any other account. diff --git a/docs/authentication-rollout.md b/docs/authentication-rollout.md index 558f1a4..a052e80 100644 --- a/docs/authentication-rollout.md +++ b/docs/authentication-rollout.md @@ -343,3 +343,157 @@ email addresses, normalized/case-insensitive local-part matches, generic default and hidden characters. Preview, inbox, queued mail and acceptance all enforce this policy, including historical pending invitations. The account email remains private. The existing general attribution masking contract above is unchanged. + +## #236 closure: inventory, identity binding and remaining direct entry points + +[`authenticated-function-inventory.md`](authenticated-function-inventory.md) classifies +every public Convex function. `scripts/public-function-boundary.test.mjs` enforces it +against the real registrations: any new public function must be actor-wrapped or +explicitly classified, and every protected function must reject anonymous, unknown-key +and asserted-identity (current and legacy DID) calls before any read or write. + +### Authentication integration: keep the session-record boundary + +The boundary from #241 stays: browser and Capacitor send the OTP-issued JWT as +`authToken`, which must match a live `accessSessions` row; agents send `apiKey`. Convex's +built-in `ctx.auth` (`auth.config.ts` custom JWT/OIDC provider) was considered and not +adopted, because: + +- it validates asymmetric (RS256/ES256) tokens against a JWKS endpoint, while the + Turnkey/OTP flow issues HS256 tokens under `JWT_SECRET`. Switching would need new + signing keys, a published JWKS and a re-login for every session, which is an + infrastructure decision this change does not need; +- `ctx.auth` identities are stateless. The `accessSessions` record is what makes logout, + expiry and persistent-mobile-session revocation invalidate reactive queries; +- API keys would still need the argument path, so it would add a second boundary rather + than replace one. + +The session boundary already gives server-derived identity for every caller. If a JWKS +issuer is adopted later, `authenticate()` in `convex/lib/actor.ts` is the single place to +add a `ctx.auth` branch. + +### Gaps closed + +- **Account DID binding.** `/api/user/remintDid` let an authenticated user move their + account onto any `did:webvh`, including another user's. Ownership is DID-based, so that + gave owner access to the victim's lists and keys. Re-mint and `/api/user/updateDID` now + require a `did:webvh` minted at the caller's own path. `applyRemint` refuses a DID held + by another account, and its publication prefix rewrite matches only `{oldDid}/…`. + `updateDID` no longer accepts `did:key`, which only server-side login derives. +- **Publication DID binding.** `publishList` requires `webvhDid` to be + `{actor current or legacy DID}/resources/list-{listId}` (what every client sends). The + `/d/*` fallback serves a list under a path only when the publication's controller is the + list owner's current or legacy DID. +- **Internal operations no longer public.** Anchor record writes, list-wide pushes, + activity writes, and the Sites/DID resolver lookups (see inventory). None were called by + any client. +- **Cross-account references.** `createList` and `updateListCategory` accept only the + actor's own categories. On create, a category deleted meanwhile leaves the list + uncategorized instead of failing. `deleteUserData` declares its account resource at the boundary. +- **HTTP status contract.** Category and billing routes returned 500 for credential + failures; they now return 401/403 like every other route. + +- **Session revocation bypass (found by adversarial review).** `jose` decodes base64url + leniently: the unused low bits of a JWT signature's last character can vary, giving + several valid strings for one signature. Sessions and revocation tombstones are keyed + by the token string's hash, so a re-encoded copy of a logged-out, revoked or expired + token could establish a fresh session. This dates from #241. `verifyAuthToken` now + accepts only the canonical encoding, which is what the signer always produces, so + existing tokens are unaffected. +- **Clients adopting refused DIDs.** On OTP login and session restore the client adopted + its newly minted `did:webvh` even when `/api/user/updateDID` failed. Since publishing and + ownership use the server's account DID, it now adopts the DID only after a successful + response; the upgrade is retried on the next restore. + +### Credential attribution and PR #277 + +Every actor-wrapped call resolves `ctx.actor.credential`: the `accessSessions` row or the +specific `agentApiKeys` row, with that key's scopes and revocation checked in the same +transaction (#273). + +This change persists it as an optional `credential` field (`{ kind: "session" | "apiKey", id }`) +on: +- activity rows: assignment, unassignment, inherited assignments and presence events; +- comments; +- a new `comment_deleted` activity row, which records only the comment ID (not its + text or author). + +Two keys on one account therefore leave distinguishable history. Credentials are stored +for audit but stripped from every read response (`getListActivity`, `getItemComments` and +the activity HTTP route), because published lists are readable by any signed-in account. +An owner-facing audit view is a possible follow-up. + +The shared assignment helpers read the credential from the actor context the wrapper +already provides. That leaves the `items.ts` call sites that PR #277 (#237) restructures +untouched; the branches merge cleanly. Server-originated rows (reconciliation, crons) +and rows written earlier have no credential. #277 adds signed action records binding the +same credential for item and list actions. Agents reading `/api/activity/list` may now see +the additive `comment_deleted` type. + +### Rollout order (coordinated with #262) + +1. **Deploy Convex first.** Everything here tightens the server. The only schema changes are + additive: optional `credential` on `activities` and `comments`, and the + `comment_deleted` activity type. Existing rows satisfy them. There is no + new client call or public name, and no ordering in which access widens. +2. Then deploy web/native. The only client change is the regenerated session registry, + which drops four names no UI calls. Older clients keep working because none call the + removed names, and every client already sends a self-path `did:webvh` and an own-DID + `webvhDid`. +3. Degradation: a stale or forked client that sends a foreign category, a non-self + publication DID, or a `did:key` to `updateDID` gets an error; nothing is written. A + failed re-mint keeps the old DID (the client retries on a later load). +4. Rollback: reverting the backend reopens the gaps. Repair forward instead, as with #241. +5. #262 is unchanged: the deployed-client inventory and staging evidence above remain + pending, and #262 still requires that evidence before recipient grants are enabled. + +**Recommended read-only data checks before or after deploy (not run here):** +- publications whose `webvhDid` controller is neither the list owner's current nor + legacy DID; +- users sharing a `did`/`legacyDid` value; +- anchors with `status` `inscribed`/`confirmed` not produced by `anchorListState`; +- lists whose `categoryId` belongs to another owner; +- active publications served through the `/d/*` fallback whose list `ownerDid` matches no + user's `did`/`legacyDid` (for example a `did:temp`/`did:key` later replaced). These + links worked before and now return 404; repair them by rewriting `webvhDid` to the + owner's current DID, not by loosening the check. + +Any hits are evidence of earlier misuse and need an owner decision; this change does not +rewrite them. + +### Follow-ups found during the audit (outside #236 scope) + +- An editor can publish a list's contents as a public template, via `createFromList` + (`isPublic`), or by saving privately and then `updateTemplate`. Any reader can also + retype the items into `createTemplate`, so a server rule cannot prevent this by itself. + Whether shared-list contents may be published as templates is a product decision. + This change does not redefine template publication. + +- Push registration re-binds an existing token or endpoint to whoever presents it, and + `registerPushToken` accepts any URL for `web`. That URL is later POSTed to server-side. + Validate push-service hosts and insert rather than re-bind. +- `users.getUsersByDids` has no input cap and scans users per DID. +- Anonymous `publication.getPublicList` does not apply the owner-deletion barrier that + `canUserViewList` applies. +- An `items:write` key can delete lists it owns (owner authority, write scope). Confirm + this is intended. +- `/api/attachments/download` does not register a never-used cookie JWT (fails closed). + +### Verification for this change + +- `bun test`: 702 pass, 0 fail (683 on `main` plus 19 new): + - 6 exhaustive boundary tests: all 172 public registrations, plus every HTTP route + queried anonymously, with a forged key, and with a forged bearer token; + - 9 identity-binding regressions, covering re-mint, `updateDID`, publication DID, + resolver fallback, categories, persisted session/key attribution (activities and + comments) and credential resolution. + - The HTTP pass also fails on any read beyond the credential tables before rejection; + `/d/*` may additionally read the public resolution tables. Discovery includes + subdirectories such as `migrations/`. + - A token-revival regression (re-encoded signatures of a revoked token) and four + client tests: refused `updateDID` keeps the server DID on login and restore. +- The boundary test was checked against deliberately reintroduced gaps: a public copy of a + formerly internal query, and a raw mutation that trusts `checkedByDid`. It fails on both. + Its HTTP pass found the 500-for-auth responses fixed above. +- Not verified: live Convex deployment and codegen, OTP/login on deployed web/iOS/Android, + real did:webvh re-mint against production data, and the data checks above. diff --git a/scripts/auth-boundary.test.mjs b/scripts/auth-boundary.test.mjs index d81bbff..83bdee1 100644 --- a/scripts/auth-boundary.test.mjs +++ b/scripts/auth-boundary.test.mjs @@ -113,7 +113,7 @@ test('published reads remain public, outsider editing is denied, and unpublishin 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.equal(await call('didResources','getPublishedListForPath',ctx,{listId:'L1',userPath:'owner'}),null); assert.deepEqual(await call('didResources','getPublicListItems',ctx,{listId:'L1'}),[]); }); test('bookmarks remain actor-owned across unpublishing, migration, and republication', async () => { @@ -136,10 +136,10 @@ test('bookmarks remain actor-owned across unpublishing, migration, and republica 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'}); + await call('publication','publishList',ctx,{authToken:ownerToken,listId:'L1',webvhDid:'did:owner/resources/list-L1'}); 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'})); + assert.ok(await call('publication','getPublicList',ctx,{webvhDid:'did:owner/resources/list-L1'})); await call('publication','bookmarkList',ctx,{authToken:strangerToken,listId:'L1'}); assert.equal(await call('publication','isBookmarked',ctx,{authToken:strangerToken,listId:'L1'}),true); @@ -157,9 +157,13 @@ test('bookmark state and publication status still require authentication and sco }); 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 assert.rejects(() => call('publication','publishList',ctx,{authToken:strangerToken,listId:'L1',webvhDid:'did:legacy/resources/list-L1',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'}); + // The publication DID must be the publisher's own resource DID for this list. + for (const webvhDid of ['did:pub','did:stranger/resources/list-L1','did:owner/resources/list-L2']) + await assert.rejects(() => call('publication','publishList',ctx,{authToken:ownerToken,listId:'L1',webvhDid}),/own resource DID/); + assert.equal(ctx.rows.publications.length,0); + await call('publication','publishList',ctx,{authToken:ownerToken,listId:'L1',webvhDid:'did:legacy/resources/list-L1'}); assert.equal(ctx.rows.publications[0].publishedByDid,'did:owner'); }); test('attachment registration is authorized and bound to the target item', async () => { diff --git a/scripts/auth-provider.test.mjs b/scripts/auth-provider.test.mjs index 81586ab..4e45ce8 100644 --- a/scripts/auth-provider.test.mjs +++ b/scripts/auth-provider.test.mjs @@ -6,7 +6,8 @@ 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 = { platform: 'web', storage: new Map(), establish: async () => {} }; +const unexpectedMint = async () => { throw new Error('Unexpected DID creation'); }; +const state = globalThis.__authProviderTest = { platform: 'web', storage: new Map(), establish: async () => {}, mint: unexpectedMint }; 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','convex/values'],plugins:[{ name:'auth-provider-fixtures', setup(b) { @@ -15,7 +16,7 @@ await build({entryPoints:['src/hooks/useAuth.tsx'],outfile:'tmp/auth-provider-te core:'export const Capacitor={getPlatform:()=>globalThis.__authProviderTest.platform,isNativePlatform:()=>globalThis.__authProviderTest.platform!=="web"};', 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")};', + webvh:'export const createUserWebVHDid=async(...args)=>globalThis.__authProviderTest.mint(...args);', useDidDomainRemint:'export const useDidDomainRemint=()=>{};', convexUrls:'export const getConvexHttpUrl=()=>"https://auth.example.test";', analytics:'export const identifyUser=()=>{};export const resetAnalytics=()=>{};', @@ -28,7 +29,7 @@ const token=`header.${btoa(JSON.stringify({exp:Math.floor(Date.now()/1000)+30*86 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.platform='web';state.establish=async()=>{};}); +afterEach(()=>{cleanup();globalThis.fetch=fetchBefore;state.storage.clear();state.platform='web';state.establish=async()=>{};state.mint=unexpectedMint;}); function seed(){state.storage.set('lisa-auth-state',JSON.stringify({user,token}));state.storage.set('lisa-jwt-token',token);} async function withEstablishClock(run) { @@ -213,3 +214,29 @@ test('mobile restore clears a definitively rejected session', async () => { assert.equal(result.current.isLoading, false); assert.equal(state.storage.size, 0); }); + +// #236: the server binds account DIDs; a client must not adopt a DID the server refused, +// or it would publish and act under an identity the server does not recognise. +for (const flow of ['restore', 'OTP']) for (const status of [403, 200]) { + test(`${flow}: a did:webvh upgrade is adopted only when updateDID succeeds (${status})`, async () => { + const minted = { did: 'did:webvh:NEW:boop.ad:user-owner', path: 'user-owner', didLogJsonl: '{}' }; + state.mint = async () => minted; + const legacy = { ...user, did: 'did:key:z6MkLegacy' }; + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push(url.split('/').slice(3).join('/')); + if (url.endsWith('/api/user/updateDID')) return Response.json(status === 200 ? { success: true } : { error: 'denied' }, { status }); + if (url.endsWith('/auth/initiate')) return Response.json({ sessionId: 'otp-session' }); + if (url.endsWith('/auth/verify')) return Response.json({ user: legacy, token }); + return Response.json({ ok: true }); + }; + if (flow === 'restore') { state.storage.set('lisa-auth-state', JSON.stringify({ user: legacy, token })); state.storage.set('lisa-jwt-token', token); } + const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); await flush(); + if (flow === 'OTP') { await act(async () => result.current.startOtp(legacy.email)); await act(async () => result.current.verifyOtp('123456')); } + await flush(); + const expected = status === 200 ? minted.did : legacy.did; + assert.equal(result.current.user?.did, expected); + assert.equal(JSON.parse(state.storage.get('lisa-auth-state')).user.did, expected); + if (status !== 200) assert.ok(!calls.includes('api/did/log'), 'no DID log is stored for a refused DID'); + }); +} diff --git a/scripts/identity-binding.test.mjs b/scripts/identity-binding.test.mjs new file mode 100644 index 0000000..d0ffc88 --- /dev/null +++ b/scripts/identity-binding.test.mjs @@ -0,0 +1,221 @@ +// #236 regressions: an authenticated account can only bind, publish under, file into, +// or act with identities and resources that server state says are its own. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { createHash } from 'node:crypto'; +import { SignJWT } from 'jose'; +import { getFunctionName } from 'convex/server'; + +process.env.JWT_SECRET = 'identity-binding-test-secret-not-a-deployed-credential'; +delete process.env.WEBVH_DOMAIN; +const outdir = 'tmp/identity-binding-test'; +const names = ['userHttp', 'auth', 'actorSession', 'migrations/remintUserDidDb', 'didResources', 'publication', 'lists', 'assignees', 'presence', 'comments', 'activity']; +await build({ + entryPoints: names.map(n => `convex/${n}.ts`), outdir, outbase: 'convex', bundle: true, platform: 'node', format: 'esm', + outExtension: { '.js': '.mjs' }, packages: 'external', logLevel: 'error', define: { 'process.env.NODE_ENV': '"production"' }, +}); +const modules = Object.fromEntries(await Promise.all(names.map(async n => [n, await import(pathToFileURL(`${process.cwd()}/${outdir}/${n}.mjs`))]))); +const call = (module, name, ctx, args) => modules[module][name]._handler(ctx, args); +const hash = value => createHash('sha256').update(value).digest('hex'); +const token = subject => new SignJWT({ email: `${subject}@example.test` }).setProtectedHeader({ alg: 'HS256' }) + .setSubject(subject).setIssuer('originals-auth').setAudience('originals-api').setExpirationTime('1h') + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); +const [ownerToken, editorToken] = await Promise.all([token('owner'), token('editor')]); + +const OWN = 'did:webvh:NEW:boop.ad:user-owner'; +const VICTIM = 'did:webvh:V:boop.ad:user-victimsuborg000'; + +function fixture() { + const rows = { + users: [ + { _id: 'U1', turnkeySubOrgId: 'owner', email: 'owner@example.test', did: 'did:webvh:OLD:trypoo.app:user-owner', legacyDid: 'did:legacy-owner', isCanonicalLogin: true }, + { _id: 'U2', turnkeySubOrgId: 'victimsuborg0000', email: 'victim@example.test', did: VICTIM, isCanonicalLogin: true }, + { _id: 'U3', turnkeySubOrgId: 'editor', email: 'editor@example.test', did: 'did:editor', isCanonicalLogin: true }, + ], + lists: [ + { _id: 'L1', ownerDid: 'did:webvh:OLD:trypoo.app:user-owner', name: 'Owner list', createdAt: 1 }, + { _id: 'LV', ownerDid: VICTIM, name: 'Victim list', createdAt: 1 }, + ], + items: [{ _id: 'I1', listId: 'L1', name: 'Private plan', checked: false, createdAt: 1 }], + categories: [{ _id: 'C-own', ownerDid: 'did:legacy-owner', name: 'Mine', order: 0, createdAt: 1 }, { _id: 'C-victim', ownerDid: VICTIM, name: 'Theirs', order: 0, createdAt: 1 }], + listGrants: [{ _id: 'G1', listId: 'L1', recipientId: 'U3', role: 'editor' }], + publications: [], didLogs: [], listTemplates: [], agentApiKeys: [{ _id: 'K1', ownerDid: 'did:webvh:OLD:trypoo.app:user-owner', keyHash: hash('owner-agent-key'), scopes: ['lists:read', 'items:write'] }, { _id: 'K2', ownerDid: 'did:webvh:OLD:trypoo.app:user-owner', keyHash: hash('second-agent-key'), scopes: ['items:write'] }], + itemAssignees: [], activities: [], presence: [], comments: [], + accessSessions: [['S-owner', ownerToken, 'owner'], ['S-editor', editorToken, 'editor']].map(([_id, t, subject]) => ({ _id, tokenHash: hash(t), subject, expiresAt: Date.now() + 3600000 })), noteBodies: [], listEnvelopes: [], referrals: [], bookmarks: [], + }; + let next = 1; + const find = id => Object.values(rows).flat().find(row => row._id === id) ?? null; + const query = table => { + const predicates = []; + const q = { + withIndex: (_i, fn) => { const b = { eq: (k, v) => { predicates.push(r => r[k] === v); return b; }, gte: () => b, lte: () => b }; fn?.(b); return q; }, + order: () => q, filter: () => q, + collect: async () => (rows[table] ?? []).filter(r => predicates.every(p => p(r))), + take: async n => (await q.collect()).slice(0, n), + first: async () => (await q.collect())[0] ?? null, + unique: async () => (await q.collect())[0] ?? null, + }; + return q; + }; + const ctx = { rows, db: { + get: async id => find(id), query, + insert: async (table, value) => { const row = { ...value, _id: `${table}-${next++}` }; (rows[table] ??= []).push(row); return row._id; }, + patch: async (id, patch) => { Object.assign(find(id), patch); }, + delete: async id => { for (const t of Object.values(rows)) { const i = t.findIndex(r => r._id === id); if (i >= 0) t.splice(i, 1); } }, + }, scheduler: { runAfter: async () => {}, runAt: async () => {} } }; + ctx.runQuery = ctx.runMutation = async (ref, args) => { const [m, f] = getFunctionName(ref).split(':'); return call(m, f, ctx, args); }; + return ctx; +} +const post = (path, body, authToken = ownerToken) => new Request(`https://test${path}`, { + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` }, body: JSON.stringify(body), +}); + +test('re-mint cannot move an account onto another account\'s DID', async () => { + for (const body of [{ did: VICTIM }, { did: VICTIM, didLog: 'log', path: 'user-owner' }, { did: VICTIM, didLog: 'log', path: 'user-victimsuborg000' }]) { + const ctx = fixture(); const before = structuredClone(ctx.rows); + const response = await modules.userHttp.remintUserDID._handler(ctx, post('/api/user/remintDid', body)); + assert.equal(response.status, 403, JSON.stringify(body)); + for (const table of ['users', 'lists', 'didLogs', 'agentApiKeys']) assert.deepEqual(ctx.rows[table], before[table]); + } +}); + +test('re-mint rejects a DID already held by another account, even at the caller\'s own path', async () => { + const ctx = fixture(); + ctx.rows.users[1].legacyDid = OWN; // e.g. an orphaned/migrated identity + const response = await modules.userHttp.remintUserDID._handler(ctx, post('/api/user/remintDid', { did: OWN })); + assert.equal(response.ok, false); + assert.equal(ctx.rows.users[0].did, 'did:webvh:OLD:trypoo.app:user-owner'); + assert.equal(ctx.rows.lists[0].ownerDid, 'did:webvh:OLD:trypoo.app:user-owner'); + await assert.rejects(() => call('migrations/remintUserDidDb', 'applyRemint', ctx, { userId: 'U1', oldDid: ctx.rows.users[0].did, newDid: VICTIM }), /another account/); +}); + +test('a legitimate re-mint at the caller\'s own path moves only whole-DID references', async () => { + const ctx = fixture(); + const old = ctx.rows.users[0].did; + ctx.rows.publications.push({ _id: 'P1', listId: 'L1', webvhDid: `${old}/resources/list-L1`, status: 'active' }, { _id: 'P2', listId: 'LV', webvhDid: `${old}0/resources/list-LV`, status: 'active' }); + const response = await modules.userHttp.remintUserDID._handler(ctx, post('/api/user/remintDid', { did: OWN })); + assert.equal(response.status, 200, await response.clone().text()); + assert.equal(ctx.rows.users[0].did, OWN); + assert.equal(ctx.rows.lists[0].ownerDid, OWN); + assert.equal(ctx.rows.lists[1].ownerDid, VICTIM); + assert.equal(ctx.rows.publications[0].webvhDid, `${OWN}/resources/list-L1`); + assert.equal(ctx.rows.publications[1].webvhDid, `${old}0/resources/list-LV`, 'a longer DID sharing the prefix is not rewritten'); +}); + +test('updateDID only binds a did:webvh minted at the caller\'s own path', async () => { + for (const did of [VICTIM, 'did:key:z6MkForged', 'did:webvh:X:boop.ad:user-owner-evil', 'did:webvh:X:boop.ad:evil:user-ownerx']) { + const ctx = fixture(); ctx.rows.users[0].did = 'did:temp:owner'; + const response = await modules.userHttp.updateUserDID._handler(ctx, post('/api/user/updateDID', { did })); + assert.ok([400, 403].includes(response.status), `${did} → ${response.status}`); + assert.equal(ctx.rows.users[0].did, 'did:temp:owner'); + } + const ctx = fixture(); ctx.rows.users[0].did = 'did:temp:owner'; + const response = await modules.userHttp.updateUserDID._handler(ctx, post('/api/user/updateDID', { did: OWN })); + assert.equal(response.status, 200, await response.clone().text()); + assert.equal(ctx.rows.users[0].did, OWN); +}); + +test('publication DIDs name the publisher, and the resolver fallback requires the list owner as controller', async () => { + const ctx = fixture(); + const own = 'did:webvh:OLD:trypoo.app:user-owner'; + for (const webvhDid of [`${VICTIM}/resources/list-L1`, `${own}/resources/list-LV`, 'did:webvh:public']) + await assert.rejects(() => call('publication', 'publishList', ctx, { authToken: ownerToken, listId: 'L1', webvhDid }), /own resource DID/); + assert.equal(ctx.rows.publications.length, 0); + await call('publication', 'publishList', ctx, { authToken: ownerToken, listId: 'L1', webvhDid: `did:legacy-owner/resources/list-L1` }); + assert.equal(ctx.rows.publications[0].publishedByDid, own); + + // A pre-existing publication claiming the victim's path never resolves there. + ctx.rows.publications[0].webvhDid = `${VICTIM}/resources/list-L1`; + assert.equal(await call('didResources', 'getPublishedListForPath', ctx, { listId: 'L1', userPath: 'user-victimsuborg000' }), null); + // The owner's current and legacy identities still resolve their own publication. + for (const controller of [own, 'did:legacy-owner']) { + ctx.rows.publications[0].webvhDid = `${controller}/resources/list-L1`; + const path = controller.split(':').at(-1); + assert.equal((await call('didResources', 'getPublishedListForPath', ctx, { listId: 'L1', userPath: path }))?.controllerDid, controller); + } + ctx.rows.publications[0].status = 'unpublished'; + assert.equal(await call('didResources', 'getPublishedListForPath', ctx, { listId: 'L1', userPath: 'user-owner' }), null); + assert.equal(await call('didResources', 'getPublishedListForPath', ctx, { listId: 'not-an-id', userPath: 'user-owner' }), null); +}); + +test('lists can only be filed in the caller\'s own categories', async () => { + const ctx = fixture(); + for (const [name, args] of [['createList', { assetDid: 'did:cel:x', name: 'New', createdAt: 2 }], ['updateListCategory', { listId: 'L1' }]]) { + await assert.rejects(() => call('lists', name, ctx, { ...args, authToken: ownerToken, categoryId: 'C-victim' }), /Resource unavailable/); + } + await assert.rejects(() => call('lists', 'updateListCategory', ctx, { listId: 'L1', authToken: ownerToken, categoryId: 'missing' }), /Resource unavailable/); + assert.equal(ctx.rows.lists.length, 2); + // A category deleted on another device leaves a new list uncategorized rather than failing. + await call('lists', 'createList', ctx, { assetDid: 'did:cel:y', name: 'New', createdAt: 2, authToken: ownerToken, categoryId: 'missing' }); + assert.equal(ctx.rows.lists.at(-1).categoryId, undefined); + ctx.rows.lists.pop(); + assert.equal(ctx.rows.lists[0].categoryId, undefined); + // A migrated owner's category under the legacy DID still counts as their own. + await call('lists', 'updateListCategory', ctx, { authToken: ownerToken, listId: 'L1', categoryId: 'C-own' }); + assert.equal(ctx.rows.lists[0].categoryId, 'C-own'); +}); + +test('activity rows record which session or specific API key acted, not just the account', async () => { + const ctx = fixture(); + await call('assignees', 'assignItem', ctx, { authToken: ownerToken, itemId: 'I1', assigneeDid: 'did:a' }); + await call('assignees', 'assignItem', ctx, { apiKey: 'owner-agent-key', itemId: 'I1', assigneeDid: 'did:b' }); + await call('assignees', 'unassignItem', ctx, { apiKey: 'second-agent-key', itemId: 'I1', assigneeDid: 'did:a' }); + await call('presence', 'heartbeat', ctx, { apiKey: 'second-agent-key', listId: 'L1' }); + const rows = ctx.rows.activities.filter(r => r.actorDid !== 'system:assignment-reconciliation'); + assert.deepEqual(rows.map(r => [r.type, r.actorDid, r.credential]), [ + ['item_assigned', 'did:webvh:OLD:trypoo.app:user-owner', { kind: 'session', id: 'S-owner' }], + ['item_assigned', 'did:webvh:OLD:trypoo.app:user-owner', { kind: 'apiKey', id: 'K1' }], + ['item_unassigned', 'did:webvh:OLD:trypoo.app:user-owner', { kind: 'apiKey', id: 'K2' }], + ['presence_heartbeat', 'did:webvh:OLD:trypoo.app:user-owner', { kind: 'apiKey', id: 'K2' }], + ]); + // Comments record the key that wrote them; deletions leave an audit row for the key that removed them. + const commentId = await call('comments', 'addComment', ctx, { apiKey: 'owner-agent-key', itemId: 'I1', text: 'from agent one' }); + assert.deepEqual(ctx.rows.comments[0].credential, { kind: 'apiKey', id: 'K1' }); + await call('comments', 'deleteComment', ctx, { apiKey: 'second-agent-key', commentId }); + assert.equal(ctx.rows.comments.length, 0); + const deletion = ctx.rows.activities.at(-1); + assert.deepEqual([deletion.type, deletion.credential], ['comment_deleted', { kind: 'apiKey', id: 'K2' }]); + assert.deepEqual(JSON.parse(deletion.metadata.note), { commentId }, 'neither text nor author of the deleted comment is retained'); + // Credentials are stored for audit but never returned to readers, including published-list readers. + await call('comments', 'addComment', ctx, { authToken: ownerToken, itemId: 'I1', text: 'visible' }); + for (const row of [...await call('activity', 'getListActivity', ctx, { authToken: editorToken, listId: 'L1' }), ...await call('comments', 'getItemComments', ctx, { authToken: editorToken, itemId: 'I1' })]) + assert.equal('credential' in row, false); + // A credential named in arguments is never what gets recorded. + await call('assignees', 'assignItem', ctx, { apiKey: 'owner-agent-key', itemId: 'I1', assigneeDid: 'did:c', credential: { kind: 'apiKey', id: 'K2' } }); + assert.deepEqual(ctx.rows.activities.at(-1).credential, { kind: 'apiKey', id: 'K1' }); +}); + +test('the boundary resolves which credential acted: a specific API key, distinct from the owner\'s session', async () => { + const ctx = fixture(); + const viaSession = await call('actorSession', 'resolve', ctx, { authToken: ownerToken }); + const viaKey = await call('actorSession', 'resolve', ctx, { apiKey: 'owner-agent-key' }); + assert.equal(viaSession.userId, 'U1'); assert.equal(viaKey.userId, 'U1'); + assert.deepEqual(viaSession.credential, { kind: 'session', id: 'S-owner' }); + assert.deepEqual(viaKey.credential, { kind: 'apiKey', id: 'K1' }); + assert.deepEqual(viaKey.scopes, ['lists:read', 'items:write']); + ctx.rows.agentApiKeys[0].revokedAt = Date.now(); + await assert.rejects(() => call('actorSession', 'resolve', ctx, { apiKey: 'owner-agent-key' }), /Invalid API key/); +}); + +test('a logged-out token cannot be revived by re-encoding its signature', async () => { + const ctx = fixture(); + ctx.rows.accessSessions = []; + const fresh = await token('owner'); + await call('actorSession', 'establish', ctx, { authToken: fresh }); + assert.equal((await call('lists', 'getUserLists', ctx, { authToken: fresh })).length, 1); + await call('actorSession', 'revoke', ctx, { authToken: fresh }); + // jose decodes the signature's unused trailing bits leniently; each variant is a + // distinct string (and hash) carrying the same signature. + const [head, body, sig] = fresh.split('.'); + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const last = alphabet.indexOf(sig.at(-1)); + const variants = [1, 2, 3].map(bits => `${head}.${body}.${sig.slice(0, -1)}${alphabet[last ^ bits]}`); + for (const variant of variants) { + await assert.rejects(() => call('actorSession', 'establish', ctx, { authToken: variant }), /Invalid or expired token/); + await assert.rejects(() => call('lists', 'getUserLists', ctx, { authToken: variant }), /Invalid or expired token/); + } + assert.equal(ctx.rows.accessSessions.filter(s => s.revokedAt === undefined).length, 0, 'no live session was created'); + await assert.rejects(() => call('lists', 'getUserLists', ctx, { authToken: fresh }), /restore your session/); +}); diff --git a/scripts/private-sharing.test.mjs b/scripts/private-sharing.test.mjs index 87c4e84..1f6833f 100644 --- a/scripts/private-sharing.test.mjs +++ b/scripts/private-sharing.test.mjs @@ -125,7 +125,6 @@ const contentWrites = [ ["itemCategories", "addListCategory", { listId: "L", name: "New category", emoji: "a" }], ["presence", "heartbeat", { listId: "L" }], ["presence", "markOffline", { listId: "L" }], - ["activity", "recordActivity", { listId: "L", itemId: "I", type: "item_updated" }], ["attachments", "addAttachment", { itemId: "I", bucketKey: attachment.key, contentType: attachment.contentType, size: 10, sha256: "abc" }], ]; @@ -152,15 +151,17 @@ const ownerWrites = [ ["lists", "updateItemViewMode", { listId: "L", itemViewMode: "alphabetical" }], ["lists", "addCustomAisle", { listId: "L", name: "New", emoji: "a" }], ["lists", "removeCustomAisle", { listId: "L", aisleId: "a" }], - ["publication", "publishList", { listId: "L", webvhDid: "did:webvh:public", celEnvelope: "new-owner-envelope" }], + ["publication", "publishList", { listId: "L", webvhDid: "did:owner/resources/list-L", celEnvelope: "new-owner-envelope" }], ["publication", "unpublishList", { listId: "L" }], ["bitcoinAnchors", "createAnchorRecord", { listId: "L", stateHash: "hash", stateSnapshot: "snapshot" }], ["bitcoinAnchors", "updateAnchorStatus", { anchorId: "ANCHOR", status: "confirmed" }], ["listGrants", "updateListGrant", { listId: "L", grantId: "G-L-viewer", role: "editor" }], ["listGrants", "revokeListGrant", { listId: "L", grantId: "G-L-viewer" }], ]; +// Anchor records are written only by the server anchoring path (#236); no public name. +const internalOnly = new Set(["createAnchorRecord", "updateAnchorStatus"]); for (const role of roles) test(`${role}: owner-only metadata, publishing/envelopes, anchoring and grant management`, async () => { - for (const [module, name, args] of ownerWrites) for (const suffix of ["", "Internal"]) { + for (const [module, name, args] of ownerWrites) for (const suffix of internalOnly.has(name) ? ["Internal"] : ["", "Internal"]) { const ctx = make({ published: name === "unpublishList" }); const before = structuredClone(ctx.rows); const invoke = () => call(module, name + suffix, ctx, { ...args, ...credentials(role) }); @@ -177,7 +178,7 @@ test("public reads remain public; bookmarks and historical authors are never edi ctx.rows.bookmarks.push({ _id: "BOOK", listId: "L", userDid: "did:outsider" }); ctx.rows.items[0].createdByDid = "did:outsider"; assert.ok(await call("publication", "getPublicList", ctx, { webvhDid: "did:webvh:public" })); - assert.ok(await call("didResources", "getListById", ctx, { listId: "L" })); + assert.ok(await call("didResources", "getPublicList", ctx, { listId: "L", ownerDid: "did:owner" })); assert.equal((await call("didResources", "getPublicListItems", ctx, { listId: "L" })).length, 1); for (const role of ["outsider", "pending", "viewer"]) { assert.ok(await call("lists", "getList", ctx, { listId: "L", ...credentials(role) })); @@ -188,7 +189,7 @@ test("public reads remain public; bookmarks and historical authors are never edi } await call("publication", "unpublishList", ctx, { listId: "L", ...credentials("owner") }); assert.equal(await call("lists", "getList", ctx, { listId: "L", ...credentials("outsider") }), null); - assert.equal(await call("didResources", "getListById", ctx, { listId: "L" }), null); + assert.equal(await call("didResources", "getPublicList", ctx, { listId: "L", ownerDid: "did:owner" }), null); assert.ok(await call("lists", "getList", ctx, { listId: "L", ...credentials("viewer") }), "accepted private access survives unpublish"); await call("items", "checkItem", ctx, { itemId: "I", checkedAt: 4, ...credentials("editor") }); assert.equal(ctx.rows.items[0].checkedByDid, "did:editor"); @@ -227,7 +228,7 @@ test("private missing/denied aliases and txid lookups have indistinguishable res for (const id of ["L", "missing"]) { assert.equal(await call("lists", "getList", ctx, { listId: id, ...auth }), null); assert.equal(await call("publication", "getPublicationStatus", ctx, { listId: id, ...auth }), null); - assert.equal(await call("didResources", "getListById", ctx, { listId: id }), null); + assert.equal(await call("didResources", "getPublicList", ctx, { listId: id, ownerDid: "did:owner" }), null); assert.deepEqual(await call("didResources", "getPublicListItems", ctx, { listId: id }), []); } for (const txid of ["tx", "missing"]) assert.equal(await call("bitcoinAnchors", "getAnchorByTxid", ctx, { txid, ...auth }), null); @@ -241,7 +242,6 @@ test("cross-resource substitution fails even when both lists are editable", asyn const ctx = make(); ctx.rows.lists.find(l => l._id === "X").ownerDid = "did:owner"; const before = structuredClone(ctx.rows); const cases = [ - ["activity", "recordActivity", { listId: "L", itemId: "IX", type: "item_updated" }], ["didResources", "checkSharedItem", { listId: "L", itemId: "IX" }], ["didResources", "uncheckSharedItem", { listId: "L", itemId: "IX" }], ["items", "addItem", { listId: "L", parentId: "IX", name: "Cross child", createdAt: 4 }], @@ -542,8 +542,8 @@ test("anchor verification is read-only for viewers/public readers and read-scope const keyVerify = () => call("bitcoinAnchors", "verifyAnchorStateInternal", ctx.action, { anchorId: "ANCHOR", apiKey: `key-${role}` }); if (role === "outsider" && !published) await assert.rejects(keyVerify, denied); else await keyVerify(); - await assert.rejects(() => call("bitcoinAnchors", "createAnchorRecord", ctx, { listId: "L", stateHash: "hash", stateSnapshot: "x", apiKey: `key-${role}` }), /Missing scope/); - if (role !== "owner") await assert.rejects(() => call("bitcoinAnchors", "createAnchorRecord", ctx, { listId: "L", stateHash: "hash", stateSnapshot: "x", ...credentials(role) }), denied); + await assert.rejects(() => call("bitcoinAnchors", "createAnchorRecordInternal", ctx, { listId: "L", stateHash: "hash", stateSnapshot: "x", apiKey: `key-${role}` }), /Missing scope/); + if (role !== "owner") await assert.rejects(() => call("bitcoinAnchors", "createAnchorRecordInternal", ctx, { listId: "L", stateHash: "hash", stateSnapshot: "x", ...credentials(role) }), denied); } } }); @@ -641,13 +641,13 @@ test("accepting, downgrading and revoking named access leaves public status unch await call("publication", "unpublishList", ctx, { listId: "L", ...owner }); assert.deepEqual(ctx.rows.listGrants, otherGrants); assert.equal(await call("publication", "getPublicList", ctx, { webvhDid: "did:webvh:public" }), null); - assert.equal(await call("didResources", "getListById", ctx, { listId: "L" }), null); + assert.equal(await call("didResources", "getPublicList", ctx, { listId: "L", ownerDid: "did:owner" }), null); assert.deepEqual(await call("didResources", "getPublicListItems", ctx, { listId: "L" }), []); assert.equal((await call("lists", "getUserLists", ctx, credentials("outsider"))).some(row => row._id === "L"), false); assert.equal((await call("lists", "getList", ctx, { listId: "L", ...credentials("editor") })).canEdit, true); assert.equal((await call("lists", "getList", ctx, { listId: "L", ...credentials("viewer") })).canEdit, false); await call("items", "uncheckItem", ctx, { itemId: "I", ...credentials("editor") }); - await call("publication", "publishList", ctx, { listId: "L", webvhDid: "did:webvh:public", ...owner }); + await call("publication", "publishList", ctx, { listId: "L", webvhDid: "did:owner/resources/list-L", ...owner }); assert.deepEqual(ctx.rows.listGrants, otherGrants); assert.equal((await call("lists", "getList", ctx, { listId: "L", ...credentials("outsider") })).canEdit, false); }); diff --git a/scripts/public-function-boundary.test.mjs b/scripts/public-function-boundary.test.mjs new file mode 100644 index 0000000..41043e2 --- /dev/null +++ b/scripts/public-function-boundary.test.mjs @@ -0,0 +1,240 @@ +// Exhaustive #236 boundary: every public Convex query, mutation and action is +// either an explicitly reviewed public endpoint below, or rejects anonymous, +// forged-key and asserted-identity callers before it reads private data or writes. +// New public registrations fail this test until they are classified. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { readdirSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { createHash } from 'node:crypto'; +import { SignJWT } from 'jose'; +import { getFunctionName } from 'convex/server'; + +process.env.JWT_SECRET = 'public-boundary-test-secret-not-a-deployed-credential'; +const outdir = 'tmp/public-function-boundary-test'; +// Every Convex module, including subdirectories such as migrations/ (Convex registers +// those too); lib/ holds helpers and _generated/ is codegen. +const names = readdirSync('convex', { recursive: true }) + .filter(f => /^(?!lib\/|_generated\/)([\w-]+\/)*[A-Za-z]\w*\.ts$/.test(f) && !f.endsWith('.d.ts')) + .map(f => f.slice(0, -3)); +await build({ + entryPoints: names.map(n => `convex/${n}.ts`), outdir, outbase: 'convex', bundle: true, platform: 'node', format: 'esm', + outExtension: { '.js': '.mjs' }, packages: 'external', logLevel: 'error', + define: { 'process.env.NODE_ENV': '"production"' }, +}); +const modules = Object.fromEntries(await Promise.all(names.map(async n => [n, await import(pathToFileURL(`${process.cwd()}/${outdir}/${n}.mjs`))]))); +await build({ entryPoints: ['convex/lib/clientAuth.ts'], outfile: `${outdir}/lib/clientAuth.mjs`, format: 'esm', logLevel: 'error' }); +const { identityAssertionFields } = await import(pathToFileURL(`${process.cwd()}/${outdir}/lib/clientAuth.mjs`)); + +/** Reviewed endpoints that intentionally run without a caller identity. None accept + * an acting identity. DID/Sites resolution lookups are internal behind HTTP projections. */ +const PUBLIC = { + 'publication:getPublicList': 'active publications only; masked attribution (#251)', + 'templates:getPublicTemplates': 'templates explicitly marked public', + 'users:getUsersByDids': 'public attribution display names only, emails masked (#251)', + 'waitlist:joinWaitlist': 'anonymous landing-page signup; records only the submitted email', +}; +/** Custom (non-wrapper) public functions that authenticate themselves. They must still + * reject anonymous callers; their identity checks have dedicated tests in auth-boundary. */ +const SELF_AUTHENTICATED = new Set([ + 'actorSession:establish', 'actorSession:revoke', + 'auth:getUserByTurnkeyId', 'auth:getUserByEmail', 'auth:upsertUser', +]); +/** Compatibility names that reject every caller (#241/#251). */ +const REJECTING = new Set([ + 'authSessions:createSession', 'authSessions:getSession', 'authSessions:markSessionVerified', 'authSessions:deleteSession', + 'rateLimits:checkAndIncrement', 'rateLimits:checkStatus', 'rateLimits:cleanupExpired', +]); + +/** Plausible business arguments from a function's validator, naming existing fixture + * rows, so a rejection is caused by missing identity rather than by missing input. */ +const FIXTURE_IDS = { lists: 'L1', items: 'I1', users: 'U1' }; +function sample(t) { + switch (t?.type) { + case 'id': return FIXTURE_IDS[t.tableName] ?? `${t.tableName}-1`; + case 'string': return 'x'; + case 'number': case 'float64': return 1; + case 'boolean': return false; + case 'array': return [sample(t.value)]; + case 'literal': return t.value; + case 'union': return sample(t.value[0]); + case 'object': return Object.fromEntries(Object.entries(t.value).map(([k, f]) => [k, sample(f.fieldType)])); + case 'record': return {}; + case 'null': return null; + default: return {}; + } +} +const nonBusiness = new Set(['authToken', 'apiKey', ...identityAssertionFields]); +function businessArgs(args) { + return Object.fromEntries(Object.entries(args.value ?? {}).filter(([k]) => !nonBusiness.has(k)).map(([k, f]) => [k, sample(f.fieldType)])); +} + +const registrations = []; +for (const [module, exports] of Object.entries(modules)) { + for (const [name, fn] of Object.entries(exports)) { + if (!fn?.isPublic || fn.isHttp || !(fn.isQuery || fn.isMutation || fn.isAction)) continue; + const args = JSON.parse(fn.exportArgs()); + registrations.push({ id: `${module}:${name}`, fn, kind: fn.isQuery ? 'query' : fn.isMutation ? 'mutation' : 'action', fields: Object.keys(args.value ?? {}), business: businessArgs(args) }); + } +} + +async function token(subject) { + return new SignJWT({ email: `${subject}@example.test` }).setProtectedHeader({ alg: 'HS256' }) + .setSubject(subject).setIssuer('originals-auth').setAudience('originals-api') + .setExpirationTime('1h').sign(new TextEncoder().encode(process.env.JWT_SECRET)); +} +const strangerToken = await token('stranger'); +const hash = value => createHash('sha256').update(value).digest('hex'); + +/** In-memory database that records every write and side effect. */ +function fixture() { + const rows = { + users: [ + { _id: 'U1', turnkeySubOrgId: 'owner', did: 'did:owner', legacyDid: 'did:legacy', email: 'owner@example.test' }, + { _id: 'U2', turnkeySubOrgId: 'stranger', did: 'did:stranger', email: 'stranger@example.test' }, + ], + lists: [{ _id: 'L1', ownerDid: 'did:owner', name: 'Private', createdAt: 1 }], + items: [{ _id: 'I1', listId: 'L1', name: 'Secret', checked: false, createdAt: 1 }], + accessSessions: [{ _id: 'S1', tokenHash: hash(strangerToken), subject: 'stranger', expiresAt: Date.now() + 3600000 }], + agentApiKeys: [{ _id: 'K1', ownerDid: 'did:owner', keyHash: hash('valid-owner-key'), scopes: ['*'] }], + }; + const effects = [], reads = new Set(); + const find = id => { + const [table, row] = Object.entries(rows).flatMap(([t, rs]) => rs.map(r => [t, r])).find(([, r]) => r._id === id) ?? []; + reads.add(table ?? 'unknown-id'); + return row ?? null; + }; + const record = kind => async (...args) => { effects.push([kind, ...args.map(a => typeof a === 'object' && a !== null && !Array.isArray(a) ? Object.keys(a).join(',') : String(a))]); }; + const query = table => { + reads.add(table); + const predicates = []; + const q = { + withIndex: (_index, fn) => { const b = { eq: (k, v) => { predicates.push(r => r[k] === v); return b; }, gte: () => b, lte: () => b, gt: () => b, lt: () => b }; fn?.(b); return q; }, + order: () => q, filter: () => q, + collect: async () => (rows[table] ?? []).filter(r => predicates.every(p => p(r))), + take: async n => (await q.collect()).slice(0, n), + first: async () => (await q.collect())[0] ?? null, + unique: async () => (await q.collect())[0] ?? null, + paginate: async () => ({ page: await q.collect(), isDone: true, continueCursor: '' }), + [Symbol.asyncIterator]: async function* () { yield* await q.collect(); }, + }; + return q; + }; + const ctx = { + rows, effects, reads, + db: { get: async id => find(id), query, insert: record('insert'), patch: record('patch'), replace: record('replace'), delete: record('delete'), system: { get: async () => null, query } }, + storage: { getUrl: async () => null, generateUploadUrl: record('storage'), delete: record('storage') }, + scheduler: { runAfter: record('schedule'), runAt: record('schedule'), cancel: record('schedule') }, + auth: { getUserIdentity: async () => null }, + }; + // Actions may only consult the internal authentication checkpoint before rejecting. + ctx.runQuery = async (ref, args) => { + const [module, name] = getFunctionName(ref).split(':'); + if (module !== 'actorSession') effects.push(['runQuery', `${module}:${name}`]); + return modules[module][name]._handler(ctx, args); + }; + ctx.runMutation = async ref => { effects.push(['runMutation', getFunctionName(ref)]); }; + ctx.runAction = async ref => { effects.push(['runAction', getFunctionName(ref)]); }; + return ctx; +} + +// Resolving a credential reads only these; anything else before rejection is a leak. +const AUTH_TABLES = new Set(['accessSessions', 'agentApiKeys', 'users']); +async function expectRejected(registration, extra, pattern) { + const ctx = fixture(); + const args = { ...registration.business, ...extra }; + let result, error; + try { result = await registration.fn._handler(ctx, args); } catch (e) { error = e; } + assert.ok(error, `${registration.id} accepted ${JSON.stringify(Object.keys(args))} and returned ${JSON.stringify(result)?.slice(0, 120)}`); + if (pattern) assert.match(String(error?.data?.message ?? error?.message), pattern, `${registration.id}: ${error?.message}`); + assert.deepEqual(ctx.effects, [], `${registration.id} had side effects before rejecting`); + const leaked = [...ctx.reads].filter(t => !AUTH_TABLES.has(t)); + assert.deepEqual(leaked, [], `${registration.id} read ${leaked} before rejecting`); + return error; +} + +test('every public registration is classified', () => { + assert.ok(names.includes('migrations/remintUserDidDb'), 'subdirectory modules are scanned'); + assert.ok(registrations.length > 150, `discovered only ${registrations.length} public functions`); + const ids = new Set(registrations.map(r => r.id)); + for (const id of [...Object.keys(PUBLIC), ...SELF_AUTHENTICATED, ...REJECTING]) assert.ok(ids.has(id), `stale classification: ${id}`); + for (const r of registrations) { + if (PUBLIC[r.id] || SELF_AUTHENTICATED.has(r.id) || REJECTING.has(r.id)) continue; + // Everything else must come from the actor wrappers: they take credentials and + // only the legacy, non-authoritative identity assertion fields. + assert.ok(r.fields.includes('authToken') && r.fields.includes('apiKey'), `${r.id} is public but not authenticated`); + for (const field of identityAssertionFields) assert.ok(r.fields.includes(field), `${r.id} is missing assertion validator ${field}`); + } +}); + +test('intentionally public endpoints accept no acting identity', () => { + for (const r of registrations.filter(r => PUBLIC[r.id])) { + for (const field of [...identityAssertionFields, 'authToken', 'apiKey', 'userId']) { + assert.ok(!r.fields.includes(field), `${r.id} accepts ${field}`); + } + } +}); + +test('anonymous callers are rejected by every non-public registration before private reads or writes', async () => { + const protectedRegistrations = registrations.filter(r => !PUBLIC[r.id]); + for (const r of protectedRegistrations) { + const error = await expectRejected(r, {}); + if (!REJECTING.has(r.id)) assert.match(String(error?.data?.message ?? error.message), /auth|token|sign in|session/i, `${r.id}: ${error.message}`); + } +}); + +test('unknown API keys and asserted owner identities never authenticate', async () => { + for (const r of registrations.filter(r => r.fields.includes('apiKey') && !SELF_AUTHENTICATED.has(r.id))) { + await expectRejected(r, { apiKey: 'forged-key' }, /invalid api key/i); + // A stranger asserting the owner's current or legacy DID in every legacy field. + for (const did of ['did:owner', 'did:legacy']) { + const assertions = Object.fromEntries(identityAssertionFields.map(f => [f, did])); + await expectRejected(r, { authToken: strangerToken, ...assertions }, /identity assertion/i); + } + } +}); + +test('asserted identities alone, with no credentials, are rejected for reads and writes', async () => { + const assertions = Object.fromEntries(identityAssertionFields.map(f => [f, 'did:owner'])); + const wrapped = registrations.filter(r => r.fields.includes('apiKey') && !SELF_AUTHENTICATED.has(r.id)); + assert.ok(wrapped.some(r => r.kind === 'query') && wrapped.some(r => r.kind === 'mutation') && wrapped.some(r => r.kind === 'action')); + for (const r of wrapped) await expectRejected(r, assertions, /authentication required/i); +}); + +/** HTTP routes that run without a caller identity: login, signed webhooks, public + * resolution, public-list attachment reads and CORS preflight. */ +const PUBLIC_ROUTES = new Set([ + 'POST /auth/initiate', 'POST /auth/verify', 'POST /auth/logout', 'POST /api/stripe/webhook', + 'GET /api/did/log', 'GET /api/sites/resolve-host', 'GET /api/sites/resolve-asset', 'GET /health', + 'GET /d/*', 'GET /api/attachments/download', +]); +test('every non-public HTTP route rejects anonymous, forged-key and asserted-identity requests without writing', async () => { + const routes = modules.http.default.getRoutes().filter(([, method]) => method !== 'OPTIONS'); + assert.ok(routes.length > 30, `discovered only ${routes.length} routes`); + const assertions = Object.fromEntries(identityAssertionFields.map(f => [f, 'did:owner'])); + const failures = []; + const body = { listId: 'L1', itemId: 'I1', assetDid: 'did:cel:x', itemIds: ['I1'], categoryId: 'C1', name: 'x', assigneeDid: 'did:owner', did: 'did:webvh:X:boop.ad:user-owner', keyId: 'K1', ...assertions }; + for (const [path, method, handler] of routes) { + if (PUBLIC_ROUTES.has(`${method} ${path}`)) continue; + const url = new URL(`https://test${path === '/d/*' ? '/d/owner/resources/list-L1/items/I1/check' : path}`); + // GET routes take one target; body routes get every field a handler might read. + if (method === 'GET') for (const [k, v] of Object.entries({ listId: 'L1', ...assertions })) url.searchParams.set(k, String(v)); + for (const headers of [{}, { 'X-API-Key': 'forged-key' }, { Authorization: 'Bearer forged.jwt.token' }]) { + const ctx = fixture(); + // A published list, so public-link writes reach the authenticated operation. + ctx.rows.publications = [{ _id: 'P1', listId: 'L1', webvhDid: 'did:owner/resources/list-L1', status: 'active' }]; + ctx.rows.didLogs = [{ _id: 'D1', path: 'owner', userDid: 'did:owner', log: '{}' }]; + // HTTP adapters dispatch to the real internal registrations of the same operation. + ctx.runQuery = ctx.runMutation = ctx.runAction = async (ref, args) => { const [m, f] = getFunctionName(ref).split(':'); return modules[m][f]._handler(ctx, args); }; + const response = await handler._handler(ctx, new Request(url, { method, headers: { 'Content-Type': 'application/json', ...headers }, ...(method === 'GET' ? {} : { body: JSON.stringify(body) }) })); + if (![401, 403].includes(response.status)) failures.push(`${method} ${path} ${JSON.stringify(headers)} → ${response.status} ${await response.text()}`); + if (ctx.effects.length) failures.push(`${method} ${path} ${JSON.stringify(headers)} wrote ${JSON.stringify(ctx.effects)}`); + // Only credential tables, plus the published-resource lookups /d/* resolves first. + const allowed = path === '/d/*' ? new Set([...AUTH_TABLES, 'didLogs', 'lists', 'publications']) : AUTH_TABLES; + const leaked = [...ctx.reads].filter(t => !allowed.has(t)); + if (leaked.length) failures.push(`${method} ${path} ${JSON.stringify(headers)} read ${leaked} before rejecting`); + } + } + assert.deepEqual(failures, []); +}); diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index e4a821b..4cf494c 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -195,7 +195,7 @@ export function AuthProvider({ children }: AuthProviderProps) { }); const httpUrl = getConvexHttpUrl(); - await fetch(`${httpUrl}/api/user/updateDID`, { + const updated = await fetch(`${httpUrl}/api/user/updateDID`, { method: "POST", headers: { "Content-Type": "application/json", @@ -204,6 +204,9 @@ export function AuthProvider({ children }: AuthProviderProps) { credentials: "include", body: JSON.stringify({ did: webvhResult.did }), }); + // Only adopt a DID the server accepted; ownership and publishing are checked + // against the server's account DID. The upgrade is retried on the next restore. + if (!updated.ok) throw new Error(`updateDID failed: ${updated.status}`); // Store DID log in Convex for resolution await fetch(`${httpUrl}/api/did/log`, { @@ -345,17 +348,18 @@ export function AuthProvider({ children }: AuthProviderProps) { email: serverUser.email, subOrgId: serverUser.turnkeySubOrgId, }); - userDid = webvhResult.did; - - await fetch(`${httpUrl}/api/user/updateDID`, { + const updated = await fetch(`${httpUrl}/api/user/updateDID`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwtToken}`, }, credentials: "include", - body: JSON.stringify({ did: userDid }), + body: JSON.stringify({ did: webvhResult.did }), }); + // Keep the server's DID unless it accepted the new one (retried on restore). + if (!updated.ok) throw new Error(`updateDID failed: ${updated.status}`); + userDid = webvhResult.did; console.log("[useAuth] Upgraded user DID to did:webvh:", userDid); } catch (didErr) { diff --git a/src/lib/authenticatedOperations.ts b/src/lib/authenticatedOperations.ts index 8968d08..0e89dcf 100644 --- a/src/lib/authenticatedOperations.ts +++ b/src/lib/authenticatedOperations.ts @@ -3,7 +3,6 @@ import { api } from "../../convex/_generated/api"; import { getFunctionName } from "convex/server"; export const authenticatedOperations = new Set([ api.activity.getListActivity, - api.activity.recordActivity, api.assignees.assignItem, api.assignees.getItemAssignees, api.assignees.unassignItem, @@ -17,7 +16,6 @@ export const authenticatedOperations = new Set([ api.billing.getUserPlan, api.billing.getUserSubscription, api.bitcoinAnchors.anchorListState, - api.bitcoinAnchors.createAnchorRecord, api.bitcoinAnchors.getAnchor, api.bitcoinAnchors.getAnchorByTxid, api.bitcoinAnchors.getItemAnchors, @@ -25,7 +23,6 @@ export const authenticatedOperations = new Set([ api.bitcoinAnchors.getListAnchors, api.bitcoinAnchors.getListDataForAnchor, api.bitcoinAnchors.getPendingAnchors, - api.bitcoinAnchors.updateAnchorStatus, api.bitcoinAnchors.verifyAnchorState, api.categories.createCategory, api.categories.deleteCategory, @@ -97,7 +94,6 @@ export const authenticatedOperations = new Set([ api.notes.getNoteBody, api.notes.getNoteCards, api.notes.updateNoteBody, - api.notificationActions.sendListNotification, api.notificationActions.sendPushNotification, api.notifications.getUserSubscriptions, api.notifications.hasSubscription,