From 7c904d1cff886f1d9404d28181f23407088d6f56 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Tue, 8 Sep 2026 22:55:14 -0700 Subject: [PATCH 1/4] fix(auth): require verified credentials for browser and HTTP operations Resolve current and migrated identities at a shared authenticated boundary, protect private reads and scoped writes, and remove acting DIDs from clients. Track session revocation and expiry so reactive subscriptions lose access. Preserve compatibility names pending deployed-client confirmation. Add forged identity, scope, session lifecycle, shared editing, migration and upload tests, and document the coordinated rollout and remaining live verification gates. --- API.md | 8 + convex/_generated/api.d.ts | 10 + convex/activity.ts | 17 +- convex/activityHttp.ts | 14 +- convex/actorSession.ts | 86 +++++++ convex/agentReadHttp.ts | 33 +-- convex/apiKeysHttp.ts | 6 +- convex/assignees.ts | 29 ++- convex/assigneesHttp.ts | 32 +-- convex/attachments.ts | 71 ++---- convex/auth.ts | 86 ++++--- convex/authSessions.ts | 30 ++- convex/billing.ts | 11 +- convex/billingHttp.ts | 14 +- convex/bitcoinAnchors.ts | 90 +++++-- convex/categories.ts | 84 ++++--- convex/categoriesHttp.ts | 71 +----- convex/comments.ts | 77 ++---- convex/didCreation.ts | 10 +- convex/didLogsHttp.ts | 2 +- convex/didResources.ts | 23 +- convex/didResourcesHttp.ts | 20 +- convex/feedback.ts | 9 +- convex/http.ts | 18 +- convex/itemCategories.ts | 39 +-- convex/items.ts | 190 +++++++-------- convex/itemsHttp.ts | 63 +---- convex/lib/actor.ts | 105 ++++---- convex/lib/apiKeyHelpers.ts | 1 + convex/lib/auth.ts | 15 +- convex/lib/authUser.ts | 36 +-- convex/lib/authenticated.ts | 75 ++++++ convex/lib/bucket.ts | 1 + convex/lib/bucketKeys.ts | 6 + convex/lib/clientAuth.ts | 5 + convex/lib/httpResponses.ts | 3 +- convex/lib/jwt.ts | 2 + convex/lib/permissions.ts | 50 ++++ convex/lib/session.ts | 18 ++ convex/lists.ts | 139 ++++++----- convex/listsHttp.ts | 32 +-- convex/notificationActions.ts | 14 +- convex/notifications.ts | 78 +++--- convex/originals.ts | 31 +-- convex/presence.ts | 33 +-- convex/presenceHttp.ts | 23 +- convex/publication.ts | 62 ++--- convex/referrals.ts | 23 +- convex/schema.ts | 6 + convex/siteActions.ts | 61 +++-- convex/siteAssets.ts | 44 ++-- convex/siteInternals.ts | 11 +- convex/sites.ts | 52 ++-- convex/tags.ts | 74 +++--- convex/templates.ts | 65 ++--- convex/userHttp.ts | 8 +- convex/users.ts | 27 ++- docs/authentication-rollout.md | 56 +++++ scripts/auth-boundary.test.mjs | 256 ++++++++++++++++++++ scripts/auth-provider.test.mjs | 82 +++++++ scripts/authenticated-client.test.mjs | 79 ++++++ scripts/copy-list.test.mjs | 12 +- scripts/generate-auth-client.mjs | 22 ++ scripts/helpers/auth-fixture.mjs | 31 +++ scripts/item-categories-mutations.test.mjs | 35 ++- scripts/login-account.test.mjs | 10 +- scripts/originals-query.test.mjs | 15 +- src/components/Attachments.tsx | 13 +- src/components/BatchOperations.tsx | 2 +- src/components/CalendarView.tsx | 2 +- src/components/ChangeCategoryDialog.tsx | 6 +- src/components/Comments.tsx | 11 +- src/components/CreateListModal.tsx | 5 +- src/components/DeleteListDialog.tsx | 6 +- src/components/ItemAttribution.tsx | 2 +- src/components/ItemDetailsModal.tsx | 5 +- src/components/ListCard.tsx | 2 +- src/components/ListItem.tsx | 7 +- src/components/NativePushRegistrar.tsx | 4 +- src/components/NestedListItem.tsx | 2 +- src/components/OnboardingFlow.tsx | 9 +- src/components/ProvenanceInfo.tsx | 3 +- src/components/ReferralInvite.tsx | 2 +- src/components/ReferralRedeemer.tsx | 2 +- src/components/RenameListDialog.tsx | 6 +- src/components/SaveAsTemplateModal.tsx | 3 +- src/components/Settings.tsx | 2 +- src/components/ShareModal.tsx | 5 +- src/components/SharedListResource.tsx | 8 +- src/components/SubItems.tsx | 16 +- src/components/TagSelector.tsx | 16 +- src/components/TemplatePickerModal.tsx | 7 +- src/components/lists/CategoryManager.tsx | 6 +- src/components/publish/PublishModal.tsx | 4 +- src/components/sites/ConnectDomainModal.tsx | 4 +- src/components/sites/SiteAssets.tsx | 10 +- src/hooks/useAuth.tsx | 70 ++++-- src/hooks/useBilling.tsx | 2 +- src/hooks/useCategories.tsx | 14 +- src/hooks/useCurrentUser.tsx | 2 +- src/hooks/useNotifications.tsx | 10 +- src/hooks/useOffline.tsx | 1 + src/hooks/useOptimisticItems.tsx | 10 +- src/lib/authenticatedConvex.ts | 45 ++++ src/lib/authenticatedOperations.ts | 132 ++++++++++ src/lib/sessionExpiry.ts | 11 + src/lib/sync.ts | 27 ++- src/main.tsx | 3 +- src/pages/Explorer.tsx | 4 +- src/pages/Home.tsx | 10 +- src/pages/ListView.tsx | 33 ++- src/pages/NoteEditor.tsx | 6 +- src/pages/PriorityFocus.tsx | 8 +- src/pages/Profile.tsx | 4 +- src/pages/PublicList.tsx | 2 +- src/pages/SiteDetail.tsx | 8 +- src/pages/Sites.tsx | 6 +- src/pages/Templates.tsx | 9 +- 118 files changed, 2181 insertions(+), 1247 deletions(-) create mode 100644 convex/actorSession.ts create mode 100644 convex/lib/authenticated.ts create mode 100644 convex/lib/bucketKeys.ts create mode 100644 convex/lib/clientAuth.ts create mode 100644 convex/lib/session.ts create mode 100644 docs/authentication-rollout.md create mode 100644 scripts/auth-boundary.test.mjs create mode 100644 scripts/auth-provider.test.mjs create mode 100644 scripts/authenticated-client.test.mjs create mode 100644 scripts/generate-auth-client.mjs create mode 100644 scripts/helpers/auth-fixture.mjs create mode 100644 src/lib/authenticatedConvex.ts create mode 100644 src/lib/authenticatedOperations.ts create mode 100644 src/lib/sessionExpiry.ts diff --git a/API.md b/API.md index 393264b..09da8c8 100644 --- a/API.md +++ b/API.md @@ -334,3 +334,11 @@ await fetch(`${BASE_URL}/api/agent/items/${itemId}`, { body: JSON.stringify({ checked: true }) }); ``` + +### Direct Convex clients + +Authenticated direct operations require `authToken` (the JWT from login) or an appropriately scoped `apiKey`. Browser/native clients first call `actorSession.establish({ authToken })` so logout and expiry invalidate reactive subscriptions. HTTP clients continue sending Bearer/cookie JWT or `X-API-Key`; the HTTP adapter establishes existing valid sessions automatically. + +Do not supply acting DIDs. Ownership, attribution and legacy-account access are resolved from authenticated server records. Old optional identity fields are compatibility checks only and never grant access. Anonymous access is limited to explicitly public resources with active publications; shared writes require authentication. + +See [authentication rollout](docs/authentication-rollout.md) for the required deployed-version confirmation and coordinated client/backend cutover. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9c698ac..db83366 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as activity from "../activity.js"; import type * as activityHttp from "../activityHttp.js"; +import type * as actorSession from "../actorSession.js"; import type * as adminGrants from "../adminGrants.js"; import type * as agentReadHttp from "../agentReadHttp.js"; import type * as apiKeys from "../apiKeys.js"; @@ -45,7 +46,10 @@ import type * as lib_analytics from "../lib/analytics.js"; import type * as lib_apiKeyHelpers from "../lib/apiKeyHelpers.js"; import type * as lib_auth from "../lib/auth.js"; import type * as lib_authUser from "../lib/authUser.js"; +import type * as lib_authenticated from "../lib/authenticated.js"; import type * as lib_bucket from "../lib/bucket.js"; +import type * as lib_bucketKeys from "../lib/bucketKeys.js"; +import type * as lib_clientAuth from "../lib/clientAuth.js"; import type * as lib_didLogAuth from "../lib/didLogAuth.js"; import type * as lib_httpResponses from "../lib/httpResponses.js"; import type * as lib_itemCategories from "../lib/itemCategories.js"; @@ -54,6 +58,7 @@ import type * as lib_legacyList from "../lib/legacyList.js"; import type * as lib_listEnvelope from "../lib/listEnvelope.js"; import type * as lib_observability from "../lib/observability.js"; import type * as lib_permissions from "../lib/permissions.js"; +import type * as lib_session from "../lib/session.js"; import type * as lib_turnkeyClient from "../lib/turnkeyClient.js"; import type * as lib_turnkeySigner from "../lib/turnkeySigner.js"; import type * as lists from "../lists.js"; @@ -93,6 +98,7 @@ import type { declare const fullApi: ApiFromModules<{ activity: typeof activity; activityHttp: typeof activityHttp; + actorSession: typeof actorSession; adminGrants: typeof adminGrants; agentReadHttp: typeof agentReadHttp; apiKeys: typeof apiKeys; @@ -128,7 +134,10 @@ declare const fullApi: ApiFromModules<{ "lib/apiKeyHelpers": typeof lib_apiKeyHelpers; "lib/auth": typeof lib_auth; "lib/authUser": typeof lib_authUser; + "lib/authenticated": typeof lib_authenticated; "lib/bucket": typeof lib_bucket; + "lib/bucketKeys": typeof lib_bucketKeys; + "lib/clientAuth": typeof lib_clientAuth; "lib/didLogAuth": typeof lib_didLogAuth; "lib/httpResponses": typeof lib_httpResponses; "lib/itemCategories": typeof lib_itemCategories; @@ -137,6 +146,7 @@ declare const fullApi: ApiFromModules<{ "lib/listEnvelope": typeof lib_listEnvelope; "lib/observability": typeof lib_observability; "lib/permissions": typeof lib_permissions; + "lib/session": typeof lib_session; "lib/turnkeyClient": typeof lib_turnkeyClient; "lib/turnkeySigner": typeof lib_turnkeySigner; lists: typeof lists; diff --git a/convex/activity.ts b/convex/activity.ts index 4bea69d..8593405 100644 --- a/convex/activity.ts +++ b/convex/activity.ts @@ -1,13 +1,14 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; + import { canUserEditList } from "./lib/permissions"; -export const recordActivity = mutation({ +export const { public: recordActivity, internal: recordActivityInternal } = actorMutation({ + resources: args => ({ lists: [args.listId], items: [args.itemId] }), + scope: "items:write", args: { listId: v.id("lists"), itemId: v.optional(v.id("items")), - actorDid: v.string(), - legacyDid: v.optional(v.string()), type: v.union( v.literal("item_assigned"), v.literal("item_unassigned"), @@ -23,13 +24,13 @@ export const recordActivity = mutation({ })), }, handler: async (ctx, args) => { - const canEdit = await canUserEditList(ctx, args.listId, args.actorDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) throw new Error("Not authorized to write activity"); return await ctx.db.insert("activities", { listId: args.listId, itemId: args.itemId, - actorDid: args.actorDid, + actorDid: ctx.actor.did, type: args.type, metadata: args.metadata, createdAt: Date.now(), @@ -37,7 +38,9 @@ export const recordActivity = mutation({ }, }); -export const getListActivity = query({ +export const { public: getListActivity, internal: getListActivityInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists"), limit: v.optional(v.number()), diff --git a/convex/activityHttp.ts b/convex/activityHttp.ts index 20a22b7..d82fad2 100644 --- a/convex/activityHttp.ts +++ b/convex/activityHttp.ts @@ -1,26 +1,24 @@ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { requireAuthenticatedUser } from "./lib/authUser"; -import { jsonResponse, errorResponse } from "./lib/httpResponses"; +import { authenticatedRequest } from "./lib/actor"; +import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; export const getListActivity = httpAction(async (ctx, request) => { try { - await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { listId, limit } = body as { listId: string; limit?: number }; if (!listId) return errorResponse(request, "listId is required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const activities = await ctx.runQuery((api as any).activity.getListActivity, { + const activities = await ctx.runQuery(internal.activity.getListActivityInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, limit, }); return jsonResponse(request, { activities }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to get activity", 500); + return handlerErrorResponse(request, error, "Failed to get activity"); } }); diff --git a/convex/actorSession.ts b/convex/actorSession.ts new file mode 100644 index 0000000..39b95b3 --- /dev/null +++ b/convex/actorSession.ts @@ -0,0 +1,86 @@ +import { authorizeResources } from "./lib/permissions"; +import { requireSession } from "./lib/session"; +import { v } from "convex/values"; +import { internalQuery, internalMutation, mutation } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { authenticate, type ResolvedActor } from "./lib/actor"; +import { verifyAuthToken } from "./lib/jwt"; +import { hashApiKey } from "./lib/apiKeyHelpers"; +import { AuthError } from "./lib/auth"; + +export const resolve = internalQuery({ + args: { authToken: v.optional(v.string()), apiKey: v.optional(v.string()) }, + handler: (ctx, args): Promise => authenticate(ctx, args), +}); + +// Establishing a record proves possession of a signed session, never a DID. +const establishOperation = { + args: { authToken: v.string() }, + handler: async (ctx: import("./_generated/server").MutationCtx, args: { authToken: string }) => { + const session = await verifyAuthToken(args.authToken).catch(() => { + throw new AuthError("Invalid or expired token", "INVALID_TOKEN"); + }); + const tokenHash = await hashApiKey(args.authToken); + const existing = await ctx.db.query("accessSessions").withIndex("by_hash", q => q.eq("tokenHash", tokenHash)).first(); + if (existing) { + if (existing.revokedAt !== undefined) throw new AuthError("Invalid or expired token", "INVALID_TOKEN"); + return; + } + const user = await ctx.db.query("users").withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", session.turnkeySubOrgId)).first(); + if (!user) throw new AuthError("User not found", "UNAUTHORIZED"); + const id = await ctx.db.insert("accessSessions", { tokenHash, subject: session.turnkeySubOrgId, expiresAt: session.expiresAt }); + await ctx.scheduler.runAt(session.expiresAt, internal.actorSession.expire, { id }); + }, +}; +export const establish = mutation(establishOperation); +export const establishInternal = internalMutation(establishOperation); +export const expire = internalMutation({ + args: { id: v.id("accessSessions") }, + handler: async (ctx, { id }) => { + const record = await ctx.db.get(id); + if (record && record.expiresAt <= Date.now()) await ctx.db.delete(id); + }, +}); +const revokeOperation = { + args: { authToken: v.string() }, + handler: async (ctx: import("./_generated/server").MutationCtx, args: { authToken: string }) => { + const tokenHash = await hashApiKey(args.authToken); + const record = await ctx.db.query("accessSessions").withIndex("by_hash", q => q.eq("tokenHash", tokenHash)).first(); + if (record) { + await ctx.db.patch(record._id, { revokedAt: Date.now() }); + } else { + // A pre-rollout JWT can be logged out before its first authenticated call. + const session = await verifyAuthToken(args.authToken); + const id = await ctx.db.insert("accessSessions", { tokenHash, subject: session.turnkeySubOrgId, expiresAt: session.expiresAt, revokedAt: Date.now() }); + await ctx.scheduler.runAt(session.expiresAt, internal.actorSession.expire, { id }); + } + }, +}; +export const revoke = mutation(revokeOperation); +export const revokeInternal = internalMutation(revokeOperation); + +export const identity = internalQuery({ + args: { authToken: v.string() }, + handler: (ctx, args) => requireSession(ctx, args.authToken), +}); + +export const authorize = internalQuery({ + args: { + authToken: v.optional(v.string()), apiKey: v.optional(v.string()), + resources: v.object({ + lists: v.optional(v.array(v.union(v.id("lists"), v.null()))), + items: v.optional(v.array(v.union(v.id("items"), v.null()))), + anchors: v.optional(v.array(v.union(v.id("bitcoinAnchors"), v.null()))), + accounts: v.optional(v.array(v.union(v.id("users"), v.null()))), + }), + }, + handler: async (ctx, args): Promise => { + const actor = await authenticate(ctx, args); + await authorizeResources(ctx, actor, { + lists: args.resources.lists?.filter(id => id !== null), + items: args.resources.items?.filter(id => id !== null), + anchors: args.resources.anchors?.filter(id => id !== null), + accounts: args.resources.accounts?.filter(id => id !== null), + }); + }, +}); diff --git a/convex/agentReadHttp.ts b/convex/agentReadHttp.ts index b4c77c1..a1ca5b0 100644 --- a/convex/agentReadHttp.ts +++ b/convex/agentReadHttp.ts @@ -1,16 +1,9 @@ -/** - * HTTP read endpoints for agents. - * - * These GET handlers authenticate via resolveActor() (JWT session or agent - * API key) and require read scopes. They wrap existing queries without changing - * their logic, and resolve to a single current DID (no legacy/wallet threading). - */ +/** HTTP adapter; authentication and authorization run in the shared operation. */ import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { resolveActor, requireScope } from "./lib/actor"; +import { authenticatedRequest } from "./lib/actor"; import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; /** @@ -21,18 +14,12 @@ import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpRes */ export const getLists = httpAction(async (ctx, request) => { try { - const actor = await resolveActor(ctx, request); - requireScope(actor, "lists:read"); + const lists = await ctx.runQuery(internal.lists.getUserListsInternal, { + ...await authenticatedRequest(ctx, request), - const lists = await ctx.runQuery(api.lists.getUserLists, { - userDid: actor.did, - legacyDid: actor.legacyDid, }); return jsonResponse(request, { lists }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[agentReadHttp] getLists error:", error); return handlerErrorResponse( request, @@ -51,9 +38,6 @@ export const getLists = httpAction(async (ctx, request) => { */ export const getListWithItems = httpAction(async (ctx, request) => { try { - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:read"); - const listId = new URL(request.url).searchParams.get("listId"); if (!listId) { return errorResponse(request, "listId query parameter is required"); @@ -63,17 +47,14 @@ export const getListWithItems = httpAction(async (ctx, request) => { // caller may not view it, so an items:read key can't read arbitrary lists. const result = await ctx.runQuery(internal.lists.getListWithItemsForViewer, { listId: listId as Id<"lists">, - viewerDid: actor.did, - legacyDid: actor.legacyDid, + ...await authenticatedRequest(ctx, request), + }); if (!result) { return errorResponse(request, "List not found", 404); } return jsonResponse(request, result); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[agentReadHttp] getListWithItems error:", error); return handlerErrorResponse( request, diff --git a/convex/apiKeysHttp.ts b/convex/apiKeysHttp.ts index ab98b57..724ad72 100644 --- a/convex/apiKeysHttp.ts +++ b/convex/apiKeysHttp.ts @@ -9,7 +9,7 @@ import { httpAction } from "./_generated/server"; import type { ActionCtx } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { requireAuth, @@ -35,8 +35,8 @@ async function requireUserDid( ctx: ActionCtx, request: Request ): Promise { - const auth = await requireAuth(request); - const user = (await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const auth = await requireAuth(ctx, request); + const user = (await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, })) as UserInfo; return user?.did ?? null; diff --git a/convex/assignees.ts b/convex/assignees.ts index 2bf74e8..6c75a37 100644 --- a/convex/assignees.ts +++ b/convex/assignees.ts @@ -1,19 +1,20 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; + import { canUserEditList } from "./lib/permissions"; -export const assignItem = mutation({ +export const { public: assignItem, internal: assignItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), assigneeDid: v.string(), - actorDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.actorDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) throw new Error("Not authorized to assign item"); const existing = await ctx.db @@ -27,14 +28,14 @@ export const assignItem = mutation({ itemId: args.itemId, listId: item.listId, assigneeDid: args.assigneeDid, - assignedByDid: args.actorDid, + assignedByDid: ctx.actor.did, assignedAt: now, }); await ctx.db.insert("activities", { listId: item.listId, itemId: args.itemId, - actorDid: args.actorDid, + actorDid: ctx.actor.did, type: "item_assigned", metadata: { assigneeDid: args.assigneeDid }, createdAt: now, @@ -45,18 +46,18 @@ export const assignItem = mutation({ }, }); -export const unassignItem = mutation({ +export const { public: unassignItem, internal: unassignItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), assigneeDid: v.string(), - actorDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.actorDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) throw new Error("Not authorized to unassign item"); const existing = await ctx.db @@ -70,7 +71,7 @@ export const unassignItem = mutation({ await ctx.db.insert("activities", { listId: item.listId, itemId: args.itemId, - actorDid: args.actorDid, + actorDid: ctx.actor.did, type: "item_unassigned", metadata: { assigneeDid: args.assigneeDid }, createdAt: now, @@ -81,7 +82,9 @@ export const unassignItem = mutation({ }, }); -export const getItemAssignees = query({ +export const { public: getItemAssignees, internal: getItemAssigneesInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, args) => { return await ctx.db diff --git a/convex/assigneesHttp.ts b/convex/assigneesHttp.ts index 30d5da1..9c2cdd9 100644 --- a/convex/assigneesHttp.ts +++ b/convex/assigneesHttp.ts @@ -1,71 +1,63 @@ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { requireAuthenticatedUser } from "./lib/authUser"; -import { jsonResponse, errorResponse } from "./lib/httpResponses"; +import { authenticatedRequest } from "./lib/actor"; +import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; export const assignItem = httpAction(async (ctx, request) => { try { - const user = await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { itemId, assigneeDid } = body as { itemId: string; assigneeDid: string }; if (!itemId || !assigneeDid) return errorResponse(request, "itemId and assigneeDid are required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await ctx.runMutation((api as any).assignees.assignItem, { + await ctx.runMutation(internal.assignees.assignItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, assigneeDid, - actorDid: user.did, - legacyDid: user.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to assign item", 500); + return handlerErrorResponse(request, error, "Failed to assign item"); } }); export const unassignItem = httpAction(async (ctx, request) => { try { - const user = await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { itemId, assigneeDid } = body as { itemId: string; assigneeDid: string }; if (!itemId || !assigneeDid) return errorResponse(request, "itemId and assigneeDid are required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await ctx.runMutation((api as any).assignees.unassignItem, { + await ctx.runMutation(internal.assignees.unassignItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, assigneeDid, - actorDid: user.did, - legacyDid: user.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to unassign item", 500); + return handlerErrorResponse(request, error, "Failed to unassign item"); } }); export const getItemAssignees = httpAction(async (ctx, request) => { try { - await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { itemId } = body as { itemId: string }; if (!itemId) return errorResponse(request, "itemId is required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const assignees = await ctx.runQuery((api as any).assignees.getItemAssignees, { + const assignees = await ctx.runQuery(internal.assignees.getItemAssigneesInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, }); return jsonResponse(request, { assignees }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to get assignees", 500); + return handlerErrorResponse(request, error, "Failed to get assignees"); } }); diff --git a/convex/attachments.ts b/convex/attachments.ts index ff62156..ce28683 100644 --- a/convex/attachments.ts +++ b/convex/attachments.ts @@ -1,18 +1,13 @@ +import { isDirectChildKey } from "./lib/bucketKeys"; +import { canUserEditList } from "./lib/permissions"; +import { actorAction, actorMutation, actorQuery } from "./lib/authenticated"; /** * File attachments for list items — stored in Railway Bucket. */ import { v } from "convex/values"; -import { - action, - internalMutation, - internalQuery, - mutation, - query, -} from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; import { internal } from "./_generated/api"; -import type { Id } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; import { bucketKey as makeBucketKey, deleteObject, @@ -37,33 +32,11 @@ function extensionFor(contentType: string): string { return EXT_BY_CONTENT_TYPE[contentType] ?? "bin"; } -async function canUserEditList( - ctx: MutationCtx | QueryCtx, - listId: Id<"lists">, - userDid: string, - legacyDid?: string -): Promise { - const list = await ctx.db.get(listId); - if (!list) return false; - - const dids = [userDid]; - if (legacyDid) dids.push(legacyDid); - - if (dids.includes(list.ownerDid)) return true; - - const pub = await ctx.db - .query("publications") - .withIndex("by_list", (q) => q.eq("listId", listId)) - .first(); - - return pub?.status === "active"; -} - -export const generateUploadUrl = action({ +export const { public: generateUploadUrl, internal: generateUploadUrlInternal } = actorAction({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), contentType: v.string(), byteLength: v.number(), }, @@ -80,8 +53,8 @@ export const generateUploadUrl = action({ const owned = await ctx.runQuery(internal.attachments.assertItemEditable, { itemId: args.itemId, - userDid: args.userDid, - legacyDid: args.legacyDid, + userDid: ctx.actor.did, + legacyDid: ctx.actor.legacyDid, }); if (!owned) { throw new Error("Not authorized to add attachments to this item"); @@ -100,11 +73,11 @@ export const generateUploadUrl = action({ }, }); -export const addAttachment = mutation({ +export const { public: addAttachment, internal: addAttachmentInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), bucketKey: v.string(), contentType: v.string(), size: v.number(), @@ -114,11 +87,12 @@ export const addAttachment = mutation({ const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to add attachments to this item"); } + if (!isDirectChildKey(args.bucketKey, `attachments/${args.itemId}`)) throw new Error("Invalid attachment key"); const current = item.attachments ?? []; await ctx.db.patch(args.itemId, { attachments: [ @@ -135,23 +109,24 @@ export const addAttachment = mutation({ }, }); -export const removeAttachment = action({ +export const { public: removeAttachment, internal: removeAttachmentInternal } = actorAction({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), bucketKey: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args): Promise => { const owned = await ctx.runQuery(internal.attachments.assertItemEditable, { itemId: args.itemId, - userDid: args.userDid, - legacyDid: args.legacyDid, + userDid: ctx.actor.did, + legacyDid: ctx.actor.legacyDid, }); if (!owned) { throw new Error("Not authorized to remove attachments from this item"); } + if (!owned.keys.includes(args.bucketKey)) throw new Error("Attachment not found on this item"); await deleteObject(args.bucketKey); await ctx.runMutation(internal.attachments.dropAttachment, { itemId: args.itemId, @@ -160,7 +135,9 @@ export const removeAttachment = action({ }, }); -export const getAttachmentUrls = query({ +export const { public: getAttachmentUrls, internal: getAttachmentUrlsInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -194,7 +171,7 @@ export const assertItemEditable = internalQuery({ const item = await ctx.db.get(args.itemId); if (!item) return null; const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); - return canEdit ? { itemId: item._id } : null; + return canEdit ? { itemId: item._id, keys: (item.attachments ?? []).filter(entry => typeof entry === "object").map(entry => entry.key) } : null; }, }); diff --git a/convex/auth.ts b/convex/auth.ts index 549c20f..65fd0f2 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -1,3 +1,4 @@ +import { requireSession } from "./lib/session"; /** * Auth-related Convex functions for Turnkey authentication. * @@ -16,11 +17,10 @@ import type { QueryCtx } from "./_generated/server"; * sub-organization ID is not found, otherwise updates the existing user's * last login timestamp. * - * Migration flow: When legacyDid is provided, it means the user is migrating - * from localStorage identity to Turnkey. We look up by legacyDid, update their - * primary DID to the new Turnkey DID, and store the old DID as legacyDid. + * Existing verified account links are preserved. New links cannot be established + * by asserting a current or legacy DID. */ -export const upsertUser = mutation({ +export const upsertUserInternal = internalMutation({ args: { turnkeySubOrgId: v.string(), email: v.string(), @@ -36,6 +36,13 @@ export const upsertUser = mutation({ .withIndex("by_turnkey_id", (q) => q.eq("turnkeySubOrgId", args.turnkeySubOrgId)) .first(); + // A supplied new DID must never take over another current or migrated account. + if (args.did) { + const claimed = await ctx.db.query("users").withIndex("by_did", q => q.eq("did", args.did)).first() + ?? await ctx.db.query("users").withIndex("by_legacy_did", q => q.eq("legacyDid", args.did)).first(); + if (claimed && claimed._id !== existingByTurnkey?._id) throw new Error("Not authorized to claim this DID"); + } + if (args.legacyDid) throw new Error("Identity migration requires verified account linking"); if (existingByTurnkey) { if (existingByTurnkey.email !== args.email) { throw new Error("This identity is linked to a different email."); @@ -54,44 +61,6 @@ export const upsertUser = mutation({ return existingByTurnkey._id; } - // Migration case: If legacyDid is provided, find user by their old DID - const legacyDid = args.legacyDid; - if (legacyDid) { - const existingByLegacyDid = await ctx.db - .query("users") - .withIndex("by_did", (q) => q.eq("did", legacyDid)) - .first(); - - if (existingByLegacyDid) { - // Migrate user: update DID to new Turnkey DID, store old DID as legacy - await ctx.db.patch(existingByLegacyDid._id, { - did: args.did, // New Turnkey DID - legacyDid, // Store old DID for list lookup - turnkeySubOrgId: args.turnkeySubOrgId, - email: args.email, - lastLoginAt: Date.now(), - legacyIdentity: false, - }); - return existingByLegacyDid._id; - } - } - - // Check if user exists by the new Turnkey DID (edge case: same DID) - const existingByDid = args.did - ? await ctx.db.query("users").withIndex("by_did", (q) => q.eq("did", args.did)).first() - : null; - - if (existingByDid) { - // Link Turnkey to existing user - await ctx.db.patch(existingByDid._id, { - turnkeySubOrgId: args.turnkeySubOrgId, - email: args.email, - lastLoginAt: Date.now(), - legacyIdentity: false, - }); - return existingByDid._id; - } - // This index read and insert share a Convex transaction. If two signup // sessions provision different identities, only the first may create a user. const existingByEmail = await ctx.db.query("users") @@ -124,7 +93,7 @@ export const upsertUser = mutation({ /** * Get a user by their Turnkey sub-organization ID. */ -export const getUserByTurnkeyId = query({ +export const getUserByTurnkeyIdInternal = internalQuery({ args: { turnkeySubOrgId: v.string() }, handler: async (ctx, args) => { return await ctx.db @@ -137,7 +106,7 @@ export const getUserByTurnkeyId = query({ /** * Get a user by their email address. */ -export const getUserByEmail = query({ +export const getUserByEmailInternal = internalQuery({ args: { email: v.string() }, handler: async (ctx, args) => { return await ctx.db @@ -189,3 +158,32 @@ export const selectLoginAccount = internalMutation({ return { userId, turnkeySubOrgId: user.turnkeySubOrgId, previous }; }, }); + +// Preserve public names during rollout. Only a verified session may access its account. +export const getUserByTurnkeyId = query({ + args: { turnkeySubOrgId: v.string(), authToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const auth = await requireSession(ctx, args.authToken); + if (auth.turnkeySubOrgId !== args.turnkeySubOrgId) throw new Error("Not authorized"); + return ctx.db.query("users").withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", auth.turnkeySubOrgId)).first(); + }, +}); +export const getUserByEmail = query({ + args: { email: v.string(), authToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const auth = await requireSession(ctx, args.authToken); + if (auth.email !== args.email) throw new Error("Not authorized"); + return ctx.db.query("users").withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", auth.turnkeySubOrgId)).first(); + }, +}); +export const upsertUser = mutation({ + args: { turnkeySubOrgId: v.string(), email: v.string(), did: v.optional(v.string()), displayName: v.optional(v.string()), legacyDid: v.optional(v.string()), authToken: v.optional(v.string()) }, + handler: async (ctx, args) => { + const auth = await requireSession(ctx, args.authToken); + if (auth.turnkeySubOrgId !== args.turnkeySubOrgId || auth.email !== args.email) throw new Error("Not authorized"); + const user = await ctx.db.query("users").withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", auth.turnkeySubOrgId)).first(); + if (!user || (args.did && args.did !== user.did) || (args.legacyDid && args.legacyDid !== user.legacyDid)) throw new Error("Not authorized to assert identity; use authenticated account setup"); + await ctx.db.patch(user._id, { lastLoginAt: Date.now() }); + return user._id; + }, +}); diff --git a/convex/authSessions.ts b/convex/authSessions.ts index a1c6dd5..6b229bd 100644 --- a/convex/authSessions.ts +++ b/convex/authSessions.ts @@ -8,7 +8,7 @@ */ import { v } from "convex/values"; -import { mutation, query, internalMutation } from "./_generated/server"; +import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; // Session timeout (15 minutes in milliseconds) const SESSION_TIMEOUT = 15 * 60 * 1000; @@ -16,7 +16,7 @@ const SESSION_TIMEOUT = 15 * 60 * 1000; /** * Create a new auth session. */ -export const createSession = mutation({ +export const createSessionInternal = internalMutation({ args: { sessionId: v.string(), email: v.string(), @@ -41,7 +41,7 @@ export const createSession = mutation({ * Get an auth session by session ID. * Returns null if session doesn't exist or has expired. */ -export const getSession = query({ +export const getSessionInternal = internalQuery({ args: { sessionId: v.string() }, handler: async (ctx, args) => { const session = await ctx.db @@ -63,7 +63,7 @@ export const getSession = query({ /** * Update a session after successful OTP verification. */ -export const markSessionVerified = mutation({ +export const markSessionVerifiedInternal = internalMutation({ args: { sessionId: v.string(), subOrgId: v.string(), @@ -94,7 +94,7 @@ export const markSessionVerified = mutation({ /** * Delete a session (cleanup after login or logout). */ -export const deleteSession = mutation({ +export const deleteSessionInternal = internalMutation({ args: { sessionId: v.string() }, handler: async (ctx, args) => { const session = await ctx.db @@ -134,3 +134,23 @@ export const cleanupExpiredSessions = internalMutation({ return deletedCount; }, }); + +// Compatibility name: OTP state is managed exclusively by verified HTTP login. +export const createSession = mutation({ args: { + sessionId: v.string(), + email: v.string(), + subOrgId: v.optional(v.string()), + otpId: v.optional(v.string()), + }, handler: async () => { throw new Error("Authentication required: use the HTTP login endpoints"); } }); + +// Compatibility name: OTP state is managed exclusively by verified HTTP login. +export const getSession = query({ args: { sessionId: v.string() }, handler: async () => { throw new Error("Authentication required: use the HTTP login endpoints"); } }); + +// Compatibility name: OTP state is managed exclusively by verified HTTP login. +export const markSessionVerified = mutation({ args: { + sessionId: v.string(), + subOrgId: v.string(), + }, handler: async () => { throw new Error("Authentication required: use the HTTP login endpoints"); } }); + +// Compatibility name: OTP state is managed exclusively by verified HTTP login. +export const deleteSession = mutation({ args: { sessionId: v.string() }, handler: async () => { throw new Error("Authentication required: use the HTTP login endpoints"); } }); diff --git a/convex/billing.ts b/convex/billing.ts index eb47da1..bd51515 100644 --- a/convex/billing.ts +++ b/convex/billing.ts @@ -1,3 +1,4 @@ +import { actorQuery } from "./lib/authenticated"; /** * Billing module — Stripe subscription management. * @@ -8,7 +9,7 @@ */ import { v } from "convex/values"; -import { internalMutation, internalQuery, query } from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; // --------------------------------------------------------------------------- @@ -30,7 +31,9 @@ export type Plan = keyof typeof PLANS; /** * Get current subscription for a user by userId. Returns null for free tier. */ -export const getUserSubscription = query({ +export const { public: getUserSubscription, internal: getUserSubscriptionAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { return await ctx.db @@ -44,7 +47,9 @@ export const getUserSubscription = query({ * Get effective plan — defaults to "free" if no active subscription. * Also grants "pro" when a referral Pro credit is active (referralProUntil > now). */ -export const getUserPlan = query({ +export const { public: getUserPlan, internal: getUserPlanAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }): Promise => { const sub = await ctx.db diff --git a/convex/billingHttp.ts b/convex/billingHttp.ts index 9baf6e7..42722ff 100644 --- a/convex/billingHttp.ts +++ b/convex/billingHttp.ts @@ -11,7 +11,7 @@ */ import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import { requireAuth } from "./lib/auth"; import { jsonResponse, errorResponse } from "./lib/httpResponses"; import type { Id } from "./_generated/dataModel"; @@ -49,8 +49,8 @@ export const stripeWebhook = httpAction(async (ctx, request) => { */ export const createCheckout = httpAction(async (ctx, request) => { try { - const auth = await requireAuth(request); - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const auth = await requireAuth(ctx, request); + const user = await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, }) as { _id: Id<"users">; email?: string } | null; if (!user) return errorResponse(request, "User not found", 404); @@ -83,8 +83,8 @@ export const createCheckout = httpAction(async (ctx, request) => { */ export const createPortal = httpAction(async (ctx, request) => { try { - const auth = await requireAuth(request); - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const auth = await requireAuth(ctx, request); + const user = await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, }) as { _id: Id<"users"> } | null; if (!user) return errorResponse(request, "User not found", 404); @@ -109,8 +109,8 @@ export const createPortal = httpAction(async (ctx, request) => { */ export const getSubscription = httpAction(async (ctx, request) => { try { - const auth = await requireAuth(request); - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const auth = await requireAuth(ctx, request); + const user = await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, }) as { _id: Id<"users"> } | null; if (!user) return errorResponse(request, "User not found", 404); diff --git a/convex/bitcoinAnchors.ts b/convex/bitcoinAnchors.ts index 1b2760b..fc8c826 100644 --- a/convex/bitcoinAnchors.ts +++ b/convex/bitcoinAnchors.ts @@ -1,3 +1,5 @@ +import { authorizeResources } from "./lib/permissions"; +import { actorMutation, actorQuery, actorAction } from "./lib/authenticated"; /** * Bitcoin Anchoring for List State * @@ -14,8 +16,8 @@ */ import { v } from "convex/values"; -import { query, mutation, action } from "./_generated/server"; -import { api } from "./_generated/api"; + +import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; /** @@ -81,12 +83,13 @@ function buildCanonicalState( * Internal mutation to create an anchor record. * Called by the action after computing the hash. */ -export const createAnchorRecord = mutation({ +export const { public: createAnchorRecord, internal: createAnchorRecordInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), stateHash: v.string(), stateSnapshot: v.string(), - anchoredByDid: v.string(), }, handler: async (ctx, args) => { return await ctx.db.insert("bitcoinAnchors", { @@ -94,7 +97,7 @@ export const createAnchorRecord = mutation({ contentHash: args.stateHash, network: BITCOIN_NETWORK, status: "pending", - requestedByDid: args.anchoredByDid, + requestedByDid: ctx.actor.did, createdAt: Date.now(), stateSnapshot: args.stateSnapshot, }); @@ -104,7 +107,9 @@ export const createAnchorRecord = mutation({ /** * Update anchor status after Bitcoin inscription. */ -export const updateAnchorStatus = mutation({ +export const { public: updateAnchorStatus, internal: updateAnchorStatusInternal } = actorMutation({ + resources: args => ({ anchors: [args.anchorId] }), + scope: "items:write", args: { anchorId: v.id("bitcoinAnchors"), status: v.union( @@ -135,7 +140,9 @@ export const updateAnchorStatus = mutation({ /** * Get list data for anchoring (internal helper query). */ -export const getListDataForAnchor = query({ +export const { public: getListDataForAnchor, internal: getListDataForAnchorInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); @@ -164,14 +171,16 @@ export const getListDataForAnchor = query({ * @param userDid - DID of user requesting the anchor * @returns The anchor record ID */ -export const anchorListState = action({ +export const { public: anchorListState, internal: anchorListStateInternal } = actorAction({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args): Promise<{ anchorId: Id<"bitcoinAnchors">; stateHash: string; status: string }> => { // 1. Fetch list data - const data = await ctx.runQuery(api.bitcoinAnchors.getListDataForAnchor, { + const data = await ctx.runQuery(internal.bitcoinAnchors.getListDataForAnchorInternal, { + ...ctx.credentials, listId: args.listId, }); @@ -182,7 +191,7 @@ export const anchorListState = action({ const { list, items } = data; // 2. Verify user has access (owner) - if (list.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(list.ownerDid)) { throw new Error("Only the owner can anchor list state"); } @@ -191,11 +200,11 @@ export const anchorListState = action({ const stateHash = await computeSha256(canonicalState); // 4. Create anchor record - const anchorId = await ctx.runMutation(api.bitcoinAnchors.createAnchorRecord, { + const anchorId = await ctx.runMutation(internal.bitcoinAnchors.createAnchorRecordInternal, { + ...ctx.credentials, listId: args.listId, stateHash, stateSnapshot: canonicalState, - anchoredByDid: args.userDid, }); // 5. Attempt Bitcoin inscription @@ -219,7 +228,8 @@ export const anchorListState = action({ // 'application/json', // feeRate // ); - // await ctx.runMutation(api.bitcoinAnchors.updateAnchorStatus, { + // await ctx.runMutation(internal.bitcoinAnchors.updateAnchorStatusInternal, { + // ...ctx.credentials, // anchorId, // status: 'inscribed', // txid: inscription.txid, @@ -233,7 +243,8 @@ export const anchorListState = action({ const simulatedTxid = `signet:${stateHash.substring(0, 16)}:${Date.now()}`; const simulatedInscriptionId = `${simulatedTxid}i0`; - await ctx.runMutation(api.bitcoinAnchors.updateAnchorStatus, { + await ctx.runMutation(internal.bitcoinAnchors.updateAnchorStatusInternal, { + ...ctx.credentials, anchorId, status: "inscribed", txid: simulatedTxid, @@ -250,7 +261,9 @@ export const anchorListState = action({ /** * Get all anchors for a list. */ -export const getListAnchors = query({ +export const { public: getListAnchors, internal: getListAnchorsInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { return await ctx.db @@ -264,7 +277,9 @@ export const getListAnchors = query({ /** * Get all Bitcoin anchors for a specific item */ -export const getItemAnchors = query({ +export const { public: getItemAnchors, internal: getItemAnchorsInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, { itemId }) => { const anchors = await ctx.db @@ -272,14 +287,21 @@ export const getItemAnchors = query({ .withIndex("by_item", (q) => q.eq("itemId", itemId)) .collect(); - return anchors; + const accessible = []; + for (const anchor of anchors) { + try { await authorizeResources(ctx, ctx.actor, { anchors: [anchor._id] }); accessible.push(anchor); } + catch { /* Private anchors are omitted from collections. */ } + } + return accessible; }, }); /** * Get anchor by transaction ID */ -export const getAnchorByTxid = query({ +export const { public: getAnchorByTxid, internal: getAnchorByTxidInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", args: { txid: v.string() }, handler: async (ctx, { txid }) => { const anchor = await ctx.db @@ -287,6 +309,7 @@ export const getAnchorByTxid = query({ .withIndex("by_txid", (q) => q.eq("txid", txid)) .first(); + if (anchor) await authorizeResources(ctx, ctx.actor, { anchors: [anchor._id] }); return anchor; }, }); @@ -294,7 +317,9 @@ export const getAnchorByTxid = query({ /** * Get the latest anchor for a list. */ -export const getLatestAnchor = query({ +export const { public: getLatestAnchor, internal: getLatestAnchorInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { return await ctx.db @@ -308,7 +333,9 @@ export const getLatestAnchor = query({ /** * Get all pending anchors (for background processing) */ -export const getPendingAnchors = query({ +export const { public: getPendingAnchors, internal: getPendingAnchorsInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", args: {}, handler: async (ctx) => { const anchors = await ctx.db @@ -316,14 +343,21 @@ export const getPendingAnchors = query({ .withIndex("by_status", (q) => q.eq("status", "pending")) .collect(); - return anchors; + const accessible = []; + for (const anchor of anchors) { + try { await authorizeResources(ctx, ctx.actor, { anchors: [anchor._id] }); accessible.push(anchor); } + catch { /* Private anchors are omitted from collections. */ } + } + return accessible; }, }); /** * Get anchor by ID. */ -export const getAnchor = query({ +export const { public: getAnchor, internal: getAnchorInternal } = actorQuery({ + resources: args => ({ anchors: [args.anchorId] }), + scope: "items:read", args: { anchorId: v.id("bitcoinAnchors") }, handler: async (ctx, args) => { return await ctx.db.get(args.anchorId); @@ -333,13 +367,16 @@ export const getAnchor = query({ /** * Verify anchor against current list state. */ -export const verifyAnchorState = action({ +export const { public: verifyAnchorState, internal: verifyAnchorStateInternal } = actorAction({ + resources: args => ({ anchors: [args.anchorId] }), + scope: "items:write", args: { anchorId: v.id("bitcoinAnchors"), }, handler: async (ctx, args): Promise<{ valid: boolean; currentHash: string; anchoredHash: string; stateChanged: boolean }> => { // Get the anchor - const anchor = await ctx.runQuery(api.bitcoinAnchors.getAnchor, { + const anchor = await ctx.runQuery(internal.bitcoinAnchors.getAnchorInternal, { + ...ctx.credentials, anchorId: args.anchorId, }); @@ -352,7 +389,8 @@ export const verifyAnchorState = action({ } // Get current list state - const data = await ctx.runQuery(api.bitcoinAnchors.getListDataForAnchor, { + const data = await ctx.runQuery(internal.bitcoinAnchors.getListDataForAnchorInternal, { + ...ctx.credentials, listId: anchor.listId, }); diff --git a/convex/categories.ts b/convex/categories.ts index 5ded0f0..7016f88 100644 --- a/convex/categories.ts +++ b/convex/categories.ts @@ -1,3 +1,4 @@ +import { actorQuery, actorMutation } from "./lib/authenticated"; /** * Convex functions for category management. * @@ -7,18 +8,24 @@ */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import type { QueryCtx } from "./_generated/server"; +import type { ResolvedActor } from "./lib/actor"; + +async function ownedCategories(ctx: QueryCtx, actor: ResolvedActor) { + const dids = [actor.did, actor.legacyDid].filter((did): did is string => !!did); + return (await Promise.all(dids.map(did => ctx.db.query("categories") + .withIndex("by_owner", q => q.eq("ownerDid", did)).collect()))).flat(); +} /** * Get all categories for a user, sorted by order. */ -export const getUserCategories = query({ - args: { userDid: v.string() }, - handler: async (ctx, args) => { - const categories = await ctx.db - .query("categories") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.userDid)) - .collect(); +export const { public: getUserCategories, internal: getUserCategoriesInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx) => { + const categories = await ownedCategories(ctx, ctx.actor); // Sort by order return categories.sort((a, b) => a.order - b.order); @@ -30,9 +37,10 @@ export const getUserCategories = query({ * * Validates that name is unique for the user. */ -export const createCategory = mutation({ +export const { public: createCategory, internal: createCategoryInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { - userDid: v.string(), name: v.string(), createdAt: v.number(), }, @@ -48,27 +56,19 @@ export const createCategory = mutation({ } // Check for duplicate name - const existing = await ctx.db - .query("categories") - .withIndex("by_owner_name", (q) => - q.eq("ownerDid", args.userDid).eq("name", trimmedName) - ) - .first(); + const existing = (await ownedCategories(ctx, ctx.actor)).find(category => category.name === trimmedName); if (existing) { throw new Error("Category with this name already exists"); } // Get max order to place new category at end - const categories = await ctx.db - .query("categories") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.userDid)) - .collect(); + const categories = await ownedCategories(ctx, ctx.actor); const maxOrder = categories.reduce((max, c) => Math.max(max, c.order), 0); return await ctx.db.insert("categories", { - ownerDid: args.userDid, + ownerDid: ctx.actor.did, name: trimmedName, order: maxOrder + 1, createdAt: args.createdAt, @@ -81,10 +81,11 @@ export const createCategory = mutation({ * * Validates ownership and unique name. */ -export const renameCategory = mutation({ +export const { public: renameCategory, internal: renameCategoryInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { categoryId: v.id("categories"), - userDid: v.string(), name: v.string(), }, handler: async (ctx, args) => { @@ -93,7 +94,7 @@ export const renameCategory = mutation({ throw new Error("Category not found"); } - if (category.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(category.ownerDid)) { throw new Error("Not authorized to modify this category"); } @@ -108,12 +109,7 @@ export const renameCategory = mutation({ } // Check for duplicate name (excluding current category) - const existing = await ctx.db - .query("categories") - .withIndex("by_owner_name", (q) => - q.eq("ownerDid", args.userDid).eq("name", trimmedName) - ) - .first(); + const existing = (await ownedCategories(ctx, ctx.actor)).find(category => category.name === trimmedName); if (existing && existing._id !== args.categoryId) { throw new Error("Category with this name already exists"); @@ -128,10 +124,11 @@ export const renameCategory = mutation({ * * Lists in this category are moved to uncategorized (categoryId = undefined). */ -export const deleteCategory = mutation({ +export const { public: deleteCategory, internal: deleteCategoryInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { categoryId: v.id("categories"), - userDid: v.string(), }, handler: async (ctx, args) => { const category = await ctx.db.get(args.categoryId); @@ -139,7 +136,7 @@ export const deleteCategory = mutation({ throw new Error("Category not found"); } - if (category.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(category.ownerDid)) { throw new Error("Not authorized to delete this category"); } @@ -162,10 +159,11 @@ export const deleteCategory = mutation({ * * Updates the order field to move category to new position. */ -export const reorderCategory = mutation({ +export const { public: reorderCategory, internal: reorderCategoryInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { categoryId: v.id("categories"), - userDid: v.string(), newOrder: v.number(), }, handler: async (ctx, args) => { @@ -174,7 +172,7 @@ export const reorderCategory = mutation({ throw new Error("Category not found"); } - if (category.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(category.ownerDid)) { throw new Error("Not authorized to modify this category"); } @@ -188,12 +186,12 @@ export const reorderCategory = mutation({ * Pass undefined for categoryId to move to uncategorized. * Validates that user has access to the list (owner or collaborator via collaborators table). */ -export const setListCategory = mutation({ +export const { public: setListCategory, internal: setListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), categoryId: v.optional(v.id("categories")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); @@ -202,9 +200,9 @@ export const setListCategory = mutation({ } // DIDs to check: current DID and optionally legacy DID - const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); + const didsToCheck = [ctx.actor.did]; + if (ctx.actor.legacyDid) { + didsToCheck.push(ctx.actor.legacyDid); } // Check user has access (owner or published list) @@ -225,7 +223,7 @@ export const setListCategory = mutation({ // If setting a category, verify ownership if (args.categoryId) { const category = await ctx.db.get(args.categoryId); - if (!category || category.ownerDid !== args.userDid) { + if (!category || ![ctx.actor.did, ctx.actor.legacyDid].includes(category.ownerDid)) { throw new Error("Category not found or not owned by user"); } } diff --git a/convex/categoriesHttp.ts b/convex/categoriesHttp.ts index 09d51d6..8a07ea9 100644 --- a/convex/categoriesHttp.ts +++ b/convex/categoriesHttp.ts @@ -1,3 +1,4 @@ +import { authenticatedRequest } from "./lib/actor"; /** * HTTP action handlers for protected category mutations. * @@ -6,20 +7,10 @@ */ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { - requireAuth, - AuthError, - unauthorizedResponseWithCors, -} from "./lib/auth"; import { jsonResponse, errorResponse } from "./lib/httpResponses"; -/** - * Helper type for user info. - */ -type UserInfo = { did: string; legacyDid?: string } | null; - /** * POST /api/categories/create * @@ -31,15 +22,8 @@ type UserInfo = { did: string; legacyDid?: string } | null; export const createCategory = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } // Parse request body const body = await request.json(); @@ -50,17 +34,15 @@ export const createCategory = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - const categoryId = await ctx.runMutation(api.categories.createCategory, { - userDid: user.did, + const categoryId = await ctx.runMutation(internal.categories.createCategoryInternal, { + ...await authenticatedRequest(ctx, request), + name, createdAt: Date.now(), }); return jsonResponse(request, { categoryId }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[categoriesHttp] createCategory error:", error); return errorResponse( request, @@ -81,15 +63,8 @@ export const createCategory = httpAction(async (ctx, request) => { export const renameCategory = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } // Parse request body const body = await request.json(); @@ -100,17 +75,14 @@ export const renameCategory = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - await ctx.runMutation(api.categories.renameCategory, { + await ctx.runMutation(internal.categories.renameCategoryInternal, { + ...await authenticatedRequest(ctx, request), categoryId: categoryId as Id<"categories">, - userDid: user.did, name, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[categoriesHttp] renameCategory error:", error); return errorResponse( request, @@ -131,15 +103,8 @@ export const renameCategory = httpAction(async (ctx, request) => { export const deleteCategory = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } // Parse request body const body = await request.json(); @@ -150,16 +115,13 @@ export const deleteCategory = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - await ctx.runMutation(api.categories.deleteCategory, { + await ctx.runMutation(internal.categories.deleteCategoryInternal, { + ...await authenticatedRequest(ctx, request), categoryId: categoryId as Id<"categories">, - userDid: user.did, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[categoriesHttp] deleteCategory error:", error); return errorResponse( request, @@ -180,15 +142,8 @@ export const deleteCategory = httpAction(async (ctx, request) => { export const setListCategory = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); // Get user's DID from their turnkeySubOrgId - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as UserInfo; - if (!user) { - return errorResponse(request, "User not found", 404); - } // Parse request body const body = await request.json(); @@ -199,18 +154,14 @@ export const setListCategory = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - await ctx.runMutation(api.categories.setListCategory, { + await ctx.runMutation(internal.categories.setListCategoryInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, categoryId: categoryId as Id<"categories">, - userDid: user.did, - legacyDid: user.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[categoriesHttp] setListCategory error:", error); return errorResponse( request, diff --git a/convex/comments.ts b/convex/comments.ts index f8a1655..8a134a9 100644 --- a/convex/comments.ts +++ b/convex/comments.ts @@ -1,61 +1,30 @@ +import { canUserEditList, canUserViewList } from "./lib/permissions"; +import { actorQuery, actorMutation } from "./lib/authenticated"; /** * Comments API - Threaded discussions on items for shared lists. * Enables collaboration through item-level comments. */ import { v } from "convex/values"; -import type { Id } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; -import { mutation, query } from "./_generated/server"; /** * Helper to check if a user can view a list. * Owner can always view. Published lists are viewable by anyone. */ -async function canUserViewList( - ctx: MutationCtx | QueryCtx, - listId: Id<"lists">, - userDid: string, - legacyDid?: string -): Promise { - const list = await ctx.db.get(listId); - if (!list) return false; - - const dids = [userDid]; - if (legacyDid) dids.push(legacyDid); - - if (dids.includes(list.ownerDid)) return true; - - // Published lists are viewable by anyone - const pub = await ctx.db - .query("publications") - .withIndex("by_list", (q) => q.eq("listId", listId)) - .first(); - - return pub?.status === "active"; -} /** * Helper to check if a user can edit a list. * Owner can always edit. Published lists are editable by anyone. */ -async function canUserEditList( - ctx: MutationCtx | QueryCtx, - listId: Id<"lists">, - userDid: string, - legacyDid?: string -): Promise { - return canUserViewList(ctx, listId, userDid, legacyDid); -} /** * Get all comments for an item, ordered by creation time. */ -export const getItemComments = query({ +export const { public: getItemComments, internal: getItemCommentsInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -67,8 +36,8 @@ export const getItemComments = query({ const canView = await canUserViewList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canView) { throw new Error("Not authorized to view comments on this item"); @@ -88,11 +57,11 @@ export const getItemComments = query({ * Add a comment to an item. * Any collaborator (owner, editor, or viewer) can comment. */ -export const addComment = mutation({ +export const { public: addComment, internal: addCommentInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), text: v.string(), }, handler: async (ctx, args) => { @@ -109,8 +78,8 @@ export const addComment = mutation({ const canView = await canUserViewList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canView) { throw new Error("Not authorized to comment on this item"); @@ -118,7 +87,7 @@ export const addComment = mutation({ return await ctx.db.insert("comments", { itemId: args.itemId, - userDid: args.userDid, + userDid: ctx.actor.did, text: args.text.trim(), createdAt: Date.now(), }); @@ -129,11 +98,11 @@ export const addComment = mutation({ * Delete a comment. * Only the comment author or list owner/editor can delete. */ -export const deleteComment = mutation({ +export const { public: deleteComment, internal: deleteCommentInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { commentId: v.id("comments"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const comment = await ctx.db.get(args.commentId); @@ -146,9 +115,9 @@ export const deleteComment = mutation({ throw new Error("Item not found"); } - const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); + const didsToCheck = [ctx.actor.did]; + if (ctx.actor.legacyDid) { + didsToCheck.push(ctx.actor.legacyDid); } // Check if user is the comment author @@ -158,8 +127,8 @@ export const deleteComment = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!isAuthor && !canEdit) { @@ -173,7 +142,9 @@ export const deleteComment = mutation({ /** * Get comment count for an item (useful for showing badge on item). */ -export const getCommentCount = query({ +export const { public: getCommentCount, internal: getCommentCountInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, args) => { const comments = await ctx.db diff --git a/convex/didCreation.ts b/convex/didCreation.ts index 78b81df..6b38d27 100644 --- a/convex/didCreation.ts +++ b/convex/didCreation.ts @@ -1,4 +1,5 @@ "use node"; +import { actorAction } from "./lib/authenticated"; /** * Server-side DID creation using Turnkey and OriginalsSDK. @@ -8,7 +9,7 @@ * - Public action for list publication (list DID) */ -import { action, internalAction } from "./_generated/server"; +import { internalAction } from "./_generated/server"; import { v } from "convex/values"; import { TurnkeyWebVHSigner } from "./lib/turnkeySigner"; import { getEd25519Account } from "./turnkeyHelpers"; @@ -111,17 +112,20 @@ export const createDIDKey = internalAction({ * Create a did:webvh DID for list publication. * Public action - called from client when publishing a list. */ -export const createListDID = action({ +export const { public: createListDID, internal: createListDIDInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { subOrgId: v.string(), domain: v.string(), slug: v.string(), }, - handler: async (_ctx, args): Promise<{ + handler: async (ctx, args): Promise<{ did: string; didDocument: unknown; didLog: unknown; }> => { + if (args.subOrgId !== ctx.actor.turnkeySubOrgId) throw new Error("Not authorized to use this signing identity"); console.log( `[didCreation] Creating list did:webvh for slug: ${args.slug} (subOrg: ${args.subOrgId})` ); diff --git a/convex/didLogsHttp.ts b/convex/didLogsHttp.ts index 91a63f3..bd54177 100644 --- a/convex/didLogsHttp.ts +++ b/convex/didLogsHttp.ts @@ -31,7 +31,7 @@ export const storeDidLog = httpAction(async (ctx, request) => { const corsHeaders = getCorsHeaders(request); try { - const auth = await requireAuth(request); + const auth = await requireAuth(ctx, request); const body = await request.json(); const { userDid, path, log } = body as { userDid: string; path: string; log: string }; diff --git a/convex/didResources.ts b/convex/didResources.ts index f26592a..479ee32 100644 --- a/convex/didResources.ts +++ b/convex/didResources.ts @@ -1,3 +1,4 @@ +import { actorMutation } from "./lib/authenticated"; /** * Queries for serving list resources publicly. * @@ -6,7 +7,7 @@ */ import { v } from "convex/values"; -import { query, mutation } from "./_generated/server"; +import { query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; /** @@ -32,7 +33,8 @@ export const getPublicList = query({ return null; } - return list; + const pub = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", list._id)).first(); + return pub?.status === "active" ? list : null; }, }); @@ -45,6 +47,8 @@ export const getPublicListItems = query({ listId: v.id("lists"), }, handler: async (ctx, args) => { + const pub = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", args.listId)).first(); + if (pub?.status !== "active") return []; const items = await ctx.db .query("items") .withIndex("by_list", (q) => q.eq("listId", args.listId)) @@ -83,7 +87,10 @@ export const getListById = query({ args: { listId: v.string() }, handler: async (ctx, args) => { try { - return await ctx.db.get(args.listId as Id<"lists">); + const list = await ctx.db.get(args.listId as Id<"lists">); + if (!list) return null; + const pub = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", list._id)).first(); + return pub?.status === "active" ? list : null; } catch { return null; } @@ -109,7 +116,9 @@ export const getActivePublicationByListId = query({ /** * Mark a shared-list item as checked (public link access). */ -export const checkSharedItem = mutation({ +export const { public: checkSharedItem, internal: checkSharedItemInternal } = actorMutation({ + resources: args => ({ lists: [args.listId], items: [args.itemId] }), + scope: "items:write", args: { listId: v.id("lists"), itemId: v.id("items"), @@ -122,6 +131,7 @@ export const checkSharedItem = mutation({ await ctx.db.patch(args.itemId, { checked: true, + checkedByDid: ctx.actor.did, checkedAt: Date.now(), updatedAt: Date.now(), }); @@ -133,7 +143,9 @@ export const checkSharedItem = mutation({ /** * Mark a shared-list item as unchecked (public link access). */ -export const uncheckSharedItem = mutation({ +export const { public: uncheckSharedItem, internal: uncheckSharedItemInternal } = actorMutation({ + resources: args => ({ lists: [args.listId], items: [args.itemId] }), + scope: "items:write", args: { listId: v.id("lists"), itemId: v.id("items"), @@ -146,6 +158,7 @@ export const uncheckSharedItem = mutation({ await ctx.db.patch(args.itemId, { checked: false, + checkedByDid: undefined, checkedAt: undefined, updatedAt: Date.now(), }); diff --git a/convex/didResourcesHttp.ts b/convex/didResourcesHttp.ts index 2a7ac0d..7a1a343 100644 --- a/convex/didResourcesHttp.ts +++ b/convex/didResourcesHttp.ts @@ -1,3 +1,5 @@ +import { handlerErrorResponse } from "./lib/httpResponses"; +import { authenticatedRequest } from "./lib/actor"; /** * HTTP actions for serving DID logs and list resources at canonical paths. * @@ -7,7 +9,7 @@ */ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { api, internal } from "./_generated/api"; function corsHeaders(request: Request): Record { const origin = request.headers.get("Origin") || "*"; @@ -69,7 +71,7 @@ export const didResourceHandler = httpAction(async (ctx, request) => { ) { const listId = parts[2].slice("list-".length); const itemId = parts[4]; - return await toggleItem(ctx, userPath, listId, itemId, true, headers); + return await toggleItem(ctx, userPath, listId, itemId, true, headers, request); } // POST /{userPath}/resources/list-{listId}/items/{itemId}/uncheck @@ -83,7 +85,7 @@ export const didResourceHandler = httpAction(async (ctx, request) => { ) { const listId = parts[2].slice("list-".length); const itemId = parts[4]; - return await toggleItem(ctx, userPath, listId, itemId, false, headers); + return await toggleItem(ctx, userPath, listId, itemId, false, headers, request); } return new Response("Not found", { status: 404, headers }); @@ -232,9 +234,11 @@ async function toggleItem( listId: string, itemId: string, checked: boolean, - headers: Record + headers: Record, + request: Request ): Promise { try { + const credentials = await authenticatedRequest(ctx as import("./_generated/server").ActionCtx, request); // Resolve list the same way as serveListResource (didLogs primary, publication fallback) const fullRecord = await ctx.runQuery(api.didLogs.getDidLogRecordByPath, { path: userPath }); let userDid: string | null = fullRecord?.userDid ?? null; @@ -261,12 +265,14 @@ async function toggleItem( } if (checked) { - await ctx.runMutation(api.didResources.checkSharedItem, { + await ctx.runMutation(internal.didResources.checkSharedItemInternal, { + ...credentials, listId: list._id, itemId, }); } else { - await ctx.runMutation(api.didResources.uncheckSharedItem, { + await ctx.runMutation(internal.didResources.uncheckSharedItemInternal, { + ...credentials, listId: list._id, itemId, }); @@ -278,6 +284,6 @@ async function toggleItem( }); } catch (error) { console.error("[didResources] Error toggling item:", error); - return new Response("Internal server error", { status: 500, headers }); + return handlerErrorResponse(request, error, "Failed to update item"); } } diff --git a/convex/feedback.ts b/convex/feedback.ts index d87f719..0a28c4e 100644 --- a/convex/feedback.ts +++ b/convex/feedback.ts @@ -1,3 +1,4 @@ +import { actorMutation } from "./lib/authenticated"; /** * Feedback module — in-app user feedback collection. * @@ -7,17 +8,19 @@ */ import { v } from "convex/values"; -import { internalAction, internalMutation, mutation } from "./_generated/server"; +import { internalAction, internalMutation } from "./_generated/server"; // --------------------------------------------------------------------------- // Public mutations // --------------------------------------------------------------------------- /** - * Submit in-app feedback. Authenticated via userId passed from frontend. + * Submit in-app feedback. Authenticated through the shared session boundary. * Takes body and category; source is always "in_app", status starts as "new". */ -export const submit = mutation({ +export const { public: submit, internal: submitAuthenticatedInternal } = actorMutation({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users"), body: v.string(), diff --git a/convex/http.ts b/convex/http.ts index 9cb99d1..c5a646d 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -1,3 +1,4 @@ +import { extractTokenFromRequest } from "./lib/jwt"; /** * Convex HTTP router for server-side endpoints. * @@ -151,7 +152,7 @@ const initiate = httpAction(async (ctx, request) => { const result = await ctx.runAction(internal.authInternal.initiateAuth, { email }); // Persist session to Convex database - await ctx.runMutation(api.authSessions.createSession, { + await ctx.runMutation(internal.authSessions.createSessionInternal, { sessionId: result.sessionId, email: result.session.email, subOrgId: result.session.subOrgId, @@ -203,7 +204,7 @@ const verify = httpAction(async (ctx, request) => { console.log(`[authHttp] Verifying OTP for session: ${sessionId}`); // Get session from database - const dbSession = await ctx.runQuery(api.authSessions.getSession, { sessionId }); + const dbSession = await ctx.runQuery(internal.authSessions.getSessionInternal, { sessionId }); if (!dbSession) { return jsonResponse({ error: "Invalid or expired session" }, 400, {}, request); } @@ -233,7 +234,7 @@ const verify = httpAction(async (ctx, request) => { console.log(`[authHttp] OTP verified for: ${result.email}`); // Mark session as verified - await ctx.runMutation(api.authSessions.markSessionVerified, { + await ctx.runMutation(internal.authSessions.markSessionVerifiedInternal, { sessionId, subOrgId: result.subOrgId, }); @@ -241,7 +242,7 @@ const verify = httpAction(async (ctx, request) => { // DID creation happens client-side after auth completes: for a NEW account // the client mints a did:webvh with BrowserWebVHSigner and posts it to // /api/user/updateDID. - await ctx.runMutation(api.auth.upsertUser, { + await ctx.runMutation(internal.auth.upsertUserInternal, { turnkeySubOrgId: result.subOrgId, email: result.email, did: undefined, @@ -254,7 +255,7 @@ const verify = httpAction(async (ctx, request) => { // the client then treated the discarded DID as its identity. That burned a // keypair per login and, worse, left the client's DID disagreeing with the // database — which is why the stale-domain re-mint never fired. - const storedUser = await ctx.runQuery(api.auth.getUserByTurnkeyId, { + const storedUser = await ctx.runQuery(internal.auth.getUserByTurnkeyIdInternal, { turnkeySubOrgId: result.subOrgId, }); @@ -264,8 +265,10 @@ const verify = httpAction(async (ctx, request) => { email: result.email, }); + await ctx.runMutation(internal.actorSession.establishInternal, { authToken: authResult.token }); + // Clean up session - await ctx.runMutation(api.authSessions.deleteSession, { sessionId }); + await ctx.runMutation(internal.authSessions.deleteSessionInternal, { sessionId }); console.log(`[authHttp] Auth complete, JWT issued for: ${result.email}`); @@ -300,6 +303,8 @@ const verify = httpAction(async (ctx, request) => { const logout = httpAction(async (ctx, request) => { console.log("[authHttp] Logging out"); + const token = extractTokenFromRequest(request); + if (token) await ctx.runMutation(internal.actorSession.revokeInternal, { authToken: token }); const result = await ctx.runAction(internal.authInternal.getLogoutCookie, {}); return jsonResponse({ success: true }, 200, { "Set-Cookie": result.cookieValue }, request); @@ -420,7 +425,6 @@ http.route({ path: "/api/sites/resolve-host", method: "OPTIONS", handler: resolv http.route({ path: "/api/sites/resolve-asset", method: "GET", handler: resolveSiteAsset }); http.route({ path: "/api/sites/resolve-asset", method: "OPTIONS", handler: resolveSiteAsset }); - // ============================================================================ // Agent API v1 (Plan 001) // API-key management (JWT-only) + agent read/write over HTTP (JWT or X-API-Key). diff --git a/convex/itemCategories.ts b/convex/itemCategories.ts index 4d50717..c46066a 100644 --- a/convex/itemCategories.ts +++ b/convex/itemCategories.ts @@ -1,3 +1,4 @@ +import { actorMutation } from "./lib/authenticated"; /** * Mutations for a list's item categories. * @@ -8,7 +9,7 @@ */ import { v } from "convex/values"; -import { mutation } from "./_generated/server"; + import type { MutationCtx } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; import { canUserEditList } from "./lib/permissions"; @@ -25,7 +26,6 @@ import { const editorArgs = { listId: v.id("lists"), - userDid: v.string(), }; /** @@ -36,11 +36,12 @@ const editorArgs = { async function loadEditableSet( ctx: MutationCtx, listId: Id<"lists">, - userDid: string + userDid: string, + legacyDid?: string ): Promise { const list = await ctx.db.get(listId); if (!list) throw new Error("List not found"); - if (!(await canUserEditList(ctx, listId, userDid))) { + if (!(await canUserEditList(ctx, listId, userDid, legacyDid))) { throw new Error("You do not have permission to edit this list"); } return materialiseCategories(list.itemCategories, list.customAisles); @@ -54,46 +55,56 @@ async function persist( await ctx.db.patch(listId, { itemCategories: categories }); } -export const addListCategory = mutation({ +export const { public: addListCategory, internal: addListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, name: v.string(), emoji: v.string() }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); await persist(ctx, args.listId, addCategory(set, args.name, args.emoji)); }, }); -export const renameListCategory = mutation({ +export const { public: renameListCategory, internal: renameListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, categoryId: v.string(), name: v.string() }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); await persist(ctx, args.listId, renameCategory(set, args.categoryId, args.name)); }, }); -export const setListCategoryEmoji = mutation({ +export const { public: setListCategoryEmoji, internal: setListCategoryEmojiInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, categoryId: v.string(), emoji: v.string() }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); await persist(ctx, args.listId, setCategoryEmoji(set, args.categoryId, args.emoji)); }, }); -export const moveListCategory = mutation({ +export const { public: moveListCategory, internal: moveListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, categoryId: v.string(), direction: v.union(v.literal("up"), v.literal("down")), }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); await persist(ctx, args.listId, moveCategory(set, args.categoryId, args.direction)); }, }); -export const deleteListCategory = mutation({ +export const { public: deleteListCategory, internal: deleteListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { ...editorArgs, categoryId: v.string() }, handler: async (ctx, args) => { - const set = await loadEditableSet(ctx, args.listId, args.userDid); + const set = await loadEditableSet(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); const next = deleteCategory(set, args.categoryId); // Items explicitly filed here would otherwise point at a category that no diff --git a/convex/items.ts b/convex/items.ts index 0d61a08..da0dd1f 100644 --- a/convex/items.ts +++ b/convex/items.ts @@ -1,6 +1,7 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; -import { mutation, query } from "./_generated/server"; + import { internal } from "./_generated/api"; import { withMutationObservability } from "./lib/observability"; import { canUserEditList } from "./lib/permissions"; @@ -111,17 +112,16 @@ function createItemCompletionVC( }; } - /** * Add an item to a list. * Supports legacy DID for migrated users. */ -export const addItem = mutation({ +export const { public: addItem, internal: addItemInternal } = actorMutation({ + resources: args => ({ lists: [args.listId], items: [args.parentId] }), + scope: "items:write", args: { listId: v.id("lists"), name: v.string(), - createdByDid: v.string(), - legacyDid: v.optional(v.string()), createdAt: v.number(), // Optional enhanced fields description: v.optional(v.string()), @@ -156,8 +156,8 @@ export const addItem = mutation({ const canEdit = await canUserEditList( ctx, args.listId, - args.createdByDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canEdit) { throw new Error("Not authorized to add items to this list"); @@ -187,7 +187,7 @@ export const addItem = mutation({ listId: args.listId, name: args.name, checked: false, - createdByDid: args.createdByDid, + createdByDid: ctx.actor.did, checkedByDid: undefined, createdAt: args.createdAt, checkedAt: undefined, @@ -207,7 +207,7 @@ export const addItem = mutation({ const authorshipVC = createItemAuthorshipVC( itemId, args.listId, - args.createdByDid, + ctx.actor.did, args.name, args.createdAt ); @@ -218,7 +218,7 @@ export const addItem = mutation({ // Notify other list members (fire-and-forget via scheduler) await ctx.scheduler.runAfter(0, internal.notificationActions.sendListNotificationInternal, { listId: args.listId, - excludeDid: args.createdByDid, + excludeDid: ctx.actor.did, title: list.name, body: `"${args.name}" was added`, data: { listId: args.listId }, @@ -232,11 +232,11 @@ export const addItem = mutation({ * Update an item's details (name, description, due date, url, recurrence, priority). * Supports legacy DID for migrated users. */ -export const updateItem = mutation({ +export const { public: updateItem, internal: updateItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), // Fields that can be updated name: v.optional(v.string()), description: v.optional(v.string()), @@ -267,7 +267,7 @@ export const updateItem = mutation({ throw new Error("Item not found"); } - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to update this item"); } @@ -329,11 +329,11 @@ function calculateNextDueDate( * Supports legacy DID for migrated users. * If the item has recurrence settings, creates a new unchecked copy with the next due date. */ -export const checkItem = mutation({ +export const { public: checkItem, internal: checkItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - checkedByDid: v.string(), - legacyDid: v.optional(v.string()), checkedAt: v.number(), }, handler: async (ctx, args) => withMutationObservability("items.checkItem", async () => { @@ -346,8 +346,8 @@ export const checkItem = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.checkedByDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canEdit) { throw new Error("Not authorized to check items in this list"); @@ -359,7 +359,7 @@ export const checkItem = mutation({ const completionVC = createItemCompletionVC( args.itemId, item.listId, - args.checkedByDid, + ctx.actor.did, item.name, args.checkedAt ); @@ -373,7 +373,7 @@ export const checkItem = mutation({ // Mark the current item as checked and add completion VC await ctx.db.patch(args.itemId, { checked: true, - checkedByDid: args.checkedByDid, + checkedByDid: ctx.actor.did, checkedAt: args.checkedAt, updatedAt: now, vcProofs: updatedProofs, @@ -383,7 +383,7 @@ export const checkItem = mutation({ const list = await ctx.db.get(item.listId); await ctx.scheduler.runAfter(0, internal.notificationActions.sendListNotificationInternal, { listId: item.listId, - excludeDid: args.checkedByDid, + excludeDid: ctx.actor.did, title: list?.name ?? "Your list", body: `"${item.name}" was completed`, data: { listId: item.listId }, @@ -416,7 +416,7 @@ export const checkItem = mutation({ listId: item.listId, name: item.name, checked: false, - createdByDid: args.checkedByDid, + createdByDid: ctx.actor.did, createdAt: now, order: minOrder - 1, updatedAt: now, @@ -438,11 +438,11 @@ export const checkItem = mutation({ * Uncheck an item. * Supports legacy DID for migrated users. */ -export const uncheckItem = mutation({ +export const { public: uncheckItem, internal: uncheckItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -454,8 +454,8 @@ export const uncheckItem = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canEdit) { throw new Error("Not authorized to uncheck items in this list"); @@ -474,11 +474,11 @@ export const uncheckItem = mutation({ * Remove an item from a list. * Supports legacy DID for migrated users. */ -export const removeItem = mutation({ +export const { public: removeItem, internal: removeItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -490,8 +490,8 @@ export const removeItem = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canEdit) { throw new Error("Not authorized to remove items from this list"); @@ -504,7 +504,9 @@ export const removeItem = mutation({ /** * Get all items for a list, ordered by position. */ -export const getListItems = query({ +export const { public: getListItems, internal: getListItemsInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { const items = await ctx.db @@ -526,31 +528,14 @@ export const getListItems = query({ * Takes the full ordered list of item IDs and updates their order values. * Supports legacy DID for migrated users. */ -export const reorderItems = mutation({ +export const { public: reorderItems, internal: reorderItemsInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), itemIds: v.array(v.id("items")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { - // Verify the list exists - const list = await ctx.db.get(args.listId); - if (!list) { - throw new Error("List not found"); - } - - // Verify user is authorized (owner or editor) - const canEdit = await canUserEditList( - ctx, - args.listId, - args.userDid, - args.legacyDid - ); - if (!canEdit) { - throw new Error("Not authorized to reorder items in this list"); - } - // Update order for each item for (let i = 0; i < args.itemIds.length; i++) { const itemId = args.itemIds[i]; @@ -569,12 +554,12 @@ export const reorderItems = mutation({ * Allows users to manually classify items into a different aisle. * Pass null/undefined aisleId to clear the override. */ -export const setAisleOverride = mutation({ +export const { public: setAisleOverride, internal: setAisleOverrideInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), aisleId: v.optional(v.string()), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -583,8 +568,8 @@ export const setAisleOverride = mutation({ const canEdit = await canUserEditList( ctx, item.listId, - args.userDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canEdit) throw new Error("Not authorized to edit this item"); @@ -599,10 +584,14 @@ export const setAisleOverride = mutation({ * Get an item by ID for sync conflict checking. * Returns null if item doesn't exist (was deleted). */ -export const getItemForSync = query({ +export const { public: getItemForSync, internal: getItemForSyncInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", args: { itemId: v.id("items") }, handler: async (ctx, args) => { - return await ctx.db.get(args.itemId); + const item = await ctx.db.get(args.itemId); + if (item && !await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid)) throw new Error("Not authorized to access this item"); + return item; }, }); @@ -611,17 +600,17 @@ export const getItemForSync = query({ * Returns null when the item does not exist OR the user cannot edit it * (in the current permission model, no edit access == no access). */ -export const getItemForEditor = query({ +export const { public: getItemForEditor, internal: getItemForEditorInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) return null; - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) return null; return { @@ -636,7 +625,9 @@ export const getItemForEditor = query({ /** * Get sub-items for a parent item. */ -export const getSubItems = query({ +export const { public: getSubItems, internal: getSubItemsInternal } = actorQuery({ + resources: args => ({ items: [args.parentId] }), + scope: "items:read", args: { parentId: v.id("items") }, handler: async (ctx, args) => { return await ctx.db @@ -650,11 +641,11 @@ export const getSubItems = query({ * Batch check multiple items at once. * Handles recurring items by creating new copies with next due dates. */ -export const batchCheckItems = mutation({ +export const { public: batchCheckItems, internal: batchCheckItemsInternal } = actorMutation({ + resources: args => ({ items: [...args.itemIds] }), + scope: "items:write", args: { itemIds: v.array(v.id("items")), - checkedByDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const checkedAt = Date.now(); @@ -667,7 +658,7 @@ export const batchCheckItems = mutation({ // Verify authorization once per list if (listId !== item.listId) { listId = item.listId; - const canEdit = await canUserEditList(ctx, item.listId, args.checkedByDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to check items in this list"); } @@ -675,7 +666,7 @@ export const batchCheckItems = mutation({ await ctx.db.patch(itemId, { checked: true, - checkedByDid: args.checkedByDid, + checkedByDid: ctx.actor.did, checkedAt, updatedAt: checkedAt, }); @@ -707,7 +698,7 @@ export const batchCheckItems = mutation({ listId: item.listId, name: item.name, checked: false, - createdByDid: args.checkedByDid, + createdByDid: ctx.actor.did, createdAt: checkedAt, order: minOrder - 1, updatedAt: checkedAt, @@ -729,11 +720,11 @@ export const batchCheckItems = mutation({ /** * Batch uncheck multiple items at once. */ -export const batchUncheckItems = mutation({ +export const { public: batchUncheckItems, internal: batchUncheckItemsInternal } = actorMutation({ + resources: args => ({ items: [...args.itemIds] }), + scope: "items:write", args: { itemIds: v.array(v.id("items")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const now = Date.now(); @@ -745,7 +736,7 @@ export const batchUncheckItems = mutation({ if (listId !== item.listId) { listId = item.listId; - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to uncheck items in this list"); } @@ -764,11 +755,11 @@ export const batchUncheckItems = mutation({ /** * Batch delete multiple items at once. */ -export const batchDeleteItems = mutation({ +export const { public: batchDeleteItems, internal: batchDeleteItemsInternal } = actorMutation({ + resources: args => ({ items: [...args.itemIds] }), + scope: "items:write", args: { itemIds: v.array(v.id("items")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { let listId: Id<"lists"> | null = null; @@ -779,7 +770,7 @@ export const batchDeleteItems = mutation({ if (listId !== item.listId) { listId = item.listId; - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to delete items in this list"); } @@ -803,7 +794,9 @@ export const batchDeleteItems = mutation({ /** * Get items with due dates for calendar view. */ -export const getItemsWithDueDates = query({ +export const { public: getItemsWithDueDates, internal: getItemsWithDueDatesInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists"), startDate: v.optional(v.number()), @@ -834,16 +827,15 @@ export const getItemsWithDueDates = query({ * Get all high-priority items across all lists the user has access to. * Used for Priority Focus mode. */ -export const getHighPriorityItems = query({ - args: { - userDid: v.string(), - legacyDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { +export const { public: getHighPriorityItems, internal: getHighPriorityItemsInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", + args: {}, + handler: async (ctx) => { // DIDs to check: current DID and optionally legacy DID - const didsToCheck = [args.userDid]; - if (args.legacyDid) { - didsToCheck.push(args.legacyDid); + const didsToCheck = [ctx.actor.did]; + if (ctx.actor.legacyDid) { + didsToCheck.push(ctx.actor.legacyDid); } // Get all list IDs the user has access to (owned + bookmarked) @@ -878,7 +870,7 @@ export const getHighPriorityItems = query({ for (const listId of listIds) { const list = await ctx.db.get(listId); - if (!list) continue; + if (!list || !(await canUserEditList(ctx, listId, ctx.actor.did, ctx.actor.legacyDid))) continue; const items = await ctx.db .query("items") @@ -916,11 +908,11 @@ export const getHighPriorityItems = query({ /** * Promote an item to a top-level item (remove parent). */ -export const promoteItem = mutation({ +export const { public: promoteItem, internal: promoteItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -928,7 +920,7 @@ export const promoteItem = mutation({ throw new Error("Item not found"); } - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to edit this item"); } @@ -945,12 +937,12 @@ export const promoteItem = mutation({ * Demote an item to become a subtask of another item. * Ensures we don't exceed max nesting depth (2 levels). */ -export const demoteItem = mutation({ +export const { public: demoteItem, internal: demoteItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), newParentId: v.id("items"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); @@ -968,7 +960,7 @@ export const demoteItem = mutation({ throw new Error("Items must be in the same list"); } - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to edit this item"); } diff --git a/convex/itemsHttp.ts b/convex/itemsHttp.ts index f31bfed..bf7e00a 100644 --- a/convex/itemsHttp.ts +++ b/convex/itemsHttp.ts @@ -1,16 +1,9 @@ -/** - * HTTP action handlers for protected item mutations. - * - * These endpoints authenticate via resolveActor(), which accepts either a JWT - * session or an agent API key (X-API-Key). Writes require the "items:write" scope. - * The acting DID is resolved server-side and passed to the mutations. - */ +/** HTTP adapter; authentication and authorization run in the shared operation. */ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { resolveActor, requireScope } from "./lib/actor"; +import { authenticatedRequest } from "./lib/actor"; import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; /** @@ -24,8 +17,6 @@ import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpRes export const addItem = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -36,19 +27,15 @@ export const addItem = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - const itemId = await ctx.runMutation(api.items.addItem, { + const itemId = await ctx.runMutation(internal.items.addItemInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, name, - createdByDid: actor.did, - legacyDid: actor.legacyDid, createdAt: Date.now(), }); return jsonResponse(request, { itemId }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] addItem error:", error); return handlerErrorResponse( request, @@ -69,8 +56,6 @@ export const addItem = httpAction(async (ctx, request) => { export const checkItem = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -81,18 +66,14 @@ export const checkItem = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - await ctx.runMutation(api.items.checkItem, { + await ctx.runMutation(internal.items.checkItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, - checkedByDid: actor.did, - legacyDid: actor.legacyDid, checkedAt: Date.now(), }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] checkItem error:", error); return handlerErrorResponse( request, @@ -113,8 +94,6 @@ export const checkItem = httpAction(async (ctx, request) => { export const uncheckItem = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -125,17 +104,13 @@ export const uncheckItem = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - await ctx.runMutation(api.items.uncheckItem, { + await ctx.runMutation(internal.items.uncheckItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, - userDid: actor.did, - legacyDid: actor.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] uncheckItem error:", error); return handlerErrorResponse( request, @@ -156,8 +131,6 @@ export const uncheckItem = httpAction(async (ctx, request) => { export const removeItem = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -168,17 +141,13 @@ export const removeItem = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - await ctx.runMutation(api.items.removeItem, { + await ctx.runMutation(internal.items.removeItemInternal, { + ...await authenticatedRequest(ctx, request), itemId: itemId as Id<"items">, - userDid: actor.did, - legacyDid: actor.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] removeItem error:", error); return handlerErrorResponse( request, @@ -199,8 +168,6 @@ export const removeItem = httpAction(async (ctx, request) => { export const reorderItems = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -211,18 +178,14 @@ export const reorderItems = httpAction(async (ctx, request) => { } // Call the mutation with server-verified acting DID - await ctx.runMutation(api.items.reorderItems, { + await ctx.runMutation(internal.items.reorderItemsInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, itemIds: itemIds as Id<"items">[], - userDid: actor.did, - legacyDid: actor.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[itemsHttp] reorderItems error:", error); return handlerErrorResponse( request, diff --git a/convex/lib/actor.ts b/convex/lib/actor.ts index ff6bb25..dbac753 100644 --- a/convex/lib/actor.ts +++ b/convex/lib/actor.ts @@ -1,72 +1,63 @@ -/** - * Shared actor resolver for HTTP handlers. - * - * Accepts either an API key (X-API-Key header) or the existing JWT session, - * and resolves both to a single current DID plus a scope set. This is the only - * new auth surface: the JWT path is unchanged and always resolves to scopes ["*"]. - */ - -import type { ActionCtx } from "../_generated/server"; -import { api, internal } from "../_generated/api"; -import { requireAuth, AuthError } from "./auth"; -import { hasScope, hashApiKey } from "./apiKeyHelpers"; +import { requireSession } from "./session"; +/** The credential boundary shared by reactive browser calls and HTTP actions. */ +import type { ActionCtx, QueryCtx, MutationCtx } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { AuthError } from "./auth"; +import { extractTokenFromRequest } from "./jwt"; +import { hasScope, hashApiKey, type Scope } from "./apiKeyHelpers"; +export type Credentials = { authToken?: string; apiKey?: string }; export type ResolvedActor = { - // Authorization identity. Everything downstream (ownership, canUserEditList) - // keys off this. For an API key it's the key owner's DID, not the agent's — - // so a key always acts *as its owner*. Distinct agent attribution is Step 5 - // and requires splitting authz-vs-attribution in the mutations first. did: string; - // Legacy DID of a migrated Turnkey user, forwarded so the existing - // canUserEditList/ownerDid checks still match lists owned under the old DID. - // Only ever set on the JWT path; API keys are a clean, legacy-free surface. + userId: import("../_generated/dataModel").Id<"users">; legacyDid?: string; - scopes: string[]; // ["*"] for JWT sessions; the key's scopes otherwise + turnkeySubOrgId?: string; + scopes: string[]; viaApiKey: boolean; }; -type UserInfo = { did?: string; legacyDid?: string } | null; +export function requestCredentials(request: Request): Credentials { + return { + authToken: extractTokenFromRequest(request) ?? undefined, + apiKey: request.headers.get("X-API-Key") ?? undefined, + }; +} -/** - * Resolve the actor behind a request. Prefers an API key; falls back to JWT. - * @throws AuthError if neither credential is valid. - */ -export async function resolveActor( - ctx: ActionCtx, - request: Request +export async function authenticate( + ctx: QueryCtx | MutationCtx, + credentials: Credentials, ): Promise { - const apiKey = request.headers.get("X-API-Key"); - if (apiKey) { - const keyHash = await hashApiKey(apiKey); - const rec = await ctx.runQuery(internal.apiKeys.getByHash, { keyHash }); - if (!rec || rec.revokedAt) { - throw new AuthError("Invalid API key", "INVALID_TOKEN"); - } - return { - did: rec.ownerDid, - scopes: rec.scopes, - viaApiKey: true, - }; + if (credentials.apiKey !== undefined) { + const keyHash = await hashApiKey(credentials.apiKey); + const key = await ctx.db.query("agentApiKeys") + .withIndex("by_hash", q => q.eq("keyHash", keyHash)).first(); + if (!key || key.revokedAt !== undefined) throw new AuthError("Invalid API key", "INVALID_TOKEN"); + // Resolve the account on every operation, including keys minted before a DID migration. + const user = await ctx.db.query("users").withIndex("by_did", q => q.eq("did", key.ownerDid)).first() + ?? await ctx.db.query("users").withIndex("by_legacy_did", q => q.eq("legacyDid", key.ownerDid)).first(); + if (!user?.did) throw new AuthError("User not found", "UNAUTHORIZED"); + return { userId: user._id, did: user.did, legacyDid: user.legacyDid, scopes: key.scopes, viaApiKey: true }; } + const session = await requireSession(ctx, credentials.authToken); + const user = await ctx.db.query("users") + .withIndex("by_turnkey_id", q => q.eq("turnkeySubOrgId", session.turnkeySubOrgId)).first(); + if (!user?.did) throw new AuthError("User not found", "UNAUTHORIZED"); + return { userId: user._id, did: user.did, legacyDid: user.legacyDid, turnkeySubOrgId: session.turnkeySubOrgId, scopes: ["*"], viaApiKey: false }; +} - const auth = await requireAuth(request); - const user = (await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - })) as UserInfo; - if (!user?.did) { - throw new AuthError("User not found", "UNAUTHORIZED"); - } - return { - did: user.did, - legacyDid: user.legacyDid, - scopes: ["*"], - viaApiKey: false, - }; +export async function resolveActor(ctx: ActionCtx, request: Request): Promise { + return ctx.runQuery(internal.actorSession.resolve, await authenticatedRequest(ctx, request)); +} + +export function requireScope(actor: ResolvedActor, scope: Scope): void { + if (!hasScope(actor.scopes, scope)) throw new AuthError(`Missing scope: ${scope}`, "UNAUTHORIZED"); } -/** Throw AuthError if the actor lacks the required scope. */ -export function requireScope(actor: ResolvedActor, scope: string): void { - if (!hasScope(actor.scopes, scope)) { - throw new AuthError(`Missing scope: ${scope}`, "UNAUTHORIZED"); +/** Compatibility for existing HTTP JWT clients: register verified sessions lazily. */ +export async function authenticatedRequest(ctx: ActionCtx, request: Request): Promise { + const credentials = requestCredentials(request); + if (credentials.apiKey === undefined && credentials.authToken) { + await ctx.runMutation(internal.actorSession.establishInternal, { authToken: credentials.authToken }); } + return credentials; } diff --git a/convex/lib/apiKeyHelpers.ts b/convex/lib/apiKeyHelpers.ts index eb37abb..66304a8 100644 --- a/convex/lib/apiKeyHelpers.ts +++ b/convex/lib/apiKeyHelpers.ts @@ -14,6 +14,7 @@ const KEY_BODY_LENGTH = 40; /** Scopes granted to agent API keys by default. */ export const AGENT_SCOPES = ["lists:read", "items:read", "items:write"] as const; +export type Scope = (typeof AGENT_SCOPES)[number] | "*"; /** * Generate a fresh raw API key: "pa_live_" + 40 chars of [A-Za-z0-9]. diff --git a/convex/lib/auth.ts b/convex/lib/auth.ts index dec4c8a..7f61c07 100644 --- a/convex/lib/auth.ts +++ b/convex/lib/auth.ts @@ -1,3 +1,5 @@ +import type { ActionCtx } from "../_generated/server"; +import { internal } from "../_generated/api"; /** * Authentication helper for protecting Convex HTTP actions. * @@ -5,7 +7,6 @@ */ import { - verifyAuthToken, extractTokenFromRequest, type AuthTokenPayload, } from "./jwt"; @@ -42,12 +43,12 @@ export class AuthError extends Error { * @example * ```typescript * export const protectedAction = httpAction(async (ctx, request) => { - * const auth = await requireAuth(request); + * const auth = await requireAuth(ctx, request); * // auth.turnkeySubOrgId and auth.email are now available * }); * ``` */ -export async function requireAuth(request: Request): Promise { +export async function requireAuth(ctx: ActionCtx, request: Request): Promise { // Extract token from request const token = extractTokenFromRequest(request); @@ -59,8 +60,8 @@ export async function requireAuth(request: Request): Promise { } try { - // Verify and decode the token - return await verifyAuthToken(token); + await ctx.runMutation(internal.actorSession.establishInternal, { authToken: token }); + return await ctx.runQuery(internal.actorSession.identity, { authToken: token }); } catch (error) { // Map specific error messages to error codes const message = error instanceof Error ? error.message : "Invalid token"; @@ -81,9 +82,9 @@ export async function requireAuth(request: Request): Promise { * @param request - HTTP request object * @returns Authenticated user payload or null if not authenticated */ -export async function tryAuth(request: Request): Promise { +export async function tryAuth(ctx: ActionCtx, request: Request): Promise { try { - return await requireAuth(request); + return await requireAuth(ctx, request); } catch { return null; } diff --git a/convex/lib/authUser.ts b/convex/lib/authUser.ts index 7f6c817..c097c04 100644 --- a/convex/lib/authUser.ts +++ b/convex/lib/authUser.ts @@ -1,32 +1,8 @@ import type { ActionCtx } from "../_generated/server"; -import { api } from "../_generated/api"; -import { AuthError, requireAuth } from "./auth"; - -export type AuthenticatedUser = { - did: string; - legacyDid?: string; - turnkeySubOrgId?: string; -}; - -/** - * Resolve authenticated user from JWT and users table. - */ -export async function requireAuthenticatedUser( - ctx: ActionCtx, - request: Request -): Promise { - const auth = await requireAuth(request); - const user = await ctx.runQuery(api.auth.getUserByTurnkeyId, { - turnkeySubOrgId: auth.turnkeySubOrgId, - }) as { did: string; legacyDid?: string } | null; - - if (!user) { - throw new AuthError("User not found", "UNAUTHORIZED"); - } - - return { - did: user.did, - legacyDid: user.legacyDid, - turnkeySubOrgId: auth.turnkeySubOrgId, - }; +import { resolveActor, requireScope, type ResolvedActor } from "./actor"; +export type AuthenticatedUser = ResolvedActor; +export async function requireAuthenticatedUser(ctx: ActionCtx, request: Request): Promise { + const actor = await resolveActor(ctx, request); + requireScope(actor, "*"); + return actor; } diff --git a/convex/lib/authenticated.ts b/convex/lib/authenticated.ts new file mode 100644 index 0000000..d4315f9 --- /dev/null +++ b/convex/lib/authenticated.ts @@ -0,0 +1,75 @@ +import { identityAssertionFields } from "./clientAuth"; +/** Public adapters authenticate before invoking private business handlers. + * HTTP adapters use the internal registration of the same operation. Credentials + * are checked inside the transaction, so key revocation and scopes cannot race a write. + */ +import { v, type PropertyValidators, type ObjectType, type VObject } from "convex/values"; +import { mutation, query, action, internalMutation, internalQuery, internalAction } from "../_generated/server"; +import type { MutationCtx, QueryCtx, ActionCtx } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { authenticate, requireScope, type ResolvedActor, type Credentials } from "./actor"; +import { authorizeResources, type ListResources } from "./permissions"; +import { AuthError } from "./auth"; + +const credentials = { authToken: v.optional(v.string()), apiKey: v.optional(v.string()) }; +// Temporary wire compatibility only. These assertions never grant access. Keep +// endpoint names and these fields until deployed browser/mobile versions are confirmed. +const optionalDid = v.optional(v.string()); +const assertions = Object.fromEntries(identityAssertionFields.map(field => [field, optionalDid])) as Record<(typeof identityAssertionFields)[number], typeof optionalDid>; + +type Assertions = ObjectType; +export type ActorCtx = C & { actor: ResolvedActor; credentials: Credentials }; +type Definition = { + args: A; + scope: import("./apiKeyHelpers").Scope; + resources: (args: ObjectType) => ListResources; + handler: (ctx: ActorCtx, args: ObjectType) => R | Promise; +}; +function checkAssertions(actor: ResolvedActor, args: Assertions) { + for (const key of identityAssertionFields) { + const did = args[key]; + if (did !== undefined && did !== actor.did && did !== actor.legacyDid) { + throw new AuthError("Identity assertion does not match authenticated account", "UNAUTHORIZED"); + } + } +} +function prepare(definition: Definition, resolve: (ctx: C, args: Credentials) => Promise) { + return { + args: v.object({ ...assertions, ...definition.args, ...credentials }) as VObject & Assertions & Credentials, A & typeof assertions & typeof credentials>, + handler: async (ctx: C, args: ObjectType & Assertions & Credentials): Promise => { + const actor = await resolve(ctx, args); + requireScope(actor, definition.scope); + checkAssertions(actor, args); + if (ctx && typeof ctx === "object" && "db" in ctx) { + await authorizeResources(ctx as unknown as QueryCtx | MutationCtx, actor, definition.resources(args)); + } else { + const resources = definition.resources(args); + await (ctx as ActionCtx).runQuery(internal.actorSession.authorize, { + authToken: args.authToken, apiKey: args.apiKey, + resources: { + lists: resources.lists?.filter(id => id !== undefined), + items: resources.items?.filter(id => id !== undefined), + anchors: resources.anchors?.filter(id => id !== undefined), + accounts: resources.accounts?.filter(id => id !== undefined), + }, + }); + } + // Only declared business arguments reach operations; credentials and old + // identity assertions cannot be accidentally persisted via an args spread. + const businessArgs = Object.fromEntries(Object.keys(definition.args).map(key => [key, (args as Record)[key]])) as ObjectType; + return definition.handler(Object.assign({}, ctx, { actor, credentials: { authToken: args.authToken, apiKey: args.apiKey } }), businessArgs); + }, + }; +} +export function actorMutation(definition: Definition) { + const config = prepare(definition, authenticate); + return { public: mutation(config), internal: internalMutation(config) }; +} +export function actorQuery(definition: Definition) { + const config = prepare(definition, authenticate); + return { public: query(config), internal: internalQuery(config) }; +} +export function actorAction(definition: Definition) { + const config = prepare(definition, (ctx, args): Promise => ctx.runQuery(internal.actorSession.resolve, { authToken: args.authToken, apiKey: args.apiKey })); + return { public: action(config), internal: internalAction(config) }; +} diff --git a/convex/lib/bucket.ts b/convex/lib/bucket.ts index a86eb82..0f856ac 100644 --- a/convex/lib/bucket.ts +++ b/convex/lib/bucket.ts @@ -33,6 +33,7 @@ function client(cfg: BucketConfig = readConfig()) { } function objectUrl(cfg: BucketConfig, key: string): string { + if (key.split("/").some(segment => segment === "." || segment === "..")) throw new Error("Invalid bucket key"); const host = new URL(cfg.endpoint).host; const encoded = key.split("/").map(encodeURIComponent).join("/"); return `https://${cfg.name}.${host}/${encoded}`; diff --git a/convex/lib/bucketKeys.ts b/convex/lib/bucketKeys.ts new file mode 100644 index 0000000..18c2378 --- /dev/null +++ b/convex/lib/bucketKeys.ts @@ -0,0 +1,6 @@ +/** Uploaded objects must be a direct child of the authorized resource prefix. */ +export function isDirectChildKey(key: string, prefix: string): boolean { + if (!key.startsWith(`${prefix}/`)) return false; + const name = key.slice(prefix.length + 1); + return name.length > 0 && name !== "." && name !== ".." && !/[\\/]/.test(name); +} diff --git a/convex/lib/clientAuth.ts b/convex/lib/clientAuth.ts new file mode 100644 index 0000000..b6d5b79 --- /dev/null +++ b/convex/lib/clientAuth.ts @@ -0,0 +1,5 @@ +/** Legacy wire fields retained until deployed client versions are confirmed. */ +export const identityAssertionFields = [ + "userDid", "ownerDid", "createdByDid", "checkedByDid", "actorDid", + "publisherDid", "viewerDid", "legacyDid", "walletDid", "anchoredByDid", +] as const; diff --git a/convex/lib/httpResponses.ts b/convex/lib/httpResponses.ts index 7e372ab..b42f1c1 100644 --- a/convex/lib/httpResponses.ts +++ b/convex/lib/httpResponses.ts @@ -73,7 +73,8 @@ export function handlerErrorResponse( fallbackMessage: string ): Response { const message = error instanceof Error ? error.message : ""; - if (/not authorized/i.test(message)) { + if (/Authentication required|Invalid or expired token|Invalid API key|User not found/i.test(message)) return errorResponse(request, "Authentication required", 401); + if (/not authorized|Missing scope|Identity assertion/i.test(message)) { return errorResponse(request, "Not authorized", 403); } return errorResponse(request, fallbackMessage, 500); diff --git a/convex/lib/jwt.ts b/convex/lib/jwt.ts index 26a40eb..7f81481 100644 --- a/convex/lib/jwt.ts +++ b/convex/lib/jwt.ts @@ -10,6 +10,7 @@ import * as jose from "jose"; * Result of a successful token verification. */ export interface AuthTokenPayload { + expiresAt: number; /** Turnkey sub-organization ID (stable user identifier) */ turnkeySubOrgId: string; /** User's email address */ @@ -71,6 +72,7 @@ export async function verifyAuthToken(token: string): Promise } return { + expiresAt: jwtPayload.exp! * 1000, turnkeySubOrgId: jwtPayload.sub, email: jwtPayload.email, sessionToken: jwtPayload.sessionToken, diff --git a/convex/lib/permissions.ts b/convex/lib/permissions.ts index 0f1022a..bbc8dca 100644 --- a/convex/lib/permissions.ts +++ b/convex/lib/permissions.ts @@ -38,3 +38,53 @@ export async function canUserViewList( ): Promise { return canUserEditList(ctx, listId, userDid, legacyDid); } + +export type ListResources = { + accounts?: Array | undefined>; + lists?: Array | undefined>; + items?: Array | undefined>; + anchors?: Array | undefined>; +}; + +/** Each operation explicitly selects its resources; argument names confer no access. */ +export async function authorizeResources( + ctx: MutationCtx | QueryCtx, + actor: import("./actor").ResolvedActor, + resources: ListResources, +): Promise { + for (const accountId of resources.accounts ?? []) { + if (accountId !== actor.userId) throw new Error("Not authorized to access this account"); + } + const listIds = new Set((resources.lists ?? []).filter((id): id is Id<"lists"> => id !== undefined)); + for (const id of new Set(resources.items ?? [])) { + if (!id) continue; + const item = await ctx.db.get(id); + if (!item) throw new Error("Item not found"); + listIds.add(item.listId); + } + for (const id of new Set(resources.anchors ?? [])) { + if (!id) continue; + const anchor = await ctx.db.get(id); + if (!anchor) throw new Error("Anchor not found"); + if (anchor.listId) listIds.add(anchor.listId); + if (anchor?.itemId) { + const item = await ctx.db.get(anchor.itemId); + if (!item) throw new Error("Item not found"); + listIds.add(item.listId); + } + } + for (const listId of listIds) { + const list = await ctx.db.get(listId); + if (!list) throw new Error("List not found"); + if ([actor.did, actor.legacyDid].includes(list.ownerDid)) continue; + const publication = await ctx.db.query("publications").withIndex("by_list", q => q.eq("listId", listId)).first(); + if (publication?.status !== "active") throw new Error("Not authorized to access this list"); + } +} + +/** actorDid is resolved by the authenticated boundary, never supplied by a public caller. */ +export async function isResourceOwner(ctx: QueryCtx | MutationCtx, ownerDid: string, actorDid: string): Promise { + if (ownerDid === actorDid) return true; + const account = await ctx.db.query("users").withIndex("by_did", q => q.eq("did", actorDid)).first(); + return account?.legacyDid === ownerDid; +} diff --git a/convex/lib/session.ts b/convex/lib/session.ts new file mode 100644 index 0000000..7e4e671 --- /dev/null +++ b/convex/lib/session.ts @@ -0,0 +1,18 @@ +import type { QueryCtx, MutationCtx } from "../_generated/server"; +import { hashApiKey } from "./apiKeyHelpers"; +import { verifyAuthToken } from "./jwt"; +import { AuthError } from "./auth"; + +/** Read the expiring DB record so logout/expiry invalidates reactive query caches. */ +export async function requireSession(ctx: QueryCtx | MutationCtx, token?: string) { + if (!token) throw new AuthError("Authentication required", "UNAUTHORIZED"); + let session; + try { session = await verifyAuthToken(token); } + catch { throw new AuthError("Invalid or expired token", "INVALID_TOKEN"); } + const tokenHash = await hashApiKey(token); + const record = await ctx.db.query("accessSessions").withIndex("by_hash", q => q.eq("tokenHash", tokenHash)).first(); + if (!record || record.revokedAt !== undefined || record.expiresAt <= Date.now() || record.subject !== session.turnkeySubOrgId) { + throw new AuthError("Authentication required: restore your session", "UNAUTHORIZED"); + } + return session; +} diff --git a/convex/lists.ts b/convex/lists.ts index d451dd7..b950a6c 100644 --- a/convex/lists.ts +++ b/convex/lists.ts @@ -1,5 +1,6 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { mutation, query, internalQuery } from "./_generated/server"; + import type { MutationCtx } from "./_generated/server"; import type { Doc, Id } from "./_generated/dataModel"; import { withMutationObservability } from "./lib/observability"; @@ -101,11 +102,12 @@ async function assertListQuota( return { owner, isFirstList: existingLists.length === 0 }; } -export const createList = mutation({ +export const { public: createList, internal: createListInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { assetDid: v.string(), name: v.string(), - ownerDid: v.string(), categoryId: v.optional(v.id("categories")), createdAt: v.number(), // Serialized AssetEnvelope from createListAsset. Optional so older clients @@ -118,12 +120,12 @@ export const createList = mutation({ if (args.name.trim().length === 0) throw new Error("List name cannot be empty"); if (args.name.length > 200) throw new Error("List name cannot exceed 200 characters"); - const { owner, isFirstList } = await assertListQuota(ctx, args.ownerDid); + const { owner, isFirstList } = await assertListQuota(ctx, ctx.actor.did); const listId = await ctx.db.insert("lists", { assetDid: args.assetDid, name: args.name, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, categoryId: args.categoryId, createdAt: args.createdAt, }); @@ -131,7 +133,7 @@ export const createList = mutation({ const vcProof = createListOwnershipVC( listId, args.assetDid, - args.ownerDid, + ctx.actor.did, args.name, args.createdAt ); @@ -173,7 +175,9 @@ export const createList = mutation({ * The copy is honestly new: it gets today's genesis and its own DID, and makes * no claim to the original's history. The source list is left untouched. */ -export const copyList = mutation({ +export const { public: copyList, internal: copyListInternal } = actorMutation({ + resources: args => ({ lists: [args.sourceListId] }), + scope: "items:write", args: { sourceListId: v.id("lists"), // Minted client-side by createListAsset — that is the whole point, so both @@ -181,7 +185,6 @@ export const copyList = mutation({ assetDid: v.string(), celEnvelope: v.string(), name: v.string(), - ownerDid: v.string(), createdAt: v.number(), }, handler: async (ctx, args) => withMutationObservability("lists.copyList", async () => { @@ -192,16 +195,16 @@ export const copyList = mutation({ if (!source) throw new Error("List not found"); // Copying mints a new identity naming this owner, so viewers who can merely // read a shared list must not be able to do it. - if (source.ownerDid !== args.ownerDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(source.ownerDid)) { throw new Error("Only the list's owner can copy it"); } - const { owner, isFirstList } = await assertListQuota(ctx, args.ownerDid); + const { owner, isFirstList } = await assertListQuota(ctx, ctx.actor.did); const listId = await ctx.db.insert("lists", { assetDid: args.assetDid, name: args.name, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, categoryId: source.categoryId, createdAt: args.createdAt, // Presentation settings belong to the list, so the copy should look like @@ -212,7 +215,7 @@ export const copyList = mutation({ }); await ctx.db.patch(listId, { - vcProof: createListOwnershipVC(listId, args.assetDid, args.ownerDid, args.name, args.createdAt), + vcProof: createListOwnershipVC(listId, args.assetDid, ctx.actor.did, args.name, args.createdAt), }); await upsertListEnvelope(ctx, listId, args.assetDid, args.celEnvelope); @@ -272,19 +275,19 @@ export const copyList = mutation({ /** * Rename a list. Only the owner can rename. */ -export const renameList = mutation({ +export const { public: renameList, internal: renameListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), name: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { throw new Error("Only the list owner can rename this list"); @@ -305,19 +308,19 @@ export const renameList = mutation({ /** * Update the category of a list. Only owner can change. */ -export const updateListCategory = mutation({ +export const { public: updateListCategory, internal: updateListCategoryInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), categoryId: v.optional(v.id("categories")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { throw new Error("Only the list owner can change the category"); @@ -335,10 +338,14 @@ export const updateListCategory = mutation({ /** * Get a list by its ID. */ -export const getList = query({ +export const { public: getList, internal: getListInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { - return await ctx.db.get(args.listId); + const list = await ctx.db.get(args.listId); + if (list && !await canUserViewList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid)) throw new Error("Not authorized to access this list"); + return list; }, }); @@ -347,11 +354,11 @@ export const getList = query({ * getList so the hot list subscriptions don't carry it. Returns null when the * list predates envelope persistence. * - * Unauthenticated, matching getList above: the envelope holds the DID document, - * the signed CEL log and the list's own name/owner — the same surface getList - * already returns to any caller with the id. + * Protected by the same authenticated list access check as getList. */ -export const getListEnvelope = query({ +export const { public: getListEnvelope, internal: getListEnvelopeInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "lists:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { const row = await ctx.db @@ -370,11 +377,12 @@ export const getListEnvelope = query({ * Internal: only the server-side agent read handler may call it, so the viewer * DID it trusts always comes from an authenticated actor, never a raw client. */ -export const getListWithItemsForViewer = internalQuery({ +const listWithItemsOperation = actorQuery({ + // This read returns null for both missing and inaccessible lists (HTTP 404). + resources: () => ({}), + scope: "items:read", args: { listId: v.id("lists"), - viewerDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); @@ -383,8 +391,8 @@ export const getListWithItemsForViewer = internalQuery({ const canView = await canUserViewList( ctx, args.listId, - args.viewerDid, - args.legacyDid + ctx.actor.did, + ctx.actor.legacyDid ); if (!canView) return null; @@ -403,16 +411,13 @@ export const getListWithItemsForViewer = internalQuery({ /** * Get all lists where user is the owner, plus any bookmarked published lists. */ -export const getUserLists = query({ - args: { - userDid: v.string(), - legacyDid: v.optional(v.string()), - walletDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const didsToCheck = [args.userDid]; - if (args.legacyDid) didsToCheck.push(args.legacyDid); - if (args.walletDid) didsToCheck.push(args.walletDid); +export const { public: getUserLists, internal: getUserListsInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx) => { + const didsToCheck = [ctx.actor.did]; + if (ctx.actor.legacyDid) didsToCheck.push(ctx.actor.legacyDid); const listMap = new Map>(); @@ -440,7 +445,7 @@ export const getUserLists = query({ for (const bookmark of bookmarks) { if (!listMap.has(bookmark.listId.toString())) { const list = await ctx.db.get(bookmark.listId); - if (list) { + if (list && await canUserViewList(ctx, list._id, ctx.actor.did, ctx.actor.legacyDid)) { listMap.set(bookmark.listId.toString(), list); } } @@ -462,7 +467,9 @@ export const getUserLists = query({ * indexed row per list and compares two numbers; the parsing happened once, at * write time. */ -export const getLegacyListIds = query({ +export const { public: getLegacyListIds, internal: getLegacyListIdsInternal } = actorQuery({ + resources: args => ({ lists: [...args.listIds] }), + scope: "lists:read", args: { listIds: v.array(v.id("lists")) }, handler: async (ctx, args) => { const legacy: Id<"lists">[] = []; @@ -492,18 +499,18 @@ export const getLegacyListIds = query({ * Delete a list and all its items. * Only the owner can delete a list. */ -export const deleteList = mutation({ +export const { public: deleteList, internal: deleteListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { throw new Error("Only the list owner can delete this list"); @@ -543,20 +550,20 @@ export const deleteList = mutation({ * Add a custom grocery aisle to a list. * Only the list owner can add custom aisles. */ -export const addCustomAisle = mutation({ +export const { public: addCustomAisle, internal: addCustomAisleInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), name: v.string(), emoji: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { throw new Error("Only the list owner can add custom aisles"); } @@ -577,19 +584,19 @@ export const addCustomAisle = mutation({ * Update the item view mode for a list. * Only the list owner can change view mode. */ -export const updateItemViewMode = mutation({ +export const { public: updateItemViewMode, internal: updateItemViewModeInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), itemViewMode: v.union(v.literal("alphabetical"), v.literal("categorized")), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { throw new Error("Only the list owner can change view mode"); } @@ -602,19 +609,19 @@ export const updateItemViewMode = mutation({ * Remove a custom grocery aisle from a list. * Only the list owner can remove custom aisles. */ -export const removeCustomAisle = mutation({ +export const { public: removeCustomAisle, internal: removeCustomAisleInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), aisleId: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) throw new Error("List not found"); - const dids = [args.userDid]; - if (args.legacyDid) dids.push(args.legacyDid); + const dids = [ctx.actor.did]; + if (ctx.actor.legacyDid) dids.push(ctx.actor.legacyDid); if (!dids.includes(list.ownerDid)) { throw new Error("Only the list owner can remove custom aisles"); } @@ -625,3 +632,5 @@ export const removeCustomAisle = mutation({ }); }, }); + +export const getListWithItemsForViewer = listWithItemsOperation.internal; diff --git a/convex/listsHttp.ts b/convex/listsHttp.ts index 84ce62f..c24b58f 100644 --- a/convex/listsHttp.ts +++ b/convex/listsHttp.ts @@ -1,16 +1,9 @@ -/** - * HTTP action handlers for protected list mutations. - * - * These endpoints authenticate via resolveActor(), which accepts either a JWT - * session or an agent API key (X-API-Key). Writes require the "items:write" scope - * (there is no lists:write scope yet). - */ +/** HTTP adapter; authentication and authorization run in the shared operation. */ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { resolveActor, requireScope } from "./lib/actor"; +import { authenticatedRequest } from "./lib/actor"; import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; /** @@ -24,8 +17,6 @@ import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpRes export const createList = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -40,19 +31,16 @@ export const createList = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - const listId = await ctx.runMutation(api.lists.createList, { + const listId = await ctx.runMutation(internal.lists.createListInternal, { + ...await authenticatedRequest(ctx, request), assetDid, name, - ownerDid: actor.did, categoryId: categoryId as unknown as undefined, // Optional category ID createdAt: Date.now(), }); return jsonResponse(request, { listId }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[listsHttp] createList error:", error); return handlerErrorResponse( request, @@ -73,8 +61,6 @@ export const createList = httpAction(async (ctx, request) => { export const deleteList = httpAction(async (ctx, request) => { try { // Accept a JWT session or an agent API key with items:write scope. - const actor = await resolveActor(ctx, request); - requireScope(actor, "items:write"); // Parse request body const body = await request.json(); @@ -85,17 +71,13 @@ export const deleteList = httpAction(async (ctx, request) => { } // Call the mutation with server-verified DID - await ctx.runMutation(api.lists.deleteList, { + await ctx.runMutation(internal.lists.deleteListInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, - userDid: actor.did, - legacyDid: actor.legacyDid, }); return jsonResponse(request, { success: true }); } catch (error) { - if (error instanceof AuthError) { - return unauthorizedResponseWithCors(request, error.message); - } console.error("[listsHttp] deleteList error:", error); return handlerErrorResponse( request, diff --git a/convex/notificationActions.ts b/convex/notificationActions.ts index 5bf2696..ab85baf 100644 --- a/convex/notificationActions.ts +++ b/convex/notificationActions.ts @@ -1,25 +1,27 @@ "use node"; +import { actorAction } from "./lib/authenticated"; /** * Push notification actions (Node.js) — APNs + Web Push. * Queries/mutations are in notifications.ts. */ import { v } from "convex/values"; -import { action, internalAction } from "./_generated/server"; +import { internalAction } from "./_generated/server"; import { internal } from "./_generated/api"; // ─── Send push notification (action) ──────────────────────────────── -export const sendPushNotification = action({ +export const { public: sendPushNotification, internal: sendPushNotificationAuthenticatedInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - userDid: v.string(), title: v.string(), body: v.string(), data: v.optional(v.any()), }, handler: async (ctx, args): Promise> => { const tokens: Array<{ token: string; platform: string; webPushKeys?: { p256dh: string; auth: string } }> = await ctx.runQuery(internal.notifications.getTokensForUser, { - userDid: args.userDid, + userDid: ctx.actor.did, }); const results: PromiseSettledResult[] = await Promise.allSettled( @@ -41,7 +43,9 @@ export const sendPushNotification = action({ }, }); -export const sendListNotification = action({ +export const { public: sendListNotification, internal: sendListNotificationAuthenticatedInternal } = actorAction({ + resources: args => ({ lists: [args.listId] }), + scope: "*", args: { listId: v.id("lists"), excludeDid: v.optional(v.string()), diff --git a/convex/notifications.ts b/convex/notifications.ts index 840ef12..07bc2d9 100644 --- a/convex/notifications.ts +++ b/convex/notifications.ts @@ -1,16 +1,18 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * Push notification management — queries & mutations (non-Node.js). * Actions that need Node.js are in notificationActions.ts. */ import { v } from "convex/values"; -import { mutation, query, internalQuery } from "./_generated/server"; +import { internalQuery } from "./_generated/server"; // ─── Token registration ───────────────────────────────────────────── -export const registerPushToken = mutation({ +export const { public: registerPushToken, internal: registerPushTokenInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { - userDid: v.string(), token: v.string(), platform: v.union(v.literal("ios"), v.literal("android"), v.literal("web")), webPushKeys: v.optional( @@ -25,7 +27,7 @@ export const registerPushToken = mutation({ if (existing) { await ctx.db.patch(existing._id, { - userDid: args.userDid, + userDid: ctx.actor.did, platform: args.platform, webPushKeys: args.webPushKeys, }); @@ -33,7 +35,7 @@ export const registerPushToken = mutation({ } return await ctx.db.insert("pushTokens", { - userDid: args.userDid, + userDid: ctx.actor.did, token: args.token, platform: args.platform, webPushKeys: args.webPushKeys, @@ -42,17 +44,18 @@ export const registerPushToken = mutation({ }, }); -export const unregisterPushToken = mutation({ +export const { public: unregisterPushToken, internal: unregisterPushTokenInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { token: v.string(), - userDid: v.string(), }, handler: async (ctx, args) => { const tok = await ctx.db .query("pushTokens") .withIndex("by_token", (q) => q.eq("token", args.token)) .first(); - if (tok && tok.userDid === args.userDid) { + if (tok && [ctx.actor.did, ctx.actor.legacyDid].includes(tok.userDid)) { await ctx.db.delete(tok._id); } }, @@ -60,30 +63,30 @@ export const unregisterPushToken = mutation({ // ─── Queries ───────────────────────────────────────────────────────── -export const hasSubscription = query({ - args: { userDid: v.string() }, - handler: async (ctx, args) => { - const legacySub = await ctx.db - .query("pushSubscriptions") - .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) - .first(); - if (legacySub) return true; - - const token = await ctx.db - .query("pushTokens") - .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) - .first(); - return token !== null; +export const { public: hasSubscription, internal: hasSubscriptionInternal } = actorQuery({ + resources: () => ({}), + scope: "*", + args: {}, + handler: async (ctx) => { + for (const did of [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did)) { + const [subscription, token] = await Promise.all([ + ctx.db.query("pushSubscriptions").withIndex("by_user", q => q.eq("userDid", did)).first(), + ctx.db.query("pushTokens").withIndex("by_user", q => q.eq("userDid", did)).first(), + ]); + if (subscription || token) return true; + } + return false; }, }); -export const getUserSubscriptions = query({ - args: { userDid: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("pushTokens") - .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) - .collect(); +export const { public: getUserSubscriptions, internal: getUserSubscriptionsInternal } = actorQuery({ + resources: () => ({}), + scope: "*", + args: {}, + handler: async (ctx) => { + const dids = [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did); + return (await Promise.all(dids.map(did => ctx.db.query("pushTokens") + .withIndex("by_user", q => q.eq("userDid", did)).collect()))).flat(); }, }); @@ -129,9 +132,10 @@ export const getTokensForList = internalQuery({ // ─── Legacy compatibility ──────────────────────────────────────────── -export const saveSubscription = mutation({ +export const { public: saveSubscription, internal: saveSubscriptionInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { - userDid: v.string(), endpoint: v.string(), keys: v.object({ p256dh: v.string(), auth: v.string() }), }, @@ -141,11 +145,11 @@ export const saveSubscription = mutation({ .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint)) .first(); if (existing) { - await ctx.db.patch(existing._id, { userDid: args.userDid, keys: args.keys }); + await ctx.db.patch(existing._id, { userDid: ctx.actor.did, keys: args.keys }); return existing._id; } return await ctx.db.insert("pushSubscriptions", { - userDid: args.userDid, + userDid: ctx.actor.did, endpoint: args.endpoint, keys: args.keys, createdAt: Date.now(), @@ -153,14 +157,16 @@ export const saveSubscription = mutation({ }, }); -export const removeSubscription = mutation({ - args: { endpoint: v.string(), userDid: v.string() }, +export const { public: removeSubscription, internal: removeSubscriptionInternal } = actorMutation({ + resources: () => ({}), + scope: "*", + args: { endpoint: v.string() }, handler: async (ctx, args) => { const sub = await ctx.db .query("pushSubscriptions") .withIndex("by_endpoint", (q) => q.eq("endpoint", args.endpoint)) .first(); - if (sub && sub.userDid === args.userDid) { + if (sub && [ctx.actor.did, ctx.actor.legacyDid].includes(sub.userDid)) { await ctx.db.delete(sub._id); } }, diff --git a/convex/originals.ts b/convex/originals.ts index 65feabf..4cf0096 100644 --- a/convex/originals.ts +++ b/convex/originals.ts @@ -1,13 +1,5 @@ -/** - * Originals Explorer — unified read-only query. - * - * Joins lists, sites, siteHostnames, publications, bitcoinAnchors, activities, - * itemAssignees into a single ExplorerRow[] sorted by updatedAt desc with - * id-ascending tiebreaker. Pure derivation logic lives in src/lib/explorer.ts. - */ +import { actorQuery } from "./lib/authenticated"; -import { v } from "convex/values"; -import { query } from "./_generated/server"; import type { Doc } from "./_generated/dataModel"; import { deriveExplorerRows, @@ -15,18 +7,17 @@ import { type ExplorerRow, } from "../src/lib/explorer"; -export const listOwnedOriginals = query({ - args: { ownerDid: v.string() }, - handler: async (ctx, args): Promise => { +export const { public: listOwnedOriginals, internal: listOwnedOriginalsInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx): Promise => { + const dids = [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did); const [lists, sites] = await Promise.all([ - ctx.db - .query("lists") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) - .collect(), - ctx.db - .query("sites") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) - .collect(), + Promise.all(dids.map(did => ctx.db.query("lists") + .withIndex("by_owner", q => q.eq("ownerDid", did)).collect())).then(rows => rows.flat()), + Promise.all(dids.map(did => ctx.db.query("sites") + .withIndex("by_owner", q => q.eq("ownerDid", did)).collect())).then(rows => rows.flat()), ]); // Per-site joins: hostnames. diff --git a/convex/presence.ts b/convex/presence.ts index 7b1a2bb..f191e73 100644 --- a/convex/presence.ts +++ b/convex/presence.ts @@ -1,18 +1,19 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; + import { canUserEditList } from "./lib/permissions"; const ACTIVE_WINDOW_MS = 60_000; -export const heartbeat = mutation({ +export const { public: heartbeat, internal: heartbeatInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), - userDid: v.string(), - legacyDid: v.optional(v.string()), status: v.optional(v.union(v.literal("active"), v.literal("idle"), v.literal("offline"))), }, handler: async (ctx, args) => { - const canAccess = await canUserEditList(ctx, args.listId, args.userDid, args.legacyDid); + const canAccess = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canAccess) throw new Error("Not authorized to update presence"); const now = Date.now(); @@ -20,7 +21,7 @@ export const heartbeat = mutation({ const existing = await ctx.db .query("presence") - .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", args.userDid)) + .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", ctx.actor.did)) .first(); if (existing) { @@ -28,7 +29,7 @@ export const heartbeat = mutation({ } else { await ctx.db.insert("presence", { listId: args.listId, - userDid: args.userDid, + userDid: ctx.actor.did, status, lastSeenAt: now, updatedAt: now, @@ -37,7 +38,7 @@ export const heartbeat = mutation({ await ctx.db.insert("activities", { listId: args.listId, - actorDid: args.userDid, + actorDid: ctx.actor.did, type: "presence_heartbeat", metadata: { status }, createdAt: now, @@ -47,19 +48,19 @@ export const heartbeat = mutation({ }, }); -export const markOffline = mutation({ +export const { public: markOffline, internal: markOfflineInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { - const canAccess = await canUserEditList(ctx, args.listId, args.userDid, args.legacyDid); + const canAccess = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canAccess) throw new Error("Not authorized to update presence"); const existing = await ctx.db .query("presence") - .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", args.userDid)) + .withIndex("by_list_user", (q) => q.eq("listId", args.listId).eq("userDid", ctx.actor.did)) .first(); const now = Date.now(); @@ -69,7 +70,7 @@ export const markOffline = mutation({ await ctx.db.insert("activities", { listId: args.listId, - actorDid: args.userDid, + actorDid: ctx.actor.did, type: "presence_offline", metadata: { status: "offline" }, createdAt: now, @@ -79,7 +80,9 @@ export const markOffline = mutation({ }, }); -export const getListPresence = query({ +export const { public: getListPresence, internal: getListPresenceInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { const now = Date.now(); diff --git a/convex/presenceHttp.ts b/convex/presenceHttp.ts index cbdc636..5c53d0b 100644 --- a/convex/presenceHttp.ts +++ b/convex/presenceHttp.ts @@ -1,47 +1,42 @@ import { httpAction } from "./_generated/server"; -import { api } from "./_generated/api"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; -import { AuthError, unauthorizedResponseWithCors } from "./lib/auth"; -import { requireAuthenticatedUser } from "./lib/authUser"; -import { jsonResponse, errorResponse } from "./lib/httpResponses"; +import { authenticatedRequest } from "./lib/actor"; +import { jsonResponse, errorResponse, handlerErrorResponse } from "./lib/httpResponses"; export const heartbeat = httpAction(async (ctx, request) => { try { - const user = await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { listId, status } = body as { listId: string; status?: "active" | "idle" | "offline" }; if (!listId) return errorResponse(request, "listId is required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await ctx.runMutation((api as any).presence.heartbeat, { + const result = await ctx.runMutation(internal.presence.heartbeatInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, - userDid: user.did, - legacyDid: user.legacyDid, status, }); return jsonResponse(request, result); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to update presence", 500); + return handlerErrorResponse(request, error, "Failed to update presence"); } }); export const listPresence = httpAction(async (ctx, request) => { try { - await requireAuthenticatedUser(ctx, request); const body = await request.json(); const { listId } = body as { listId: string }; if (!listId) return errorResponse(request, "listId is required"); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const presence = await ctx.runQuery((api as any).presence.getListPresence, { + const presence = await ctx.runQuery(internal.presence.getListPresenceInternal, { + ...await authenticatedRequest(ctx, request), listId: listId as Id<"lists">, }); return jsonResponse(request, { presence }); } catch (error) { - if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); - return errorResponse(request, error instanceof Error ? error.message : "Failed to read presence", 500); + return handlerErrorResponse(request, error, "Failed to read presence"); } }); diff --git a/convex/publication.ts b/convex/publication.ts index c1a5a22..0de2cc2 100644 --- a/convex/publication.ts +++ b/convex/publication.ts @@ -1,3 +1,4 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * Publication functions for did:webvh public list publishing. * @@ -6,7 +7,7 @@ */ import { v } from "convex/values"; -import { query, mutation } from "./_generated/server"; +import { query } from "./_generated/server"; import { upsertListEnvelope } from "./lib/listEnvelope"; import { internal } from "./_generated/api"; @@ -14,13 +15,14 @@ import { internal } from "./_generated/api"; * Record a publication for a list. * Only the owner can publish a list. */ -export const publishList = mutation({ +export const { public: publishList, internal: publishListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "*", args: { listId: v.id("lists"), webvhDid: v.string(), didDocument: v.optional(v.string()), didLog: v.optional(v.string()), - publisherDid: v.string(), // The asset envelope after appending the published-version event. Optional: // a list whose signing key was lost to the celAssetDids migration can still // be published, it just cannot record the fact in its own log. @@ -32,7 +34,7 @@ export const publishList = mutation({ if (!list) { throw new Error("List not found"); } - if (list.ownerDid !== args.publisherDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(list.ownerDid)) { throw new Error("Only the owner can publish a list"); } @@ -72,7 +74,7 @@ export const publishList = mutation({ didDocument: args.didDocument, didLog: args.didLog, publishedAt: Date.now(), - publishedByDid: args.publisherDid, + publishedByDid: ctx.actor.did, status: "active", }); }, @@ -82,17 +84,18 @@ export const publishList = mutation({ * Unpublish a list. * Only the owner can unpublish. */ -export const unpublishList = mutation({ +export const { public: unpublishList, internal: unpublishListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "*", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); if (!list) { throw new Error("List not found"); } - if (list.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(list.ownerDid)) { throw new Error("Only the owner can unpublish a list"); } @@ -205,10 +208,11 @@ export const getPublicList = query({ /** * Bookmark a published list so it shows in the user's list view. */ -export const bookmarkList = mutation({ +export const { public: bookmarkList, internal: bookmarkListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "*", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args) => { // Verify list exists and is published @@ -225,7 +229,7 @@ export const bookmarkList = mutation({ const existing = await ctx.db .query("bookmarks") .withIndex("by_user_list", (q) => - q.eq("userDid", args.userDid).eq("listId", args.listId) + q.eq("userDid", ctx.actor.did).eq("listId", args.listId) ) .first(); @@ -257,12 +261,12 @@ export const bookmarkList = mutation({ } const bookmarkId = await ctx.db.insert("bookmarks", { - userDid: args.userDid, + userDid: ctx.actor.did, listId: args.listId, bookmarkedAt: Date.now(), }); - if (list && list.ownerDid !== args.userDid) { + if (list && ![ctx.actor.did, ctx.actor.legacyDid].includes(list.ownerDid)) { // Notify the list owner: a new collaborator joined await ctx.scheduler.runAfter(0, internal.notificationActions.sendPushNotificationInternal, { userDid: list.ownerDid, @@ -272,7 +276,7 @@ export const bookmarkList = mutation({ }); // Notify the joiner: the list was shared with them (delivers to their other devices) await ctx.scheduler.runAfter(0, internal.notificationActions.sendPushNotificationInternal, { - userDid: args.userDid, + userDid: ctx.actor.did, title: list.name, body: "Added to your lists", data: { listId: args.listId }, @@ -286,16 +290,17 @@ export const bookmarkList = mutation({ /** * Remove a bookmark. */ -export const unbookmarkList = mutation({ +export const { public: unbookmarkList, internal: unbookmarkListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "*", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args) => { const existing = await ctx.db .query("bookmarks") .withIndex("by_user_list", (q) => - q.eq("userDid", args.userDid).eq("listId", args.listId) + q.eq("userDid", ctx.actor.did).eq("listId", args.listId) ) .first(); @@ -308,16 +313,17 @@ export const unbookmarkList = mutation({ /** * Check if a list is bookmarked by the user. */ -export const isBookmarked = query({ +export const { public: isBookmarked, internal: isBookmarkedInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "lists:read", args: { listId: v.id("lists"), - userDid: v.string(), }, handler: async (ctx, args) => { const existing = await ctx.db .query("bookmarks") .withIndex("by_user_list", (q) => - q.eq("userDid", args.userDid).eq("listId", args.listId) + q.eq("userDid", ctx.actor.did).eq("listId", args.listId) ) .first(); @@ -329,7 +335,9 @@ export const isBookmarked = query({ * Get publication status for a list. * Returns publication info if the list is published, null otherwise. */ -export const getPublicationStatus = query({ +export const { public: getPublicationStatus, internal: getPublicationStatusInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "lists:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { const pub = await ctx.db @@ -356,14 +364,14 @@ export const getPublicationStatus = query({ /** * Get all bookmarked list IDs for a user. */ -export const getUserBookmarkIds = query({ - args: { - userDid: v.string(), - }, - handler: async (ctx, args) => { +export const { public: getUserBookmarkIds, internal: getUserBookmarkIdsInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx) => { const bookmarks = await ctx.db .query("bookmarks") - .withIndex("by_user", (q) => q.eq("userDid", args.userDid)) + .withIndex("by_user", (q) => q.eq("userDid", ctx.actor.did)) .collect(); return bookmarks.map((b) => b.listId); }, diff --git a/convex/referrals.ts b/convex/referrals.ts index 9f264da..de93ef7 100644 --- a/convex/referrals.ts +++ b/convex/referrals.ts @@ -1,3 +1,4 @@ +import { actorQuery, actorMutation } from "./lib/authenticated"; /** * Referral growth loop — invite a friend, unlock +1 list. * @@ -10,7 +11,7 @@ */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; + // --------------------------------------------------------------------------- // Helpers @@ -35,7 +36,9 @@ function generateCode(): string { /** * Get the referral code for a user (by userId). Returns null if none exists yet. */ -export const getReferralCode = query({ +export const { public: getReferralCode, internal: getReferralCodeAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { return await ctx.db @@ -48,7 +51,9 @@ export const getReferralCode = query({ /** * Get referral stats for a user: total successful referrals and Pro credit status. */ -export const getReferralStats = query({ +export const { public: getReferralStats, internal: getReferralStatsAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { const referrals = await ctx.db @@ -67,7 +72,9 @@ export const getReferralStats = query({ * Get referral Pro credit status for the current user (as referee). * Returns whether they came via referral and when their Pro expires. */ -export const getReferralProStatus = query({ +export const { public: getReferralProStatus, internal: getReferralProStatusAuthenticatedInternal } = actorQuery({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { const user = await ctx.db.get(userId); @@ -90,7 +97,9 @@ export const getReferralProStatus = query({ * Get or create a referral code for the current user. * Idempotent — always returns the same code for the same user. */ -export const getOrCreateReferralCode = mutation({ +export const { public: getOrCreateReferralCode, internal: getOrCreateReferralCodeAuthenticatedInternal } = actorMutation({ + resources: args => ({ accounts: [args.userId] }), + scope: "*", args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { // Return existing code if one exists @@ -135,7 +144,9 @@ export const getOrCreateReferralCode = mutation({ * * Safe to call multiple times — idempotent via the referee uniqueness check. */ -export const redeemReferral = mutation({ +export const { public: redeemReferral, internal: redeemReferralAuthenticatedInternal } = actorMutation({ + resources: args => ({ accounts: [args.refereeUserId] }), + scope: "*", args: { code: v.string(), refereeUserId: v.id("users"), diff --git a/convex/schema.ts b/convex/schema.ts index f4d4c6a..f81a997 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -9,6 +9,12 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ + accessSessions: defineTable({ + tokenHash: v.string(), + subject: v.string(), + expiresAt: v.number(), + revokedAt: v.optional(v.number()), + }).index("by_hash", ["tokenHash"]), // DID logs table - stores did:webvh logs for resolution didLogs: defineTable({ userDid: v.string(), // The user's did:webvh diff --git a/convex/siteActions.ts b/convex/siteActions.ts index 2bb1810..2c1b48a 100644 --- a/convex/siteActions.ts +++ b/convex/siteActions.ts @@ -1,7 +1,10 @@ "use node"; +import { isDirectChildKey } from "./lib/bucketKeys"; -import { action, internalAction } from "./_generated/server"; -import { internal, api } from "./_generated/api"; +import { actorAction } from "./lib/authenticated"; + +import { internalAction } from "./_generated/server"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { v } from "convex/values"; import { createCustomHostname, getCustomHostname } from "./cloudflare"; @@ -207,12 +210,14 @@ function normalizeCustomHostname(hostname: string): string { return normalized; } -export const createSiteFromUpload = action({ +export const { public: createSiteFromUpload, internal: createSiteFromUploadInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), bucketKey: v.string(), }, handler: async (ctx, args): Promise<{ siteId: string; hostname: string; url: string; did: string; scid: string }> => { + if (!isDirectChildKey(args.bucketKey, `siteFiles/${encodeURIComponent(ctx.actor.did)}`)) throw new Error("Invalid site upload key"); configureEd25519Sha512(); const baseDomain = process.env.SITE_BASE_DOMAIN || process.env.WEBVH_DOMAIN || "boop.ad"; @@ -276,7 +281,7 @@ export const createSiteFromUpload = action({ const createdAt = Date.now(); const record = await ctx.runMutation(internal.siteInternals.createSiteRecord, { - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, bucketKey: args.bucketKey, contentType: "text/html; charset=utf-8", sha256, @@ -300,9 +305,10 @@ export const createSiteFromUpload = action({ }, }); -export const createSite = action({ +export const { public: createSite, internal: createSiteInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), html: v.string(), }, handler: async (): Promise => { @@ -310,7 +316,7 @@ export const createSite = action({ }, }); -export const migrateVerifiedCustomDomain = action({ +export const migrateCustomDomainInternal = internalAction({ args: { ownerDid: v.string(), siteId: v.id("sites"), @@ -403,9 +409,10 @@ function normalizeRequestedHostname(input: string): string { return lowered; } -export const requestCustomHostname = action({ +export const { public: requestCustomHostname, internal: requestCustomHostnameInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), siteId: v.id("sites"), hostname: v.string(), }, @@ -414,7 +421,7 @@ export const requestCustomHostname = action({ const record = await ctx.runQuery( internal.siteInternals.getSiteIdentityForUpdate, - { siteId: args.siteId, ownerDid: args.ownerDid } + { siteId: args.siteId, ownerDid: ctx.actor.did } ); if (!record) { throw new Error("Site not found"); @@ -494,7 +501,7 @@ export const pollCustomHostname = internalAction({ }); if (!owner) return; try { - await ctx.runAction(api.siteActions.migrateVerifiedCustomDomain, { + await ctx.runAction(internal.siteActions.migrateCustomDomainInternal, { ownerDid: owner.ownerDid, siteId: row.siteId, hostname: row.hostname, @@ -530,9 +537,10 @@ export const pollPendingCustomHostnames = internalAction({ const MAX_REPLACE_HTML_BYTES = 2 * 1024 * 1024; // 2 MB -export const replaceSiteFile = action({ +export const { public: replaceSiteFile, internal: replaceSiteFileInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), siteId: v.id("sites"), bucketKey: v.string(), }, @@ -540,10 +548,11 @@ export const replaceSiteFile = action({ // Ownership check via the existing query. const owned = await ctx.runQuery(internal.siteInternals.getSiteIdentityForUpdate, { siteId: args.siteId, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, }); if (!owned) throw new Error("Site not found"); + if (!isDirectChildKey(args.bucketKey, `siteFiles/${encodeURIComponent(ctx.actor.did)}`)) throw new Error("Invalid site upload key"); const head = await headObject(args.bucketKey); if (!head.exists) throw new Error("Uploaded file not found."); if (!head.contentLength || head.contentLength === 0) { @@ -578,9 +587,10 @@ export const replaceSiteFile = action({ }, }); -export const retryCustomHostname = action({ +export const { public: retryCustomHostname, internal: retryCustomHostnameInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), hostnameId: v.id("siteHostnames"), }, handler: async (ctx, args): Promise => { @@ -593,7 +603,7 @@ export const retryCustomHostname = action({ const owner = await ctx.runQuery(internal.siteInternals.getSiteOwner, { siteId: row.siteId, }); - if (!owner || owner.ownerDid !== args.ownerDid) { + if (!owner || ![ctx.actor.did, ctx.actor.legacyDid].includes(owner.ownerDid)) { throw new Error("Not authorized"); } @@ -607,3 +617,18 @@ export const retryCustomHostname = action({ }); }, }); + +// Preserve the public name while requiring an authenticated owner and verified DNS. +export const { public: migrateVerifiedCustomDomain, internal: migrateVerifiedCustomDomainAuthenticatedInternal } = actorAction({ + scope: "*", + resources: () => ({}), + args: { siteId: v.id("sites"), hostname: v.string() }, + handler: async (ctx, args): Promise<{ siteId: string; hostname: string; did: string; scid: string }> => { + const hostname = normalizeCustomHostname(args.hostname); + const record = await ctx.runQuery(internal.siteInternals.getSiteIdentityForUpdate, { siteId: args.siteId, ownerDid: ctx.actor.did }); + if (!record) throw new Error("Not authorized"); + const verified = await ctx.runQuery(internal.siteInternals.isVerifiedCustomHostname, { siteId: args.siteId, hostname }); + if (!verified) throw new Error("Custom hostname is not verified"); + return ctx.runAction(internal.siteActions.migrateCustomDomainInternal, { siteId: args.siteId, hostname, ownerDid: ctx.actor.did }); + }, +}); diff --git a/convex/siteAssets.ts b/convex/siteAssets.ts index 672fd6f..9d1db56 100644 --- a/convex/siteAssets.ts +++ b/convex/siteAssets.ts @@ -1,11 +1,7 @@ +import { isResourceOwner } from "./lib/permissions"; +import { actorAction, actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { - action, - internalMutation, - internalQuery, - mutation, - query, -} from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { @@ -59,9 +55,10 @@ function sanitizeFileName(fileName: string): string { return cleaned; } -export const generateSiteAssetUploadUrl = action({ +export const { public: generateSiteAssetUploadUrl, internal: generateSiteAssetUploadUrlInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), siteId: v.id("sites"), fileName: v.string(), contentType: v.string(), @@ -80,7 +77,7 @@ export const generateSiteAssetUploadUrl = action({ const owned = await ctx.runQuery(internal.siteAssets.assertOwnsSite, { siteId: args.siteId, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, }); if (!owned) throw new Error("Site not found"); @@ -94,9 +91,10 @@ export const generateSiteAssetUploadUrl = action({ }, }); -export const addSiteAsset = mutation({ +export const { public: addSiteAsset, internal: addSiteAssetInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), siteId: v.id("sites"), fileName: v.string(), bucketKey: v.string(), @@ -106,11 +104,12 @@ export const addSiteAsset = mutation({ }, handler: async (ctx, args): Promise<{ assetId: Id<"siteAssets"> }> => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) { + if (!site || !await isResourceOwner(ctx, site.ownerDid, ctx.actor.did)) { throw new Error("Site not found"); } const fileName = sanitizeFileName(args.fileName); + if (args.bucketKey !== makeBucketKey("site-assets", args.siteId, fileName)) throw new Error("Invalid site asset key"); const now = Date.now(); const existing = await ctx.db .query("siteAssets") @@ -143,11 +142,13 @@ export const addSiteAsset = mutation({ }, }); -export const listSiteAssets = query({ - args: { ownerDid: v.string(), siteId: v.id("sites") }, +export const { public: listSiteAssets, internal: listSiteAssetsInternal } = actorQuery({ + resources: () => ({}), + scope: "*", + args: { siteId: v.id("sites") }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return []; + if (!site || !await isResourceOwner(ctx, site.ownerDid, ctx.actor.did)) return []; return await ctx.db .query("siteAssets") @@ -157,15 +158,16 @@ export const listSiteAssets = query({ }, }); -export const removeSiteAsset = action({ +export const { public: removeSiteAsset, internal: removeSiteAssetInternal } = actorAction({ + resources: () => ({}), + scope: "*", args: { - ownerDid: v.string(), assetId: v.id("siteAssets"), }, handler: async (ctx, args): Promise => { const asset = await ctx.runQuery(internal.siteAssets.getOwnedAsset, { assetId: args.assetId, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, }); if (!asset) throw new Error("Asset not found"); @@ -180,7 +182,7 @@ export const assertOwnsSite = internalQuery({ args: { siteId: v.id("sites"), ownerDid: v.string() }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, args.ownerDid)) return null; return { siteId: site._id }; }, }); @@ -191,7 +193,7 @@ export const getOwnedAsset = internalQuery({ const asset = await ctx.db.get(args.assetId); if (!asset) return null; const site = await ctx.db.get(asset.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, args.ownerDid)) return null; return asset; }, }); diff --git a/convex/siteInternals.ts b/convex/siteInternals.ts index 75c1f56..a88fc0d 100644 --- a/convex/siteInternals.ts +++ b/convex/siteInternals.ts @@ -1,3 +1,4 @@ +import { isResourceOwner } from "./lib/permissions"; import { v } from "convex/values"; import { internalMutation, internalQuery } from "./_generated/server"; @@ -100,7 +101,7 @@ export const getSiteIdentityForUpdate = internalQuery({ }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, args.ownerDid)) return null; const [key, hostnames, didLogEntries] = await Promise.all([ ctx.db @@ -356,3 +357,11 @@ export const replaceSiteFileRecord = internalMutation({ return { fileId: newFileId }; }, }); + +export const isVerifiedCustomHostname = internalQuery({ + args: { siteId: v.id("sites"), hostname: v.string() }, + handler: async (ctx, args) => { + const row = await ctx.db.query("siteHostnames").withIndex("by_hostname", q => q.eq("hostname", args.hostname)).first(); + return !!row && row.siteId === args.siteId && row.kind === "custom" && row.cfStatus === "active" && row.cfSslStatus === "active"; + }, +}); diff --git a/convex/sites.ts b/convex/sites.ts index 7904b11..9c11012 100644 --- a/convex/sites.ts +++ b/convex/sites.ts @@ -1,5 +1,7 @@ +import { isResourceOwner } from "./lib/permissions"; +import { actorAction, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; -import { action, internalQuery, query } from "./_generated/server"; +import { internalQuery, query } from "./_generated/server"; import { internal } from "./_generated/api"; import { bucketKey as makeBucketKey, @@ -11,17 +13,18 @@ const UPLOAD_EXPIRY_SEC = 600; const PREVIEW_EXPIRY_SEC = 300; const SITE_HTML_CONTENT_TYPE = "text/html; charset=utf-8"; -function newSiteBucketKey(): string { - return makeBucketKey("siteFiles", `${crypto.randomUUID()}.html`); +function newSiteBucketKey(ownerDid: string): string { + return makeBucketKey("siteFiles", encodeURIComponent(ownerDid), `${crypto.randomUUID()}.html`); } -export const generateSiteUploadUrl = action({ - args: { ownerDid: v.string() }, +export const { public: generateSiteUploadUrl, internal: generateSiteUploadUrlInternal } = actorAction({ + resources: () => ({}), + scope: "*", + args: {}, handler: async ( - _ctx, - _args + ctx ): Promise<{ uploadUrl: string; bucketKey: string }> => { - const key = newSiteBucketKey(); + const key = newSiteBucketKey(ctx.actor.did); const uploadUrl = await presignPut(key, { contentType: SITE_HTML_CONTENT_TYPE, expiresSec: UPLOAD_EXPIRY_SEC, @@ -30,14 +33,14 @@ export const generateSiteUploadUrl = action({ }, }); -export const listSites = query({ - args: { ownerDid: v.string() }, - handler: async (ctx, args) => { - const sites = await ctx.db - .query("sites") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) - .order("desc") - .collect(); +export const { public: listSites, internal: listSitesInternal } = actorQuery({ + resources: () => ({}), + scope: "*", + args: {}, + handler: async (ctx) => { + const identities = [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did); + const sites = (await Promise.all(identities.map(did => ctx.db.query("sites") + .withIndex("by_owner", q => q.eq("ownerDid", did)).order("desc").collect()))).flat(); return Promise.all( sites.map(async (site) => { @@ -50,14 +53,15 @@ export const listSites = query({ }, }); -export const getSite = query({ +export const { public: getSite, internal: getSiteInternal } = actorQuery({ + resources: () => ({}), + scope: "*", args: { siteId: v.id("sites"), - ownerDid: v.string(), }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, ctx.actor.did)) return null; const [file, key, hostnames, didLogEntries] = await Promise.all([ ctx.db.get(site.fileId), @@ -101,12 +105,14 @@ export const getSite = query({ }, }); -export const getSitePreviewUrl = action({ - args: { siteId: v.id("sites"), ownerDid: v.string() }, +export const { public: getSitePreviewUrl, internal: getSitePreviewUrlInternal } = actorAction({ + resources: () => ({}), + scope: "*", + args: { siteId: v.id("sites") }, handler: async (ctx, args): Promise => { const site = await ctx.runQuery(internal.sites.getSiteFileBucketKey, { siteId: args.siteId, - ownerDid: args.ownerDid, + ownerDid: ctx.actor.did, }); if (!site?.bucketKey) return null; return await presignGet(site.bucketKey, { expiresSec: PREVIEW_EXPIRY_SEC }); @@ -117,7 +123,7 @@ export const getSiteFileBucketKey = internalQuery({ args: { siteId: v.id("sites"), ownerDid: v.string() }, handler: async (ctx, args) => { const site = await ctx.db.get(args.siteId); - if (!site || site.ownerDid !== args.ownerDid) return null; + if (!site || !await isResourceOwner(ctx, site.ownerDid, args.ownerDid)) return null; const file = await ctx.db.get(site.fileId); if (!file) return null; return { bucketKey: file.bucketKey ?? null }; diff --git a/convex/tags.ts b/convex/tags.ts index 67d73a2..38c9fce 100644 --- a/convex/tags.ts +++ b/convex/tags.ts @@ -1,36 +1,16 @@ +import { canUserEditList } from "./lib/permissions"; +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * Tag management for categorizing items. */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; -import type { Id, Doc } from "./_generated/dataModel"; -import type { MutationCtx, QueryCtx } from "./_generated/server"; + +import type { Doc } from "./_generated/dataModel"; /** * Helper to check if a user can edit a list (owner or editor). */ -async function canUserEditList( - ctx: MutationCtx | QueryCtx, - listId: Id<"lists">, - userDid: string, - legacyDid?: string -): Promise { - const list = await ctx.db.get(listId); - if (!list) return false; - - const dids = [userDid]; - if (legacyDid) dids.push(legacyDid); - - if (dids.includes(list.ownerDid)) return true; - - const pub = await ctx.db - .query("publications") - .withIndex("by_list", (q) => q.eq("listId", listId)) - .first(); - - return pub?.status === "active"; -} // Predefined tag colors export const TAG_COLORS = [ @@ -48,16 +28,16 @@ export const TAG_COLORS = [ /** * Create a new tag for a list. */ -export const createTag = mutation({ +export const { public: createTag, internal: createTagInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), name: v.string(), color: v.string(), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { - const canEdit = await canUserEditList(ctx, args.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, args.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to create tags for this list"); } @@ -76,7 +56,7 @@ export const createTag = mutation({ listId: args.listId, name: args.name, color: args.color, - createdByDid: args.userDid, + createdByDid: ctx.actor.did, createdAt: Date.now(), }); }, @@ -85,19 +65,19 @@ export const createTag = mutation({ /** * Update a tag's name or color. */ -export const updateTag = mutation({ +export const { public: updateTag, internal: updateTagInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { tagId: v.id("tags"), name: v.optional(v.string()), color: v.optional(v.string()), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const tag = await ctx.db.get(args.tagId); if (!tag) throw new Error("Tag not found"); - const canEdit = await canUserEditList(ctx, tag.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, tag.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to update this tag"); } @@ -114,17 +94,17 @@ export const updateTag = mutation({ /** * Delete a tag and remove it from all items. */ -export const deleteTag = mutation({ +export const { public: deleteTag, internal: deleteTagInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { tagId: v.id("tags"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const tag = await ctx.db.get(args.tagId); if (!tag) throw new Error("Tag not found"); - const canEdit = await canUserEditList(ctx, tag.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, tag.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to delete this tag"); } @@ -150,7 +130,9 @@ export const deleteTag = mutation({ /** * Get all tags for a list. */ -export const getListTags = query({ +export const { public: getListTags, internal: getListTagsInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "items:read", args: { listId: v.id("lists") }, handler: async (ctx, args) => { return await ctx.db @@ -163,18 +145,18 @@ export const getListTags = query({ /** * Add a tag to an item. */ -export const addTagToItem = mutation({ +export const { public: addTagToItem, internal: addTagToItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), tagId: v.id("tags"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to update this item"); } @@ -197,18 +179,18 @@ export const addTagToItem = mutation({ /** * Remove a tag from an item. */ -export const removeTagFromItem = mutation({ +export const { public: removeTagFromItem, internal: removeTagFromItemInternal } = actorMutation({ + resources: args => ({ items: [args.itemId] }), + scope: "items:write", args: { itemId: v.id("items"), tagId: v.id("tags"), - userDid: v.string(), - legacyDid: v.optional(v.string()), }, handler: async (ctx, args) => { const item = await ctx.db.get(args.itemId); if (!item) throw new Error("Item not found"); - const canEdit = await canUserEditList(ctx, item.listId, args.userDid, args.legacyDid); + const canEdit = await canUserEditList(ctx, item.listId, ctx.actor.did, ctx.actor.legacyDid); if (!canEdit) { throw new Error("Not authorized to update this item"); } diff --git a/convex/templates.ts b/convex/templates.ts index bd28b3d..f1fd439 100644 --- a/convex/templates.ts +++ b/convex/templates.ts @@ -1,9 +1,10 @@ +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * List templates - save and reuse list structures. */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { query } from "./_generated/server"; import { upsertListEnvelope } from "./lib/listEnvelope"; // Id type used in function arguments via v.id() @@ -17,13 +18,14 @@ const templateItemValidator = v.object({ /** * Create a template from an existing list. */ -export const createFromList = mutation({ +export const { public: createFromList, internal: createFromListInternal } = actorMutation({ + resources: args => ({ lists: [args.listId] }), + scope: "items:write", args: { listId: v.id("lists"), templateName: v.string(), description: v.optional(v.string()), isPublic: v.optional(v.boolean()), - userDid: v.string(), }, handler: async (ctx, args) => { const list = await ctx.db.get(args.listId); @@ -49,7 +51,7 @@ export const createFromList = mutation({ return await ctx.db.insert("listTemplates", { name: args.templateName, description: args.description, - ownerDid: args.userDid, + ownerDid: ctx.actor.did, items: templateItems, createdAt: Date.now(), isPublic: args.isPublic ?? false, @@ -60,19 +62,20 @@ export const createFromList = mutation({ /** * Create a new template manually. */ -export const createTemplate = mutation({ +export const { public: createTemplate, internal: createTemplateInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { name: v.string(), description: v.optional(v.string()), items: v.array(templateItemValidator), isPublic: v.optional(v.boolean()), - userDid: v.string(), }, handler: async (ctx, args) => { return await ctx.db.insert("listTemplates", { name: args.name, description: args.description, - ownerDid: args.userDid, + ownerDid: ctx.actor.did, items: args.items, createdAt: Date.now(), isPublic: args.isPublic ?? false, @@ -83,19 +86,20 @@ export const createTemplate = mutation({ /** * Update a template. */ -export const updateTemplate = mutation({ +export const { public: updateTemplate, internal: updateTemplateInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { templateId: v.id("listTemplates"), name: v.optional(v.string()), description: v.optional(v.string()), items: v.optional(v.array(templateItemValidator)), isPublic: v.optional(v.boolean()), - userDid: v.string(), }, handler: async (ctx, args) => { const template = await ctx.db.get(args.templateId); if (!template) throw new Error("Template not found"); - if (template.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) { throw new Error("Not authorized to update this template"); } @@ -113,15 +117,16 @@ export const updateTemplate = mutation({ /** * Delete a template. */ -export const deleteTemplate = mutation({ +export const { public: deleteTemplate, internal: deleteTemplateInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { templateId: v.id("listTemplates"), - userDid: v.string(), }, handler: async (ctx, args) => { const template = await ctx.db.get(args.templateId); if (!template) throw new Error("Template not found"); - if (template.ownerDid !== args.userDid) { + if (![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) { throw new Error("Not authorized to delete this template"); } @@ -132,13 +137,14 @@ export const deleteTemplate = mutation({ /** * Get user's templates. */ -export const getUserTemplates = query({ - args: { userDid: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("listTemplates") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.userDid)) - .collect(); +export const { public: getUserTemplates, internal: getUserTemplatesInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", + args: {}, + handler: async (ctx) => { + const dids = [ctx.actor.did, ctx.actor.legacyDid].filter((did): did is string => !!did); + return (await Promise.all(dids.map(did => ctx.db.query("listTemplates") + .withIndex("by_owner", q => q.eq("ownerDid", did)).collect()))).flat(); }, }); @@ -158,21 +164,26 @@ export const getPublicTemplates = query({ /** * Get a single template. */ -export const getTemplate = query({ +export const { public: getTemplate, internal: getTemplateInternal } = actorQuery({ + resources: () => ({}), + scope: "lists:read", args: { templateId: v.id("listTemplates") }, handler: async (ctx, args) => { - return await ctx.db.get(args.templateId); + const template = await ctx.db.get(args.templateId); + if (template && !template.isPublic && ![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) throw new Error("Not authorized to read this template"); + return template; }, }); /** * Create a new list from a template. */ -export const createListFromTemplate = mutation({ +export const { public: createListFromTemplate, internal: createListFromTemplateInternal } = actorMutation({ + resources: () => ({}), + scope: "items:write", args: { templateId: v.id("listTemplates"), listName: v.string(), - userDid: v.string(), // Genesis happens client-side (only the client holds the key), same as lists.createList. assetDid: v.string(), celEnvelope: v.optional(v.string()), @@ -182,7 +193,7 @@ export const createListFromTemplate = mutation({ if (!template) throw new Error("Template not found"); // Check if template is accessible - if (!template.isPublic && template.ownerDid !== args.userDid) { + if (!template.isPublic && ![ctx.actor.did, ctx.actor.legacyDid].includes(template.ownerDid)) { throw new Error("Not authorized to use this template"); } @@ -192,7 +203,7 @@ export const createListFromTemplate = mutation({ const listId = await ctx.db.insert("lists", { assetDid: args.assetDid, name: args.listName, - ownerDid: args.userDid, + ownerDid: ctx.actor.did, createdAt: now, }); @@ -208,7 +219,7 @@ export const createListFromTemplate = mutation({ description: templateItem.description, priority: templateItem.priority, checked: false, - createdByDid: args.userDid, + createdByDid: ctx.actor.did, createdAt: now, order: templateItem.order, }); diff --git a/convex/userHttp.ts b/convex/userHttp.ts index 33c5acf..04264dd 100644 --- a/convex/userHttp.ts +++ b/convex/userHttp.ts @@ -5,7 +5,7 @@ */ import { httpAction } from "./_generated/server"; -import { api, internal } from "./_generated/api"; +import { internal } from "./_generated/api"; import { requireAuth, AuthError, @@ -37,7 +37,7 @@ function didWebvhDomain(did: string): string | null { export const updateUserDID = httpAction(async (ctx, request) => { try { // Require authentication - const auth = await requireAuth(request); + const auth = await requireAuth(ctx, request); // Parse request body const body = await request.json(); @@ -59,7 +59,7 @@ export const updateUserDID = httpAction(async (ctx, request) => { console.log(`[userHttp] Updating DID for ${auth.email} to ${did}`); // Call upsertUser which handles the DID upgrade logic - await ctx.runMutation(api.auth.upsertUser, { + await ctx.runMutation(internal.auth.upsertUserInternal, { turnkeySubOrgId: auth.turnkeySubOrgId, email: auth.email, did, @@ -103,7 +103,7 @@ export const updateUserDID = httpAction(async (ctx, request) => { */ export const remintUserDID = httpAction(async (ctx, request) => { try { - const auth = await requireAuth(request); + const auth = await requireAuth(ctx, request); const body = await request.json(); const { did: newDid, didLog, path } = body as { did: string; diff --git a/convex/users.ts b/convex/users.ts index 48536fa..c1405ef 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,10 +1,12 @@ +import { canUserViewList } from "./lib/permissions"; +import { actorMutation, actorQuery } from "./lib/authenticated"; /** * User-related queries and mutations. * Provides user statistics and profile information. */ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { query } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; /** @@ -13,13 +15,16 @@ import type { Id } from "./_generated/dataModel"; * categories, bookmarks, push tokens, referrals, feedback, subscriptions, * and the user record itself. */ -export const deleteUserData = mutation({ +export const { public: deleteUserData, internal: deleteUserDataInternal } = actorMutation({ + resources: () => ({}), + scope: "*", args: { userId: v.id("users"), }, handler: async (ctx, { userId }) => { const user = await ctx.db.get(userId); if (!user) return; + if (user.turnkeySubOrgId !== ctx.actor.turnkeySubOrgId || ctx.actor.viaApiKey) throw new Error("Not authorized to delete this account"); const dids = [user.did, user.legacyDid].filter(Boolean) as string[]; @@ -194,13 +199,12 @@ export const getUsersByDids = query({ /** * Get aggregate statistics for a user across all their lists. */ -export const getUserStats = query({ - args: { - userDid: v.string(), - legacyDid: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const { userDid, legacyDid } = args; +export const { public: getUserStats, internal: getUserStatsInternal } = actorQuery({ + resources: () => ({}), + scope: "items:read", + args: {}, + handler: async (ctx) => { + const { did: userDid, legacyDid } = ctx.actor; // Get all lists where user is owner const ownedLists = await ctx.db @@ -227,7 +231,10 @@ export const getUserStats = query({ } const ownedListIds = new Set(ownedLists.map((l) => l._id)); - const sharedListIds = bookmarkedListIds.filter((id) => !ownedListIds.has(id)); + const sharedListIds: Id<"lists">[] = []; + for (const id of new Set(bookmarkedListIds)) { + if (!ownedListIds.has(id) && await canUserViewList(ctx, id, userDid, legacyDid)) sharedListIds.push(id); + } const allListIds = [...ownedListIds, ...sharedListIds]; diff --git a/docs/authentication-rollout.md b/docs/authentication-rollout.md new file mode 100644 index 0000000..a594ba0 --- /dev/null +++ b/docs/authentication-rollout.md @@ -0,0 +1,56 @@ +# Authenticated operations rollout + +This change is prepared locally. Do not retire compatibility names or deploy the authorization cutover until the deployed-client inventory below is confirmed. Merely retaining a function name does not make an old unauthenticated client compatible. + +## Client evidence required + +| Client | Evidence in this checkout | Deployed evidence still required | +|---|---|---| +| Browser | package version 1.0.0; this branch adds the session adapter | Railway release commit, cached service-worker versions, and confirmation that authenticated direct calls are in use | +| iOS | marketing version 1.0, build 3 | App Store/TestFlight versions, supported installed versions, and minimum-version/update policy | +| Android | versionName 1.0, versionCode 1 | Play/internal-track releases, supported installed versions, and minimum-version/update policy | +| HTTP/agents | JWT and X-API-Key adapters retained | Active integrations and confirmation whether any bypass HTTP and call Convex directly | + +Local version strings are not evidence of deployed or active versions. No release inventory or production verification was available in this task. Confirmation was requested from the owner. Unauthenticated direct callers must upgrade; there is no safe fallback that accepts an asserted identity. + +## Boundary and compatibility + +- Browser/Capacitor establish the existing signed JWT with `actorSession.establish` before subscribing. Reactive calls carry `authToken`; HTTP calls continue using Bearer/cookie JWT or X-API-Key. HTTP registers valid pre-rollout JWTs automatically. +- `accessSessions` stores only token hashes, subjects, expiry and revocation. Scheduled expiry and logout change database state, invalidating private query caches. A revoked token cannot establish another session. Existing verified account records and legacy DID associations remain intact. +- Every protected operation resolves current/legacy identity from server records. Each operation declares its resources and required scope. Internal HTTP registrations use the same authenticated boundary and recheck key revocation in the data transaction. +- Public operation names remain, with optional legacy assertion fields accepted only when matching the authenticated account. These fields confer no authority. Shared business handlers receive authenticated context and declared business arguments only. +- OTP/session storage helpers retain rejecting public compatibility names; the verified login HTTP flow uses internal registrations. The public account lookup is limited to the signed-in account. New identity links cannot be established by asserting another current or legacy DID. +- Public list/resource reads require an active publication. Published lists remain readable without login; editing requires a logged-in human or an appropriately scoped agent. Bookmarks stop exposing a list when it is unpublished. +- Site uploads now bind pending object keys to the authenticated account. Finish or restart any pre-cutover pending site uploads; existing stored site files remain readable through authenticated ownership checks. Attachment and site upload references reject path traversal. +- The generated browser operation registry uses typed Convex references. After adding/renaming an authenticated operation, run `node scripts/generate-auth-client.mjs`; verify with `--check`. + +## Coordinated rollout + +1. Confirm the inventory above, including old mobile builds and stale browser tabs. Choose the supported client floor and user update/reauthentication messaging. +2. In staging, deploy the schema/functions together, then the matching browser/native clients. Verify scheduling is operational for session expiry. A frontend deployed against the previous backend will not find `actorSession.establish`; an old frontend against the secured backend cannot make unauthenticated direct calls. Coordinate the cutover or enforce an update window. +3. Validate OTP login/new-account DID setup, session restore, logout/expired-session invalidation, private list reads, shared edit/unpublish, attachment upload/read/removal, publishing and migrated accounts on web/iOS/Android. Test old valid JWT HTTP clients and current/legacy-owner API keys, then revoke keys and test insufficient scopes. Use designated test accounts and lists. +4. Approve the production cutover only after those checks and version confirmation. Remove old public names/assertion validators only in a later release after usage and supported versions demonstrate they are unused. + +## Post-Deploy Monitoring & Validation + +Release owner: the person approving the coordinated deployment; assign a named owner before cutover. Observe continuously for the first 30 minutes and review again at 24 hours. + +- Convex logs: search `Authentication required`, `Invalid or expired token`, `Invalid API key`, `Missing scope`, `Not authorized`, and unknown-function/argument-validation errors. Check `actorSession.establish`, `actorSession.expire` and scheduled-function failures. +- Watch login success, private query failures, offline sync retries and HTTP 401/403 rates, broken down by release/platform where available. Healthy behavior: test-account login/restore works; revoked/forged requests fail; no anonymous private reads; expiry removes access; supported releases produce no new unknown-function errors. +- Failure trigger: legitimate supported clients cannot login/read/write, scheduling fails, or any private bypass succeeds. Pause rollout, keep access restricted, and repair/update clients or provide a maintenance response. Do not restore DID-only authorization as an automatic rollback. + +## Evidence limits + +Regression tests exercise real signed JWT verification, database-owned identities, handler business behavior and HTTP-to-internal dispatch against in-memory fixtures. React provider tests exercise restore acceptance, rejection cleanup, and logout/login serialization; adapter tests cover token changes and long-session expiry. Convex code generation/type analysis, TypeScript and the application build validate integration statically. The repository lint baseline and any new diagnostics are checked separately. These do not prove live OTP delivery, WebSocket cache invalidation timing, bucket upload completion or deployed native behavior. Those checks remain staging/production release gates above. + +## Local validation + +- `node --test scripts/*.test.mjs`: 198 passed. +- `bun test`: 222 passed, including browser component and authentication lifecycle tests. +- `npx convex codegen --typecheck enable`, `npx tsc -b`, and `npx vite build`: passed. Code generation performs deployment analysis without completing a deployment. The build retains existing large-chunk warnings. +- `npm run lint` does not pass: it includes existing source errors, nested checkouts and generated test bundles. A comparison restricted to `src` and `convex` reports 49 errors versus 51 on the pre-change baseline, with no new error diagnostics. This change does not claim a clean repository lint baseline. +- The completed `ce-code-review` run (`20260908-223043-d36023ea`) reports no actionable findings after fixes. Ten local review passes and independent validation completed; the external cross-model pass timed out, so no cross-model corroboration is claimed. + +The originally reported anonymous handler reproduction is now a regression test. Actual React provider tests additionally cover expiry of a mounted 30-day session, logout/login ordering and credential cleanup; server tests cover idempotent establishment and premature expiry callbacks. + +PR preparation replayed the authorization change on `024144f` from `main`, preserving the canonical account-selection and duplicate-email signup protections from PR #235. The combined login and authorization tests pass; an independent conflict review found no issues. diff --git a/scripts/auth-boundary.test.mjs b/scripts/auth-boundary.test.mjs new file mode 100644 index 0000000..26ecc12 --- /dev/null +++ b/scripts/auth-boundary.test.mjs @@ -0,0 +1,256 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { SignJWT } from 'jose'; +import { getFunctionName } from 'convex/server'; +import { createHash } from 'node:crypto'; + +process.env.JWT_SECRET = 'boundary-test-secret-not-a-deployed-credential'; +const names = ['items','lists','publication','attachments','activity','assignees','presence','comments','tags','itemCategories','auth','authSessions','actorSession','didResources','itemsHttp','listsHttp','agentReadHttp','users','bitcoinAnchors','siteActions','siteInternals','sites','siteAssets','didCreation','billing','referrals','feedback','notificationActions','categories','templates','notifications']; +await build({ entryPoints: names.map(n => `convex/${n}.ts`), outdir: 'tmp/auth-boundary-test', bundle: true, platform: 'node', format: 'esm', outExtension: { '.js': '.mjs' }, external: ['convex/*','@originals/*','@turnkey/*','didwebvh-ts','@noble/*'] }); +const modules = Object.fromEntries(await Promise.all(names.map(async n => [n, await import(pathToFileURL(`${process.cwd()}/tmp/auth-boundary-test/${n}.mjs`))]))); +const call = (module, name, ctx, args) => modules[module][name]._handler(ctx, args); +async function token(subject = 'owner', options = {}) { + return new SignJWT({ email: `${subject}@example.test` }).setProtectedHeader({ alg: 'HS256' }) + .setSubject(subject).setIssuer('originals-auth').setAudience('originals-api') + .setExpirationTime(options.exp ?? '1h').sign(new TextEncoder().encode(options.secret ?? process.env.JWT_SECRET)); +} +const ownerToken = await token(), strangerToken = await token('stranger'); +function fixture({ published = false, migrated = false, keyScopes = ['lists:read','items:read','items:write'], revokedAt } = {}) { + const rows = { + users: [{ _id: 'U1', turnkeySubOrgId: 'owner', did: 'did:owner', legacyDid: migrated ? 'did:legacy' : undefined, email: 'owner@example.test' }, { _id: 'U2', turnkeySubOrgId: 'stranger', did: 'did:stranger', email: 'stranger@example.test' }], + lists: [{ _id: 'L1', ownerDid: migrated ? 'did:legacy' : 'did:owner', name: 'Private', createdAt: 1, assetDid: 'did:list' }, { _id: 'L2', ownerDid: 'did:other', name: 'Other private', createdAt: 2 }], + items: [{ _id: 'I1', listId: 'L1', name: 'Secret', checked: false, createdAt: 1, createdByDid: 'did:owner', vcProofs: [], priority: 'high' }, { _id: 'I2', listId: 'L2', name: 'Other secret', checked: false, createdAt: 1, vcProofs: [] }], + publications: published ? [{ _id: 'P1', listId: 'L1', webvhDid: 'did:webvh:public', status: 'active' }] : [], + agentApiKeys: [{ _id: 'K1', ownerDid: migrated ? 'did:legacy' : 'did:owner', keyHash: createHash('sha256').update('valid-key').digest('hex'), scopes: keyScopes, revokedAt }], + accessSessions: [ownerToken,strangerToken].map((t,i) => ({_id:`S${i}`,tokenHash:createHash("sha256").update(t).digest("hex"),subject:i ? "stranger" : "owner",expiresAt:Date.now()+3600000})), + bookmarks: [], listEnvelopes: [], subscriptions: [], referrals: [], categories: [], bitcoinAnchors: [], + }; + let next = 1; + const find = id => Object.values(rows).flat().find(row => row._id === id) ?? null; + const ctx = { rows, db: { + get: async id => find(id), + patch: async (id, patch) => { const row = find(id); assert.ok(row, `missing ${id}`); Object.assign(row, patch); }, + insert: async (table, values) => { const row = { ...values, _id: `new${next++}` }; (rows[table] ??= []).push(row); return row._id; }, + delete: async id => { for (const table of Object.values(rows)) { const at = table.findIndex(row => row._id === id); if(at >= 0) table.splice(at,1); } }, + query: table => { + let predicates = []; + const q = { + withIndex: (_index, fn) => { const b = { eq: (key,value) => { predicates.push(row => row[key] === value); return b; } }; fn?.(b); return q; }, + order: () => q, + filter: fn => { const b = { field: key => row => row[key], eq: (left,right) => row => (typeof left === 'function' ? left(row) : left) === right, or: (...ps) => row => ps.some(p => p(row)), and: (...ps) => row => ps.every(p => p(row)) }; predicates.push(fn(b)); return q; }, + collect: async () => (rows[table] ?? []).filter(row => predicates.every(p => p(row))), + first: async () => (await q.collect())[0] ?? null, + unique: async () => (await q.collect())[0] ?? null, + }; return q; + }, + }, scheduler: { runAfter: async () => {}, runAt: async () => {} } }; + ctx.runQuery = ctx.runMutation = async (ref,args) => { const [mod,fn] = getFunctionName(ref).split(':'); return call(mod,fn,ctx,args); }; + return ctx; +} + +test('asserted owner without credentials cannot complete an item', async () => { + const ctx = fixture(); + await assert.rejects(() => call('items','checkItem',ctx,{ itemId:'I1', checkedByDid:'did:owner', checkedAt:10 }), /auth|token/i); + assert.equal(ctx.rows.items[0].checked,false); +}); +test('forged current, legacy, and wallet identities never confer access', async () => { + for (const field of ['checkedByDid','legacyDid','walletDid']) { + const ctx = fixture({ migrated:true }); + await assert.rejects(() => call('items','checkItem',ctx,{ authToken:strangerToken, itemId:'I1', checkedAt:10, [field]: field === 'checkedByDid' ? 'did:owner' : 'did:legacy' }), /assertion|authorized/i); + assert.equal(ctx.rows.items[0].checked,false); + } +}); +test('anonymous and unrelated authenticated callers cannot read private list surfaces', async () => { + const reads = [ ['lists','getList',{listId:'L1'}], ['lists','getListEnvelope',{listId:'L1'}], ['lists','getLegacyListIds',{listIds:['L1']}], ['items','getListItems',{listId:'L1'}], ['items','getItemForSync',{itemId:'I1'}], ['items','getSubItems',{parentId:'I1'}], ['attachments','getAttachmentUrls',{itemId:'I1'}], ['activity','getListActivity',{listId:'L1'}], ['presence','getListPresence',{listId:'L1'}], ['assignees','getItemAssignees',{itemId:'I1'}], ['comments','getCommentCount',{itemId:'I1'}], ['bitcoinAnchors','getListDataForAnchor',{listId:'L1'}] ]; + for (const [mod,fn,args] of reads) for (const authToken of [undefined,strangerToken]) { + await assert.rejects(() => call(mod,fn,fixture(),{...args,authToken}), /auth|token/i, `${mod}.${fn}`); + } +}); +test('invalid signatures and expired sessions are rejected', async () => { + for (const authToken of [await token('owner',{secret:'wrong'}), await token('owner',{exp:1})]) + await assert.rejects(() => call('lists','getUserLists',fixture(),{authToken}), /token/i); +}); +test('authenticated browser and agent editing use server attribution, including migrated accounts', async () => { + for (const migrated of [false,true]) for (const credentials of [{authToken:ownerToken},{apiKey:'valid-key'}]) { + const ctx=fixture({migrated}); + const lists=await call('lists','getUserLists',ctx,credentials); assert.equal(lists.length,1); + await call('items','checkItem',ctx,{...credentials,itemId:'I1',checkedAt:10}); + assert.equal(ctx.rows.items[0].checked,true); assert.equal(ctx.rows.items[0].checkedByDid,'did:owner'); + await call('items','uncheckItem',ctx,{...credentials,itemId:'I1'}); assert.equal(ctx.rows.items[0].checked,false); + } +}); +test('revoked and unknown keys fail even alongside a valid JWT', async () => { + for(const [apiKey,revokedAt] of [['valid-key',0],['unknown',undefined]]) + await assert.rejects(() => call('items','checkItem',fixture({revokedAt}),{apiKey,authToken:ownerToken,itemId:'I1',checkedAt:10}),/API key/); +}); +test('scopes apply equally to direct and internal operations, and revocation is rechecked', async () => { + const ctx=fixture({keyScopes:['lists:read']}); + await call('lists','getUserListsInternal',ctx,{apiKey:'valid-key'}); + for(const fn of ['checkItem','checkItemInternal']) await assert.rejects(() => call('items',fn,ctx,{apiKey:'valid-key',itemId:'I1',checkedAt:10}),/Missing scope/); + await assert.rejects(() => call('items','getListItems',ctx,{apiKey:'valid-key',listId:'L1'}),/Missing scope/); + ctx.rows.agentApiKeys[0].revokedAt=Date.now(); + await assert.rejects(() => call('lists','getUserListsInternal',ctx,{apiKey:'valid-key'}),/API key/); +}); +test('published reads remain public, shared editing requires login, and unpublishing removes access', async () => { + const ctx=fixture({published:true}); + assert.ok(await call('publication','getPublicList',ctx,{webvhDid:'did:webvh:public'})); + await assert.rejects(() => call('didResources','checkSharedItem',ctx,{listId:'L1',itemId:'I1'}),/Authentication/); + await call('items','checkItem',ctx,{authToken:strangerToken,itemId:'I1',checkedAt:10}); + assert.equal(ctx.rows.items[0].checkedByDid,'did:stranger'); + await call('publication','bookmarkList',ctx,{authToken:strangerToken,listId:'L1'}); + await call('publication','unpublishList',ctx,{authToken:ownerToken,listId:'L1'}); + assert.equal(await call('publication','getPublicList',ctx,{webvhDid:'did:webvh:public'}),null); + assert.deepEqual(await call('lists','getUserLists',ctx,{authToken:strangerToken}),[]); + assert.deepEqual(await call('items','getHighPriorityItems',ctx,{authToken:strangerToken}),[]); + await assert.rejects(() => call('items','uncheckItem',ctx,{authToken:strangerToken,itemId:'I1'}),/authorized/); +}); +test('private resource aliases cannot bypass publication protection', async () => { + const ctx=fixture(); + assert.equal(await call('didResources','getPublicList',ctx,{listId:'L1',ownerDid:'did:owner'}),null); + assert.equal(await call('didResources','getListById',ctx,{listId:'L1'}),null); + assert.deepEqual(await call('didResources','getPublicListItems',ctx,{listId:'L1'}),[]); +}); +test('migrated owners can publish and edit categories, strangers cannot publish', async () => { + const ctx=fixture({migrated:true}); + await assert.rejects(() => call('publication','publishList',ctx,{authToken:strangerToken,listId:'L1',webvhDid:'did:pub',publisherDid:'did:legacy'}),/assertion/); + await call('itemCategories','addListCategory',ctx,{authToken:ownerToken,listId:'L1',name:'Travel',emoji:'🧳'}); + await call('publication','publishList',ctx,{authToken:ownerToken,listId:'L1',webvhDid:'did:pub'}); + assert.equal(ctx.rows.publications[0].publishedByDid,'did:owner'); +}); +test('attachment registration is authorized and bound to the target item', async () => { + const ctx=fixture(); const args={itemId:'I1',bucketKey:'attachments/I1/file.png',contentType:'image/png',size:10,sha256:'abc'}; + await assert.rejects(() => call('attachments','addAttachment',ctx,{...args,userDid:'did:owner'}),/Authentication/); + await assert.rejects(() => call('attachments','addAttachment',ctx,{...args,authToken:ownerToken,bucketKey:'attachments/I2/file.png'}),/key/); + await call('attachments','addAttachment',ctx,{...args,authToken:ownerToken}); assert.equal(ctx.rows.items[0].attachments.length,1); +}); +test('batch operations refuse a mixed unauthorized list before any write', async () => { + const ctx=fixture(); + await assert.rejects(() => call('items','batchCheckItems',ctx,{authToken:ownerToken,itemIds:['I1','I2'],checkedAt:10}),/authorized/); + assert.equal(ctx.rows.items[0].checked,false); +}); +test('account and OTP storage cannot be used to forge a login or legacy link', async () => { + const ctx=fixture(); + await assert.rejects(() => call('auth','upsertUser',ctx,{turnkeySubOrgId:'attacker',email:'attacker@example.test',legacyDid:'did:owner'}),/Authentication/); + await assert.rejects(() => call('auth','upsertUser',ctx,{authToken:strangerToken,turnkeySubOrgId:'stranger',email:'stranger@example.test',legacyDid:'did:owner'}),/authorized/); + await assert.rejects(() => call('authSessions','markSessionVerified',ctx,{sessionId:'stolen',subOrgId:'owner'}),/Authentication/); + await assert.rejects(() => call('auth','getUserByTurnkeyId',ctx,{authToken:strangerToken,turnkeySubOrgId:'owner'}),/authorized/); + assert.equal((await call('auth','getUserByTurnkeyId',ctx,{authToken:ownerToken,turnkeySubOrgId:'owner'})).did,'did:owner'); +}); +test('HTTP writes and reads execute the same authenticated internal boundary', async () => { + for(const credential of [{Authorization:`Bearer ${ownerToken}`},{'X-API-Key':'valid-key'}]) { + const ctx=fixture({migrated:true}); + const response=await modules.itemsHttp.checkItem._handler(ctx,new Request('https://test/api/items/check',{method:'POST',headers:{'Content-Type':'application/json',...credential},body:JSON.stringify({itemId:'I1',checkedByDid:'did:forged'})})); + assert.equal(response.status,200);assert.equal(ctx.rows.items[0].checkedByDid,'did:owner'); + const read=await modules.agentReadHttp.getLists._handler(ctx,new Request('https://test/api/v1/lists',{headers:credential}));assert.equal(read.status,200); + } + for(const keyScopes of [[],['items:read']]) { + const ctx=fixture({keyScopes}); const response=await modules.itemsHttp.checkItem._handler(ctx,new Request('https://test/api/items/check',{method:'POST',headers:{'X-API-Key':'valid-key'},body:JSON.stringify({itemId:'I1'})})); + assert.equal(response.status,403);assert.equal(ctx.rows.items[0].checked,false); + } +}); + +test('session logout and scheduled expiry invalidate private reads and prevent re-establishment', async () => { + const ctx=fixture(); + await call('lists','getList',ctx,{authToken:ownerToken,listId:'L1'}); + await call('actorSession','revoke',ctx,{authToken:ownerToken}); + await assert.rejects(() => call('lists','getList',ctx,{authToken:ownerToken,listId:'L1'}),/Authentication/); + await assert.rejects(() => call('actorSession','establish',ctx,{authToken:ownerToken}),/token/); + ctx.rows.accessSessions[1].expiresAt=1; + await call('actorSession','expire',ctx,{id:'S1'}); + assert.equal(ctx.rows.accessSessions.some(s=>s._id==='S1'),false); + await assert.rejects(() => call('lists','getUserLists',ctx,{authToken:strangerToken}),/Authentication/); +}); +test('existing valid JWT clients can establish a cache-aware session without asserting identity', async () => { + const ctx=fixture();ctx.rows.accessSessions=[]; + await assert.rejects(() => call('lists','getUserLists',ctx,{authToken:ownerToken}),/restore your session/); + await call('actorSession','establish',ctx,{authToken:ownerToken}); + assert.equal((await call('lists','getUserLists',ctx,{authToken:ownerToken})).length,1); + assert.equal(ctx.rows.accessSessions[0].subject,'owner'); +}); + +test('attachment capabilities reject forged callers and cross-item removal before storage access', async () => { + const ctx=fixture();ctx.rows.items[0].attachments=[{key:'attachments/I1/file.png',contentType:'image/png',size:10,sha256:'abc'}]; + const actionCtx={runQuery:ctx.runQuery,runMutation:ctx.runMutation}; + await assert.rejects(() => call('attachments','generateUploadUrl',actionCtx,{itemId:'I1',userDid:'did:owner',contentType:'image/png',byteLength:10}),/Authentication/); + await assert.rejects(() => call('attachments','generateUploadUrl',actionCtx,{authToken:strangerToken,itemId:'I1',contentType:'image/png',byteLength:10}),/authorized/); + await assert.rejects(() => call('attachments','removeAttachment',actionCtx,{authToken:ownerToken,itemId:'I1',bucketKey:'attachments/I2/file.png'}),/Attachment not found/); +}); +test('agent combined read preserves indistinguishable missing/private responses', async () => { + const ctx=fixture(); + for (const listId of ['L2','missing']) { + const response=await modules.agentReadHttp.getListWithItems._handler(ctx,new Request(`https://test/api/v1/lists/items?listId=${listId}`,{headers:{'X-API-Key':'valid-key'}})); + assert.equal(response.status,404); + } +}); + +test('missing parents and deleted lists never authorize retained private data', async()=>{ + const ctx=fixture();ctx.rows.items.push({_id:'child',listId:'L1',parentId:'I1',name:'Private child'}); + await call('items','removeItem',ctx,{authToken:ownerToken,itemId:'I1'}); + await assert.rejects(()=>call('items','getSubItems',ctx,{authToken:strangerToken,parentId:'I1'}),/not found|authorized/i); + ctx.rows.activities=[{_id:'A1',listId:'L1',metadata:{note:'Private note'}}]; + ctx.rows.bitcoinAnchors=[{_id:'B1',listId:'L1',status:'pending',stateSnapshot:'Private snapshot'}]; + await call('lists','deleteList',ctx,{authToken:ownerToken,listId:'L1'}); + await assert.rejects(()=>call('activity','getListActivity',ctx,{authToken:strangerToken,listId:'L1'}),/not found|authorized/i); + assert.deepEqual(await call('bitcoinAnchors','getPendingAnchors',ctx,{authToken:strangerToken}),[]); + assert.equal(await call('lists','getList',ctx,{authToken:ownerToken,listId:'L1'}),null); + assert.equal(await call('items','getItemForSync',ctx,{authToken:ownerToken,itemId:'I1'}),null); +}); + +test('site publication and signing actions reject forged owner and signing identities', async()=>{ + const ctx=fixture();const actionCtx={runQuery:ctx.runQuery,runMutation:ctx.runMutation}; + ctx.rows.sites=[{_id:'site1',ownerDid:'did:owner'}]; + for (const name of ['replaceSiteFile','migrateVerifiedCustomDomain','requestCustomHostname']) { + await assert.rejects(()=>call('siteActions',name,actionCtx,{authToken:strangerToken,ownerDid:'did:owner',siteId:'site1',bucketKey:'known',hostname:'example.test'}),/assertion/); + } + await assert.rejects(()=>call('siteActions','replaceSiteFile',actionCtx,{authToken:strangerToken,siteId:'site1',bucketKey:'known'}),/not found/i); + await assert.rejects(()=>call('siteActions','createSiteFromUpload',actionCtx,{authToken:ownerToken,bucketKey:'attachments/I2/private.html'}),/upload key/i); + await assert.rejects(()=>call('didCreation','createListDID',actionCtx,{authToken:strangerToken,subOrgId:'owner',domain:'example.test',slug:'list'}),/signing identity/i); +}); +test('account-id callers cannot bypass the authenticated boundary through billing, referrals or feedback', async()=>{ + for(const [mod,fn] of [['billing','getUserPlan'],['referrals','getReferralCode'],['feedback','submit']]) { + await assert.rejects(()=>call(mod,fn,fixture(),{authToken:strangerToken,userId:'U1',body:'Forged',category:'bug'}),/authorized/); + await assert.rejects(()=>call(mod,fn,fixture(),{userId:'U1',body:'Forged',category:'bug'}),/Authentication/); + } +}); + +test('upload references cannot traverse from an authorized prefix into private objects', async()=>{ + const ctx=fixture(), actionCtx={runQuery:ctx.runQuery,runMutation:ctx.runMutation}; + for (const suffix of ['../../attachments/I2/private.html','../victim.html','x/../../../private.html']) { + await assert.rejects(()=>call('siteActions','createSiteFromUpload',actionCtx,{authToken:ownerToken,bucketKey:`siteFiles/${encodeURIComponent('did:owner')}/${suffix}`}),/upload key/); + await assert.rejects(()=>call('attachments','addAttachment',ctx,{authToken:ownerToken,itemId:'I1',bucketKey:`attachments/I1/${suffix}`,contentType:'image/png',size:10,sha256:'abc'}),/attachment key/); + } + assert.equal(ctx.rows.items[0].attachments,undefined); +}); +test('referral redemption binds the referee account to the authenticated user',async()=>{ + await assert.rejects(()=>call('referrals','redeemReferral',fixture(),{authToken:strangerToken,refereeUserId:'U1',code:'known'}),/authorized/); + assert.deepEqual(await call('referrals','redeemReferral',fixture(),{authToken:ownerToken,refereeUserId:'U1',code:'invalid'}),{success:false,reason:'invalid_code'}); +}); + +test('migrated accounts retain their saved categories, templates, sites, and push subscriptions', async()=>{ + const ctx=fixture({migrated:true}); + ctx.rows.categories=[{_id:'C1',ownerDid:'did:legacy',name:'Saved',order:1}]; + ctx.rows.listTemplates=[{_id:'T1',ownerDid:'did:legacy',name:'Saved',items:[]}]; + ctx.rows.sites=[{_id:'site1',ownerDid:'did:legacy'}]; + ctx.rows.pushTokens=[{_id:'push1',userDid:'did:legacy',token:'old-device'}]; + for(const [mod,fn] of [['categories','getUserCategories'],['templates','getUserTemplates'],['sites','listSites'],['notifications','getUserSubscriptions']]) { + assert.equal((await call(mod,fn,ctx,{authToken:ownerToken})).length,1); + assert.equal((await call(mod,fn,ctx,{authToken:strangerToken})).length,0); + } + await call('categories','renameCategory',ctx,{authToken:ownerToken,categoryId:'C1',name:'Renamed'}); + assert.equal(ctx.rows.categories[0].name,'Renamed'); + await call('notifications','unregisterPushToken',ctx,{authToken:ownerToken,token:'old-device'}); + assert.equal(ctx.rows.pushTokens.length,0); +}); + +test('session establishment is idempotent and premature or missing expiry callbacks are harmless',async()=>{ + const ctx=fixture(),count=ctx.rows.accessSessions.length; + await call('actorSession','establish',ctx,{authToken:ownerToken}); + await call('actorSession','establish',ctx,{authToken:ownerToken}); + assert.equal(ctx.rows.accessSessions.length,count); + await call('actorSession','expire',ctx,{id:'S0'}); + await call('actorSession','expire',ctx,{id:'missing'}); + assert.equal(ctx.rows.accessSessions.length,count); + assert.equal((await call('lists','getUserLists',ctx,{authToken:ownerToken})).length,1); +}); diff --git a/scripts/auth-provider.test.mjs b/scripts/auth-provider.test.mjs new file mode 100644 index 0000000..8d69f46 --- /dev/null +++ b/scripts/auth-provider.test.mjs @@ -0,0 +1,82 @@ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { GlobalRegistrator } from '@happy-dom/global-registrator'; +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +const { renderHook, cleanup, act } = await import('@testing-library/react'); +const state = globalThis.__authProviderTest = { storage: new Map(), establish: async () => {} }; +state.convex = { mutation: (...args) => state.establish(...args) }; +await build({entryPoints:['src/hooks/useAuth.tsx'],outfile:'tmp/auth-provider-test.mjs',bundle:true,jsx:'automatic',platform:'node',format:'esm',external:['react','react/jsx-runtime','convex/server'],plugins:[{ + name:'auth-provider-fixtures', setup(b) { + b.onResolve({filter:/^(convex\/react|\.\.?\/.*(storageAdapter|webvh|useDidDomainRemint|convexUrls|analytics))$/}, args=>({path:args.path.split('/').at(-1),namespace:'fixture'})); + b.onLoad({filter:/.*/,namespace:'fixture'},({path})=>({contents:({ + react:'export function useConvex(){return globalThis.__authProviderTest.convex;}', + storageAdapter:'export const storageAdapter={get:async k=>globalThis.__authProviderTest.storage.get(k)??null,set:async(k,v)=>globalThis.__authProviderTest.storage.set(k,v),remove:async k=>globalThis.__authProviderTest.storage.delete(k)};', + webvh:'export const createUserWebVHDid=async()=>{throw new Error("Unexpected DID creation")};', + useDidDomainRemint:'export const useDidDomainRemint=()=>{};', + convexUrls:'export const getConvexHttpUrl=()=>"https://auth.example.test";', + analytics:'export const identifyUser=()=>{};export const resetAnalytics=()=>{};', + })[path]})); + } +}]}); +const {AuthProvider,useAuth}=await import(pathToFileURL(`${process.cwd()}/tmp/auth-provider-test.mjs`)); +const user={turnkeySubOrgId:'owner',email:'owner@example.test',did:'did:webvh:owner',displayName:'Owner'}; +const token=`header.${btoa(JSON.stringify({exp:Math.floor(Date.now()/1000)+30*86400}))}.signature`; +const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};}; +const flush=()=>act(async()=>{await Promise.resolve();}); +const fetchBefore=globalThis.fetch; +afterEach(()=>{cleanup();globalThis.fetch=fetchBefore;state.storage.clear();state.establish=async()=>{};}); +function seed(){state.storage.set('lisa-auth-state',JSON.stringify({user,token}));state.storage.set('lisa-jwt-token',token);} + +test('restore exposes credentials only after the server accepts the session',async()=>{ + seed();const pending=deferred();state.establish=()=>pending.promise; + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + assert.equal(result.current.token,null);assert.equal(result.current.isLoading,true); + await act(async()=>pending.resolve());assert.equal(result.current.token,token);assert.equal(result.current.isAuthenticated,true);assert.equal(result.current.isLoading,false); +}); +test('failed restore removes persisted credentials and leaves private queries signed out',async()=>{ + seed();state.establish=async()=>{throw new Error('revoked session')}; + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + assert.equal(result.current.token,null);assert.equal(result.current.isAuthenticated,false);assert.equal(result.current.isLoading,false);assert.equal(state.storage.size,0); +}); +test('OTP cannot persist or expose a token when session establishment fails',async()=>{ + globalThis.fetch=async url=>Response.json(url.endsWith('/auth/initiate')?{sessionId:'otp-session'}:{user,token}); + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + await act(async()=>result.current.startOtp(user.email)); + state.establish=async()=>{throw new Error('session rejected')}; + await act(async()=>assert.rejects(()=>result.current.verifyOtp('123456'),/session rejected/)); + assert.equal(result.current.token,null);assert.equal(result.current.isAuthenticated,false);assert.equal(state.storage.size,0); +}); +test('logout drops credentials immediately and serializes the next login until its response settles',async()=>{ + seed();const pending=deferred();let logoutRequest; + globalThis.fetch=async(url,options)=>{logoutRequest={url,options};return pending.promise;}; + const {result}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + let logout;await act(async()=>{logout=result.current.logout();await Promise.resolve();}); + assert.equal(result.current.token,null);assert.equal(result.current.isAuthenticated,false);assert.equal(result.current.isLoading,true);assert.equal(state.storage.size,0); + assert.equal(logoutRequest.options.headers.Authorization,`Bearer ${token}`); + await assert.rejects(()=>result.current.startOtp(user.email),/already in progress/); + await act(async()=>{pending.resolve(new Response(null,{status:204}));await logout;});assert.equal(result.current.isLoading,false); + globalThis.fetch=async()=>Response.json({sessionId:'new-otp'}); + await act(async()=>result.current.startOtp(user.email)); +}); + +test('the mounted provider expires a long session at its deadline and clears persisted credentials',async()=>{ + seed(); + const originalNow=Date.now, originalSet=globalThis.setTimeout, originalClear=globalThis.clearTimeout; + let now=Date.now(),next=0;const timers=new Map(); + Date.now=()=>now; + globalThis.setTimeout=(fn,delay,...args)=>{ + if(delay>100000){const id={expiryTimer:++next};timers.set(id,{fn,at:now+delay});return id;} + return originalSet(fn,delay,...args); + }; + globalThis.clearTimeout=id=>{if(!timers.delete(id))originalClear(id);}; + try { + const {result,unmount}=renderHook(()=>useAuth(),{wrapper:AuthProvider});await flush(); + assert.equal(result.current.token,token); + const fireNext=async()=>{const [id,timer]=timers.entries().next().value;timers.delete(id);now=timer.at;await act(async()=>timer.fn());}; + await fireNext();assert.equal(result.current.token,token); + await fireNext();assert.equal(result.current.token,null);assert.equal(result.current.isAuthenticated,false);assert.equal(state.storage.size,0); + unmount(); + } finally {Date.now=originalNow;globalThis.setTimeout=originalSet;globalThis.clearTimeout=originalClear;} +}); diff --git a/scripts/authenticated-client.test.mjs b/scripts/authenticated-client.test.mjs new file mode 100644 index 0000000..2497aa5 --- /dev/null +++ b/scripts/authenticated-client.test.mjs @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { GlobalRegistrator } from '@happy-dom/global-registrator'; +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +const { renderHook, cleanup } = await import('@testing-library/react'); +const { makeFunctionReference } = await import('convex/server'); +const state = globalThis.__authAdapterTest = { token: null, calls: [] }; +await build({entryPoints:['src/lib/authenticatedConvex.ts'],outfile:'tmp/authenticated-client-test.mjs',bundle:true,platform:'node',format:'esm',external:['react','convex/server'],plugins:[{ + name:'hook-fixtures', setup(b) { + b.onResolve({filter:/convex\/react$/},()=>({path:'convex-react',namespace:'fixture'})); + b.onResolve({filter:/hooks\/useAuth$/},()=>({path:'auth',namespace:'fixture'})); + b.onLoad({filter:/.*/,namespace:'fixture'},({path})=>({contents:path==='auth' ? `export function useAuth(){return globalThis.__authAdapterTest}` : ` + export function useQuery(ref,args){globalThis.__authAdapterTest.calls.push({ref,args});return args==='skip'?undefined:args;} + function mutation(ref){const fn=async args=>{globalThis.__authAdapterTest.calls.push({ref,args});return args;};fn.withOptimisticUpdate=()=>mutation(ref);return fn;} + export const useMutation=mutation;export const useAction=mutation; + `})); + } +}]}); +const hooks=await import(pathToFileURL(`${process.cwd()}/tmp/authenticated-client-test.mjs`)); +const privateQuery=makeFunctionReference('lists:getList'),publicQuery=makeFunctionReference('publication:getPublicList'),write=makeFunctionReference('items:checkItem'); + +test('login, account changes and logout update query credentials and skip private subscriptions',()=>{ + state.token=null;state.calls=[]; + const {result,rerender,unmount}=renderHook(()=>hooks.useQuery(privateQuery,{listId:'L1',userDid:'did:forged',legacyDid:'did:forged'})); + assert.equal(result.current,undefined); + state.token='session-A';rerender();assert.deepEqual(result.current,{listId:'L1',authToken:'session-A'}); + state.token='session-B';rerender();assert.deepEqual(result.current,{listId:'L1',authToken:'session-B'}); + state.token=null;rerender();assert.equal(result.current,undefined);assert.equal(state.calls.at(-1).args,'skip');unmount();cleanup(); +}); +test('public reads remain available without a session',()=>{ + state.token=null; + const {result,unmount}=renderHook(()=>hooks.useQuery(publicQuery,{webvhDid:'did:public'})); + assert.deepEqual(result.current,{webvhDid:'did:public'});unmount();cleanup(); +}); +test('writes and actions carry session credentials and omit identity assertions',async()=>{ + state.token='session-A'; + const {result,rerender,unmount}=renderHook(()=>({write:hooks.useMutation(write),upload:hooks.useAction(makeFunctionReference('attachments:generateUploadUrl'))})); + assert.deepEqual(await result.current.write({itemId:'I1',checkedByDid:'did:forged'}),{itemId:'I1',authToken:'session-A'}); + assert.deepEqual(await result.current.upload({itemId:'I1',contentType:'image/png',byteLength:10,userDid:'did:forged'}),{itemId:'I1',contentType:'image/png',byteLength:10,authToken:'session-A'}); + assert.deepEqual(await result.current.write.withOptimisticUpdate(()=>{})({itemId:'I1'}),{itemId:'I1',authToken:'session-A'}); + state.token=null;rerender();await assert.rejects(async()=>result.current.write({itemId:'I1'}),/Sign in/);unmount();cleanup(); +}); +test('generated client registry matches authenticated server registrations',()=>{ + execFileSync(process.execPath,['scripts/generate-auth-client.mjs','--check']); +}); + +await build({entryPoints:['src/lib/sessionExpiry.ts'],outfile:'tmp/session-expiry-test.mjs',bundle:true,platform:'node',format:'esm'}); +const {onSessionExpiry}=await import(pathToFileURL(`${process.cwd()}/tmp/session-expiry-test.mjs`)); +test('30-day sessions expire at their deadline, not on timer overflow, and cancelled timers stay cancelled', () => { + const originalNow = Date.now, originalSet = globalThis.setTimeout, originalClear = globalThis.clearTimeout; + let now = 0, next = 0, expired = 0; + const timers = new Map(); + Date.now = () => now; + globalThis.setTimeout = (fn, delay) => { const id = ++next; timers.set(id, {fn, at: now + delay}); return id; }; + globalThis.clearTimeout = id => timers.delete(id); + const tick = ms => { + const end = now + ms; + for (;;) { + const entry = [...timers.entries()].filter(([, t]) => t.at <= end).sort((a,b) => a[1].at-b[1].at)[0]; + if (!entry) break; + now = entry[1].at; timers.delete(entry[0]); entry[1].fn(); + } + now = end; + }; + try { + const lifetime = 30*24*60*60*1000; + const cancel = onSessionExpiry(lifetime, () => expired++); + tick(2_147_483_647); assert.equal(expired, 0); + tick(lifetime-2_147_483_647-1); assert.equal(expired, 0); + tick(1); assert.equal(expired, 1); cancel(); + const stop = onSessionExpiry(Date.now()+100, () => expired++); stop(); + tick(100); assert.equal(expired, 1); + } finally { + Date.now = originalNow; globalThis.setTimeout = originalSet; globalThis.clearTimeout = originalClear; + } +}); diff --git a/scripts/copy-list.test.mjs b/scripts/copy-list.test.mjs index 9a1cef6..b18c49c 100644 --- a/scripts/copy-list.test.mjs +++ b/scripts/copy-list.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, rm } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { build } from "esbuild"; +import { createAuthFixture } from "./helpers/auth-fixture.mjs"; const outdir = "tmp/copy-list-test"; @@ -28,6 +29,8 @@ const unwrap = (fn) => fn._handler ?? fn.handler; const OWNER = "did:webvh:QmS:boop.ad:user-owner"; const STRANGER = "did:webvh:QmS:boop.ad:user-stranger"; +const ownerSession = await createAuthFixture(OWNER); +const strangerSession = await createAuthFixture(STRANGER); /** * The source list is a migrated one: its envelope exists but nobody holds the @@ -40,7 +43,8 @@ function makeCtx({ items = [], lists: extraLists = [], user = null, subscription ...extraLists, ], items: items.map((i, n) => ({ _id: `I${n}`, listId: "L1", ...i })), - users: user ? [user] : [], + users: [{ ...ownerSession.user, ...user }, strangerSession.user], + accessSessions: [ownerSession.accessSession, strangerSession.accessSession], subscriptions: subscription ? [subscription] : [], referrals: [], listEnvelopes: [], @@ -87,7 +91,7 @@ const MINTED = { assetDid: "did:cel:fresh", celEnvelope: '{"format":"originals/asset"}', name: "Camping (copy)", - ownerDid: OWNER, + authToken: ownerSession.authToken, createdAt: 5000, }; @@ -171,8 +175,8 @@ test("drops vcProofs — they attest actions against the source asset", async () test("only the owner can copy a list", async () => { const ctx = makeCtx({ items: [] }); await assert.rejects( - () => unwrap(mod.copyList)(ctx, { sourceListId: "L1", ...MINTED, ownerDid: STRANGER }), - /owner/i + () => unwrap(mod.copyList)(ctx, { sourceListId: "L1", ...MINTED, authToken: strangerSession.authToken }), + /not authorized|owner/i ); }); diff --git a/scripts/generate-auth-client.mjs b/scripts/generate-auth-client.mjs new file mode 100644 index 0000000..4a1fa26 --- /dev/null +++ b/scripts/generate-auth-client.mjs @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import ts from 'typescript'; + +const refs = ['api.auth.getUserByTurnkeyId', 'api.auth.getUserByEmail', 'api.auth.upsertUser']; +for (const file of fs.readdirSync('convex').filter(f => f.endsWith('.ts')).sort()) { + const source = ts.createSourceFile(file, fs.readFileSync(`convex/${file}`, 'utf8'), ts.ScriptTarget.Latest, true); + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (!declaration.initializer || !ts.isCallExpression(declaration.initializer) + || !['actorQuery','actorMutation','actorAction'].includes(declaration.initializer.expression.getText(source)) + || !ts.isObjectBindingPattern(declaration.name)) continue; + const name = declaration.name.elements.find(element => element.propertyName?.getText(source) === 'public')?.name.getText(source); + if (name) refs.push(`api.${file.slice(0,-3)}.${name}`); + } + } +} +const content = `// Generated by scripts/generate-auth-client.mjs. Function references are type checked.\nimport { api } from "../../convex/_generated/api";\nimport { getFunctionName } from "convex/server";\nexport const authenticatedOperations = new Set([\n${refs.sort().map(ref => ` ${ref},`).join('\n')}\n].map(getFunctionName));\n`; +const target = 'src/lib/authenticatedOperations.ts'; +if (process.argv.includes('--check')) { + if (fs.readFileSync(target,'utf8') !== content) throw new Error('Authenticated client registry is stale: run node scripts/generate-auth-client.mjs'); +} else fs.writeFileSync(target,content); diff --git a/scripts/helpers/auth-fixture.mjs b/scripts/helpers/auth-fixture.mjs new file mode 100644 index 0000000..fe338b9 --- /dev/null +++ b/scripts/helpers/auth-fixture.mjs @@ -0,0 +1,31 @@ +import { SignJWT } from "jose"; +import { createHash } from "node:crypto"; + +// Exercise the production verifier with a test-only signing secret. The DID is +// deliberately absent from the JWT: the database account owns that association. +process.env.JWT_SECRET = "boop-handler-regression-test-secret-only"; + +export async function createAuthFixture(did, overrides = {}) { + const user = { + _id: `user-${did}`, + did, + turnkeySubOrgId: `org-${did}`, + ...overrides, + }; + const expiresAt = (Math.floor(Date.now() / 1000) + 3600) * 1000; + const authToken = await new SignJWT({ email: "fixture@example.test" }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(user.turnkeySubOrgId) + .setIssuer("originals-auth") + .setAudience("originals-api") + .setIssuedAt() + .setExpirationTime(expiresAt / 1000) + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); + const accessSession = { + _id: `session-${did}`, + tokenHash: createHash("sha256").update(authToken).digest("hex"), + subject: user.turnkeySubOrgId, + expiresAt, + }; + return { user, authToken, accessSession }; +} diff --git a/scripts/item-categories-mutations.test.mjs b/scripts/item-categories-mutations.test.mjs index fbc8942..735583e 100644 --- a/scripts/item-categories-mutations.test.mjs +++ b/scripts/item-categories-mutations.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, rm } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { build } from "esbuild"; +import { createAuthFixture } from "./helpers/auth-fixture.mjs"; const outdir = "tmp/item-categories-mutations-test"; @@ -28,10 +29,17 @@ const unwrap = (fn) => fn._handler ?? fn.handler; const OWNER = "did:webvh:QmS:boop.ad:user-owner"; const STRANGER = "did:webvh:QmS:boop.ad:user-stranger"; +const ownerSession = await createAuthFixture(OWNER); +const strangerSession = await createAuthFixture(STRANGER); function makeCtx({ list, items = [] } = {}) { const lists = [{ _id: "L1", ownerDid: OWNER, name: "Rachel's 40th", ...list }]; - const rows = { lists, items: items.map((i) => ({ listId: "L1", ...i })) }; + const rows = { + lists, + items: items.map((i) => ({ listId: "L1", ...i })), + users: [ownerSession.user, strangerSession.user], + accessSessions: [ownerSession.accessSession, strangerSession.accessSession], + }; const byId = new Map(); rows.lists.forEach((l) => byId.set(l._id, l)); rows.items.forEach((i) => byId.set(i._id, i)); @@ -42,11 +50,20 @@ function makeCtx({ list, items = [] } = {}) { get: async (id) => byId.get(id) ?? null, patch: async (id, fields) => Object.assign(byId.get(id), fields), query: (table) => { + let working = rows[table] ?? []; const result = { - // Index filtering is irrelevant here: each fixture holds one list. - withIndex: () => result, - collect: async () => rows[table] ?? [], - first: async () => (rows[table] ?? [])[0] ?? null, + withIndex: (_name, fn) => { + const builder = { + eq: (field, value) => { + working = working.filter((row) => row[field] === value); + return builder; + }, + }; + fn?.(builder); + return result; + }, + collect: async () => working, + first: async () => working[0] ?? null, }; return result; }, @@ -54,7 +71,7 @@ function makeCtx({ list, items = [] } = {}) { }; } -const call = (fn, ctx, args) => unwrap(mod[fn])(ctx, { listId: "L1", userDid: OWNER, ...args }); +const call = (fn, ctx, args) => unwrap(mod[fn])(ctx, { listId: "L1", authToken: ownerSession.authToken, ...args }); test("the first edit materialises the grocery set onto the list", async () => { const ctx = makeCtx(); @@ -145,8 +162,8 @@ test("the Other bucket is protected at the mutation layer too", async () => { test("a non-editor cannot change categories", async () => { const ctx = makeCtx(); await assert.rejects( - () => unwrap(mod.addListCategory)(ctx, { listId: "L1", userDid: STRANGER, name: "X", emoji: "🏷️" }), - /permission/ + () => unwrap(mod.addListCategory)(ctx, { listId: "L1", authToken: strangerSession.authToken, name: "X", emoji: "🏷️" }), + /not authorized|permission/i ); assert.equal(ctx.rows.lists[0].itemCategories, undefined, "nothing persisted on refusal"); }); @@ -171,7 +188,7 @@ test("a rejected edit leaves the stored set untouched", async () => { test("a missing list is an error, not a silent no-op", async () => { const ctx = makeCtx(); await assert.rejects( - () => unwrap(mod.addListCategory)(ctx, { listId: "nope", userDid: OWNER, name: "X", emoji: "🏷️" }), + () => call("addListCategory", ctx, { listId: "nope", name: "X", emoji: "🏷️" }), /List not found/ ); }); diff --git a/scripts/login-account.test.mjs b/scripts/login-account.test.mjs index 5553012..f0628e0 100644 --- a/scripts/login-account.test.mjs +++ b/scripts/login-account.test.mjs @@ -149,7 +149,7 @@ test("verification rejects an old session pointing at a different account", asyn test("competing signup sessions cannot insert a second user for an existing email", async () => { const ctx = context(); - const upsert = handler(auth.upsertUser); + const upsert = handler(auth.upsertUserInternal); await upsert(ctx, { email: EMAIL, turnkeySubOrgId: "first-signup" }); await assert.rejects(upsert(ctx, { email: EMAIL, turnkeySubOrgId: "second-signup" }), /request a new code/i); assert.equal(ctx.users.length, 1); @@ -158,23 +158,23 @@ test("competing signup sessions cannot insert a second user for an existing emai test("unfinished signup for another email cannot be linked through an undefined DID", async () => { const ctx = context([{ ...original, did: undefined }]); - await handler(auth.upsertUser)(ctx, { email: "new@example.com", turnkeySubOrgId: "new-identity" }); + await handler(auth.upsertUserInternal)(ctx, { email: "new@example.com", turnkeySubOrgId: "new-identity" }); assert.equal(ctx.users.length, 2); assert.equal(ctx.users[0].turnkeySubOrgId, original.turnkeySubOrgId); }); test("upsert rechecks operator selection after OTP verification", async () => { const ctx = context([{ ...original, isCanonicalLogin: true }, { ...duplicate, isCanonicalLogin: false }]); - await assert.rejects(handler(auth.upsertUser)(ctx, { email: EMAIL, turnkeySubOrgId: duplicate.turnkeySubOrgId }), /request a new code/i); + await assert.rejects(handler(auth.upsertUserInternal)(ctx, { email: EMAIL, turnkeySubOrgId: duplicate.turnkeySubOrgId }), /request a new code/i); assert.equal(ctx.users.length, 2); assert.equal(ctx.users[1].lastLoginAt, undefined); - await handler(auth.upsertUser)(ctx, { email: EMAIL, turnkeySubOrgId: original.turnkeySubOrgId }); + await handler(auth.upsertUserInternal)(ctx, { email: EMAIL, turnkeySubOrgId: original.turnkeySubOrgId }); assert.equal(ctx.users.length, 2); assert.ok(ctx.users[0].lastLoginAt); }); test("an existing identity cannot be reused under a different email", async () => { const ctx = context([original]); - await assert.rejects(handler(auth.upsertUser)(ctx, { email: "stranger@example.com", turnkeySubOrgId: original.turnkeySubOrgId }), /different email/i); + await assert.rejects(handler(auth.upsertUserInternal)(ctx, { email: "stranger@example.com", turnkeySubOrgId: original.turnkeySubOrgId }), /different email/i); assert.equal(ctx.users[0].lastLoginAt, undefined); }); diff --git a/scripts/originals-query.test.mjs b/scripts/originals-query.test.mjs index 1a77bec..96ceb15 100644 --- a/scripts/originals-query.test.mjs +++ b/scripts/originals-query.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdir, rm } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { build } from "esbuild"; +import { createAuthFixture } from "./helpers/auth-fixture.mjs"; const outdir = "tmp/originals-query-test"; @@ -25,9 +26,11 @@ async function loadModule() { const mod = await loadModule(); const handler = mod.listOwnedOriginals._handler ?? mod.listOwnedOriginals.handler; +const ownerSession = await createAuthFixture("me"); // In-memory ctx.db mock matching Convex's surface. function makeDb(tables) { + tables = { users: [ownerSession.user], accessSessions: [ownerSession.accessSession], ...tables }; const all = new Map(); for (const [name, rows] of Object.entries(tables)) { for (const row of rows) all.set(row._id, row); @@ -69,7 +72,7 @@ function makeQuery(rows) { }; } -test("ownerDid filter applied", async () => { +test("authenticated owner filter applied", async () => { const ctx = { db: makeDb({ lists: [ @@ -84,7 +87,7 @@ test("ownerDid filter applied", async () => { itemAssignees: [], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.equal(rows.length, 1); assert.equal(rows[0].title, "Mine"); }); @@ -105,7 +108,7 @@ test("joins all source tables and produces rows in updatedAt desc order", async ], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.equal(rows.length, 2); assert.equal(rows[0].source, "site"); assert.equal(rows[1].source, "list"); @@ -128,7 +131,7 @@ test("missing optional joins do not crash", async () => { itemAssignees: [], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.equal(rows[0].layer, "did:cel"); assert.equal(rows[0].verification, "none"); assert.equal(rows[0].collaborators, undefined); @@ -146,7 +149,7 @@ test("a site never reports anchored", async () => { itemAssignees: [], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.notEqual(rows[0].verification, "anchored"); }); @@ -165,7 +168,7 @@ test("multiple confirmed anchors → most recent confirmedAt wins", async () => itemAssignees: [], }), }; - const rows = await handler(ctx, { ownerDid: "me" }); + const rows = await handler(ctx, { authToken: ownerSession.authToken }); assert.equal(rows[0].anchorTxId, "newer"); }); diff --git a/src/components/Attachments.tsx b/src/components/Attachments.tsx index c097fe0..51e39fc 100644 --- a/src/components/Attachments.tsx +++ b/src/components/Attachments.tsx @@ -4,7 +4,7 @@ */ import { useState, useRef } from "react"; -import { useAction, useMutation, useQuery } from "convex/react"; +import { useAction, useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; @@ -40,7 +40,7 @@ const ALLOWED_TYPES = [ "application/json", ]; -export function Attachments({ itemId, userDid, legacyDid, canEdit }: AttachmentsProps) { +export function Attachments({ itemId, canEdit }: AttachmentsProps) { const { haptic } = useSettings(); const fileInputRef = useRef(null); const [uploadingCount, setUploadingCount] = useState(0); @@ -68,8 +68,7 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments const { uploadUrl, bucketKey } = await generateUploadUrl({ itemId, - userDid, - legacyDid, + contentType, byteLength: file.size, }); @@ -86,8 +85,7 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments await addAttachment({ itemId, - userDid, - legacyDid, + bucketKey, contentType, size: file.size, @@ -164,8 +162,7 @@ export function Attachments({ itemId, userDid, legacyDid, canEdit }: Attachments await removeAttachment({ itemId, bucketKey, - userDid, - legacyDid, + }); setFailedPreviewKeys((prev) => { if (!prev[bucketKey]) return prev; diff --git a/src/components/BatchOperations.tsx b/src/components/BatchOperations.tsx index 1c75fed..551a9a0 100644 --- a/src/components/BatchOperations.tsx +++ b/src/components/BatchOperations.tsx @@ -3,7 +3,7 @@ */ import { useState } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; diff --git a/src/components/CalendarView.tsx b/src/components/CalendarView.tsx index 7cee4ca..45b0c9e 100644 --- a/src/components/CalendarView.tsx +++ b/src/components/CalendarView.tsx @@ -3,7 +3,7 @@ */ import { useState, useMemo } from "react"; -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id, Doc } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; diff --git a/src/components/ChangeCategoryDialog.tsx b/src/components/ChangeCategoryDialog.tsx index c5a6e92..86239c7 100644 --- a/src/components/ChangeCategoryDialog.tsx +++ b/src/components/ChangeCategoryDialog.tsx @@ -3,7 +3,7 @@ */ import { useState } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -20,7 +20,7 @@ export function ChangeCategoryDialog({ currentCategoryId, onClose, }: ChangeCategoryDialogProps) { - const { did, legacyDid } = useCurrentUser(); + const { did } = useCurrentUser(); const updateCategory = useMutation(api.lists.updateListCategory); const [categoryId, setCategoryId] = useState | undefined>(currentCategoryId); const [saving, setSaving] = useState(false); @@ -34,8 +34,6 @@ export function ChangeCategoryDialog({ await updateCategory({ listId, categoryId, - userDid: did, - legacyDid: legacyDid ?? undefined, }); onClose(); } catch (err) { diff --git a/src/components/Comments.tsx b/src/components/Comments.tsx index 09b17b3..26340ff 100644 --- a/src/components/Comments.tsx +++ b/src/components/Comments.tsx @@ -4,7 +4,7 @@ */ import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; @@ -43,8 +43,7 @@ export function Comments({ itemId, userDid, legacyDid, canEdit }: CommentsProps) const comments = useQuery(api.comments.getItemComments, { itemId, - userDid, - legacyDid, + }); const addComment = useMutation(api.comments.addComment); @@ -60,8 +59,7 @@ export function Comments({ itemId, userDid, legacyDid, canEdit }: CommentsProps) try { await addComment({ itemId, - userDid, - legacyDid, + text: newComment.trim(), }); setNewComment(""); @@ -83,8 +81,7 @@ export function Comments({ itemId, userDid, legacyDid, canEdit }: CommentsProps) try { await deleteComment({ commentId, - userDid, - legacyDid, + }); haptic("success"); } catch (err) { diff --git a/src/components/CreateListModal.tsx b/src/components/CreateListModal.tsx index e167d0a..22519ff 100644 --- a/src/components/CreateListModal.tsx +++ b/src/components/CreateListModal.tsx @@ -5,7 +5,7 @@ */ import { useState, type FormEvent } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { useNavigate, Link } from "react-router-dom"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; @@ -28,7 +28,7 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps const navigate = useNavigate(); const { haptic } = useSettings(); const createList = useMutation(api.lists.createList); - const existingLists = useQuery(api.lists.getUserLists, did ? { userDid: did } : "skip"); + const existingLists = useQuery(api.lists.getUserLists, did ? {} : "skip"); const [name, setName] = useState(""); const [categoryId, setCategoryId] = useState | undefined>(undefined); @@ -64,7 +64,6 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, name: trimmedName, - ownerDid: did, categoryId, createdAt: Date.now(), }); diff --git a/src/components/DeleteListDialog.tsx b/src/components/DeleteListDialog.tsx index 8567f2e..de51513 100644 --- a/src/components/DeleteListDialog.tsx +++ b/src/components/DeleteListDialog.tsx @@ -3,7 +3,7 @@ */ import { useState } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useFocusTrap } from "../hooks/useFocusTrap"; @@ -16,7 +16,7 @@ interface DeleteListDialogProps { } export function DeleteListDialog({ list, onClose, onDeleted }: DeleteListDialogProps) { - const { did, legacyDid } = useCurrentUser(); + const { did } = useCurrentUser(); const deleteList = useMutation(api.lists.deleteList); const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(null); @@ -32,8 +32,6 @@ export function DeleteListDialog({ list, onClose, onDeleted }: DeleteListDialogP // Pass both current and legacy DID for migrated users await deleteList({ listId: list._id, - userDid: did, - legacyDid: legacyDid ?? undefined, }); onDeleted(); } catch (err) { diff --git a/src/components/ItemAttribution.tsx b/src/components/ItemAttribution.tsx index 31cbd0f..801aa68 100644 --- a/src/components/ItemAttribution.tsx +++ b/src/components/ItemAttribution.tsx @@ -2,7 +2,7 @@ * Component showing who added/checked an item and when. */ -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { formatRelativeTime } from "../lib/time"; diff --git a/src/components/ItemDetailsModal.tsx b/src/components/ItemDetailsModal.tsx index b8429ef..c3103a4 100644 --- a/src/components/ItemDetailsModal.tsx +++ b/src/components/ItemDetailsModal.tsx @@ -5,7 +5,7 @@ */ import { useState, useEffect, useMemo } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { useNavigate } from "react-router-dom"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -93,8 +93,7 @@ export function ItemDetailsModal({ const comments = useQuery(api.comments.getItemComments, { itemId: item._id, - userDid, - legacyDid, + }); const participantDids = useMemo(() => { diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx index 27d7140..a3165dd 100644 --- a/src/components/ListCard.tsx +++ b/src/components/ListCard.tsx @@ -6,7 +6,7 @@ */ import { memo } from "react"; -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { Link } from "react-router-dom"; import type { Doc } from "../../convex/_generated/dataModel"; import { api } from "../../convex/_generated/api"; diff --git a/src/components/ListItem.tsx b/src/components/ListItem.tsx index 949c7e3..2dc9f48 100644 --- a/src/components/ListItem.tsx +++ b/src/components/ListItem.tsx @@ -7,7 +7,7 @@ */ import { useState, useRef, lazy, Suspense, memo } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { ItemAttribution } from "./ItemAttribution"; @@ -119,7 +119,7 @@ export const ListItem = memo(function ListItem({ if (onUncheck) { await onUncheck(item._id, userDid, legacyDid); } else { - await uncheckItemMutation({ itemId: item._id, userDid, legacyDid }); + await uncheckItemMutation({ itemId: item._id, }); } } else { if (onCheck) { @@ -127,8 +127,7 @@ export const ListItem = memo(function ListItem({ } else { await checkItemMutation({ itemId: item._id, - checkedByDid: userDid, - legacyDid, + checkedAt: Date.now(), }); } diff --git a/src/components/NativePushRegistrar.tsx b/src/components/NativePushRegistrar.tsx index 3bb3c20..f11a6ba 100644 --- a/src/components/NativePushRegistrar.tsx +++ b/src/components/NativePushRegistrar.tsx @@ -10,7 +10,7 @@ */ import { useEffect, useRef } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { Capacitor } from "@capacitor/core"; @@ -33,7 +33,7 @@ export function NativePushRegistrar() { clearInterval(interval); registered.current = true; try { - await registerPushToken({ userDid: did, token, platform: "ios" }); + await registerPushToken({ token, platform: "ios" }); } catch (err) { console.error("[NativePushRegistrar] Failed to register token:", err); } diff --git a/src/components/NestedListItem.tsx b/src/components/NestedListItem.tsx index 87e12df..4a94f9f 100644 --- a/src/components/NestedListItem.tsx +++ b/src/components/NestedListItem.tsx @@ -4,7 +4,7 @@ */ import { useState, useCallback, useMemo } from "react"; -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { ListItem } from "./ListItem"; diff --git a/src/components/OnboardingFlow.tsx b/src/components/OnboardingFlow.tsx index f02b03c..8a7da27 100644 --- a/src/components/OnboardingFlow.tsx +++ b/src/components/OnboardingFlow.tsx @@ -6,7 +6,7 @@ import { useState, type FormEvent, type KeyboardEvent } from "react"; import { createPortal } from "react-dom"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { useNavigate } from "react-router-dom"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; @@ -41,7 +41,7 @@ type Step = "welcome" | "create-list" | "add-items" | "share"; const STEPS: Step[] = ["welcome", "create-list", "add-items", "share"]; export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { - const { did, legacyDid } = useCurrentUser(); + const { did } = useCurrentUser(); const navigate = useNavigate(); const { haptic } = useSettings(); @@ -100,7 +100,6 @@ export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, name, - ownerDid: did, createdAt: Date.now(), }); setListId(id); @@ -127,8 +126,6 @@ export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { await addItem({ listId, name, - createdByDid: did, - legacyDid: legacyDid ?? undefined, createdAt: Date.now(), }); setAddedItems((prev) => [...prev, name]); @@ -157,7 +154,7 @@ export function OnboardingFlow({ onComplete }: OnboardingFlowProps) { try { const webvhDid = buildListResourceDid(did, listId); - await publishList({ listId, webvhDid, publisherDid: did }); + await publishList({ listId, webvhDid }); const url = buildListResourceUrl(did, listId); setShareUrl(url); haptic("success"); diff --git a/src/components/ProvenanceInfo.tsx b/src/components/ProvenanceInfo.tsx index c53eddd..30222df 100644 --- a/src/components/ProvenanceInfo.tsx +++ b/src/components/ProvenanceInfo.tsx @@ -11,7 +11,7 @@ */ import { useEffect, useState } from "react"; -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { useNavigate } from "react-router-dom"; @@ -272,7 +272,6 @@ function CopyForProvenance({ list }: { list: Doc<"lists"> }) { assetDid: asset.assetDid, celEnvelope: asset.envelope, name, - ownerDid: did, createdAt: Date.now(), }); navigate(`/list/${listId}`); diff --git a/src/components/ReferralInvite.tsx b/src/components/ReferralInvite.tsx index fb63796..552e3b8 100644 --- a/src/components/ReferralInvite.tsx +++ b/src/components/ReferralInvite.tsx @@ -5,7 +5,7 @@ */ import { useState } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import type { Id } from "../../convex/_generated/dataModel"; diff --git a/src/components/ReferralRedeemer.tsx b/src/components/ReferralRedeemer.tsx index 03b893e..8143669 100644 --- a/src/components/ReferralRedeemer.tsx +++ b/src/components/ReferralRedeemer.tsx @@ -9,7 +9,7 @@ */ import { useEffect, useRef } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { REFERRAL_CODE_KEY } from "../pages/InviteLanding"; diff --git a/src/components/RenameListDialog.tsx b/src/components/RenameListDialog.tsx index fefa16a..1818e84 100644 --- a/src/components/RenameListDialog.tsx +++ b/src/components/RenameListDialog.tsx @@ -3,7 +3,7 @@ */ import { useState } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useFocusTrap } from "../hooks/useFocusTrap"; @@ -15,7 +15,7 @@ interface RenameListDialogProps { } export function RenameListDialog({ list, onClose }: RenameListDialogProps) { - const { did, legacyDid } = useCurrentUser(); + const { did } = useCurrentUser(); const renameList = useMutation(api.lists.renameList); const [name, setName] = useState(list.name); const [isRenaming, setIsRenaming] = useState(false); @@ -35,8 +35,6 @@ export function RenameListDialog({ list, onClose }: RenameListDialogProps) { await renameList({ listId: list._id, name: name.trim(), - userDid: did, - legacyDid: legacyDid ?? undefined, }); onClose(); } catch (err) { diff --git a/src/components/SaveAsTemplateModal.tsx b/src/components/SaveAsTemplateModal.tsx index 9020227..40e41b9 100644 --- a/src/components/SaveAsTemplateModal.tsx +++ b/src/components/SaveAsTemplateModal.tsx @@ -4,7 +4,7 @@ */ import { useState, type FormEvent } from "react"; -import { useMutation } from "convex/react"; +import { useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -57,7 +57,6 @@ export function SaveAsTemplateModal({ listId, listName, onClose, onSuccess }: Sa templateName: trimmedName, description: description.trim() || undefined, isPublic, - userDid: did, }); haptic('success'); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 0f8b258..785daa4 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -14,7 +14,7 @@ import { supportsPushNotifications } from '../lib/notifications'; import { biometrics } from '../lib/biometrics'; import { checkForUpdateAndApply, clearAllCachesAndReload } from '../lib/sw-registration'; import { Panel } from './ui/Panel'; -import { useMutation, useQuery } from 'convex/react'; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from '../../convex/_generated/api'; import { useToast } from '../hooks/useToast'; diff --git a/src/components/ShareModal.tsx b/src/components/ShareModal.tsx index 040e3d7..166346e 100644 --- a/src/components/ShareModal.tsx +++ b/src/components/ShareModal.tsx @@ -4,7 +4,7 @@ */ import { useState, useEffect } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -66,7 +66,6 @@ export function ShareModal({ list, onClose }: ShareModalProps) { await publishListMutation({ listId: list._id, webvhDid: listResourceDid, - publisherDid: did, }); trackListShared('webvh'); @@ -84,7 +83,7 @@ export function ShareModal({ list, onClose }: ShareModalProps) { if (!did) return; setError(null); try { - await unpublishListMutation({ listId: list._id, userDid: did }); + await unpublishListMutation({ listId: list._id }); haptic('success'); } catch (err) { setError(err instanceof Error ? err.message : "Failed to unpublish"); diff --git a/src/components/SharedListResource.tsx b/src/components/SharedListResource.tsx index eacf0ff..ca96927 100644 --- a/src/components/SharedListResource.tsx +++ b/src/components/SharedListResource.tsx @@ -8,7 +8,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { useParams, Link, useNavigate } from "react-router-dom"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import type { Id } from "../../convex/_generated/dataModel"; @@ -79,7 +79,7 @@ export function SharedListResource() { const isBookmarked = useQuery( api.publication.isBookmarked, - did && convexListId ? { listId: convexListId, userDid: did } : "skip" + did && convexListId ? { listId: convexListId } : "skip" ); const [favouritePending, setFavouritePending] = useState(false); @@ -113,10 +113,10 @@ export function SharedListResource() { setFavouritePending(true); try { if (isBookmarked) { - await unbookmarkMutation({ listId: convexListId, userDid: did }); + await unbookmarkMutation({ listId: convexListId }); setBookmarkPlanLimit(false); } else { - await bookmarkMutation({ listId: convexListId, userDid: did }); + await bookmarkMutation({ listId: convexListId }); } } catch (err) { const msg = err instanceof Error ? err.message : ""; diff --git a/src/components/SubItems.tsx b/src/components/SubItems.tsx index 6514e42..ece2279 100644 --- a/src/components/SubItems.tsx +++ b/src/components/SubItems.tsx @@ -4,7 +4,7 @@ */ import { useState, useRef } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id, Doc } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; @@ -28,8 +28,6 @@ interface SubItemsProps { export function SubItems({ parentId, listId, - userDid, - legacyDid, canEdit, }: SubItemsProps) { const { haptic } = useSettings(); @@ -67,8 +65,7 @@ export function SubItems({ await addItem({ listId, name: newItemName.trim(), - createdByDid: userDid, - legacyDid, + createdAt: Date.now(), parentId: parentId as Id<"items">, }); @@ -96,14 +93,12 @@ export function SubItems({ if (item.checked) { await uncheckItem({ itemId: item._id, - userDid, - legacyDid, + }); } else { await checkItem({ itemId: item._id, - checkedByDid: userDid, - legacyDid, + checkedAt: Date.now(), }); } @@ -119,8 +114,7 @@ export function SubItems({ try { await removeItem({ itemId, - userDid, - legacyDid, + }); } catch (err) { console.error("Failed to remove sub-item:", err); diff --git a/src/components/TagSelector.tsx b/src/components/TagSelector.tsx index dede93a..02d1f03 100644 --- a/src/components/TagSelector.tsx +++ b/src/components/TagSelector.tsx @@ -3,7 +3,7 @@ */ import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id, Doc } from "../../convex/_generated/dataModel"; import { useSettings } from "../hooks/useSettings"; @@ -33,8 +33,6 @@ export function TagSelector({ listId, itemId, selectedTagIds, - userDid, - legacyDid, canEdit, }: TagSelectorProps) { const { haptic } = useSettings(); @@ -60,16 +58,14 @@ export function TagSelector({ listId, name: newTagName.trim(), color: newTagColor, - userDid, - legacyDid, + }); // Automatically add the new tag to the current item await addTagToItem({ itemId, tagId: newTagId, - userDid, - legacyDid, + }); haptic("success"); @@ -92,15 +88,13 @@ export function TagSelector({ await removeTagFromItem({ itemId, tagId, - userDid, - legacyDid, + }); } else { await addTagToItem({ itemId, tagId, - userDid, - legacyDid, + }); } } catch (err) { diff --git a/src/components/TemplatePickerModal.tsx b/src/components/TemplatePickerModal.tsx index efca049..a7a047f 100644 --- a/src/components/TemplatePickerModal.tsx +++ b/src/components/TemplatePickerModal.tsx @@ -5,7 +5,7 @@ */ import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { useNavigate } from "react-router-dom"; import { api } from "../../convex/_generated/api"; import type { Id } from "../../convex/_generated/dataModel"; @@ -34,7 +34,7 @@ export function TemplatePickerModal({ onClose, onCreateBlank }: TemplatePickerMo // Fetch user's saved templates const userTemplates = useQuery( api.templates.getUserTemplates, - did ? { userDid: did } : "skip" + did ? {} : "skip" ); // Mutations @@ -63,7 +63,6 @@ export function TemplatePickerModal({ onClose, onCreateBlank }: TemplatePickerMo assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, name: listName, - ownerDid: did, createdAt: Date.now(), }); @@ -73,7 +72,6 @@ export function TemplatePickerModal({ onClose, onCreateBlank }: TemplatePickerMo await addItem({ listId, name: item.name, - createdByDid: did, createdAt: now, priority: item.priority, description: item.description, @@ -102,7 +100,6 @@ export function TemplatePickerModal({ onClose, onCreateBlank }: TemplatePickerMo const listId = await createListFromTemplate({ templateId, listName: templateName, - userDid: did, assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, }); diff --git a/src/components/lists/CategoryManager.tsx b/src/components/lists/CategoryManager.tsx index f78b6a4..e078002 100644 --- a/src/components/lists/CategoryManager.tsx +++ b/src/components/lists/CategoryManager.tsx @@ -6,7 +6,7 @@ */ import { useState } from "react"; -import { useQuery } from "convex/react"; +import { useQuery } from "../../lib/authenticatedConvex"; import { api } from "../../../convex/_generated/api"; import type { Doc, Id } from "../../../convex/_generated/dataModel"; import { useCategories } from "../../hooks/useCategories"; @@ -19,7 +19,7 @@ interface CategoryManagerProps { } export function CategoryManager({ onClose }: CategoryManagerProps) { - const { did, legacyDid } = useCurrentUser(); + const { did } = useCurrentUser(); const { categories, createCategory, renameCategory, deleteCategory } = useCategories(); const dialogRef = useFocusTrap({ onEscape: onClose }); @@ -34,7 +34,7 @@ export function CategoryManager({ onClose }: CategoryManagerProps) { // Get lists to count per category const lists = useQuery( api.lists.getUserLists, - did ? { userDid: did, legacyDid: legacyDid ?? undefined } : "skip" + did ? {} : "skip" ); const getListCountForCategory = (categoryId: Id<"categories">) => { diff --git a/src/components/publish/PublishModal.tsx b/src/components/publish/PublishModal.tsx index 9db7720..80a081f 100644 --- a/src/components/publish/PublishModal.tsx +++ b/src/components/publish/PublishModal.tsx @@ -7,7 +7,7 @@ */ import { useState } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../../lib/authenticatedConvex"; import { api } from "../../../convex/_generated/api"; import type { Doc } from "../../../convex/_generated/dataModel"; import { useCurrentUser } from "../../hooks/useCurrentUser"; @@ -90,7 +90,6 @@ export function PublishModal({ list, onClose }: PublishModalProps) { await publishListMutation({ listId: list._id, webvhDid: listResourceDid, - publisherDid: did, celEnvelope, }); @@ -119,7 +118,6 @@ export function PublishModal({ list, onClose }: PublishModalProps) { try { await unpublishListMutation({ listId: list._id, - userDid: did, }); haptic('success'); } catch (err) { diff --git a/src/components/sites/ConnectDomainModal.tsx b/src/components/sites/ConnectDomainModal.tsx index 0f0a571..324ac10 100644 --- a/src/components/sites/ConnectDomainModal.tsx +++ b/src/components/sites/ConnectDomainModal.tsx @@ -1,5 +1,5 @@ import { useState, type FormEvent } from "react"; -import { useAction, useQuery } from "convex/react"; +import { useAction, useQuery } from "../../lib/authenticatedConvex"; import { api } from "../../../convex/_generated/api"; import type { Id } from "../../../convex/_generated/dataModel"; @@ -33,7 +33,7 @@ export function ConnectDomainModal({ "boop.ad"; const requestCustomHostname = useAction(api.siteActions.requestCustomHostname); - const site = useQuery(api.sites.getSite, { siteId, ownerDid }); + const site = useQuery(api.sites.getSite, { siteId }); const customRow = site?.hostnames.find((h) => h.kind === "custom"); const phase = derivePhase(customRow, submitting); diff --git a/src/components/sites/SiteAssets.tsx b/src/components/sites/SiteAssets.tsx index 7a48e0f..bd9c79b 100644 --- a/src/components/sites/SiteAssets.tsx +++ b/src/components/sites/SiteAssets.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from "react"; -import { useAction, useMutation, useQuery } from "convex/react"; +import { useAction, useMutation, useQuery } from "../../lib/authenticatedConvex"; import { api } from "../../../convex/_generated/api"; import type { Doc, Id } from "../../../convex/_generated/dataModel"; import { useToast } from "../../hooks/useToast"; @@ -54,8 +54,8 @@ function assetIcon(contentType: string): string { return "📁"; } -export function SiteAssets({ siteId, ownerDid, hostname }: Props) { - const assets = useQuery(api.siteAssets.listSiteAssets, { ownerDid, siteId }); +export function SiteAssets({ siteId, hostname }: Props) { + const assets = useQuery(api.siteAssets.listSiteAssets, { siteId }); const generateUploadUrl = useAction(api.siteAssets.generateSiteAssetUploadUrl); const addAsset = useMutation(api.siteAssets.addSiteAsset); const removeAsset = useAction(api.siteAssets.removeSiteAsset); @@ -81,7 +81,6 @@ export function SiteAssets({ siteId, ownerDid, hostname }: Props) { const sha256 = await sha256Hex(buffer); const contentType = file.type || "application/octet-stream"; const { uploadUrl, bucketKey, fileName } = await generateUploadUrl({ - ownerDid, siteId, fileName: file.name, contentType, @@ -96,7 +95,6 @@ export function SiteAssets({ siteId, ownerDid, hostname }: Props) { throw new Error(`Upload failed (${putRes.status})`); } await addAsset({ - ownerDid, siteId, fileName, bucketKey, @@ -119,7 +117,7 @@ export function SiteAssets({ siteId, ownerDid, hostname }: Props) { const handleRemove = async (asset: Doc<"siteAssets">) => { if (!confirm(`Delete ${asset.fileName}?`)) return; try { - await removeAsset({ ownerDid, assetId: asset._id }); + await removeAsset({ assetId: asset._id }); addToast(`Deleted ${asset.fileName}`); } catch (err) { addToast( diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 2e111d5..ba08192 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -1,3 +1,6 @@ +import { onSessionExpiry } from "../lib/sessionExpiry"; +import { useConvex } from "convex/react"; +import { api } from "../../convex/_generated/api"; /** * Auth context and hook for server-side authentication. * @@ -109,6 +112,7 @@ const AUTH_STORAGE_KEY = "lisa-auth-state"; * Wraps the app to provide authentication state via useAuth hook. */ export function AuthProvider({ children }: AuthProviderProps) { + const convex = useConvex(); const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); // JWT token for API authentication @@ -123,6 +127,8 @@ export function AuthProvider({ children }: AuthProviderProps) { // Track mounted state to prevent setState after unmount const isMountedRef = useRef(true); + // Serialize restore/login/logout, including the cookie response from logout. + const authTransitionRef = useRef(true); /** * Restore session from localStorage. @@ -158,6 +164,7 @@ export function AuthProvider({ children }: AuthProviderProps) { } // Restore auth state + await convex.mutation(api.actorSession.establish, { authToken: parsed.token }); setUser(parsed.user); setToken(parsed.token); await storageAdapter.set(JWT_STORAGE_KEY, parsed.token); @@ -208,8 +215,12 @@ export function AuthProvider({ children }: AuthProviderProps) { } } catch (err) { console.error("[useAuth] Error restoring session:", err); + setUser(null); + setToken(null); await storageAdapter.remove(AUTH_STORAGE_KEY); + await storageAdapter.remove(JWT_STORAGE_KEY); } finally { + authTransitionRef.current = false; if (isMountedRef.current) setIsLoading(false); } }; @@ -219,7 +230,7 @@ export function AuthProvider({ children }: AuthProviderProps) { return () => { isMountedRef.current = false; }; - }, []); + }, [convex]); /** * Start OTP flow by sending verification code to email. @@ -230,6 +241,8 @@ export function AuthProvider({ children }: AuthProviderProps) { */ const startOtp = useCallback( async (email: string, legacyDid?: string) => { + if (authTransitionRef.current) throw new Error("Authentication is already in progress"); + authTransitionRef.current = true; setIsLoading(true); try { console.log("[useAuth] Sending OTP to:", email); @@ -259,6 +272,7 @@ export function AuthProvider({ children }: AuthProviderProps) { console.error("[useAuth] Failed to start OTP:", err); throw err; } finally { + authTransitionRef.current = false; setIsLoading(false); } }, @@ -276,6 +290,8 @@ export function AuthProvider({ children }: AuthProviderProps) { throw new Error("OTP flow not started. Call startOtp first."); } + if (authTransitionRef.current) throw new Error("Authentication is already in progress"); + authTransitionRef.current = true; setIsLoading(true); try { console.log("[useAuth] Verifying OTP via server..."); @@ -301,9 +317,7 @@ export function AuthProvider({ children }: AuthProviderProps) { const { token: jwtToken, user: serverUser } = await response.json(); console.log("[useAuth] OTP verified, got JWT for:", serverUser.email); - // Store JWT - await storageAdapter.set(JWT_STORAGE_KEY, jwtToken); - setToken(jwtToken); + await convex.mutation(api.actorSession.establish, { authToken: jwtToken }); // Start with server-provided DID. If it is not already did:webvh, // create did:webvh client-side and persist it. @@ -346,6 +360,8 @@ export function AuthProvider({ children }: AuthProviderProps) { token: jwtToken, }; await storageAdapter.set(AUTH_STORAGE_KEY, JSON.stringify(persistedState)); + await storageAdapter.set(JWT_STORAGE_KEY, jwtToken); + setToken(jwtToken); // Update state setUser(authUser); @@ -357,12 +373,17 @@ export function AuthProvider({ children }: AuthProviderProps) { console.log("[useAuth] Authentication complete, DID:", userDid); } catch (err) { console.error("[useAuth] Failed to verify OTP:", err); + setUser(null); + setToken(null); + await storageAdapter.remove(AUTH_STORAGE_KEY); + await storageAdapter.remove(JWT_STORAGE_KEY); throw err; } finally { + authTransitionRef.current = false; setIsLoading(false); } }, - [otpFlowState] + [otpFlowState, convex] ); /** @@ -370,28 +391,45 @@ export function AuthProvider({ children }: AuthProviderProps) { * Calls /auth/logout HTTP endpoint to clear the auth cookie. */ const logout = useCallback(async () => { - console.log("[useAuth] Logging out"); + if (authTransitionRef.current) throw new Error("Authentication is already in progress"); + authTransitionRef.current = true; + setIsLoading(true); + setToken(null); + setUser(null); + setOtpFlowState({ sessionId: null, email: null, legacyDid: null }); + resetAnalytics(); - // Call logout endpoint to clear httpOnly cookie try { + await storageAdapter.remove(AUTH_STORAGE_KEY); + await storageAdapter.remove(JWT_STORAGE_KEY); const httpUrl = getConvexHttpUrl(); await fetch(`${httpUrl}/auth/logout`, { method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, credentials: "include", }); } catch (err) { console.error("[useAuth] Logout endpoint failed:", err); - // Continue with local cleanup even if server call fails + } finally { + authTransitionRef.current = false; + setIsLoading(false); } + }, [token]); - // Clear local state and analytics identity - resetAnalytics(); - setUser(null); - setToken(null); - setOtpFlowState({ sessionId: null, email: null, legacyDid: null }); - await storageAdapter.remove(AUTH_STORAGE_KEY); - await storageAdapter.remove(JWT_STORAGE_KEY); - }, []); + // Drop subscriptions promptly at expiry; the server independently expires the + // database session so cached private queries are invalidated on every device. + useEffect(() => { + if (!token) return; + let expiresAt: number; + try { expiresAt = JSON.parse(atob(token.split(".")[1])).exp * 1000; } + catch { return; } + return onSessionExpiry(expiresAt, () => { + setToken(null); + setUser(null); + void storageAdapter.remove(AUTH_STORAGE_KEY); + void storageAdapter.remove(JWT_STORAGE_KEY); + }); + }, [token]); // Repairs identities minted on a domain we no longer serve. Temporary. useDidDomainRemint(user, token); diff --git a/src/hooks/useBilling.tsx b/src/hooks/useBilling.tsx index f110ac4..01f83a7 100644 --- a/src/hooks/useBilling.tsx +++ b/src/hooks/useBilling.tsx @@ -2,7 +2,7 @@ * Hook for accessing and managing the current user's billing subscription. */ -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "./useCurrentUser"; diff --git a/src/hooks/useCategories.tsx b/src/hooks/useCategories.tsx index 2c64fe2..1c8c151 100644 --- a/src/hooks/useCategories.tsx +++ b/src/hooks/useCategories.tsx @@ -4,7 +4,7 @@ * Provides access to the user's categories and mutations for CRUD operations. */ -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "./useCurrentUser"; import type { Id } from "../../convex/_generated/dataModel"; @@ -14,7 +14,7 @@ export function useCategories() { const categories = useQuery( api.categories.getUserCategories, - did ? { userDid: did } : "skip" + did ? {} : "skip" ); const createCategoryMutation = useMutation(api.categories.createCategory); @@ -25,7 +25,6 @@ export function useCategories() { const createCategory = async (name: string) => { if (!did) throw new Error("Not authenticated"); return createCategoryMutation({ - userDid: did, name, createdAt: Date.now(), }); @@ -35,7 +34,6 @@ export function useCategories() { if (!did) throw new Error("Not authenticated"); return renameCategoryMutation({ categoryId, - userDid: did, name, }); }; @@ -44,21 +42,17 @@ export function useCategories() { if (!did) throw new Error("Not authenticated"); return deleteCategoryMutation({ categoryId, - userDid: did, }); }; const setListCategory = async ( listId: Id<"lists">, - categoryId: Id<"categories"> | undefined, - legacyDid?: string - ) => { + categoryId: Id<"categories"> | undefined ) => { if (!did) throw new Error("Not authenticated"); return setListCategoryMutation({ listId, categoryId, - userDid: did, - legacyDid, + }); }; diff --git a/src/hooks/useCurrentUser.tsx b/src/hooks/useCurrentUser.tsx index c7ee226..a6c10ee 100644 --- a/src/hooks/useCurrentUser.tsx +++ b/src/hooks/useCurrentUser.tsx @@ -5,7 +5,7 @@ * All signing and DID operations are now handled server-side. */ -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useAuth } from "./useAuth"; diff --git a/src/hooks/useNotifications.tsx b/src/hooks/useNotifications.tsx index 96dc476..55fc2a5 100644 --- a/src/hooks/useNotifications.tsx +++ b/src/hooks/useNotifications.tsx @@ -4,7 +4,7 @@ */ import { useState, useEffect, useCallback, useRef } from 'react'; -import { useMutation, useQuery } from 'convex/react'; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from '../../convex/_generated/api'; import { supportsPushNotifications, @@ -62,7 +62,7 @@ export function useNotifications({ userDid }: UseNotificationsOptions) { // Check if user has server-side subscription const hasServerSubscription = useQuery( api.notifications.hasSubscription, - userDid ? { userDid } : 'skip' + userDid ? {} : 'skip' ); // Check current subscription status on mount @@ -100,7 +100,6 @@ export function useNotifications({ userDid }: UseNotificationsOptions) { // Save to Convex (both legacy and new pushTokens table) const json = subscription.toJSON(); await saveSubscription({ - userDid, endpoint: json.endpoint!, keys: { p256dh: json.keys!.p256dh, @@ -108,7 +107,6 @@ export function useNotifications({ userDid }: UseNotificationsOptions) { }, }); await registerPushToken({ - userDid, token: json.endpoint!, platform: 'web', webPushKeys: { @@ -148,11 +146,11 @@ export function useNotifications({ userDid }: UseNotificationsOptions) { await unsubscribeFromPush(); await removeSubscription({ endpoint: subscription.endpoint, - userDid, + }); await unregisterPushToken({ token: subscription.endpoint, - userDid, + }); } diff --git a/src/hooks/useOffline.tsx b/src/hooks/useOffline.tsx index 35eac71..1a6d5f8 100644 --- a/src/hooks/useOffline.tsx +++ b/src/hooks/useOffline.tsx @@ -7,6 +7,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useConvex } from "convex/react"; + import { syncManager, type SyncStatus } from "../lib/sync"; import { getQueuedMutations } from "../lib/offline"; import { getNetworkStatus, onNetworkChange } from "../lib/network"; diff --git a/src/hooks/useOptimisticItems.tsx b/src/hooks/useOptimisticItems.tsx index c630c01..c757174 100644 --- a/src/hooks/useOptimisticItems.tsx +++ b/src/hooks/useOptimisticItems.tsx @@ -6,7 +6,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Doc, Id } from "../../convex/_generated/dataModel"; import { useOffline } from "./useOffline"; @@ -233,7 +233,7 @@ export function useOptimisticItems(listId: Id<"lists">) { if (isOnline) { try { - await checkItemMutation({ itemId, checkedByDid, legacyDid, checkedAt }); + await checkItemMutation({ itemId, checkedAt }); } catch (err) { // Rollback: remove the optimistic state setOptimisticItems((prev) => prev.filter((i) => i._id !== itemId)); @@ -295,7 +295,7 @@ export function useOptimisticItems(listId: Id<"lists">) { if (isOnline) { try { - await uncheckItemMutation({ itemId, userDid, legacyDid }); + await uncheckItemMutation({ itemId, }); } catch (err) { // Rollback: remove the optimistic state setOptimisticItems((prev) => prev.filter((i) => i._id !== itemId)); @@ -321,7 +321,7 @@ export function useOptimisticItems(listId: Id<"lists">) { const reorderItems = useCallback( async (itemIds: Id<"items">[], userDid: string, legacyDid?: string) => { if (isOnline) { - await reorderItemsMutation({ listId, itemIds, userDid, legacyDid }); + await reorderItemsMutation({ listId, itemIds, }); } else { await queueMutation({ type: "reorderItem", @@ -499,7 +499,7 @@ export function useOptimisticItems(listId: Id<"lists">) { if (isOnline) { try { - await removeItemMutation({ itemId, userDid, legacyDid }); + await removeItemMutation({ itemId, }); } catch (err) { // Rollback: remove the marker setOptimisticItems((prev) => prev.filter((i) => i._id !== itemId)); diff --git a/src/lib/authenticatedConvex.ts b/src/lib/authenticatedConvex.ts new file mode 100644 index 0000000..481dc62 --- /dev/null +++ b/src/lib/authenticatedConvex.ts @@ -0,0 +1,45 @@ +import { identityAssertionFields } from "../../convex/lib/clientAuth"; +/** One session adapter for browser and Capacitor reactive calls. */ +import { useCallback, useMemo } from "react"; +import { useQuery as convexQuery, useMutation as convexMutation, useAction as convexAction } from "convex/react"; +import { getFunctionName, type FunctionReference } from "convex/server"; +import { useAuth } from "../hooks/useAuth"; +import { authenticatedOperations } from "./authenticatedOperations"; + +const assertionFields = new Set(identityAssertionFields); +export function requiresSession(ref: FunctionReference<"query" | "mutation" | "action">): boolean { + return authenticatedOperations.has(getFunctionName(ref)); +} +export function sessionArgs>(ref: FunctionReference<"query" | "mutation" | "action">, args: T, token: string | null): T & { authToken?: string } { + if (!requiresSession(ref)) return args; + if (!token) throw new Error("Sign in to continue"); + const clean = Object.fromEntries(Object.entries(args).filter(([key]) => !assertionFields.has(key))); + return { ...clean, authToken: token } as T & { authToken: string }; +} + +// The adapters preserve Convex's function-reference inference. The casts are +// confined here because its generic rest tuple does not express injected args. +export const useQuery: typeof convexQuery = (ref, ...args) => { + const { token } = useAuth(); + const input = args[0]; + const protectedQuery = requiresSession(ref); + const next = input === "skip" || (protectedQuery && !token) + ? "skip" : protectedQuery ? sessionArgs(ref, input ?? {}, token) : input ?? {}; + return convexQuery(ref, next as never); +}; +export const useMutation: typeof convexMutation = (ref) => { + const { token } = useAuth(); + const mutation = convexMutation(ref); + return useMemo(() => { + const wrap = (base: typeof mutation): typeof mutation => Object.assign( + (args: Record = {}) => base(...[sessionArgs(ref, args, token)] as never), + { withOptimisticUpdate: (update: Parameters[0]) => wrap(base.withOptimisticUpdate(update)) }, + ) as typeof mutation; + return wrap(mutation); + }, [ref, mutation, token]); +}; +export const useAction: typeof convexAction = (ref) => { + const { token } = useAuth(); + const action = convexAction(ref); + return useCallback((args: Record = {}) => action(...[sessionArgs(ref, args, token)] as never), [ref, action, token]) as typeof action; +}; diff --git a/src/lib/authenticatedOperations.ts b/src/lib/authenticatedOperations.ts new file mode 100644 index 0000000..1f1f889 --- /dev/null +++ b/src/lib/authenticatedOperations.ts @@ -0,0 +1,132 @@ +// Generated by scripts/generate-auth-client.mjs. Function references are type checked. +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, + api.attachments.addAttachment, + api.attachments.generateUploadUrl, + api.attachments.getAttachmentUrls, + api.attachments.removeAttachment, + api.auth.getUserByEmail, + api.auth.getUserByTurnkeyId, + api.auth.upsertUser, + api.billing.getUserPlan, + api.billing.getUserSubscription, + api.bitcoinAnchors.anchorListState, + api.bitcoinAnchors.createAnchorRecord, + api.bitcoinAnchors.getAnchor, + api.bitcoinAnchors.getAnchorByTxid, + api.bitcoinAnchors.getItemAnchors, + api.bitcoinAnchors.getLatestAnchor, + api.bitcoinAnchors.getListAnchors, + api.bitcoinAnchors.getListDataForAnchor, + api.bitcoinAnchors.getPendingAnchors, + api.bitcoinAnchors.updateAnchorStatus, + api.bitcoinAnchors.verifyAnchorState, + api.categories.createCategory, + api.categories.deleteCategory, + api.categories.getUserCategories, + api.categories.renameCategory, + api.categories.reorderCategory, + api.categories.setListCategory, + api.comments.addComment, + api.comments.deleteComment, + api.comments.getCommentCount, + api.comments.getItemComments, + api.didCreation.createListDID, + api.didResources.checkSharedItem, + api.didResources.uncheckSharedItem, + api.feedback.submit, + api.itemCategories.addListCategory, + api.itemCategories.deleteListCategory, + api.itemCategories.moveListCategory, + api.itemCategories.renameListCategory, + api.itemCategories.setListCategoryEmoji, + api.items.addItem, + api.items.batchCheckItems, + api.items.batchDeleteItems, + api.items.batchUncheckItems, + api.items.checkItem, + api.items.demoteItem, + api.items.getHighPriorityItems, + api.items.getItemForEditor, + api.items.getItemForSync, + api.items.getItemsWithDueDates, + api.items.getListItems, + api.items.getSubItems, + api.items.promoteItem, + api.items.removeItem, + api.items.reorderItems, + api.items.setAisleOverride, + api.items.uncheckItem, + api.items.updateItem, + api.lists.addCustomAisle, + api.lists.copyList, + api.lists.createList, + api.lists.deleteList, + api.lists.getLegacyListIds, + api.lists.getList, + api.lists.getListEnvelope, + api.lists.getUserLists, + api.lists.removeCustomAisle, + api.lists.renameList, + api.lists.updateItemViewMode, + api.lists.updateListCategory, + api.notificationActions.sendListNotification, + api.notificationActions.sendPushNotification, + api.notifications.getUserSubscriptions, + api.notifications.hasSubscription, + api.notifications.registerPushToken, + api.notifications.removeSubscription, + api.notifications.saveSubscription, + api.notifications.unregisterPushToken, + api.originals.listOwnedOriginals, + api.presence.getListPresence, + api.presence.heartbeat, + api.presence.markOffline, + api.publication.bookmarkList, + api.publication.getPublicationStatus, + api.publication.getUserBookmarkIds, + api.publication.isBookmarked, + api.publication.publishList, + api.publication.unbookmarkList, + api.publication.unpublishList, + api.referrals.getOrCreateReferralCode, + api.referrals.getReferralCode, + api.referrals.getReferralProStatus, + api.referrals.getReferralStats, + api.referrals.redeemReferral, + api.siteActions.createSite, + api.siteActions.createSiteFromUpload, + api.siteActions.migrateVerifiedCustomDomain, + api.siteActions.replaceSiteFile, + api.siteActions.requestCustomHostname, + api.siteActions.retryCustomHostname, + api.siteAssets.addSiteAsset, + api.siteAssets.generateSiteAssetUploadUrl, + api.siteAssets.listSiteAssets, + api.siteAssets.removeSiteAsset, + api.sites.generateSiteUploadUrl, + api.sites.getSite, + api.sites.getSitePreviewUrl, + api.sites.listSites, + api.tags.addTagToItem, + api.tags.createTag, + api.tags.deleteTag, + api.tags.getListTags, + api.tags.removeTagFromItem, + api.tags.updateTag, + api.templates.createFromList, + api.templates.createListFromTemplate, + api.templates.createTemplate, + api.templates.deleteTemplate, + api.templates.getTemplate, + api.templates.getUserTemplates, + api.templates.updateTemplate, + api.users.deleteUserData, + api.users.getUserStats, +].map(getFunctionName)); diff --git a/src/lib/sessionExpiry.ts b/src/lib/sessionExpiry.ts new file mode 100644 index 0000000..38863b4 --- /dev/null +++ b/src/lib/sessionExpiry.ts @@ -0,0 +1,11 @@ +/** Schedule long-lived sessions without overflowing the browser timer limit. */ +export function onSessionExpiry(expiresAt: number, expire: () => void): () => void { + let timer: ReturnType; + const check = () => { + const remaining = expiresAt - Date.now(); + if (remaining > 0) timer = setTimeout(check, Math.min(remaining, 2_147_483_647)); + else expire(); + }; + check(); + return () => clearTimeout(timer); +} diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 72f20e0..48876fb 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -1,3 +1,4 @@ +import { storageAdapter } from "./storageAdapter"; /** * Sync Manager for offline mutation synchronization (Phase 5.3) * @@ -268,7 +269,7 @@ export class SyncManager { const itemId = payload.itemId; try { - const serverItem = await convex.query(api.items.getItemForSync, { itemId }); + const serverItem = await convex.query(api.items.getItemForSync, { itemId, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); if (!serverItem) { // Item was deleted remotely @@ -304,73 +305,73 @@ export class SyncManager { switch (mutation.type) { case "addItem": { const payload = mutation.payload as AddItemPayload; - await convex.mutation(api.items.addItem, payload); + await convex.mutation(api.items.addItem, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "checkItem": { const payload = mutation.payload as CheckItemPayload; - await convex.mutation(api.items.checkItem, payload); + await convex.mutation(api.items.checkItem, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "uncheckItem": { const payload = mutation.payload as UncheckItemPayload; - await convex.mutation(api.items.uncheckItem, payload); + await convex.mutation(api.items.uncheckItem, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "reorderItem": { const payload = mutation.payload as ReorderItemPayload; - await convex.mutation(api.items.reorderItems, payload); + await convex.mutation(api.items.reorderItems, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "updateItem": { const payload = mutation.payload as UpdateItemPayload; - await convex.mutation(api.items.updateItem, payload); + await convex.mutation(api.items.updateItem, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "removeItem": { const payload = mutation.payload as RemoveItemPayload; - await convex.mutation(api.items.removeItem, payload); + await convex.mutation(api.items.removeItem, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "batchCheckItems": { const payload = mutation.payload as BatchCheckItemsPayload; - await convex.mutation(api.items.batchCheckItems, payload); + await convex.mutation(api.items.batchCheckItems, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "batchUncheckItems": { const payload = mutation.payload as BatchUncheckItemsPayload; - await convex.mutation(api.items.batchUncheckItems, payload); + await convex.mutation(api.items.batchUncheckItems, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "batchDeleteItems": { const payload = mutation.payload as BatchDeleteItemsPayload; - await convex.mutation(api.items.batchDeleteItems, payload); + await convex.mutation(api.items.batchDeleteItems, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "createList": { const payload = mutation.payload as CreateListPayload; - await convex.mutation(api.lists.createList, payload); + await convex.mutation(api.lists.createList, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "renameList": { const payload = mutation.payload as RenameListPayload; - await convex.mutation(api.lists.renameList, payload); + await convex.mutation(api.lists.renameList, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } case "deleteList": { const payload = mutation.payload as DeleteListPayload; - await convex.mutation(api.lists.deleteList, payload); + await convex.mutation(api.lists.deleteList, { ...payload, authToken: await storageAdapter.get("lisa-jwt-token") ?? undefined }); break; } diff --git a/src/main.tsx b/src/main.tsx index 1282c57..4a199c3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,8 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' -import { ConvexProvider, ConvexReactClient } from 'convex/react' +import { ConvexProvider, ConvexReactClient } from "convex/react"; + import { Capacitor } from '@capacitor/core' import { AuthProvider } from './hooks/useAuth' import { ToastProvider } from './hooks/useToast' diff --git a/src/pages/Explorer.tsx b/src/pages/Explorer.tsx index 92378a9..026456b 100644 --- a/src/pages/Explorer.tsx +++ b/src/pages/Explorer.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useQuery } from "convex/react"; +import { useQuery } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import { useCurrentUser } from "../hooks/useCurrentUser"; import { useOffline } from "../hooks/useOffline"; @@ -29,7 +29,7 @@ export function Explorer() { const data = useQuery( api.originals.listOwnedOriginals, - did ? { ownerDid: did } : "skip", + did ? {} : "skip", ); const filteredSorted = useMemo(() => { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 6f6c839..3dc112d 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -7,7 +7,7 @@ */ import { useState, useMemo, useEffect, useRef } from "react"; -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { useSearchParams, Link } from "react-router-dom"; import { api } from "../../convex/_generated/api"; import type { Doc, Id } from "../../convex/_generated/dataModel"; @@ -70,10 +70,9 @@ export function Home() { // Query lists for current DID, including legacyDid for backwards compat const serverLists = useQuery( api.lists.getUserLists, - did ? { userDid: did, legacyDid: legacyDid ?? undefined } : "skip" + did ? {} : "skip" ); - // Cache lists when online and data is available useEffect(() => { if (serverLists && isOnline) { @@ -120,7 +119,6 @@ export function Home() { assetDid: listAsset.assetDid, celEnvelope: listAsset.envelope, name: "Getting Started", - ownerDid: did, createdAt: Date.now(), }); const demoItems = [ @@ -132,8 +130,6 @@ export function Home() { await addItem({ listId, name, - createdByDid: did, - legacyDid: legacyDid ?? undefined, createdAt: Date.now(), }); } @@ -200,7 +196,7 @@ export function Home() { // Get bookmarked list IDs for favourites section const bookmarkedIds = useQuery( api.publication.getUserBookmarkIds, - did ? { userDid: did } : "skip" + did ? {} : "skip" ); const bookmarkedIdSet = useMemo(() => new Set(bookmarkedIds ?? []), [bookmarkedIds]); diff --git a/src/pages/ListView.tsx b/src/pages/ListView.tsx index 38c1378..3397804 100644 --- a/src/pages/ListView.tsx +++ b/src/pages/ListView.tsx @@ -8,7 +8,7 @@ import React, { useState, useCallback, useRef, lazy, Suspense, useEffect, useMemo } from "react"; import { useParams, useNavigate, useLocation, Link } from "react-router-dom"; -import { useQuery, useMutation } from "convex/react"; +import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Id, Doc } from "../../convex/_generated/dataModel"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -270,7 +270,7 @@ export function ListView() { const unbookmarkMutation = useMutation(api.publication.unbookmarkList); const isBookmarked = useQuery( api.publication.isBookmarked, - did ? { listId, userDid: did } : "skip" + did ? { listId } : "skip" ); const [favouritePending, setFavouritePending] = useState(false); @@ -280,9 +280,9 @@ export function ListView() { haptic("light"); try { if (isBookmarked) { - await unbookmarkMutation({ listId, userDid: did }); + await unbookmarkMutation({ listId }); } else { - await bookmarkMutation({ listId, userDid: did }); + await bookmarkMutation({ listId }); } } catch (err) { console.error("Failed to toggle favourite:", err); @@ -394,8 +394,6 @@ export function ListView() { haptic('medium'); await updateItemMutation({ itemId: draggedId as Id<"items">, - userDid: did, - legacyDid: legacyDid ?? undefined, groceryAisle: targetAisleId, }); }, [did, legacyDid, sortedItems, haptic, updateItemMutation]); @@ -558,7 +556,7 @@ export function ListView() { const item = sortedItems[focusedIndex]; if (item) { haptic('medium'); - removeItemMutation({ itemId: item._id, userDid: did }); + removeItemMutation({ itemId: item._id }); // Move focus up if at end of list if (focusedIndex >= sortedItems.length - 1) { setFocusedIndex(Math.max(0, sortedItems.length - 2)); @@ -574,7 +572,7 @@ export function ListView() { const item = sortedItems[focusedIndex]; if (item) { haptic('medium'); - removeItemMutation({ itemId: item._id, userDid: did }); + removeItemMutation({ itemId: item._id }); // Move focus up if at end of list if (focusedIndex >= sortedItems.length - 1) { setFocusedIndex(Math.max(0, sortedItems.length - 2)); @@ -590,7 +588,7 @@ export function ListView() { const item = sortedItems[focusedIndex]; if (item) { haptic('medium'); - removeItemMutation({ itemId: item._id, userDid: did }); + removeItemMutation({ itemId: item._id }); // Move focus up if at end of list if (focusedIndex >= sortedItems.length - 1) { setFocusedIndex(Math.max(0, sortedItems.length - 2)); @@ -617,7 +615,6 @@ export function ListView() { shortcuts, }); - // Reset focus when items change significantly useEffect(() => { if (focusedIndex !== null && focusedIndex >= sortedItems.length) { @@ -809,7 +806,7 @@ export function ListView() { setViewMode("list"); if (itemViewMode !== "alphabetical") { setLocalItemViewMode("alphabetical"); - updateItemViewModeMutation({ listId, itemViewMode: "alphabetical", userDid: did }); + updateItemViewModeMutation({ listId, itemViewMode: "alphabetical" }); } }} className={`p-1.5 sm:px-2.5 sm:py-1.5 rounded-full transition-all active:scale-95 ${ @@ -830,7 +827,7 @@ export function ListView() { setViewMode("list"); if (itemViewMode !== "categorized") { setLocalItemViewMode("categorized"); - updateItemViewModeMutation({ listId, itemViewMode: "categorized", userDid: did }); + updateItemViewModeMutation({ listId, itemViewMode: "categorized" }); } }} className={`p-1.5 sm:px-2.5 sm:py-1.5 rounded-full transition-all active:scale-95 ${ @@ -1016,10 +1013,10 @@ export function ListView() { isFirst={groupIndex === 0} isLast={groupIndex === aisleGroups.groups.length - 1} haptic={haptic} - onRename={(name) => renameCategoryMutation({ listId, categoryId: aisle.id, name, userDid: did })} - onSetEmoji={(emoji) => setCategoryEmojiMutation({ listId, categoryId: aisle.id, emoji, userDid: did })} - onMove={(direction) => moveCategoryMutation({ listId, categoryId: aisle.id, direction, userDid: did })} - onDelete={() => deleteCategoryMutation({ listId, categoryId: aisle.id, userDid: did })} + onRename={(name) => renameCategoryMutation({ listId, categoryId: aisle.id, name })} + onSetEmoji={(emoji) => setCategoryEmojiMutation({ listId, categoryId: aisle.id, emoji })} + onMove={(direction) => moveCategoryMutation({ listId, categoryId: aisle.id, direction })} + onDelete={() => deleteCategoryMutation({ listId, categoryId: aisle.id })} /> ) : ( @@ -1086,7 +1083,7 @@ export function ListView() { autoFocus onKeyDown={e => { if (e.key === "Enter" && newAisleName.trim()) { - addCategoryMutation({ listId, name: newAisleName.trim(), emoji: newAisleEmoji || "🏷️", userDid: did }); + addCategoryMutation({ listId, name: newAisleName.trim(), emoji: newAisleEmoji || "🏷️" }); setNewAisleName(""); setNewAisleEmoji("🏷️"); setShowAddAisle(false); @@ -1099,7 +1096,7 @@ export function ListView() {