diff --git a/API.md b/API.md index 2d243de..3eaa2a6 100644 --- a/API.md +++ b/API.md @@ -237,6 +237,12 @@ with a key. - response: `{ "lists": [...] }` - `GET /api/v1/lists/items?listId=` — get a list and its items (`items:read`). - response: `{ "list": {...} | null, "items": [...] }` +- `GET /api/v1/action-records?itemId=` — action records for an item (`items:read`). +- `GET /api/v1/action-records?listId=` — a list's own records, e.g. created/renamed (`lists:read`). + - response: `{ "records": [...] }`, newest first, at most 50. Each record carries the exact + signed `payload`, its `digest`, `status` (`pending` | `signed` | `failed` | `unsigned`) and, + once signed, `signature`, `publicKeyMultibase` and `verificationMethod`. See + [action records](docs/action-records.md) for the payload and how to verify it independently. ### Writes (JWT or `X-API-Key`) @@ -247,9 +253,11 @@ The existing mutation endpoints now also accept an `X-API-Key`. All require the - `POST /api/items/add`, `POST /api/items/check`, `POST /api/items/uncheck`, `POST /api/items/remove`, `POST /api/items/reorder` -Item writes made via an API key are attributed to the key's owner DID (or its -`agentDid`, if set). The item-authorship credential remains an unsigned -placeholder — cryptographic signing is out of scope for now. +Writes made via an API key are authorized by the key's owner account. Creating, +completing and reopening items and creating or renaming lists each write an +[action record](docs/action-records.md) naming that account and the API key row +used. Records are not signed yet. A +caller-supplied DID or a key's `agentDid` never establishes who acted. ### Scopes diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 7f7826d..0f53487 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as actionRecords from "../actionRecords.js"; import type * as activity from "../activity.js"; import type * as activityHttp from "../activityHttp.js"; import type * as actorSession from "../actorSession.js"; @@ -44,6 +45,7 @@ import type * as invitations from "../invitations.js"; import type * as invitationMail from "../invitationMail.js"; import type * as items from "../items.js"; import type * as itemsHttp from "../itemsHttp.js"; +import type * as lib_actionRecords from "../lib/actionRecords.js"; import type * as lib_actor from "../lib/actor.js"; import type * as lib_analytics from "../lib/analytics.js"; import type * as lib_apiKeyHelpers from "../lib/apiKeyHelpers.js"; @@ -112,6 +114,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + actionRecords: typeof actionRecords; activity: typeof activity; activityHttp: typeof activityHttp; actorSession: typeof actorSession; @@ -148,6 +151,7 @@ declare const fullApi: ApiFromModules<{ invitationMail: typeof invitationMail; items: typeof items; itemsHttp: typeof itemsHttp; + "lib/actionRecords": typeof lib_actionRecords; "lib/actor": typeof lib_actor; "lib/analytics": typeof lib_analytics; "lib/apiKeyHelpers": typeof lib_apiKeyHelpers; diff --git a/convex/actionRecords.ts b/convex/actionRecords.ts new file mode 100644 index 0000000..b0dfc7b --- /dev/null +++ b/convex/actionRecords.ts @@ -0,0 +1,56 @@ +/** + * Action records (#237): reads for provenance and independent verification. + * Records are written by lib/actionRecords.ts recordActions. No signer is wired + * up yet; see docs/action-records.md. + */ +import { v } from "convex/values"; +import { internalMutation } from "./_generated/server"; +import type { Doc } from "./_generated/dataModel"; +import { actorQuery } from "./lib/authenticated"; +import { deleteActionRecords } from "./lib/actionRecords"; + +const MAX_RECORDS_PER_READ = 50; + +/** Everything shared/actionRecord.ts verifyActionRecord needs, and nothing internal. */ +function evidence(record: Doc<"actionRecords">) { + return { + _id: record._id, + payload: record.payload, + digest: record.digest, + status: record.status, + listId: record.listId, + itemId: record.itemId, + ownerUserId: record.ownerUserId, + signature: record.signature, + verificationMethod: record.verificationMethod, + publicKeyMultibase: record.publicKeyMultibase, + signedAt: record.signedAt, + error: record.error, + }; +} + +/** Newest action records for one item. Same read access as the item itself. */ +export const { public: getItemActionRecords, internal: getItemActionRecordsInternal } = actorQuery({ + resources: args => ({ items: [args.itemId] }), + scope: "items:read", + args: { itemId: v.id("items") }, + handler: async (ctx, args) => (await ctx.db.query("actionRecords") + .withIndex("by_item", q => q.eq("itemId", args.itemId)) + .order("desc").take(MAX_RECORDS_PER_READ)).map(evidence), +}); + +/** Newest list-level records (created, renamed). Item records are read per item. */ +export const { public: getListActionRecords, internal: getListActionRecordsInternal } = actorQuery({ + resources: args => ({ lists: [args.listId] }), + scope: "lists:read", + args: { listId: v.id("lists") }, + handler: async (ctx, args) => (await ctx.db.query("actionRecords") + .withIndex("by_list", q => q.eq("listId", args.listId).eq("itemId", undefined)) + .order("desc").take(MAX_RECORDS_PER_READ)).map(evidence), +}); + +/** Continues deleteActionRecords for subjects with more records than one batch. */ +export const deleteRemaining = internalMutation({ + args: v.union(v.object({ listId: v.id("lists") }), v.object({ itemId: v.id("items") })), + handler: async (ctx, target) => deleteActionRecords(ctx, target), +}); diff --git a/convex/agentReadHttp.ts b/convex/agentReadHttp.ts index 573d895..ee1a552 100644 --- a/convex/agentReadHttp.ts +++ b/convex/agentReadHttp.ts @@ -61,3 +61,41 @@ export const getListWithItems = httpAction(async (ctx, request) => { ); } }); + +/** + * GET /api/v1/action-records?itemId= (requires "items:read") + * GET /api/v1/action-records?listId= (requires "lists:read"; list-level records) + * + * Action records in the shape shared/actionRecord.ts verifyActionRecord + * takes, so a caller can verify them without trusting this server. + * Response: { "records": [...] } + */ +export const getActionRecords = httpAction(async (ctx, request) => { + try { + const params = new URL(request.url).searchParams; + const itemId = params.get("itemId"); + const listId = params.get("listId"); + if (!itemId === !listId) { + return errorResponse(request, "Provide exactly one of itemId or listId"); + } + + const credentials = await authenticatedRequest(ctx, request); + const records = itemId + ? await ctx.runQuery(internal.actionRecords.getItemActionRecordsInternal, { + itemId: itemId as Id<"items">, + ...credentials, + }) + : await ctx.runQuery(internal.actionRecords.getListActionRecordsInternal, { + listId: listId as Id<"lists">, + ...credentials, + }); + return jsonResponse(request, { records }); + } catch (error) { + console.error("[agentReadHttp] getActionRecords error:", error); + return handlerErrorResponse( + request, + error, + "Failed to get action records" + ); + } +}); diff --git a/convex/didResources.ts b/convex/didResources.ts index 53feb31..cd37976 100644 --- a/convex/didResources.ts +++ b/convex/didResources.ts @@ -1,5 +1,6 @@ import { resourceUnavailable } from "./lib/authError"; import { actorMutation } from "./lib/authenticated"; +import { itemCompleted, itemReopened, recordActions } from "./lib/actionRecords"; /** * Queries for serving list resources publicly. * @@ -127,12 +128,15 @@ export const { public: checkSharedItem, internal: checkSharedItemInternal } = ac throw resourceUnavailable(); } + const now = Date.now(); await ctx.db.patch(args.itemId, { checked: true, checkedByDid: ctx.actor.did, - checkedAt: Date.now(), - updatedAt: Date.now(), + checkedAt: now, + updatedAt: now, }); + const list = await ctx.db.get(item.listId); + await recordActions(ctx, [itemCompleted(list ?? { _id: item.listId }, item, now)]); return { ok: true }; }, @@ -160,6 +164,8 @@ export const { public: uncheckSharedItem, internal: uncheckSharedItemInternal } checkedAt: undefined, updatedAt: Date.now(), }); + const list = await ctx.db.get(item.listId); + await recordActions(ctx, [itemReopened(list ?? { _id: item.listId }, item)]); return { ok: true }; }, diff --git a/convex/http.ts b/convex/http.ts index 479157a..71108dd 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -32,7 +32,7 @@ import { getListActivity as getListActivityHttp } from "./activityHttp"; import { stripeWebhook, createCheckout, createPortal, getSubscription } from "./billingHttp"; import { resolveSiteHost, resolveSiteAsset } from "./sitesHttp"; import { createApiKey, listApiKeys, revokeApiKey } from "./apiKeysHttp"; -import { getLists, getListWithItems } from "./agentReadHttp"; +import { getActionRecords, getLists, getListWithItems } from "./agentReadHttp"; const RATE_LIMITS = { initiate: { windowMs: 60000, maxAttempts: 5 }, verify: { windowMs: 60000, maxAttempts: 5 }, @@ -442,6 +442,8 @@ http.route({ path: "/api/v1/lists", method: "GET", handler: getLists }); http.route({ path: "/api/v1/lists", method: "OPTIONS", handler: corsHandler }); http.route({ path: "/api/v1/lists/items", method: "GET", handler: getListWithItems }); http.route({ path: "/api/v1/lists/items", method: "OPTIONS", handler: corsHandler }); +http.route({ path: "/api/v1/action-records", method: "GET", handler: getActionRecords }); +http.route({ path: "/api/v1/action-records", method: "OPTIONS", handler: corsHandler }); // ============================================================================ // Health check endpoint (public, no auth) diff --git a/convex/items.ts b/convex/items.ts index 23b7846..63d7138 100644 --- a/convex/items.ts +++ b/convex/items.ts @@ -12,112 +12,7 @@ import { internal } from "./_generated/api"; import { withMutationObservability } from "./lib/observability"; import { canUserEditList, canUserViewList } from "./lib/permissions"; import { MAX_NOTE_LENGTH, isNote } from "./lib/noteBody"; - -/** - * Creates a Verifiable Credential for item authorship (creation). - * - * This follows the W3C VC Data Model structure with a placeholder proof. - * The proof can be replaced with a cryptographic signature when server-side - * signing is implemented. - * - * @see https://www.w3.org/TR/vc-data-model/ - */ -export function createItemAuthorshipVC( - itemId: Id<"items">, - listId: Id<"lists">, - creatorDid: string, - itemName: string, - createdAt: number -): { - type: string; - issuer: string; - issuanceDate: number; - action: string; - actorDid: string; - proof?: string; -} { - // Build the full W3C VC for signing/verification - const fullVc = { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://originals.tech/credentials/v1" - ], - type: ["VerifiableCredential", "ItemAuthorshipCredential"], - id: `urn:uuid:${crypto.randomUUID()}`, - issuer: creatorDid, - issuanceDate: new Date(createdAt).toISOString(), - credentialSubject: { - id: creatorDid, - itemId: itemId.toString(), - listId: listId.toString(), - itemName, - action: "created", - }, - }; - - // Return the structured VC object for storage - return { - type: "ItemAuthorshipCredential", - issuer: creatorDid, - issuanceDate: createdAt, - action: "created", - actorDid: creatorDid, - proof: JSON.stringify(fullVc), - }; -} - -/** - * Creates a Verifiable Credential for item completion. - * - * This follows the W3C VC Data Model structure with a placeholder proof. - * The proof can be replaced with a cryptographic signature when server-side - * signing is implemented. - * - * @see https://www.w3.org/TR/vc-data-model/ - */ -function createItemCompletionVC( - itemId: Id<"items">, - listId: Id<"lists">, - completerDid: string, - itemName: string, - checkedAt: number -): { - type: string; - issuer: string; - issuanceDate: number; - action: string; - actorDid: string; - proof?: string; -} { - // Build the full W3C VC for signing/verification - const fullVc = { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://originals.tech/credentials/v1" - ], - type: ["VerifiableCredential", "ItemCompletionCredential"], - id: `urn:uuid:${crypto.randomUUID()}`, - issuer: completerDid, - issuanceDate: new Date(checkedAt).toISOString(), - credentialSubject: { - id: completerDid, - itemId: itemId.toString(), - listId: listId.toString(), - itemName, - action: "completed", - }, - }; - - // Return the structured VC object for storage - return { - type: "ItemCompletionCredential", - issuer: completerDid, - issuanceDate: checkedAt, - action: "completed", - actorDid: completerDid, - proof: JSON.stringify(fullVc), - }; -} +import { deleteActionRecords, itemCompleted, itemCreated, itemReopened, recordActions, type ListRef } from "./lib/actionRecords"; /** * Add an item to a list. @@ -216,17 +111,7 @@ export const { public: addItem, internal: addItemInternal, replay: addItemReplay await changeAssignments(ctx, (await ctx.db.get(itemId))!, ctx.actor.did, { replace: args.assigneeDids ?? (args.assigneeDid ? [args.assigneeDid] : []) }); - // Issue Verifiable Credential proving item authorship - const authorshipVC = createItemAuthorshipVC( - itemId, - args.listId, - ctx.actor.did, - args.name, - args.createdAt - ); - - // Store the VC proof on the item - await ctx.db.patch(itemId, { vcProofs: [authorshipVC] }); + await recordActions(ctx, [itemCreated(list, itemId, args.name)]); // Notify other list members (fire-and-forget via scheduler) await ctx.scheduler.runAfter(0, internal.notificationActions.sendListNotificationInternal, { @@ -379,32 +264,18 @@ export const { public: checkItem, internal: checkItemInternal, replay: checkItem const now = Date.now(); - // Issue Verifiable Credential proving item completion - const completionVC = createItemCompletionVC( - args.itemId, - item.listId, - ctx.actor.did, - item.name, - args.checkedAt - ); - - // Append completion VC to existing proofs (filter out any legacy string-format proofs) - const existingProofs = (item.vcProofs ?? []).filter( - (p): p is NonNullable[number] => typeof p === "object" && p !== null - ); - const updatedProofs = [...existingProofs, completionVC]; - - // Mark the current item as checked and add completion VC + // Historical vcProofs stay as they are; new actions go to actionRecords. await ctx.db.patch(args.itemId, { checked: true, checkedByDid: ctx.actor.did, checkedAt: args.checkedAt, updatedAt: now, - vcProofs: updatedProofs, }); // Notify other list members about the completion const list = await ctx.db.get(item.listId); + const listRef = list ?? { _id: item.listId }; + const actions = [itemCompleted(listRef, item, args.checkedAt)]; await ctx.scheduler.runAfter(0, internal.notificationActions.sendListNotificationInternal, { listId: item.listId, excludeDid: ctx.actor.did, @@ -456,8 +327,10 @@ export const { public: checkItem, internal: checkItemInternal, replay: checkItem }); await insertInheritedAssignments(ctx, { sourceId: item._id, targetId: nextId, listId: item.listId, assigneeDids, actorDid: ctx.actor.did, assignedAt: now, reason: "recurrence" }); + actions.push(itemCreated(listRef, nextId, item.name, { kind: "recurrence", sourceItemId: item._id })); } } + await recordActions(ctx, actions); }), }); @@ -495,6 +368,8 @@ export const { public: uncheckItem, internal: uncheckItemInternal, replay: unche checkedAt: undefined, updatedAt: Date.now(), }); + const list = await ctx.db.get(item.listId); + await recordActions(ctx, [itemReopened(list ?? { _id: item.listId }, item)]); }, }); @@ -527,6 +402,7 @@ export const { public: removeItem, internal: removeItemInternal, replay: removeI } await deleteAssignments(ctx, args.itemId); + await deleteActionRecords(ctx, { itemId: args.itemId }); await ctx.db.delete(args.itemId); }, }); @@ -687,6 +563,8 @@ export const { public: batchCheckItems, internal: batchCheckItemsInternal, repla minOrders: Map | undefined, number>; }>(); let listId: Id<"lists"> | null = null; + const lists = new Map, ListRef>(); + const actions = []; for (const itemId of args.itemIds) { const item = await ctx.db.get(itemId); @@ -700,6 +578,8 @@ export const { public: batchCheckItems, internal: batchCheckItemsInternal, repla throw resourceUnavailable(); } } + const listRef = lists.get(item.listId) ?? await ctx.db.get(item.listId) ?? { _id: item.listId }; + lists.set(item.listId, listRef); await ctx.db.patch(itemId, { checked: true, @@ -707,6 +587,7 @@ export const { public: batchCheckItems, internal: batchCheckItemsInternal, repla checkedAt, updatedAt: checkedAt, }); + actions.push(itemCompleted(listRef, item, checkedAt)); // If item has recurrence, create a new unchecked copy with next due date if (item.recurrence) { @@ -754,9 +635,11 @@ export const { public: batchCheckItems, internal: batchCheckItemsInternal, repla }); await insertInheritedAssignments(ctx, { sourceId: item._id, targetId: nextId, listId: item.listId, assigneeDids, actorDid: ctx.actor.did, assignedAt: checkedAt, reason: "recurrence" }); + actions.push(itemCreated(listRef, nextId, item.name, { kind: "recurrence", sourceItemId: item._id })); } } } + await recordActions(ctx, actions); }, }); @@ -773,6 +656,8 @@ export const { public: batchUncheckItems, internal: batchUncheckItemsInternal, r handler: async (ctx, args) => { const now = Date.now(); let listId: Id<"lists"> | null = null; + const lists = new Map, ListRef>(); + const actions = []; for (const itemId of args.itemIds) { const item = await ctx.db.get(itemId); @@ -785,6 +670,8 @@ export const { public: batchUncheckItems, internal: batchUncheckItemsInternal, r throw resourceUnavailable(); } } + const listRef = lists.get(item.listId) ?? await ctx.db.get(item.listId) ?? { _id: item.listId }; + lists.set(item.listId, listRef); await ctx.db.patch(itemId, { checked: false, @@ -792,7 +679,9 @@ export const { public: batchUncheckItems, internal: batchUncheckItemsInternal, r checkedAt: undefined, updatedAt: now, }); + actions.push(itemReopened(listRef, item)); } + await recordActions(ctx, actions); }, }); @@ -829,10 +718,12 @@ export const { public: batchDeleteItems, internal: batchDeleteItemsInternal, rep for (const subItem of subItems) { await deleteAssignments(ctx, subItem._id); + await deleteActionRecords(ctx, { itemId: subItem._id }); await ctx.db.delete(subItem._id); } await deleteAssignments(ctx, itemId); + await deleteActionRecords(ctx, { itemId }); await ctx.db.delete(itemId); } }, diff --git a/convex/lib/actionRecords.ts b/convex/lib/actionRecords.ts new file mode 100644 index 0000000..f61cd64 --- /dev/null +++ b/convex/lib/actionRecords.ts @@ -0,0 +1,76 @@ +/** The single writer of action records. Every recorded transition goes through + * recordActions so individual, batch, template and recurring flows agree. */ +import type { MutationCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { internal } from "../_generated/api"; +import type { ActorCtx } from "./authenticated"; +import { buildActionRecord, type ActionChange } from "../../shared/actionRecord"; + +type RecordedChange = ActionChange & { subject: { listId: Id<"lists">; itemId?: Id<"items"> } }; +export type ListRef = { _id: Id<"lists">; assetDid?: string }; +type Origin = NonNullable["origin"]>; + +const subject = (list: ListRef) => ({ listId: list._id, ...(list.assetDid ? { listAssetDid: list.assetDid } : {}) }); + +export const itemCreated = (list: ListRef, itemId: Id<"items">, name: string, origin?: Origin<"item.created">): RecordedChange => ({ + action: "item.created", subject: { ...subject(list), itemId }, before: null, after: { name, checked: false }, + ...(origin ? { origin } : {}), +}); + +export const itemCompleted = (list: ListRef, item: Pick, "_id" | "checked">, checkedAt: number): RecordedChange => ({ + action: "item.completed", subject: { ...subject(list), itemId: item._id }, + // checkedAt can be client-supplied; a value JSON cannot carry must not fail the write. + before: { checked: item.checked }, after: { checked: true, checkedAt: Number.isFinite(checkedAt) ? checkedAt : null }, +}); + +export const itemReopened = (list: ListRef, item: Pick, "_id" | "checked">): RecordedChange => ({ + action: "item.reopened", subject: { ...subject(list), itemId: item._id }, + before: { checked: item.checked }, after: { checked: false }, +}); + +export const listCreated = (list: ListRef & { name: string; kind?: "note" }, origin?: Origin<"list.created">): RecordedChange => ({ + action: "list.created", subject: subject(list), before: null, after: { name: list.name, kind: list.kind ?? "list" }, + ...(origin ? { origin } : {}), +}); + +export const listRenamed = (list: ListRef & { name: string }, name: string): RecordedChange => ({ + action: "list.renamed", subject: subject(list), before: { name: list.name }, after: { name }, +}); + +/** + * Persists the exact canonical payloads for this mutation's actions. Records + * are stored unsigned until a signer exists (see docs/action-records.md), so + * recording can never fail the write. Identity comes only from ctx.actor. + */ +export async function recordActions(ctx: ActorCtx, changes: RecordedChange[]): Promise { + if (changes.length === 0) return; + const occurredAt = Date.now(); + for (const change of changes) { + const { payload, digest } = buildActionRecord(change, { + occurredAt, + owner: { did: ctx.actor.did, userId: ctx.actor.userId }, + credential: ctx.actor.credential, + }); + await ctx.db.insert("actionRecords", { + payload, digest, listId: change.subject.listId, itemId: change.subject.itemId, + ownerUserId: ctx.actor.userId, status: "unsigned", + }); + } +} + +// Records grow with every toggle, so deletion is bounded per mutation. +export const DELETE_RECORDS_BATCH = 500; + +/** Records name their subject, so they are removed with it like vcProofs were. + * Deletes one batch now and schedules the remainder. */ +export async function deleteActionRecords( + ctx: MutationCtx, target: { listId: Id<"lists"> } | { itemId: Id<"items"> }, +): Promise { + const records = "itemId" in target + ? await ctx.db.query("actionRecords").withIndex("by_item", q => q.eq("itemId", target.itemId)).take(DELETE_RECORDS_BATCH) + : await ctx.db.query("actionRecords").withIndex("by_list", q => q.eq("listId", target.listId)).take(DELETE_RECORDS_BATCH); + for (const record of records) await ctx.db.delete(record._id); + if (records.length === DELETE_RECORDS_BATCH) { + await ctx.scheduler.runAfter(0, internal.actionRecords.deleteRemaining, target); + } +} diff --git a/convex/lists.ts b/convex/lists.ts index 0983c0e..522d6a5 100644 --- a/convex/lists.ts +++ b/convex/lists.ts @@ -11,56 +11,7 @@ import { canUserEditList, canUserViewList } from "./lib/permissions"; import { upsertListEnvelope } from "./lib/listEnvelope"; import { isLegacyGenesis } from "./lib/legacyList"; import { isNote } from "./lib/noteBody"; - -/** - * Creates a placeholder Verifiable Credential for list ownership. - * - * Exported for migrations/celAssetDids — the credential embeds assetDid in both - * credentialSubject.id and the serialized proof, so rewriting a list's DID has - * to rebuild the VC or the subject points at a DID that no longer names it. - */ -export function createListOwnershipVC( - listId: Id<"lists">, - assetDid: string, - ownerDid: string, - listName: string, - createdAt: number -): { - type: string; - issuer: string; - issuanceDate: number; - credentialSubject: { id: string; ownerDid: string }; - proof?: string; -} { - const fullVc = { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://originals.tech/credentials/v1" - ], - type: ["VerifiableCredential", "ListOwnershipCredential"], - id: `urn:uuid:${crypto.randomUUID()}`, - issuer: ownerDid, - issuanceDate: new Date(createdAt).toISOString(), - credentialSubject: { - id: ownerDid, - listId: listId.toString(), - assetDid, - listName, - role: "owner", - }, - }; - - return { - type: "ListOwnershipCredential", - issuer: ownerDid, - issuanceDate: createdAt, - credentialSubject: { - id: assetDid, - ownerDid, - }, - proof: JSON.stringify(fullVc), - }; -} +import { deleteActionRecords, listCreated, listRenamed, recordActions } from "./lib/actionRecords"; /** * Create a new list. @@ -180,15 +131,7 @@ export const { public: createList, internal: createListInternal, replay: createL await ctx.db.insert("noteBodies", { listId, body: "", updatedAt: args.createdAt }); } - const vcProof = createListOwnershipVC( - listId, - args.assetDid, - ctx.actor.did, - args.name, - args.createdAt - ); - - await ctx.db.patch(listId, { vcProof }); + await recordActions(ctx, [listCreated({ _id: listId, assetDid: args.assetDid, name: args.name, kind: args.kind })]); if (args.celEnvelope) { await upsertListEnvelope(ctx, listId, args.assetDid, args.celEnvelope); @@ -256,9 +199,12 @@ export const { public: copyList, internal: copyListInternal } = actorMutation({ itemViewMode: source.itemViewMode, }); - await ctx.db.patch(listId, { - vcProof: createListOwnershipVC(listId, args.assetDid, ctx.actor.did, args.name, args.createdAt), - }); + // Only the list is recorded: copied items keep their original attribution + // fields, so a per-item creation record here would claim someone else's work. + await recordActions(ctx, [listCreated( + { _id: listId, assetDid: args.assetDid, name: args.name }, + { kind: "copy", sourceListId: args.sourceListId }, + )]); await upsertListEnvelope(ctx, listId, args.assetDid, args.celEnvelope); @@ -346,15 +292,9 @@ export const { public: renameList, internal: renameListInternal, replay: renameL throw resourceUnavailable(); } - const vcProof = createListOwnershipVC( - args.listId, - list.assetDid ?? "", - list.ownerDid, - args.name, - list.createdAt - ); - - await ctx.db.patch(args.listId, { name: args.name, vcProof }); + // The historical vcProof is left as issued; the rename is its own record. + await ctx.db.patch(args.listId, { name: args.name }); + await recordActions(ctx, [listRenamed(list, args.name)]); }, }); @@ -586,6 +526,7 @@ export const { public: deleteList, internal: deleteListInternal, replay: deleteL for (const item of items) { await ctx.db.delete(item._id); } + await deleteActionRecords(ctx, { listId: args.listId }); // Includes historical orphan membership rows whose items were already removed. for (const row of await ctx.db.query("itemAssignees").withIndex("by_list", q => q.eq("listId", args.listId)).collect()) await ctx.db.delete(row._id); diff --git a/convex/migrations/celAssetDidsDb.ts b/convex/migrations/celAssetDidsDb.ts index fa6d761..8278599 100644 --- a/convex/migrations/celAssetDidsDb.ts +++ b/convex/migrations/celAssetDidsDb.ts @@ -8,9 +8,51 @@ import { v } from "convex/values"; import { internalMutation, internalQuery } from "../_generated/server"; -import { createListOwnershipVC } from "../lists"; +import type { Id } from "../_generated/dataModel"; import { upsertListEnvelope } from "../lib/listEnvelope"; +/** + * Rebuilds a legacy list's UNSIGNED ownership placeholder for its new asset DID. + * The placeholder embeds assetDid, so it must follow the rewrite. Live writers + * no longer issue these (see lib/actionRecords.ts); only this migration does. + */ +function createListOwnershipVC( + listId: Id<"lists">, + assetDid: string, + ownerDid: string, + listName: string, + createdAt: number +) { + const fullVc = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://originals.tech/credentials/v1" + ], + type: ["VerifiableCredential", "ListOwnershipCredential"], + id: `urn:uuid:${crypto.randomUUID()}`, + issuer: ownerDid, + issuanceDate: new Date(createdAt).toISOString(), + credentialSubject: { + id: ownerDid, + listId: listId.toString(), + assetDid, + listName, + role: "owner", + }, + }; + + return { + type: "ListOwnershipCredential", + issuer: ownerDid, + issuanceDate: createdAt, + credentialSubject: { + id: assetDid, + ownerDid, + }, + proof: JSON.stringify(fullVc), + }; +} + /** Rows still carrying a pre-2.0.0 genesis DID (real or `temp-` placeholder). */ export function needsCelMigration(assetDid: string | undefined | null): boolean { return typeof assetDid === "string" && assetDid.startsWith("did:peer:"); diff --git a/convex/migrations/remintUserDidDb.ts b/convex/migrations/remintUserDidDb.ts index 0ef6e05..48ea21f 100644 --- a/convex/migrations/remintUserDidDb.ts +++ b/convex/migrations/remintUserDidDb.ts @@ -24,6 +24,7 @@ import type { MutationCtx } from "../_generated/server"; * - lists.assetDid / listEnvelopes.assetDid — did:cel asset ids, not user DIDs * - sites.did — the site's own did:webvh * - users.did / users.legacyDid — handled separately, they ARE the identity + * - actionRecords — signed bytes naming the DID at action time; never rewritten */ const EXACT_MATCH_FIELDS: Record = { didLogs: ["userDid"], diff --git a/convex/schema.ts b/convex/schema.ts index 00e9a8d..3003e6d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -293,6 +293,33 @@ export default defineSchema({ .index("by_parent", ["parentId"]) .index("by_due_date", ["listId", "dueDate"]), + // Action records (#237), replacing the unsigned vcProof(s) placeholders. + // `payload` is the exact canonical JSON that gets signed; never rewrite it, and + // keep this table out of DID-rewrite migrations. See docs/action-records.md. + actionRecords: defineTable({ + payload: v.string(), // Canonical JSON (shared/actionRecord.ts), immutable once written + digest: v.string(), // Lowercase hex SHA-256 of payload + // Index columns copied from the payload; verification requires them to agree. + listId: v.id("lists"), + itemId: v.optional(v.id("items")), + ownerUserId: v.id("users"), // Account that authorized the action + // Only "unsigned" is written until a signer exists; the other states are + // what the provenance UI and verifier already understand. + status: v.union( + v.literal("pending"), // Awaiting a signature + v.literal("signed"), + v.literal("failed"), // Signing did not complete; see error + v.literal("unsigned"), // No signature exists; nothing is fabricated + ), + signature: v.optional(v.string()), // Multibase base58btc Ed25519 signature + verificationMethod: v.optional(v.string()), // did:key of the key bound at signing time + publicKeyMultibase: v.optional(v.string()), + signedAt: v.optional(v.number()), + error: v.optional(v.string()), // Non-secret reason code, set when failed + }) + .index("by_list", ["listId", "itemId"]) + .index("by_item", ["itemId"]), + // Item assignees table - tracks who is assigned to each item (Phase 1 foundation) itemAssignees: defineTable({ itemId: v.id("items"), diff --git a/convex/templates.ts b/convex/templates.ts index 694dbeb..3821e56 100644 --- a/convex/templates.ts +++ b/convex/templates.ts @@ -6,8 +6,8 @@ import { actorMutation, actorQuery } from "./lib/authenticated"; import { v } from "convex/values"; import { query } from "./_generated/server"; import { BUILTIN_TEMPLATES } from "./lib/templateCatalog"; -import { assertListQuota, createListOwnershipVC, grantFirstListReferral } from "./lists"; -import { createItemAuthorshipVC } from "./items"; +import { assertListQuota, grantFirstListReferral } from "./lists"; +import { itemCreated, listCreated, recordActions } from "./lib/actionRecords"; import { upsertListEnvelope } from "./lib/listEnvelope"; // Id type used in function arguments via v.id() @@ -225,7 +225,9 @@ export const { public: createListFromTemplate, internal: createListFromTemplateI createdAt: now, }); - await ctx.db.patch(listId, { vcProof: createListOwnershipVC(listId, args.assetDid, ctx.actor.did, args.listName, now) }); + const origin = { kind: "template" as const, source: templateSource }; + const listRef = { _id: listId, assetDid: args.assetDid }; + const actions = [listCreated({ ...listRef, name: args.listName }, origin)]; if (args.celEnvelope) { await upsertListEnvelope(ctx, listId, args.assetDid, args.celEnvelope); } @@ -244,12 +246,10 @@ export const { public: createListFromTemplate, internal: createListFromTemplateI assignmentsVersion: 1, order: templateItem.order, }); - // Preserve the same historical authorship record as ordinary item creation. - // This is the existing unsigned placeholder, not a new signature claim. - await ctx.db.patch(itemId, { - vcProofs: [createItemAuthorshipVC(itemId, listId, ctx.actor.did, templateItem.name, now)], - }); + // Same creation record as ordinary addItem, tagged with its template origin. + actions.push(itemCreated(listRef, itemId, templateItem.name, origin)); } + await recordActions(ctx, actions); await grantFirstListReferral(ctx, owner, isFirstList); return listId; diff --git a/convex/users.ts b/convex/users.ts index 557820b..0089ea3 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -81,10 +81,11 @@ async function deleteUserStep(ctx: MutationCtx, user: Doc<"users">): Promise q.eq("itemId", item._id)).take(DELETE_BATCH_SIZE))) return false; if (await drain(ctx.db.query("itemAssignees").withIndex("by_item", q => q.eq("itemId", item._id)).take(DELETE_BATCH_SIZE))) return false; + if (await drain(ctx.db.query("actionRecords").withIndex("by_item", q => q.eq("itemId", item._id)).take(DELETE_BATCH_SIZE))) return false; await ctx.db.delete(item._id); return false; } - for (const table of ["itemAssignees", "tags", "activities", "presence", "publications", "bookmarks", "bitcoinAnchors", "noteBodies", "listEnvelopes"] as const) { + for (const table of ["itemAssignees", "tags", "activities", "presence", "publications", "bookmarks", "bitcoinAnchors", "noteBodies", "listEnvelopes", "actionRecords"] as const) { if (await drain(ctx.db.query(table).withIndex("by_list", q => q.eq("listId", listId)).take(DELETE_BATCH_SIZE))) return false; } if (await drain(ctx.db.query("listGrants").withIndex("by_list_recipient", q => q.eq("listId", listId)).take(DELETE_BATCH_SIZE))) return false; diff --git a/docs/action-records.md b/docs/action-records.md index 27cba73..fc08c7b 100644 --- a/docs/action-records.md +++ b/docs/action-records.md @@ -1,7 +1,19 @@ # Action records and signature boundaries -The signing custody decision for #237 is pending. This foundation does not change -the current `vcProof` / `vcProofs` writers into cryptographic signers. +Recorded actions are written as **action records**: an exact, canonical payload +naming the account and credential that authorized the action (#237). Records are +stored **unsigned** for now. Who holds the signing key, and whether the account +holder must be present to sign, is still being decided (#244), so no signer is +wired up and nothing claims a signature. They replace the unsigned `vcProof` / `vcProofs` placeholders, which remain +readable as historical data and are no longer written. + +| Piece | Where | +| --- | --- | +| Payload contract, canonical bytes, verifier | `shared/actionRecord.ts` (pure: no Convex, no network) | +| The one writer | `convex/lib/actionRecords.ts` `recordActions` | +| Storage and reads | `convex/actionRecords.ts`, table `actionRecords` | +| Signing | Not implemented yet; see [Custody](#custody) | +| Standalone check | `scripts/verify-action-records.mjs` | ## Authenticated identity @@ -19,9 +31,12 @@ person or software possessed it. A session does not prove personal intent. ## Historical compatibility -`vcProof` / `vcProofs` remain unchanged on the wire. ProvenanceInfo, query/HTTP -responses and migrations depend on them. External consumers cannot be -exhaustively enumerated from this repository, so no field removal is justified. +The `vcProof` / `vcProofs` schema fields and wire shape are unchanged, and +existing values are returned as before. ProvenanceInfo, query/HTTP responses and +migrations still read them. External consumers cannot be exhaustively enumerated +from this repository, so no field removal is justified. No live writer adds to +them any more: new lists and items simply lack the fields, and completing or +renaming leaves existing values as issued. Historical JSON wrappers without signatures are labeled **unsigned**. Opaque or potentially genuine historical proof material is labeled **unverified** in the @@ -30,9 +45,11 @@ and WebVH verification remain separate. A valid asset history does not prove tha an action-placeholder JSON was signed. Do not fabricate historical signatures or alter genuine evidence's signed bytes -or historical identity bindings. Current legacy DID migrations rewrite wrapper -fields; the replacement must keep signed evidence out of those rewrite paths. -This foundation does not change those migrations. +or historical identity bindings. The legacy DID migrations still rewrite the +placeholder wrapper fields, and `celAssetDids` still rebuilds a legacy list's +unsigned ownership placeholder for its new asset DID (the constructor now lives +in that migration, its only remaining user). Action records are kept out of +every rewrite path, because their payload is meant to be signed as written. ## Identifier badges and evidence boundaries @@ -54,37 +71,234 @@ say **Not anchored**. The provenance panel's CEL replay and WebVH verification paths are unchanged; successful event-log verification is not action-record or personal-ownership verification. -## Custody and verification requirements still awaiting selection - -Service attestations must say that boop observed an authenticated operation. They -must not claim an author-held signature or personal authorship. Author-held -cross-device note sealing belongs to #244. - -Independent service-attestation verification must require an externally pinned -issuer key. A key supplied only by the record establishes mathematical -consistency, not that boop issued it. Rotation requires retaining trusted -historical public keys with an explicit trust policy. Never include secrets in -payloads, envelopes, exports, or logs. - -Availability behavior must be selected with custody. Synchronous signing can -make key loss or configuration failure reject otherwise valid writes. -Asynchronous signing requires durable pending records, retries, truthful status, -and exact persisted payload/binding checks at signing time. Neither behavior has -been enabled while this product/deployment choice is pending. - -The installed Originals SDK 4.0.0 exposes local CEL creation and cold envelope -loading through `@originals/sdk/v3`. It can bind exact UTF-8 resource bytes through -a signed resource digest. The existing Turnkey WebVH adapter is Node/network-only -and cannot simply be called inside a Convex mutation. Existing browser CEL keys -are not server-held. - -## Current verification and gaps - -Tests exercise credential distinction/revocation and rendered historical -unsigned/unverified presentation, header/compact/public identifier badges, and -separate anchor states. Local Originals tests exercise genuine CEL verification -and tamper rejection. Signed recording, independent signature -verification, individual/batch history parity, recurring records, key rotation, -and signing-availability behavior remain unimplemented pending custody. Native -and live flows have not been exercised. No live Convex operations are required -or authorized for this work. +## Custody + +Undecided. #244 rules out boop's parent-org Turnkey API key signing as a user +without the user present, which is what a server-initiated `signRawPayload` in +the account's sub-org would be. Until the replacement model lands: + +- every record is written with `status: "unsigned"`; +- the payload carries no `signer` or custody field, so stored bytes make no + claim about a key that does not exist yet; +- the schema, verifier and provenance UI already understand `pending`, `signed` + and `failed`, so a signer can be added without changing them. + +Records written now will not be signed retroactively under an author-present +model, because the author is no longer present for them. + +## What is recorded + +Every one of these transitions goes through `recordActions`, whether it arrives +as a single call, a batch, a template activation, a recurrence, an offline +replay, or the HTTP API: + +| Action | Written by | +| --- | --- | +| `item.created` | `addItem`; each item of `createListFromTemplate`; the next occurrence generated when a recurring item is completed | +| `item.completed` | `checkItem`, `batchCheckItems`, `checkSharedItem` (DID resource toggle) | +| `item.reopened` | `uncheckItem`, `batchUncheckItems`, `uncheckSharedItem` (DID resource toggle) | +| `list.created` | `createList` (lists and notes), `copyList`, `createListFromTemplate` | +| `list.renamed` | `renameList` | + +A batch writes one record per item, identical in shape to the record the +individual call writes. Recording happens inside the mutation and schedules +nothing, so it cannot fail or delay the write. + +Not recorded: item edits other than the above, deletion, reordering, +assignment, and the items carried by `copyList` (they keep their original +attribution fields, so a creation record would claim someone else's work). + +## Payload (version 1) + +`payload` is stored as a string and is the exact UTF-8 text a signature covers. +It is canonical JSON per RFC 8785: object keys sorted by UTF-16 code unit, no +whitespace, strings and finite numbers as `JSON.stringify` writes them. Values +JSON cannot carry are never put in a payload. + +```jsonc +{ + "schema": "boop.action-record", + "version": 1, + "action": "item.completed", + "subject": { + "listId": "", + "listAssetDid": "did:cel:…", // omitted when the list has none + "itemId": "" // item.* actions only + }, + "before": { "checked": false }, // null for *.created + "after": { "checked": true, "checkedAt": 1700000000000 }, + "origin": { "kind": "recurrence", "sourceItemId": "…" }, // optional, *.created only + "occurredAt": 1700000000000, // server clock, ms + "owner": { "did": "did:webvh:…", "userId": "" }, + "credential": { "kind": "session", "id": "" } +} +``` + +| Action | `before` | `after` | `origin` | +| --- | --- | --- | --- | +| `item.created` | `null` | `{ name, checked: false }` | `recurrence` + `sourceItemId`, or `template` + `source` | +| `item.completed` | `{ checked }` | `{ checked: true, checkedAt \| null }` | — | +| `item.reopened` | `{ checked }` | `{ checked: false }` | — | +| `list.created` | `null` | `{ name, kind: "list" \| "note" }` | `template` + `source`, or `copy` + `sourceListId` | +| `list.renamed` | `{ name }` | `{ name }` | — | + +- `owner` is the account that owns the credential and authorized the action. On + a shared list that is the editor who acted, not the list's owner. +- `credential` is the authenticated session row or API-key row from + `ctx.actor.credential`. A human session and each API key of one account are + distinct here while sharing one `owner`. +- `checkedAt` is the value stored on the item. The individual call takes it from + the client (`null` here if it was not a finite number); the batch call uses the + server clock. `occurredAt` is always the server clock. +- Nothing in the payload comes from a caller-supplied DID, and it never contains + tokens, token hashes, API keys or key hashes, or Turnkey identifiers. + +The stored row adds `digest` (lowercase hex SHA-256 of `payload`) and unsigned +index columns `listId`, `itemId`, `ownerUserId` copied from the payload. + +### Signature + +The signed message is 64 bytes: + +``` +SHA-256("boop.action-record/v1") || SHA-256(payload) +``` + +The prefix separates these signatures from anything else the same key signs. `signature` is the Ed25519 signature, multibase base58btc (`z…`). +`publicKeyMultibase` (Multikey, `z6Mk…`) and `verificationMethod` +(`did:key:#`) record the key that signed. + +## Status lifecycle + +| Status | Meaning | Written today | +| --- | --- | --- | +| `unsigned` | No signature exists | Yes, for every record | +| `pending` | Awaiting a signature | No | +| `signed` | Carries `signature`, `publicKeyMultibase`, `verificationMethod`, `signedAt` | No | +| `failed` | Signing did not complete; `error` holds a non-secret reason code | No | + +Whatever signer is added must sign only the stored bytes, never a payload +rebuilt from live list or item state, and must check that they still hash to +`digest`. + +## Independent verification + +`verifyActionRecord(record, { trustedKeys | resolvePublicKey, expected? })` +needs only the record and keys the caller already trusts. It: + +1. requires `status: "signed"` and a well-formed signature; +2. re-canonicalizes `payload` and requires byte equality, then recomputes the digest; +3. requires the row's index columns to match the payload, and any `expected` + owner DID or credential to match; +4. takes the caller's trusted keys **for `payload.owner.did`** and requires the + record's declared key to be one of them; +5. verifies the Ed25519 signature under that trusted key. + +A key carried by the record is never trusted on its own. It only selects among +the caller's keys. Rejections are specific: `not_signed`, `malformed_payload`, +`non_canonical_payload`, `digest_mismatch`, `binding_mismatch`, +`no_trusted_key`, `untrusted_key`, `bad_signature`. + +Records can be fetched with `GET /api/v1/action-records?itemId=…` or +`?listId=…` (see `API.md`) and checked offline: + +``` +bun scripts/verify-action-records.mjs records.json 'did:webvh:…=z6Mk…' +``` + +### Where the trusted key comes from + +Unsolved, and tied to the custody decision. An account's did:webvh is minted in +the browser with a browser-held key (`src/lib/webvh.ts`), so a verifier can only +take a signing key from that DID document if the signing key is listed there. +Otherwise it has to pin "this owner DID → this key" by a route it trusts. If the +only source of the key is boop, verification shows the record is intact and was +signed by the key boop names, not that the key is the owner's. + +An account whose DID is a `did:key` of the signing key is the exception: the DID +is the key, so no pin is needed. + +### Key rotation + +A record keeps the `verificationMethod` and `publicKeyMultibase` it was signed +with, and its payload keeps the owner DID that authorized it. Neither is +rewritten when the account's key or DID later changes, and the +`remintUserDid` migration deliberately leaves `actionRecords` alone. A verifier +must accept the key bound at signing time, so `trustedKeys` takes a list per +owner DID: keep rotated-out keys there for as long as older records should +verify, and keep the pin under the DID the record names. + +Nothing in a record says when a key stopped being valid. A verifier that learns +of a compromise must stop trusting that key itself. + +## What a signature proves + +No record is signed today, so action records currently prove nothing +cryptographically. They are boop's own account of what happened, and are shown +as **Unsigned**. + +Once a signer exists, a verified record will prove that the named key signed +exactly this payload, and so that it has not changed since. What else it proves +depends on the custody model. Even then it does **not** prove: + +- who or what was holding the session or API key; +- that the action was permitted (authorization is enforced at write time and is + not part of the evidence); +- when the action happened, beyond boop's own clock in `occurredAt` and + `signedAt`; +- that the record set is complete. Records are deleted with their item or list, + and nothing chains them together, so an absent record proves nothing. + +## Presentation + +`ProvenanceInfo` lists action records for an item, and list-level records for a +list, under **Action records**, separate from **Historical action records**: + +| Status | Label | +| --- | --- | +| `signed` | **Signed** | +| `pending` | **Pending signature** | +| `failed` | **Signing failed** | +| `unsigned` | **Unsigned** | + +Only **Unsigned** occurs today. Each row names the authorizing account and whether a signed-in session or an +API key was used. The browser re-checks every signed record. It says "signature +verified" only when the owner DID is itself a `did:key` for the signing key. +Otherwise it says the signature matches the key boop recorded and that the key +is not independently verified there. A record that fails even that check is +shown as **Signature check failed** instead of signed. + +## Lifetime and deletion + +Action records are deleted with their subject: an item's records when the item +is removed, a list's records when the list or its owner's account is deleted. +Records an account wrote on someone else's list stay with that list, as +`createdByDid` does. + +Every toggle adds a record, so a long-lived list can hold many. Deletion removes +up to 500 in the deleting mutation and schedules `actionRecords.deleteRemaining` +for the rest, so deleting a list or item never hits Convex's per-mutation limits. + +## Verification status and remaining gaps + +Covered by `scripts/signed-action-records.test.mjs`, +`scripts/provenance-presentation.test.mjs` and the share-modal E2E tests: +canonical determinism; create, complete, reopen, batch, recurring, template, +copy and rename flows; individual versus batch equivalence; session versus +distinct API keys; every record written unsigned with nothing scheduled; +verification of records signed with a test key, and rejection of altered +payloads, signatures, keys and bindings and of every non-signed status; reads, +the HTTP export and the standalone verifier; deletion; the re-mint migration +leaving records untouched; all four rendered statuses. + +Not done or not verified: + +- **No signer.** Signing waits on the custody follow-up from #244. +- Where an outside verifier gets the signing key from is unsolved (above). +- No revocation or validity window for signing keys. +- Records are not chained or anchored; completeness and ordering are not + provable. +- Existing historical placeholders are not converted, and never will be signed + retroactively. +- Item edits, deletions, reordering and assignment are not recorded. +- Native builds and live flows have not been exercised. diff --git a/scripts/copy-list.test.mjs b/scripts/copy-list.test.mjs index c5ef52c..aac0251 100644 --- a/scripts/copy-list.test.mjs +++ b/scripts/copy-list.test.mjs @@ -172,6 +172,11 @@ test("drops vcProofs — they attest actions against the source asset", async () const copied = ctx.rows.items.find((i) => i.listId === listId); assert.equal(copied.vcProofs, undefined, "carrying these would claim another asset's provenance"); + // The copy itself is one recorded action; copied items get no creation record. + assert.deepEqual(ctx.rows.actionRecords.map((r) => JSON.parse(r.payload)).map((p) => [p.action, p.subject, p.origin]), [ + ["list.created", { listId, listAssetDid: MINTED.assetDid }, { kind: "copy", sourceListId: "L1" }], + ]); + assert.equal(ctx.rows.lists.find((l) => l._id === listId).vcProof, undefined); }); test("an outsider cannot copy a private list", async () => { diff --git a/scripts/offline-replay.test.mjs b/scripts/offline-replay.test.mjs index b5913da..c4bbd70 100644 --- a/scripts/offline-replay.test.mjs +++ b/scripts/offline-replay.test.mjs @@ -2,12 +2,15 @@ import 'fake-indexeddb/auto'; import { openDB } from 'idb'; import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { getFunctionName } from 'convex/server'; import { loadReplayModules, replayFixture } from './helpers/replay-fixture.mjs'; const modules = await loadReplayModules('offline-replay-tests'); const { offline: store, sync: { SyncManager }, shared: { revision }, optimistic: { projectItems } } = modules; const queue = (f, type, payload) => store.queueMutation(f.session.accountId, { type, payload }, structuredClone(f.rows.items)); const pending = f => store.getQueuedMutations(f.session.accountId); const all = f => store.getOperations(f.session.accountId); +const notifications = f => f.effects.filter(([, ref]) => getFunctionName(ref).startsWith('notificationActions:')); +const recorded = (f, action, itemId) => (f.rows.actionRecords ?? []).map(r => JSON.parse(r.payload)).filter(p => p.action === action && (!itemId || p.subject.itemId === itemId)); const meta = async (f, operationId, items = f.rows.items) => ({ operationId, accountId: f.session.accountId, expected: await Promise.all(items.map(async i => ({ id: i._id, revision: await revision(i) }))) }); test('offline check then uncheck survives reload, clock skew, and a lost check acknowledgment without duplicate recurring items or proof records', async () => { @@ -29,9 +32,13 @@ test('offline check then uncheck survives reload, clock skew, and a lost check a await new SyncManager().sync(f.client, f.session); assert.equal(f.rows.items[0].checked, false); assert.equal(f.rows.items.length, 2); - assert.equal(f.rows.items[0].vcProofs.length, 2); - assert.equal(f.rows.items[0].vcProofs[0].proof, 'do-not-replace'); - assert.equal(f.effects.length, 1); + // Historical evidence is untouched; each replayed action is recorded exactly once. + assert.deepEqual(f.rows.items[0].vcProofs.map(p => p.proof), ['do-not-replace']); + assert.equal(recorded(f, 'item.completed', 'I1').length, 1); + assert.equal(recorded(f, 'item.reopened', 'I1').length, 1); + assert.equal(recorded(f, 'item.created').length, 1); + assert.deepEqual(recorded(f, 'item.created')[0].origin, { kind: 'recurrence', sourceItemId: 'I1' }); + assert.equal(notifications(f).length, 1); assert.equal(f.rows.offlineReceipts.length, 2); assert.equal((await pending(f)).length, 0); }); @@ -48,7 +55,8 @@ test('duplicate names are distinct creates, and retrying either stable ID never await new SyncManager().sync(f.client, f.session); assert.equal(f.rows.items.length, 3); assert.equal(f.rows.offlineReceipts.length, 2); - assert.equal(f.effects.length, 2); + assert.equal(notifications(f).length, 2); + assert.equal(recorded(f, 'item.created').length, 2); }); test('an offline create can be checked and unchecked through its stable temporary ID', async () => { @@ -60,7 +68,9 @@ test('an offline create can be checked and unchecked through its stable temporar await new SyncManager().sync(f.client, f.session); assert.equal((await pending(f)).length, 0); assert.equal(f.rows.items.find(i => i.name === 'New').checked, false); - assert.equal(f.rows.items.find(i => i.name === 'New').vcProofs.length, 2); + const created = f.rows.items.find(i => i.name === 'New'); + assert.equal(created.vcProofs, undefined, 'new items get action records, not placeholders'); + assert.deepEqual(['item.created', 'item.completed', 'item.reopened'].map(action => recorded(f, action, created._id).length), [1, 1, 1]); }); test('concurrent same-revision edits admit one write; conflicts retain ordered successors and support deliberate rebase', async () => { @@ -118,7 +128,8 @@ test('same operation ID is immutable; concurrent duplicate delivery and deletion const f = await replayFixture(modules); const args = { itemId: 'I1', checkedAt: 10, replay: await meta(f, 'one-check') }; const [a, b] = await Promise.all([f.call('items', 'checkItemReplay', args), f.call('items', 'checkItemReplay', args)]); - assert.deepEqual(a, b); assert.equal(f.rows.items[0].vcProofs.length, 2); + assert.deepEqual(a, b); assert.equal(f.rows.items[0].vcProofs.length, 1); + assert.equal(recorded(f, 'item.completed', 'I1').length, 1); await assert.rejects(f.call('items', 'checkItemReplay', { ...args, checkedAt: 11 }), /reused/); const remove = { itemId: 'I1', replay: await meta(f, 'one-delete') }; const first = await f.call('items', 'removeItemReplay', remove); diff --git a/scripts/provenance-presentation.test.mjs b/scripts/provenance-presentation.test.mjs index 912281e..1bc3a59 100644 --- a/scripts/provenance-presentation.test.mjs +++ b/scripts/provenance-presentation.test.mjs @@ -6,8 +6,11 @@ import { GlobalRegistrator } from '@happy-dom/global-registrator'; if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); globalThis.IS_REACT_ACT_ENVIRONMENT = true; const React = await import('react'); -const { render, cleanup, fireEvent } = await import('@testing-library/react'); +const { render, cleanup, fireEvent, waitFor } = await import('@testing-library/react'); const { MemoryRouter } = await import('react-router-dom'); +const { getFunctionName } = await import('convex/server'); +const ed25519 = await import('@noble/ed25519'); +const { actionRecordSigningInput, base58Encode, buildActionRecord, ed25519Multikey, encodeSignature } = await import('../shared/actionRecord.ts'); await build({ entryPoints: ['src/components/ProvenanceInfo.tsx'], outfile: 'tmp/provenance-presentation/component.mjs', @@ -16,7 +19,7 @@ await build({ plugins: [{ name: 'provenance-fixture', setup(b) { b.onResolve({ filter: /(?:lib\/(authenticatedConvex|originals)|hooks\/(useCurrentUser|useSettings))$/ }, ({ path }) => ({ path: path.split('/').pop(), namespace: 'fixture' })); b.onLoad({ filter: /.*/, namespace: 'fixture' }, ({ path }) => ({ contents: { - authenticatedConvex: 'export const useQuery=()=>null; export const useMutation=()=>async()=>{};', + authenticatedConvex: 'export const useQuery=(ref,args)=>globalThis.__provenanceQuery?.(ref,args)??null; export const useMutation=()=>async()=>{};', useCurrentUser: 'export const useCurrentUser=()=>({did:"did:owner"});', useSettings: 'export const useSettings=()=>({haptic:()=>{}});', originals: 'export const verifyListEnvelope=async()=>({verified:false}); export const isRetroactiveGenesis=()=>false; export const createListAsset=async()=>{};', @@ -24,7 +27,25 @@ await build({ } }], }); const { ItemProvenanceInfo, ListProvenanceInfo } = await import(pathToFileURL(`${process.cwd()}/tmp/provenance-presentation/component.mjs`)); -afterEach(cleanup); +afterEach(() => { cleanup(); delete globalThis.__provenanceQuery; }); + +/** Serves stable query results by function name, as Convex subscriptions do. */ +function serve(results) { + globalThis.__provenanceQuery = ref => results[getFunctionName(ref)] ?? null; +} +async function actionRecord(_id, change, { ownerDid, secret, status = 'signed', credential = { kind: 'session', id: 'session-row-abc123' } }) { + const built = buildActionRecord(change, { occurredAt: 1700000000000, owner: { did: ownerDid, userId: 'U-owner' }, credential }); + const record = { _id, ...built, status, listId: change.subject.listId, itemId: change.subject.itemId, ownerUserId: 'U-owner' }; + if (status !== 'signed') return status === 'failed' ? { ...record, error: 'signing_request_failed' } : record; + const publicKeyMultibase = ed25519Multikey(await ed25519.getPublicKeyAsync(secret)); + return { ...record, signature: encodeSignature(await ed25519.signAsync(actionRecordSigningInput(built.payload), secret)), + publicKeyMultibase, verificationMethod: `did:key:${publicKeyMultibase}#${publicKeyMultibase}`, signedAt: 1700000005000 }; +} +const subject = { listId: 'L', itemId: 'I' }; +const completed = { action: 'item.completed', subject, before: { checked: false }, after: { checked: true, checkedAt: 5 } }; +const created = { action: 'item.created', subject, before: null, after: { name: 'Item', checked: false } }; +const reopened = { action: 'item.reopened', subject, before: { checked: true }, after: { checked: false } }; +const bareItem = { _id: 'I', listId: 'L', name: 'Item', createdAt: 1, createdByDid: 'did:owner', checked: false }; test('historical item placeholders and opaque evidence receive explicit nonverified presentation', () => { const base = { type: 'ItemCreation', issuer: 'did:owner', actorDid: 'did:owner', issuanceDate: 1, action: 'created' }; @@ -47,3 +68,77 @@ test('historical list placeholder does not imply verified ownership', () => { assert.match(container.textContent, /Historical ownership record \(unsigned\)/); assert.doesNotMatch(container.textContent, /Ownership Verifiable Credential|cryptographic proof of ownership and authenticity/); }); + +test('new action records show their truthful signing status without claiming verification or intent', async () => { + const secret = ed25519.utils.randomSecretKey(); + const ownerDid = 'did:webvh:QmScid:boop.ad:user-owner'; + serve({ 'actionRecords:getItemActionRecords': [ + await actionRecord('R1', created, { ownerDid, secret }), + await actionRecord('R2', completed, { ownerDid, secret, status: 'pending', credential: { kind: 'apiKey', id: 'key-row-111aaa' } }), + await actionRecord('R3', reopened, { ownerDid, secret, status: 'failed', credential: { kind: 'apiKey', id: 'key-row-222bbb' } }), + await actionRecord('R4', completed, { ownerDid, secret, status: 'unsigned' }), + ] }); + const { container } = render(React.createElement(ItemProvenanceInfo, { item: bareItem })); + fireEvent.click(container.querySelector('button')); + const text = () => container.textContent; + assert.match(text(), /Action records \(4\)/); + assert.match(text(), /Creation recordSigned/); + assert.match(text(), /Completion recordPending signature/); + assert.match(text(), /Reopen recordSigning failed/); + assert.match(text(), /Completion recordUnsigned/); + // Sessions and distinct API keys of one account read differently. + assert.match(text(), /Via signed-in session …abc123/); + assert.match(text(), /Via API key …111aaa/); + assert.match(text(), /Via API key …222bbb/); + // A did:webvh owner's key cannot be checked from the DID alone, so nothing says "verified". + await waitFor(() => assert.match(text(), /matches the key boop recorded.*not independently verified here/)); + assert.doesNotMatch(text(), /Signature verified/); + assert.match(text(), /after an operation authenticated by the named session or API key.*does not show personal intent or who was using that credential/); + assert.doesNotMatch(text(), /Verifiable Credential|cryptographic proof|Historical action records/); +}); + +test('a signature is called verified only against a key the owner DID itself encodes, and tampering is flagged', async () => { + const secret = ed25519.utils.randomSecretKey(); + const didKey = `did:key:${base58Encode(await ed25519.getPublicKeyAsync(secret))}`; + const genuine = await actionRecord('R1', completed, { ownerDid: didKey, secret }); + serve({ 'actionRecords:getItemActionRecords': [genuine] }); + const first = render(React.createElement(ItemProvenanceInfo, { item: bareItem })); + fireEvent.click(first.container.querySelector('button')); + await waitFor(() => assert.match(first.container.textContent, /Completion recordSignedSignature verified against the key in the authorizing account's DID/)); + cleanup(); + + // Same record, signed by a key the did:key owner does not name: integrity holds, binding does not. + const other = ed25519.utils.randomSecretKey(); + serve({ 'actionRecords:getItemActionRecords': [await actionRecord('R1', completed, { ownerDid: didKey, secret: other })] }); + const second = render(React.createElement(ItemProvenanceInfo, { item: bareItem })); + fireEvent.click(second.container.querySelector('button')); + await waitFor(() => assert.match(second.container.textContent, /not independently verified here/)); + assert.doesNotMatch(second.container.textContent, /Signature verified/); + cleanup(); + + const tampered = { ...genuine, payload: genuine.payload.replace('"checkedAt":5', '"checkedAt":6') }; + serve({ 'actionRecords:getItemActionRecords': [tampered] }); + const third = render(React.createElement(ItemProvenanceInfo, { item: bareItem })); + fireEvent.click(third.container.querySelector('button')); + await waitFor(() => assert.match(third.container.textContent, /Completion recordSignature check failed/)); + assert.doesNotMatch(third.container.textContent, /recordSigned|Signature verified/); +}); + +test('list-level records and historical placeholders are presented side by side with separate labels', async () => { + const secret = ed25519.utils.randomSecretKey(); + const ownerDid = 'did:webvh:QmScid:boop.ad:user-owner'; + const listSubject = { listId: 'L', listAssetDid: 'did:cel:list' }; + serve({ 'actionRecords:getListActionRecords': [ + await actionRecord('R2', { action: 'list.renamed', subject: listSubject, before: { name: 'Old' }, after: { name: 'List' } }, { ownerDid, secret, status: 'pending' }), + await actionRecord('R1', { action: 'list.created', subject: listSubject, before: null, after: { name: 'Old', kind: 'list' } }, { ownerDid, secret }), + ] }); + const list = { _id: 'L', assetDid: 'did:cel:list', name: 'List', ownerDid, createdAt: 1, + vcProof: { type: 'ListOwnershipCredential', issuer: ownerDid, issuanceDate: 1, credentialSubject: { id: 'did:cel:list', ownerDid }, + proof: JSON.stringify({ '@context': ['https://www.w3.org/2018/credentials/v1'], type: ['VerifiableCredential'], issuer: ownerDid, credentialSubject: { id: ownerDid } }) } }; + const { container } = render(React.createElement(MemoryRouter, null, React.createElement(ListProvenanceInfo, { list }))); + fireEvent.click(container.querySelector('button')); + assert.match(container.textContent, /Rename recordPending signature/); + assert.match(container.textContent, /List creation recordSigned/); + assert.match(container.textContent, /Historical ownership record \(unsigned\)/); + await waitFor(() => assert.match(container.textContent, /not independently verified here/)); +}); diff --git a/scripts/signed-action-records.test.mjs b/scripts/signed-action-records.test.mjs new file mode 100644 index 0000000..21fb478 --- /dev/null +++ b/scripts/signed-action-records.test.mjs @@ -0,0 +1,444 @@ +import 'fake-indexeddb/auto'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { getFunctionName } from 'convex/server'; +import * as ed25519 from '@noble/ed25519'; +import { MultibaseEncoding, multibaseEncode } from 'didwebvh-ts'; +import { loadReplayModules, replayFixture } from './helpers/replay-fixture.mjs'; +import { legacyActionEvidence } from '../shared/legacyActionEvidence.ts'; +import { + actionRecordDigest, actionRecordSigningInput, base58Decode, base58Encode, buildActionRecord, + canonicalize, ed25519Multikey, encodeSignature, parseEd25519PublicKey, verifyActionRecord, +} from '../shared/actionRecord.ts'; + +const modules = await loadReplayModules('signed-action-records'); +const extra = ['templates', 'actionRecords', 'agentReadHttp', 'users', 'didResources', 'migrations/remintUserDidDb']; +await build({ + entryPoints: extra.map(n => `convex/${n}.ts`), outdir: 'tmp/signed-action-records-extra', bundle: true, + platform: 'node', format: 'esm', external: ['convex/*', '@turnkey/*'], outExtension: { '.js': '.mjs' }, +}); +for (const name of extra) modules[name] = await import(pathToFileURL(`${process.cwd()}/tmp/signed-action-records-extra/${name}.mjs`)); +const digest = value => createHash('sha256').update(value).digest('hex'); + +/** Test-only Ed25519 keys, one per account DID. No signer is wired up yet, so + * tests sign stored records themselves to exercise the verifier and read paths. */ +async function testKeys() { + const keys = new Map(); + const keyFor = async did => { + if (!keys.has(did)) { + const secret = ed25519.utils.randomSecretKey(); + keys.set(did, { secret, publicKey: await ed25519.getPublicKeyAsync(secret) }); + } + return keys.get(did); + }; + return { + keyFor, + trusted: async user => ({ [user.did]: [ed25519Multikey((await keyFor(user.did)).publicKey)] }), + /** Settles a stored record as signed, in the evidence shape the verifier takes. */ + async sign(record, user) { + const { secret, publicKey } = await keyFor(user.did); + const publicKeyMultibase = ed25519Multikey(publicKey); + return Object.assign(record, { + status: 'signed', signature: encodeSignature(await ed25519.signAsync(actionRecordSigningInput(record.payload), secret)), + publicKeyMultibase, verificationMethod: `did:key:${publicKeyMultibase}#${publicKeyMultibase}`, signedAt: Date.now(), + }); + }, + }; +} + +async function fixture() { + const f = await replayFixture(modules); + const query = f.ctx.db.query; + f.ctx.db.query = table => { const q = query(table); q.order = () => q; return q; }; + f.ctx.runQuery = f.ctx.runMutation = (ref, args) => { const [ns, name] = getFunctionName(ref).split(':'); return modules[ns][name]._handler(f.ctx, args); }; + const keys = await testKeys(); + return Object.assign(f, { + keys, + records: () => f.rows.actionRecords ?? [], + payloads: (action) => (f.rows.actionRecords ?? []).map(r => JSON.parse(r.payload)).filter(p => !action || p.action === action), + /** Signs every unsigned record with its authorizing account's test key. */ + async signAll() { + for (const record of f.rows.actionRecords ?? []) { + if (record.status === 'unsigned') await keys.sign(record, f.rows.users.find(u => u._id === record.ownerUserId)); + } + }, + async addApiKey(raw, owner = f.owner.user) { + return f.ctx.db.insert('agentApiKeys', { ownerDid: owner.did, keyHash: digest(raw), scopes: ['items:write', 'items:read', 'lists:read'], agentDid: 'did:agent:caller-claimed' }); + }, + viaKey: (ns, name, args, apiKey) => modules[ns][name]._handler(f.ctx, { ...args, apiKey }), + }); +} +/** Shape of a payload with per-call identifiers and clocks removed. */ +const shape = payload => ({ ...payload, occurredAt: 0, subject: { ...payload.subject, itemId: 'ITEM' }, after: { ...payload.after, ...('checkedAt' in payload.after ? { checkedAt: 0 } : {}) }, ...(payload.origin ? { origin: { ...payload.origin, sourceItemId: 'SOURCE' } } : {}) }); + +test('canonical payload bytes are deterministic and reject values that have no single encoding', () => { + const context = { occurredAt: 1700000000000, owner: { did: 'did:owner', userId: 'U' }, credential: { kind: 'session', id: 'S' } }; + const change = { action: 'item.completed', subject: { listId: 'L', itemId: 'I' }, before: { checked: false }, after: { checked: true, checkedAt: 5 } }; + const reordered = { after: { checkedAt: 5, checked: true }, before: { checked: false }, subject: { itemId: 'I', listId: 'L' }, action: 'item.completed' }; + const a = buildActionRecord(change, context), b = buildActionRecord(reordered, { credential: { id: 'S', kind: 'session' }, owner: { userId: 'U', did: 'did:owner' }, occurredAt: 1700000000000 }); + assert.deepEqual(a, b); + assert.equal(a.payload, '{"action":"item.completed","after":{"checked":true,"checkedAt":5},"before":{"checked":false},"credential":{"id":"S","kind":"session"},"occurredAt":1700000000000,"owner":{"did":"did:owner","userId":"U"},"schema":"boop.action-record","subject":{"itemId":"I","listId":"L"},"version":1}'); + assert.equal(a.digest, digest(a.payload)); + assert.equal(canonicalize({ b: [1, 'é"\n', null, true], a: {} }), '{"a":{},"b":[1,"é\\"\\n",null,true]}'); + assert.equal(canonicalize({ n: [1.5, -0, 1e21, 2 ** 60] }), '{"n":[1.5,0,1e+21,1152921504606847000]}'); + for (const bad of [{ a: undefined }, { a: NaN }, { a: Infinity }, { a: new Date(0) }, { a: () => 1 }]) assert.throws(() => canonicalize(bad)); + assert.equal(actionRecordSigningInput(a.payload).length, 64); + assert.notDeepEqual(actionRecordSigningInput(a.payload).slice(32), actionRecordSigningInput(a.payload).slice(0, 32)); + // The local base58 codec agrees with the multibase implementation the DID code uses. + const bytes = Uint8Array.from([0, 0, 237, 1, ...Array.from({ length: 32 }, (_, i) => (i * 37) % 256)]); + assert.equal(`z${base58Encode(bytes)}`, multibaseEncode(bytes, MultibaseEncoding.BASE58_BTC)); + assert.deepEqual(base58Decode(base58Encode(bytes)), bytes); + const key = bytes.slice(4); + for (const form of [key, ed25519Multikey(key), `did:key:${ed25519Multikey(key)}#${ed25519Multikey(key)}`, base58Encode(key), `did:key:${base58Encode(key)}`]) { + assert.deepEqual(parseEd25519PublicKey(form), key); + } + assert.equal(parseEd25519PublicKey('not base58 0OIl'), null); +}); + +test('create, complete, reopen, list create and rename each write one unsigned record bound to the authenticated session', async () => { + const f = await fixture(); + const listId = await f.call('lists', 'createList', { assetDid: 'did:cel:new', name: 'Errands', createdAt: 1 }); + await f.call('lists', 'renameList', { listId, name: 'Chores' }); + const itemId = await f.call('items', 'addItem', { listId, name: 'Sweep', createdAt: 2 }); + await f.call('items', 'checkItem', { itemId, checkedAt: 3 }); + await f.call('items', 'uncheckItem', { itemId }); + const subject = { listId, listAssetDid: 'did:cel:new' }; + assert.deepEqual(f.payloads().map(p => [p.action, p.subject, p.before, p.after]), [ + ['list.created', subject, null, { name: 'Errands', kind: 'list' }], + ['list.renamed', subject, { name: 'Errands' }, { name: 'Chores' }], + ['item.created', { ...subject, itemId }, null, { name: 'Sweep', checked: false }], + ['item.completed', { ...subject, itemId }, { checked: false }, { checked: true, checkedAt: 3 }], + ['item.reopened', { ...subject, itemId }, { checked: true }, { checked: false }], + ]); + for (const [record, payload] of f.records().map((r, i) => [r, f.payloads()[i]])) { + assert.deepEqual(payload.owner, { did: f.owner.user.did, userId: f.owner.user._id }); + assert.deepEqual(payload.credential, { kind: 'session', id: f.owner.accessSession._id }); + assert.equal(payload.signer, undefined, 'custody is not decided, so the payload makes no claim about it'); + assert.deepEqual([payload.schema, payload.version], ['boop.action-record', 1]); + assert.ok(Math.abs(payload.occurredAt - Date.now()) < 5000); + assert.equal(record.payload, canonicalize(payload)); + assert.equal(record.digest, actionRecordDigest(record.payload)); + assert.deepEqual([record.status, record.signature, record.ownerUserId, record.listId, record.itemId], + ['unsigned', undefined, f.owner.user._id, listId, payload.subject.itemId]); + } + // No new unsigned placeholder JSON is written anywhere. + assert.equal(f.rows.lists.find(l => l._id === listId).vcProof, undefined); + assert.equal(f.rows.items.find(i => i._id === itemId).vcProofs, undefined); + // Recording happens entirely inside the mutation; nothing is scheduled for it. + assert.deepEqual(f.effects.filter(([, ref]) => getFunctionName(ref).startsWith('actionRecord')), []); +}); + +test('batch and individual actions produce equivalent records, including recurring generation', async () => { + const single = await fixture(), batch = await fixture(); + for (const f of [single, batch]) { + f.rows.items[0].recurrence = { frequency: 'daily' }; + f.rows.items.push({ ...structuredClone(f.rows.items[0]), _id: 'I2', name: 'Eggs', recurrence: undefined }); + } + for (const itemId of ['I1', 'I2']) await single.call('items', 'checkItem', { itemId, checkedAt: 50 }); + for (const itemId of ['I1', 'I2']) await single.call('items', 'uncheckItem', { itemId }); + await batch.call('items', 'batchCheckItems', { itemIds: ['I1', 'I2'] }); + await batch.call('items', 'batchUncheckItems', { itemIds: ['I1', 'I2'] }); + + const normalize = f => f.payloads().map(p => ({ ...shape(p), owner: 'OWNER', credential: 'CREDENTIAL', item: p.action === 'item.created' ? 'NEXT' : p.subject.itemId })) + .sort((a, b) => `${a.action}${a.item}`.localeCompare(`${b.action}${b.item}`)); + assert.deepEqual(normalize(batch), normalize(single)); + assert.deepEqual(batch.payloads().map(p => p.action).sort(), ['item.completed', 'item.completed', 'item.created', 'item.reopened', 'item.reopened']); + for (const f of [single, batch]) { + const [generated] = f.payloads('item.created'); + const next = f.rows.items.find(i => !['I1', 'I2'].includes(i._id)); + assert.deepEqual(generated.subject, { listId: 'L1', listAssetDid: 'did:list', itemId: next._id }); + assert.deepEqual(generated.origin, { kind: 'recurrence', sourceItemId: 'I1' }); + assert.deepEqual(generated.after, { name: 'Milk', checked: false }); + assert.deepEqual(f.payloads('item.completed').map(p => p.before), [{ checked: false }, { checked: false }]); + assert.deepEqual(f.payloads('item.reopened').map(p => p.before), [{ checked: true }, { checked: true }]); + // Historical evidence on the source item is neither replaced nor extended. + assert.deepEqual(f.rows.items[0].vcProofs, [{ type: 'ExistingSignedProof', proof: 'do-not-replace', issuer: f.owner.user.did }]); + } +}); + +test('DID resource toggles record the same completion and reopen as checkItem and uncheckItem', async () => { + const direct = await fixture(), shared = await fixture(); + await direct.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await direct.call('items', 'uncheckItem', { itemId: 'I1' }); + await shared.call('didResources', 'checkSharedItem', { listId: 'L1', itemId: 'I1' }); + await shared.call('didResources', 'uncheckSharedItem', { listId: 'L1', itemId: 'I1' }); + const normalize = f => f.payloads().map(p => ({ ...shape(p), owner: 'OWNER', credential: 'CREDENTIAL' })); + assert.deepEqual(normalize(shared), normalize(direct)); + const [completed] = shared.payloads('item.completed'); + assert.ok(Math.abs(completed.after.checkedAt - Date.now()) < 5000, 'the shared toggle stamps server time'); + assert.deepEqual(completed.subject, { listId: 'L1', listAssetDid: 'did:list', itemId: 'I1' }); +}); + +test('a session and two API keys of one account are distinguishable from each other and from the owner', async () => { + const f = await fixture(); + const first = await f.addApiKey('key-one'), second = await f.addApiKey('key-two'); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 1 }); + await f.viaKey('items', 'uncheckItem', { itemId: 'I1' }, 'key-one'); + await f.viaKey('items', 'checkItem', { itemId: 'I1', checkedAt: 2 }, 'key-two'); + await f.call('items', 'uncheckItem', { itemId: 'I1' }, f.collaborator); + const [session, keyOne, keyTwo, editor] = f.payloads(); + assert.deepEqual([session, keyOne, keyTwo].map(p => p.credential), [ + { kind: 'session', id: f.owner.accessSession._id }, { kind: 'apiKey', id: first }, { kind: 'apiKey', id: second }, + ]); + for (const payload of [session, keyOne, keyTwo]) assert.deepEqual(payload.owner, { did: f.owner.user.did, userId: f.owner.user._id }); + // An editor's action is authorized by the editor's account, not the list owner's. + assert.deepEqual(editor.owner, { did: f.collaborator.user.did, userId: f.collaborator.user._id }); + assert.deepEqual(editor.credential, { kind: 'session', id: f.collaborator.accessSession._id }); + // Identity is never taken from caller input or key metadata, and no secret material is recorded. + await assert.rejects(f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 3, userDid: 'did:somebody-else' }), /Identity assertion/); + const stored = JSON.stringify(f.records()); + for (const secret of ['did:agent:caller-claimed', 'key-one', 'key-two', digest('key-one'), f.owner.authToken, f.owner.accessSession.tokenHash, f.owner.user.turnkeySubOrgId]) { + assert.ok(!stored.includes(secret), `records must not contain ${secret.slice(0, 12)}…`); + } + assert.equal(f.payloads().length, 4); +}); + +test('a record signed with the authorizing account key verifies independently, and only under that account', async () => { + const f = await fixture(); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await f.call('items', 'uncheckItem', { itemId: 'I1' }, f.collaborator); + await f.signAll(); + const [byOwner, byEditor] = f.records(); + for (const [record, user] of [[byOwner, f.owner.user], [byEditor, f.collaborator.user]]) { + const publicKey = (await f.keys.keyFor(user.did)).publicKey; + const result = await verifyActionRecord(record, { trustedKeys: await f.keys.trusted(user) }); + assert.equal(result.verified, true); + assert.equal(result.publicKeyMultibase, record.publicKeyMultibase); + assert.deepEqual(result.payload, JSON.parse(record.payload)); + // Equivalent trust sources: a resolver, raw bytes, and a bare base58 key. + assert.equal((await verifyActionRecord(record, { resolvePublicKey: (did, method) => did === user.did && method === record.verificationMethod ? publicKey : null })).verified, true); + assert.equal((await verifyActionRecord(record, { trustedKeys: { [user.did]: [base58Encode(publicKey)] } })).verified, true); + } + // The owner's key does not vouch for the editor's record, and vice versa. + assert.deepEqual(await verifyActionRecord(byEditor, { trustedKeys: await f.keys.trusted(f.owner.user) }), { verified: false, reason: 'no_trusted_key' }); +}); + +test('the verifier rejects altered payloads, signatures, keys, bindings and every non-signed status', async () => { + const f = await fixture(); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await f.signAll(); + const [record] = structuredClone(f.records()); + const trustedKeys = await f.keys.trusted(f.owner.user); + const verify = (changes, options = { trustedKeys }) => verifyActionRecord({ ...record, ...changes }, options).then(r => r.verified ? 'verified' : r.reason); + const rewrite = edit => { const payload = JSON.parse(record.payload); edit(payload); const text = canonicalize(payload); return { payload: text, digest: actionRecordDigest(text) }; }; + assert.equal(await verify({}), 'verified'); + + // Payload: changed in place, changed with a matching digest, or re-serialized. + assert.equal(await verify({ payload: record.payload.replace('"checkedAt":7', '"checkedAt":8') }), 'digest_mismatch'); + assert.equal(await verify(rewrite(p => { p.after.checkedAt = 8; })), 'bad_signature'); + assert.equal(await verify(rewrite(p => { p.credential = { kind: 'apiKey', id: 'forged-key' }; })), 'bad_signature'); + assert.equal(await verify(rewrite(p => { p.subject.itemId = 'another-item'; })), 'binding_mismatch'); + assert.equal(await verify({ payload: JSON.stringify(JSON.parse(record.payload), null, 1), digest: digest(JSON.stringify(JSON.parse(record.payload), null, 1)) }), 'non_canonical_payload'); + assert.equal(await verify({ payload: '{"schema":"boop.action-record"', digest: digest('{"schema":"boop.action-record"') }), 'malformed_payload'); + assert.equal(await verify(rewrite(p => { p.version = 2; })), 'malformed_payload'); + assert.equal(await verify({ digest: digest('something else') }), 'digest_mismatch'); + + // Signature: flipped, truncated, or valid but for another payload. + const signature = base58Decode(record.signature.slice(1)); + signature[5] ^= 1; + assert.equal(await verify({ signature: encodeSignature(signature) }), 'bad_signature'); + assert.equal(await verify({ signature: encodeSignature(signature.slice(1)) }), 'not_signed'); + await f.call('items', 'uncheckItem', { itemId: 'I1' }); + await f.signAll(); + assert.equal(await verify({ signature: f.records()[1].signature }), 'bad_signature'); + + // Key: an attacker re-signs with their own key and supplies it in the record. + const secret = ed25519.utils.randomSecretKey(); + const attackerKey = ed25519Multikey(await ed25519.getPublicKeyAsync(secret)); + const forged = rewrite(p => { p.after.checkedAt = 999; }); + const attack = { ...forged, signature: encodeSignature(await ed25519.signAsync(actionRecordSigningInput(forged.payload), secret)), publicKeyMultibase: attackerKey, verificationMethod: `did:key:${attackerKey}#${attackerKey}` }; + assert.equal(await verify(attack), 'untrusted_key'); + assert.equal(await verify({ ...attack, publicKeyMultibase: undefined, verificationMethod: undefined }), 'bad_signature'); + assert.equal(await verify(attack, { trustedKeys: { [f.owner.user.did]: [attackerKey] } }), 'verified', 'sanity: the forgery is otherwise well-formed'); + assert.equal(await verify({}, { trustedKeys: { [f.owner.user.did]: [attackerKey] } }), 'untrusted_key'); + assert.equal(await verify({}, { trustedKeys: { 'did:someone-else': trustedKeys[f.owner.user.did] } }), 'no_trusted_key'); + assert.equal(await verify({}, {}), 'no_trusted_key'); + assert.equal(await verify({}, { resolvePublicKey: () => null }), 'no_trusted_key'); + assert.equal(await verify({ publicKeyMultibase: attackerKey }), 'binding_mismatch', 'record key and verification method must agree'); + + // Binding: the attribution a record is filed or shown under must match what was signed. + assert.equal(await verify({ ownerUserId: f.collaborator.user._id }), 'binding_mismatch'); + assert.equal(await verify({ listId: 'another-list' }), 'binding_mismatch'); + assert.equal(await verify({ itemId: 'another-item' }), 'binding_mismatch'); + assert.equal(await verify({}, { trustedKeys, expected: { ownerDid: f.collaborator.user.did } }), 'binding_mismatch'); + assert.equal(await verify({}, { trustedKeys, expected: { credential: { kind: 'apiKey', id: f.owner.accessSession._id } } }), 'binding_mismatch'); + assert.equal(await verify({}, { trustedKeys, expected: { ownerDid: f.owner.user.did, credential: { kind: 'session', id: f.owner.accessSession._id } } }), 'verified'); + // Naming another account as owner needs that account's key, even with the original key attached. + const reattributed = { ...rewrite(p => { p.owner = { did: f.collaborator.user.did, userId: f.collaborator.user._id }; }), ownerUserId: f.collaborator.user._id }; + assert.equal(await verify(reattributed), 'no_trusted_key'); + assert.equal(await verify(reattributed, { trustedKeys: { ...trustedKeys, ...await f.keys.trusted(f.collaborator.user) } }), 'untrusted_key'); + + for (const status of ['pending', 'failed', 'unsigned']) assert.equal(await verify({ status }), 'not_signed'); + assert.equal(await verify({ signature: undefined }), 'not_signed'); +}); + +test('no caller-supplied value can make recording fail the write', async () => { + const f = await fixture(); + for (const checkedAt of [1700000000000.5, -100000, NaN, Infinity]) { + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt }); + } + await f.call('items', 'addItem', { listId: 'L1', name: 'lone surrogate \ud800 "quoted" \n\u2028 ünïcode', createdAt: 1 }); + assert.deepEqual(f.payloads('item.completed').map(p => p.after.checkedAt), [1700000000000.5, -100000, null, null]); + assert.ok(f.records().every(r => r.status === 'unsigned')); + await f.signAll(); + const trustedKeys = await f.keys.trusted(f.owner.user); + for (const record of f.records()) { + assert.equal((await verifyActionRecord(JSON.parse(JSON.stringify(record)), { trustedKeys })).verified, true); + } +}); + +test('recording never depends on signing infrastructure, so an account without a Turnkey sub-org records the same way', async () => { + const f = await fixture(); + // API-key authentication does not depend on a sub-org; the account simply has none. + await f.addApiKey('key-one'); + delete f.rows.users[0].turnkeySubOrgId; + await f.viaKey('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }, 'key-one'); + const [record] = f.records(); + assert.deepEqual([record.status, record.signature], ['unsigned', undefined]); + assert.equal(f.rows.items[0].checked, true); + assert.deepEqual(await verifyActionRecord(record, { trustedKeys: { [f.owner.user.did]: ['z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'] } }), { verified: false, reason: 'not_signed' }); +}); + +test('template creation records the list and every item through the same path', async () => { + const f = await fixture(); + f.rows.listTemplates = [{ _id: 'T1', name: 'Saved', ownerDid: f.owner.user.did, isPublic: false, items: [{ name: 'Step one', order: 0 }, { name: 'Step two', order: 1 }] }]; + const listId = await f.call('templates', 'createListFromTemplate', { templateId: 'T1', listName: 'From template', assetDid: 'did:cel:template' }); + const origin = { kind: 'template', source: 'saved:T1' }; + assert.deepEqual(f.payloads().map(p => [p.action, p.after, p.origin]), [ + ['list.created', { name: 'From template', kind: 'list' }, origin], + ['item.created', { name: 'Step one', checked: false }, origin], + ['item.created', { name: 'Step two', checked: false }, origin], + ]); + // Apart from its origin, a template item's record is the record ordinary creation writes. + const direct = await f.call('items', 'addItem', { listId, name: 'Step one', createdAt: 1 }); + const [fromTemplate, , added] = f.payloads('item.created'); + assert.equal(added.subject.itemId, direct); + assert.deepEqual(shape({ ...fromTemplate, origin: undefined }), shape({ ...added, origin: undefined })); + await f.signAll(); + const trustedKeys = await f.keys.trusted(f.owner.user); + for (const record of f.records()) assert.equal((await verifyActionRecord(record, { trustedKeys })).verified, true); +}); + +test('copying a list records the new list only, and notes are recorded as notes', async () => { + const f = await fixture(); + const { listId } = await f.call('lists', 'copyList', { sourceListId: 'L1', assetDid: 'did:cel:copy', celEnvelope: '{}', name: 'Copy', createdAt: 5 }); + const noteId = await f.call('lists', 'createList', { assetDid: 'did:cel:note', name: 'Thoughts', createdAt: 6, kind: 'note' }); + assert.deepEqual(f.payloads().map(p => [p.action, p.subject, p.after, p.origin]), [ + ['list.created', { listId, listAssetDid: 'did:cel:copy' }, { name: 'Copy', kind: 'list' }, { kind: 'copy', sourceListId: 'L1' }], + ['list.created', { listId: noteId, listAssetDid: 'did:cel:note' }, { name: 'Thoughts', kind: 'note' }, undefined], + ]); + assert.equal(f.rows.items.find(i => i.listId === listId).vcProofs, undefined); +}); + +test('records are readable with the access of their subject and are removed with it', async () => { + const f = await fixture(); + const stranger = await (await import('./helpers/auth-fixture.mjs')).createAuthFixture('did:stranger-records'); + f.rows.users.push(stranger.user); f.rows.accessSessions.push(stranger.accessSession); + f.rows.publications.length = 0; // private list + await f.call('lists', 'renameList', { listId: 'L1', name: 'Food' }); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await f.call('items', 'addItem', { listId: 'L1', name: 'Bread', createdAt: 8 }); + await f.signAll(); + const itemRecords = await f.call('actionRecords', 'getItemActionRecords', { itemId: 'I1' }, f.collaborator); + assert.deepEqual(itemRecords.map(r => JSON.parse(r.payload).action), ['item.completed']); + const listRecords = await f.call('actionRecords', 'getListActionRecords', { listId: 'L1' }, f.collaborator); + assert.deepEqual(listRecords.map(r => JSON.parse(r.payload).action), ['list.renamed']); + // What a reader receives is sufficient for independent verification, and nothing internal. + assert.equal((await verifyActionRecord(itemRecords[0], { trustedKeys: await f.keys.trusted(f.owner.user) })).verified, true); + assert.deepEqual(Object.keys(itemRecords[0]).sort(), ['_id', 'digest', 'error', 'itemId', 'listId', 'ownerUserId', 'payload', 'publicKeyMultibase', 'signature', 'signedAt', 'status', 'verificationMethod']); + await assert.rejects(f.call('actionRecords', 'getItemActionRecords', { itemId: 'I1' }, stranger)); + await assert.rejects(f.call('actionRecords', 'getListActionRecords', { listId: 'L1' }, stranger)); + + // The HTTP read hands an outside verifier the same evidence, JSON round trip included. + await f.addApiKey('reader-key'); + await f.addApiKey('stranger-key', stranger.user); + const http = (query, apiKey) => modules.agentReadHttp.getActionRecords._handler(f.ctx, new Request(`https://example.test/api/v1/action-records?${query}`, { headers: { 'X-API-Key': apiKey } })); + const fetched = await http('itemId=I1', 'reader-key'); + assert.equal(fetched.status, 200); + const { records } = await fetched.json(); + assert.deepEqual(records.map(r => r._id), itemRecords.map(r => r._id)); + assert.equal((await verifyActionRecord(records[0], { trustedKeys: await f.keys.trusted(f.owner.user) })).verified, true); + assert.deepEqual((await (await http('listId=L1', 'reader-key')).json()).records.map(r => JSON.parse(r.payload).action), ['list.renamed']); + assert.equal((await http('itemId=I1&listId=L1', 'reader-key')).status, 400); + + // The standalone verifier accepts that export against a pinned key, and nothing else. + const exported = 'tmp/signed-action-records-extra/export.json'; + const pin = Object.entries(await f.keys.trusted(f.owner.user)).map(([did, [key]]) => `${did}=${key}`); + const cli = (...args) => spawnSync(process.execPath, ['scripts/verify-action-records.mjs', ...args], { encoding: 'utf8' }); + writeFileSync(exported, JSON.stringify({ records })); + const accepted = cli(exported, ...pin); + assert.deepEqual([accepted.status, accepted.stdout.trim().split('\t').slice(1, 3)], [0, ['verified', 'item.completed']]); + assert.equal(cli(exported, `${f.owner.user.did}=z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK`).status, 1); + writeFileSync(exported, JSON.stringify({ records: [{ ...records[0], payload: records[0].payload.replace('"checkedAt":7', '"checkedAt":8') }] })); + const refused = cli(exported, ...pin); + assert.deepEqual([refused.status, refused.stdout.trim().split('\t').slice(1)], [1, ['REJECTED', 'digest_mismatch']]); + assert.equal(cli(exported).status, 2); + assert.equal((await http('', 'reader-key')).status, 400); + for (const query of ['itemId=I1', 'listId=L1']) { + const denied = await http(query, 'stranger-key'); + assert.notEqual(denied.status, 200); + assert.equal((await denied.json()).records, undefined); + } + + await f.call('items', 'removeItem', { itemId: 'I1' }); + assert.deepEqual(f.payloads().map(p => p.action), ['list.renamed', 'item.created']); + await f.call('items', 'batchDeleteItems', { itemIds: [f.payloads('item.created')[0].subject.itemId] }); + assert.deepEqual(f.payloads().map(p => p.action), ['list.renamed']); + await f.call('lists', 'deleteList', { listId: 'L1' }); + assert.deepEqual(f.records(), []); + + // Subjects with more records than one mutation should touch are drained in batches. + const DELETE_RECORDS_BATCH = 500; // convex/lib/actionRecords.ts + const h = await fixture(); + for (let i = 0; i <= DELETE_RECORDS_BATCH; i++) await h.ctx.db.insert('actionRecords', { listId: 'L1', itemId: 'I1', status: 'unsigned' }); + await h.call('items', 'removeItem', { itemId: 'I1' }); + assert.equal(h.records().length, 1); + const continuations = h.effects.filter(([, ref]) => getFunctionName(ref) === 'actionRecords:deleteRemaining'); + assert.deepEqual(continuations.map(([delay, , args]) => [delay, args]), [[0, { itemId: 'I1' }]]); + await modules.actionRecords.deleteRemaining._handler(h.ctx, continuations[0][2]); + assert.deepEqual(h.records(), []); + for (let i = 0; i <= DELETE_RECORDS_BATCH; i++) await h.ctx.db.insert('actionRecords', { listId: 'L1', status: 'unsigned' }); + await h.call('lists', 'deleteList', { listId: 'L1' }); + assert.equal(h.records().length, 1); + await modules.actionRecords.deleteRemaining._handler(h.ctx, { listId: 'L1' }); + assert.deepEqual(h.records(), []); + + // Account erasure removes the records of that account's lists and items as well. + const g = await fixture(); + await g.call('lists', 'renameList', { listId: 'L1', name: 'Food' }); + await g.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await g.call('items', 'uncheckItem', { itemId: 'I1' }, g.collaborator); + assert.equal(g.records().length, 3); + await g.call('users', 'deleteUserData', { userId: g.owner.user._id }); + for (let step = 0; step < 40 && g.rows.lists.length; step++) await modules.users.continueUserDeletion._handler(g.ctx, { userId: g.owner.user._id }); + assert.equal(g.rows.lists.length, 0); + assert.deepEqual(g.records(), []); +}); + +test('historical placeholders stay untouched and unsigned; a DID re-mint never rewrites signed payloads', async () => { + const f = await fixture(); + const placeholder = { type: 'ItemAuthorshipCredential', issuer: f.owner.user.did, issuanceDate: 1, action: 'created', actorDid: f.owner.user.did, + proof: JSON.stringify({ '@context': ['https://www.w3.org/2018/credentials/v1'], type: ['VerifiableCredential', 'ItemAuthorshipCredential'], issuer: f.owner.user.did, credentialSubject: { id: f.owner.user.did } }) }; + f.rows.items[0].vcProofs = [placeholder]; + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await f.signAll(); + assert.deepEqual(f.rows.items[0].vcProofs, [placeholder]); + assert.equal(legacyActionEvidence(f.rows.items[0].vcProofs[0].proof), 'unsigned'); + assert.equal(legacyActionEvidence(f.records()[0].payload), 'unverified', 'a new record is never mistaken for a legacy placeholder'); + + const before = structuredClone(f.records()); + const oldDid = f.owner.user.did; + const [signingKey] = Object.values(await f.keys.trusted(f.owner.user))[0]; + const result = await modules['migrations/remintUserDidDb'].applyRemint._handler(f.ctx, { userId: f.owner.user._id, oldDid, newDid: 'did:webvh:reminted' }); + assert.ok(result.rewritten > 0); + assert.equal(f.rows.items[0].createdByDid, 'did:webvh:reminted', 'sanity: the re-mint did rewrite mutable attribution'); + assert.deepEqual(f.records(), before); + // The record keeps naming the DID that authorized it; trust is pinned for that DID. + assert.equal((await verifyActionRecord(f.records()[0], { trustedKeys: { [oldDid]: [signingKey] } })).verified, true); + assert.deepEqual(await verifyActionRecord(f.records()[0], { trustedKeys: { 'did:webvh:reminted': [signingKey] } }), { verified: false, reason: 'no_trusted_key' }); +}); diff --git a/scripts/templates.test.mjs b/scripts/templates.test.mjs index 41997ef..780ca58 100644 --- a/scripts/templates.test.mjs +++ b/scripts/templates.test.mjs @@ -54,8 +54,11 @@ function makeCtx({ items = [], lists: extraLists = [], user = null, subscription for (const table of Object.values(rows)) for (const r of table) byId.set(r._id, r); let seq = 0; + const scheduled = []; return { rows, + scheduled, + scheduler: { runAfter: async (_delay, ref, args) => { scheduled.push({ ref, args }); } }, db: { get: async (id) => byId.get(id) ?? null, patch: async (id, fields) => Object.assign(byId.get(id), fields), @@ -92,6 +95,7 @@ function makeCtx({ items = [], lists: extraLists = [], user = null, subscription const create = unwrap(mod.createListFromTemplate); +const records = (ctx, action) => (ctx.rows.actionRecords ?? []).map(r => JSON.parse(r.payload)).filter(p => p.action === action); const args = { builtinId: 'release-checklist', listName: 'Release checklist', assetDid: 'did:cel:template-test', authToken: ownerSession.authToken, expectedOwnerDid: OWNER }; test('all ten catalog runbooks have actionable, ordered steps and distinct landing copy', async () => { @@ -117,9 +121,11 @@ test('retry returns the complete original list without extra items, even at quot assert.equal(ctx.rows.lists.length, 5); assert.equal(ctx.rows.items.length, 6); assert.ok(ctx.rows.items.every(i => i.checked === false && i.createdByDid === OWNER)); - assert.ok((await ctx.db.get(id)).vcProof); + assert.equal((await ctx.db.get(id)).vcProof, undefined, 'no new unsigned ownership placeholder'); + assert.equal(records(ctx, 'list.created').length, 1); + assert.deepEqual(ctx.scheduled, [], 'recording schedules no background work'); }); -test('quick-start and saved template items retain authorship and revision metadata across retries', async () => { +test('quick-start and saved template items get one creation record each, like ordinary creation, across retries', async () => { for (const saved of [false, true]) { const ctx = makeCtx(); const source = saved @@ -132,23 +138,26 @@ test('quick-start and saved template items retain authorship and revision metada const listId = await create(ctx, request); const items = ctx.rows.items.filter(item => item.listId === listId); assert.ok(items.length > 0); + const origin = { kind: 'template', source: saved ? `saved:${source.templateId}` : 'builtin:grocery' }; + assert.deepEqual(records(ctx, 'list.created').map(r => [r.subject, r.after, r.origin]), + [[{ listId, listAssetDid: args.assetDid }, { name: args.listName, kind: 'list' }, origin]]); for (const item of items) { assert.equal(item.updatedAt, item.createdAt); assert.equal(item.assignmentsVersion, 1); - assert.equal(item.vcProofs.length, 1); - const [record] = item.vcProofs; - assert.equal(record.type, 'ItemAuthorshipCredential'); - assert.equal(record.actorDid, OWNER); - assert.equal(record.issuanceDate, item.createdAt); - const historicalPayload = JSON.parse(record.proof); - assert.deepEqual(historicalPayload.credentialSubject, { - id: OWNER, itemId: item._id, listId, itemName: item.name, action: 'created', - }); - assert.equal(historicalPayload.proof, undefined); // Preserve unsigned historical semantics. + assert.equal(item.vcProofs, undefined, 'no new unsigned authorship placeholder'); + const [record, ...extra] = records(ctx, 'item.created').filter(r => r.subject.itemId === item._id); + assert.deepEqual(extra, []); + assert.deepEqual(record.subject, { listId, listAssetDid: args.assetDid, itemId: item._id }); + assert.deepEqual(record.after, { name: item.name, checked: false }); + assert.deepEqual(record.origin, origin); + assert.deepEqual(record.owner, { did: OWNER, userId: ownerSession.user._id }); + assert.deepEqual(record.credential, { kind: 'session', id: ownerSession.accessSession._id }); } - const before = structuredClone(items); + assert.equal(ctx.rows.actionRecords.length, items.length + 1); + assert.ok(ctx.rows.actionRecords.every(r => r.status === 'unsigned' && r.signature === undefined)); + const before = structuredClone([ctx.rows.items, ctx.rows.actionRecords]); assert.equal(await create(ctx, request), listId); - assert.deepEqual(ctx.rows.items, before); + assert.deepEqual([ctx.rows.items, ctx.rows.actionRecords], before); } }); test('new creation enforces free-plan quota before writing', async () => { diff --git a/scripts/verify-action-records.mjs b/scripts/verify-action-records.mjs new file mode 100644 index 0000000..b347cde --- /dev/null +++ b/scripts/verify-action-records.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env bun +// Independently verifies exported action records against keys YOU pin. No network. +// Usage: bun scripts/verify-action-records.mjs records.json '=' [...] +// records.json is the body of GET /api/v1/action-records, or a bare array of records. +// A key may be a Multikey (z6Mk…), a did:key, or a base58 Ed25519 public key. +import { readFileSync } from 'node:fs'; +import { verifyActionRecord } from '../shared/actionRecord.ts'; + +const [file, ...pins] = process.argv.slice(2); +if (!file || pins.length === 0 || pins.some(pin => !pin.includes('='))) { + console.error("usage: bun scripts/verify-action-records.mjs records.json '=' [...]"); + process.exit(2); +} +const trustedKeys = {}; +for (const pin of pins) { + const at = pin.lastIndexOf('='); + (trustedKeys[pin.slice(0, at)] ??= []).push(pin.slice(at + 1)); +} + +const parsed = JSON.parse(readFileSync(file, 'utf8')); +let rejected = 0; +for (const record of Array.isArray(parsed) ? parsed : parsed.records) { + const result = await verifyActionRecord(record, { trustedKeys }); + if (!result.verified) rejected++; + const { payload } = result; + console.log([record._id ?? record.digest, ...(result.verified + ? ['verified', payload.action, payload.owner.did, `${payload.credential.kind}:${payload.credential.id}`] + : ['REJECTED', result.reason])].join('\t')); +} +process.exit(rejected > 0 ? 1 : 0); diff --git a/shared/actionRecord.ts b/shared/actionRecord.ts new file mode 100644 index 0000000..fc4090b --- /dev/null +++ b/shared/actionRecord.ts @@ -0,0 +1,302 @@ +/** Signed action records: payload contract, canonical bytes, and an independent + * verifier. Pure (no Convex ctx, no network) so the server, the browser and + * outside verifiers share one definition. See docs/action-records.md. */ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, concatBytes, utf8ToBytes } from "@noble/hashes/utils.js"; +import { verifyAsync } from "@noble/ed25519"; + +export const ACTION_RECORD_SCHEMA = "boop.action-record"; +export const ACTION_RECORD_VERSION = 1; +// Domain-separates action signatures from anything else the same key signs. +const SIGNING_DOMAIN = `${ACTION_RECORD_SCHEMA}/v${ACTION_RECORD_VERSION}`; + +/** Row identity of the authenticated session or API key; never a token or hash. */ +export type ActionCredential = { kind: "session" | "apiKey"; id: string }; + +type ListSubject = { listId: string; listAssetDid?: string }; +type ItemSubject = ListSubject & { itemId: string }; + +/** What happened, and to what. Individual and batch operations build the same shape. */ +export type ActionChange = + | { + action: "item.created"; + subject: ItemSubject; + before: null; + after: { name: string; checked: false }; + origin?: { kind: "recurrence"; sourceItemId: string } | { kind: "template"; source: string }; + } + | { + action: "item.completed"; + subject: ItemSubject; + before: { checked: boolean }; + /** The item's stored checkedAt; null when the caller sent a non-finite number. */ + after: { checked: true; checkedAt: number | null }; + } + | { + action: "item.reopened"; + subject: ItemSubject; + before: { checked: boolean }; + after: { checked: false }; + } + | { + action: "list.created"; + subject: ListSubject; + before: null; + after: { name: string; kind: "list" | "note" }; + origin?: { kind: "template"; source: string } | { kind: "copy"; sourceListId: string }; + } + | { + action: "list.renamed"; + subject: ListSubject; + before: { name: string }; + after: { name: string }; + }; + +export type ActionType = ActionChange["action"]; +const ACTION_TYPES: readonly string[] = [ + "item.created", "item.completed", "item.reopened", "list.created", "list.renamed", +] satisfies ActionType[]; + +export type ActionRecordPayload = ActionChange & { + schema: typeof ACTION_RECORD_SCHEMA; + version: typeof ACTION_RECORD_VERSION; + /** Server time of the mutation, ms since epoch. */ + occurredAt: number; + /** Account that owns the credential and authorized the action; not necessarily the list owner. */ + owner: { did: string; userId: string }; + credential: ActionCredential; +}; + +export type ActionRecordStatus = "pending" | "signed" | "failed" | "unsigned"; + +/** Wire shape of a stored record. `payload` is the exact UTF-8 text that was signed. */ +export type ActionRecordEvidence = { + payload: string; + /** Lowercase hex SHA-256 of `payload`. */ + digest: string; + status: ActionRecordStatus; + /** Multibase base58btc Ed25519 signature over `actionRecordSigningInput(payload)`. */ + signature?: string; + verificationMethod?: string; + publicKeyMultibase?: string; + signedAt?: number; + // Unsigned index columns; the verifier requires them to agree with the payload. + listId?: string; + itemId?: string; + ownerUserId?: string; +}; + +/** Deterministic JSON per RFC 8785: sorted keys, no whitespace, ECMAScript number and string forms. */ +export function canonicalize(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("Action payload numbers must be finite"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) { + return `{${Object.keys(value).sort().map(key => + `${JSON.stringify(key)}:${canonicalize((value as Record)[key])}`).join(",")}}`; + } + throw new Error(`Action payload cannot contain ${typeof value}`); +} + +export function actionRecordDigest(payload: string): string { + return bytesToHex(sha256(utf8ToBytes(payload))); +} + +/** The 64 bytes actually signed: SHA-256(domain) || SHA-256(payload). */ +export function actionRecordSigningInput(payload: string): Uint8Array { + return concatBytes(sha256(utf8ToBytes(SIGNING_DOMAIN)), sha256(utf8ToBytes(payload))); +} + +export function buildActionRecord(change: ActionChange, context: { + occurredAt: number; + owner: { did: string; userId: string }; + credential: ActionCredential; +}): { payload: string; digest: string } { + const body: ActionRecordPayload = { + ...change, + schema: ACTION_RECORD_SCHEMA, + version: ACTION_RECORD_VERSION, + occurredAt: context.occurredAt, + owner: { did: context.owner.did, userId: context.owner.userId }, + credential: { kind: context.credential.kind, id: context.credential.id }, + }; + const payload = canonicalize(body); + return { payload, digest: actionRecordDigest(payload) }; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Structural check of the envelope fields every version-1 payload carries. */ +export function parseActionRecordPayload(payload: string): ActionRecordPayload | null { + let parsed: unknown; + try { parsed = JSON.parse(payload); } catch { return null; } + if (!isRecord(parsed) || parsed.schema !== ACTION_RECORD_SCHEMA || parsed.version !== ACTION_RECORD_VERSION) return null; + const { action, subject, owner, credential, occurredAt } = parsed; + if (typeof action !== "string" || !ACTION_TYPES.includes(action)) return null; + if (!isRecord(subject) || typeof subject.listId !== "string") return null; + if (action.startsWith("item.") !== (typeof subject.itemId === "string")) return null; + if (!isRecord(owner) || typeof owner.did !== "string" || typeof owner.userId !== "string") return null; + if (!isRecord(credential) || typeof credential.id !== "string" + || (credential.kind !== "session" && credential.kind !== "apiKey")) return null; + if (typeof occurredAt !== "number" || !isRecord(parsed.after)) return null; + return parsed as ActionRecordPayload; +} + +const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +export function base58Encode(bytes: Uint8Array): string { + const digits: number[] = []; + for (const byte of bytes) { + let carry = byte; + for (let i = 0; i < digits.length; i++) { + carry += digits[i] << 8; + digits[i] = carry % 58; + carry = Math.floor(carry / 58); + } + for (; carry > 0; carry = Math.floor(carry / 58)) digits.push(carry % 58); + } + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + return "1".repeat(zeros) + digits.reverse().map(digit => BASE58[digit]).join(""); +} + +export function base58Decode(text: string): Uint8Array | null { + const bytes: number[] = []; + for (const char of text) { + let carry = BASE58.indexOf(char); + if (carry < 0) return null; + for (let i = 0; i < bytes.length; i++) { + carry += bytes[i] * 58; + bytes[i] = carry & 0xff; + carry >>= 8; + } + for (; carry > 0; carry >>= 8) bytes.push(carry & 0xff); + } + let zeros = 0; + while (zeros < text.length && text[zeros] === "1") zeros++; + return Uint8Array.from([...new Array(zeros).fill(0), ...bytes.reverse()]); +} + +const ED25519_MULTICODEC = [0xed, 0x01]; + +/** Standard Multikey form (`z6Mk…`) of a raw Ed25519 public key. */ +export function ed25519Multikey(publicKey: Uint8Array): string { + return `z${base58Encode(Uint8Array.from([...ED25519_MULTICODEC, ...publicKey]))}`; +} + +/** Accepts raw bytes, a Multikey, a bare base58 key (Turnkey/Solana address), or either as did:key. */ +export function parseEd25519PublicKey(key: string | Uint8Array): Uint8Array | null { + if (typeof key !== "string") return key.length === 32 ? key : null; + const text = key.replace(/^did:key:/, "").split("#")[0]; + if (text.startsWith("z")) { + const multikey = base58Decode(text.slice(1)); + if (multikey?.length === 34 && multikey[0] === ED25519_MULTICODEC[0] && multikey[1] === ED25519_MULTICODEC[1]) { + return multikey.slice(2); + } + } + const raw = base58Decode(text); + return raw?.length === 32 ? raw : null; +} + +export function encodeSignature(signature: Uint8Array): string { + return `z${base58Encode(signature)}`; +} + +function decodeSignature(signature: string): Uint8Array | null { + const bytes = signature.startsWith("z") ? base58Decode(signature.slice(1)) : null; + return bytes?.length === 64 ? bytes : null; +} + +export async function verifyActionSignature(payload: string, signature: Uint8Array, publicKey: Uint8Array): Promise { + try { + return await verifyAsync(signature, actionRecordSigningInput(payload), publicKey); + } catch { + return false; + } +} + +type TrustedKey = string | Uint8Array; +type MaybePromise = T | Promise; + +export type VerifyActionRecordOptions = { + /** Keys the caller already trusts, by owner DID. Keep rotated-out keys listed for older records. */ + trustedKeys?: Readonly>; + /** Alternative lookup. The record's verification method is passed only as a hint. */ + resolvePublicKey?: (ownerDid: string, verificationMethod?: string) => + MaybePromise; + /** Attribution the caller was shown; the signed payload must agree with it. */ + expected?: { ownerDid?: string; credential?: ActionCredential }; +}; + +export type VerifyFailure = + | "not_signed" + | "malformed_payload" + | "non_canonical_payload" + | "digest_mismatch" + | "binding_mismatch" + | "no_trusted_key" + | "untrusted_key" + | "bad_signature"; + +export type VerifyActionRecordResult = + | { verified: true; payload: ActionRecordPayload; publicKeyMultibase: string } + | { verified: false; reason: VerifyFailure }; + +const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((byte, i) => byte === b[i]); + +/** + * Checks one record against keys the CALLER trusts for the payload's owner DID. + * A key carried by the record itself only selects among those; it is never trusted. + */ +export async function verifyActionRecord( + record: ActionRecordEvidence, + options: VerifyActionRecordOptions, +): Promise { + const fail = (reason: VerifyFailure): VerifyActionRecordResult => ({ verified: false, reason }); + + const signature = record.status === "signed" && record.signature ? decodeSignature(record.signature) : null; + if (!signature) return fail("not_signed"); + + const payload = parseActionRecordPayload(record.payload); + if (!payload) return fail("malformed_payload"); + let canonical: string; + try { canonical = canonicalize(JSON.parse(record.payload)); } catch { return fail("malformed_payload"); } + if (canonical !== record.payload) return fail("non_canonical_payload"); + if (actionRecordDigest(record.payload) !== record.digest) return fail("digest_mismatch"); + + const itemId = "itemId" in payload.subject ? payload.subject.itemId : undefined; + if ((record.listId !== undefined && record.listId !== payload.subject.listId) + || (record.itemId !== undefined && record.itemId !== itemId) + || (record.ownerUserId !== undefined && record.ownerUserId !== payload.owner.userId)) return fail("binding_mismatch"); + const { expected } = options; + if (expected?.ownerDid !== undefined && expected.ownerDid !== payload.owner.did) return fail("binding_mismatch"); + if (expected?.credential && (expected.credential.kind !== payload.credential.kind + || expected.credential.id !== payload.credential.id)) return fail("binding_mismatch"); + + const declared = [record.publicKeyMultibase, record.verificationMethod] + .filter((key): key is string => key !== undefined).map(parseEd25519PublicKey); + if (declared.some(key => key === null) || (declared.length === 2 && !sameBytes(declared[0]!, declared[1]!))) { + return fail("binding_mismatch"); + } + + const resolved = await options.resolvePublicKey?.(payload.owner.did, record.verificationMethod); + const supplied: readonly TrustedKey[] = [ + ...(options.trustedKeys?.[payload.owner.did] ?? []), + ...(resolved == null ? [] : typeof resolved === "string" || resolved instanceof Uint8Array ? [resolved] : resolved), + ]; + const trusted = supplied.map(parseEd25519PublicKey).filter((key): key is Uint8Array => key !== null); + if (trusted.length === 0) return fail("no_trusted_key"); + + const candidates = declared[0] ? trusted.filter(key => sameBytes(key, declared[0]!)) : trusted; + if (candidates.length === 0) return fail("untrusted_key"); + for (const key of candidates) { + if (await verifyActionSignature(record.payload, signature, key)) { + return { verified: true, payload, publicKeyMultibase: ed25519Multikey(key) }; + } + } + return fail("bad_signature"); +} diff --git a/src/components/ProvenanceInfo.tsx b/src/components/ProvenanceInfo.tsx index db23685..87be143 100644 --- a/src/components/ProvenanceInfo.tsx +++ b/src/components/ProvenanceInfo.tsx @@ -1,4 +1,11 @@ import { legacyActionEvidence } from "../../shared/legacyActionEvidence"; +import { + parseActionRecordPayload, + parseEd25519PublicKey, + verifyActionRecord, + type ActionRecordEvidence, + type ActionType, +} from "../../shared/actionRecord"; /** * ProvenanceInfo - Shows Originals DID/provenance chain information * @@ -15,6 +22,7 @@ import { useEffect, useState } from "react"; import { useQuery, useMutation } from "../lib/authenticatedConvex"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; +import type { FunctionReturnType } from "convex/server"; import { useNavigate } from "react-router-dom"; import { useSettings } from "../hooks/useSettings"; import { useCurrentUser } from "../hooks/useCurrentUser"; @@ -505,6 +513,170 @@ function VcProofRow({ ); } +type StoredActionRecord = FunctionReturnType[number]; + +const ACTION_RECORD_LABELS: Record = { + "item.created": "Creation record", + "item.completed": "Completion record", + "item.reopened": "Reopen record", + "list.created": "List creation record", + "list.renamed": "Rename record", +}; + +/** + * verified: checked against the key the owner's own DID encodes. + * unbound: the signature matches the key boop recorded, which proves integrity + * but not whose key it is. invalid: even that check fails. + */ +type SignatureCheck = "verified" | "unbound" | "invalid"; + +async function checkSignedRecord(record: ActionRecordEvidence, ownerDid: string): Promise { + // Only a did:key owner DID carries its own key; other DIDs need a pinned key. + if (ownerDid.startsWith("did:key:") && parseEd25519PublicKey(ownerDid) + && (await verifyActionRecord(record, { trustedKeys: { [ownerDid]: [ownerDid] } })).verified) return "verified"; + const recordedKey = record.publicKeyMultibase ? [record.publicKeyMultibase] : []; + return (await verifyActionRecord(record, { trustedKeys: { [ownerDid]: recordedKey } })).verified ? "unbound" : "invalid"; +} + +/** Signature checks hash and verify, so they run in an effect, keyed by record. */ +function useSignatureChecks(records: StoredActionRecord[] | undefined): Record { + const [checks, setChecks] = useState>({}); + + useEffect(() => { + let cancelled = false; + const signed = (records ?? []).filter((record) => record.status === "signed"); + if (signed.length === 0) return; + void Promise.all(signed.map(async (record) => { + const ownerDid = parseActionRecordPayload(record.payload)?.owner.did; + return [record._id, ownerDid ? await checkSignedRecord(record, ownerDid) : "invalid"] as const; + })).then((entries) => { + if (!cancelled) setChecks(Object.fromEntries(entries)); + }); + return () => { + cancelled = true; + }; + }, [records]); + + return checks; +} + +function actionRecordPresentation( + record: StoredActionRecord, + check: SignatureCheck | undefined +): { status: string; detail: string; tone: string } { + const neutral = "text-gray-500 dark:text-gray-400"; + if (record.status === "pending") { + return { status: "Pending signature", detail: "Waiting for this record to be signed.", tone: neutral }; + } + if (record.status === "failed") { + return { status: "Signing failed", detail: "Signing did not complete, so this record carries no signature.", tone: "text-red-600 dark:text-red-400" }; + } + if (record.status === "unsigned") { + return { status: "Unsigned", detail: "No signature exists for this record.", tone: neutral }; + } + if (check === "invalid") { + return { status: "Signature check failed", detail: "The stored signature does not match this record. Treat it as unverified.", tone: "text-red-600 dark:text-red-400" }; + } + return { + status: "Signed", + detail: check === "verified" + ? "Signature verified against the key in the authorizing account's DID." + : check === "unbound" + ? "The signature matches the key boop recorded for this account. That key is not independently verified here." + : "Checking signature…", + tone: "text-green-600 dark:text-green-400", + }; +} + +/** + * A signed-path action record with its truthful signing status. + */ +function ActionRecordRow({ + record, + check, + displayNames, + haptic, +}: { + record: StoredActionRecord; + check: SignatureCheck | undefined; + displayNames: Record | undefined; + haptic: HapticFn; +}) { + const payload = parseActionRecordPayload(record.payload); + if (!payload) { + return
Unreadable action record
; + } + const { status, detail, tone } = actionRecordPresentation(record, check); + const credential = payload.credential.kind === "apiKey" ? "API key" : "signed-in session"; + + return ( +
+
+ + + + + {ACTION_RECORD_LABELS[payload.action]} + +
+
{status}
+
{detail}
+ +
+ Via {credential} …{payload.credential.id.slice(-6)} • Recorded {formatDate(payload.occurredAt)} + {record.signedAt !== undefined && • Signed {formatDate(record.signedAt)}} +
+
+ ); +} + +function ActionRecordList({ + records, + displayNames, + haptic, +}: { + records: StoredActionRecord[] | undefined; + displayNames: Record | undefined; + haptic: HapticFn; +}) { + const checks = useSignatureChecks(records); + if (!records || records.length === 0) return null; + + return ( +
+
+ 🖊️ Action records ({records.length}) +
+
+ {records.map((record) => ( + + ))} +
+

+ A signature shows that the key recorded with it signed this record, after an + operation authenticated by the named session or API key. It does not show + personal intent or who was using that credential. +

+
+ ); +} + +/** Owner DIDs named by action records, for display-name lookup. */ +function actionRecordOwnerDids(records: StoredActionRecord[] | undefined): string[] { + return (records ?? []).flatMap((record) => parseActionRecordPayload(record.payload)?.owner.did ?? []); +} + /** * Provenance Timeline - shows the chain of events */ @@ -570,9 +742,11 @@ function ProvenanceTimeline({ export function ListProvenanceInfo({ list }: ListProvenanceProps) { const { haptic } = useSettings(); - // Look up the owner's display name - const userInfo = useQuery(api.users.getUsersByDids, { - dids: [list.ownerDid] + const actionRecords = useQuery(api.actionRecords.getListActionRecords, { listId: list._id }); + + // Look up the owner's display name, and whoever authorized recorded actions + const userInfo = useQuery(api.users.getUsersByDids, { + dids: [...new Set([list.ownerDid, ...actionRecordOwnerDids(actionRecords)])] }); // Fetch Bitcoin anchors for this list @@ -653,6 +827,8 @@ export function ListProvenanceInfo({ list }: ListProvenanceProps) { )} + + {/* Ownership VC */} {list.vcProof && ( )} + + {/* Item VCs */} {item.vcProofs && item.vcProofs.length > 0 && (
- 📜 Action records ({item.vcProofs.length}) + 📜 Historical action records ({item.vcProofs.length})
{item.vcProofs.map((vc, idx) => { diff --git a/src/lib/authenticatedOperations.ts b/src/lib/authenticatedOperations.ts index 0e89dcf..fd28fa2 100644 --- a/src/lib/authenticatedOperations.ts +++ b/src/lib/authenticatedOperations.ts @@ -2,6 +2,8 @@ import { api } from "../../convex/_generated/api"; import { getFunctionName } from "convex/server"; export const authenticatedOperations = new Set([ + api.actionRecords.getItemActionRecords, + api.actionRecords.getListActionRecords, api.activity.getListActivity, api.assignees.assignItem, api.assignees.getItemAssignees, diff --git a/tests/fixtures/app.ts b/tests/fixtures/app.ts index a9b033f..552948a 100644 --- a/tests/fixtures/app.ts +++ b/tests/fixtures/app.ts @@ -14,6 +14,8 @@ export interface ScenarioOptions { authenticated?: boolean; onboarding?: boolean; inviteNudgeDone?: boolean; + /** Status every recorded action settles to (default unsigned, as in production). */ + actionRecordStatus?: 'pending' | 'signed' | 'failed' | 'unsigned'; } interface FixtureState { diff --git a/tests/fixtures/server.mjs b/tests/fixtures/server.mjs index c49e3f9..52ad5c5 100644 --- a/tests/fixtures/server.mjs +++ b/tests/fixtures/server.mjs @@ -4,6 +4,10 @@ import { createServer } from 'vite'; import { randomUUID } from 'node:crypto'; import { BUILTIN_TEMPLATES } from '../../convex/lib/templateCatalog.ts'; import { canonical, revision, replayTargets } from '../../shared/replay.ts'; +import { getPublicKeyAsync, signAsync } from '@noble/ed25519'; +import { + actionRecordSigningInput, buildActionRecord, ed25519Multikey, encodeSignature, +} from '../../shared/actionRecord.ts'; const TS_ZERO = 'AAAAAAAAAAA='; const accounts = new Map(); @@ -13,7 +17,36 @@ const user = { email: 'e2e@example.test', did: 'did:webvh:e2e:boop.ad:user-e2e', displayName: 'E2E User', }; -function seed(options) { +// Test-only Ed25519 key for seeding signed records; no real signer exists yet. +const SIGNING_SEED = new Uint8Array(32).fill(7); +const signingKey = ed25519Multikey(await getPublicKeyAsync(SIGNING_SEED)); + +const listSubject = list => ({ listId: list._id, ...(list.assetDid ? { listAssetDid: list.assetDid } : {}) }); +const itemSubject = (account, item) => { + const list = account.lists.find(l => l._id === item.listId); + return { ...(list ? listSubject(list) : { listId: item.listId }), itemId: item._id }; +}; + +// Mirrors convex/lib/actionRecords.ts recordActions, except the status can be +// seeded via options.actionRecordStatus. Shape matches convex/actionRecords.ts evidence(). +async function recordAction(account, change) { + const status = account.options.actionRecordStatus ?? 'unsigned'; + const { payload, digest } = buildActionRecord(change, { + occurredAt: Date.now(), owner: { did: user.did, userId: user._id }, + credential: { kind: 'session', id: `actorSessions:${account.key}` }, + }); + const record = { _id: `actionRecords:${++account.nextId}`, payload, digest, status, + listId: change.subject.listId, ownerUserId: user._id }; + if (change.subject.itemId) record.itemId = change.subject.itemId; + if (status === 'signed') Object.assign(record, { + signature: encodeSignature(await signAsync(actionRecordSigningInput(payload), SIGNING_SEED)), + publicKeyMultibase: signingKey, verificationMethod: `did:key:${signingKey}#${signingKey}`, signedAt: Date.now(), + }); + if (status === 'failed') record.error = 'signing_key_unavailable'; + account.actionRecords.unshift(record); +} + +async function seed(options) { const key = randomUUID(); const token = `e2e.${Buffer.from(JSON.stringify({ sub: key, exp: Math.floor(Date.now() / 1000) + 86400 })).toString('base64url')}.fixture`; const lists = Array.from({ length: options.lists ?? 0 }, (_, i) => ({ @@ -26,7 +59,12 @@ function seed(options) { createdAt: i + 1, updatedAt: i + 1, order: i, })); const account = { key, token, options, lists, items, published: options.published ?? false, - sequence: 0, receipts: [], calls: [], errors: [], nextId: 0, offline: false, dropReplayResponses: 0 }; + sequence: 0, receipts: [], calls: [], errors: [], nextId: 0, offline: false, dropReplayResponses: 0, + actionRecords: [] }; + for (const list of lists) await recordAction(account, { action: 'list.created', subject: listSubject(list), + before: null, after: { name: list.name, kind: 'list' } }); + for (const item of items) await recordAction(account, { action: 'item.created', subject: itemSubject(account, item), + before: null, after: { name: item.name, checked: false } }); accounts.set(token, account); return account; } @@ -74,6 +112,8 @@ function query(account, path, args) { case 'referrals:getReferralCode': return { code: 'e2e-code' }; case 'notifications:hasSubscription': return false; case 'lists:getListEnvelope': return null; + case 'actionRecords:getListActionRecords': return account.actionRecords.filter(r => r.listId === args.listId && !r.itemId); + case 'actionRecords:getItemActionRecords': return account.actionRecords.filter(r => r.itemId === args.itemId); case 'listGrants:getSharedWithMe': case 'listGrants:getListGrants': case 'invitations:getListInvitations': @@ -163,6 +203,14 @@ async function apply(account, endpoint, args) { assetDid: args.assetDid, ownerDid: user.did, createdAt: Date.now() }); account.items.push(...template.items.map((item, i) => ({ ...item, _id: `items:template${++account.nextId}`, _creationTime: Date.now(), listId: result, checked: false, createdByDid: user.did, createdAt: Date.now(), order: i }))); + const origin = { kind: 'template', source: `builtin:${args.builtinId}` }; + const created = account.lists.at(-1); + await recordAction(account, { action: 'list.created', subject: listSubject(created), + before: null, after: { name: created.name, kind: 'list' }, origin }); + for (const item of account.items.filter(i => i.listId === result)) { + await recordAction(account, { action: 'item.created', subject: itemSubject(account, item), + before: null, after: { name: item.name, checked: false }, origin }); + } break; } case 'lists:createList': { @@ -170,27 +218,43 @@ async function apply(account, endpoint, args) { result = `lists:created${++account.nextId}`; account.lists.push({ _id: result, _creationTime: Date.now(), name: args.name, assetDid: args.assetDid, ownerDid: user.did, createdAt: args.createdAt }); + await recordAction(account, { action: 'list.created', subject: listSubject(account.lists.at(-1)), + before: null, after: { name: args.name, kind: args.kind ?? 'list' } }); break; } case 'lists:renameList': if (!list) throw new Error('List not found'); + await recordAction(account, { action: 'list.renamed', subject: listSubject(list), + before: { name: list.name }, after: { name: args.name } }); list.name = args.name; break; - case 'lists:deleteList': account.lists = account.lists.filter(l => l._id !== args.listId); break; + case 'lists:deleteList': + account.lists = account.lists.filter(l => l._id !== args.listId); + account.actionRecords = account.actionRecords.filter(r => r.listId !== args.listId); + break; case 'items:addItem': { result = `items:created${++account.nextId}`; account.items.push({ _id: result, _creationTime: Date.now(), listId: args.listId, name: args.name, checked: false, createdByDid: user.did, createdAt: args.createdAt, updatedAt: args.createdAt, order: account.items.length }); + await recordAction(account, { action: 'item.created', subject: itemSubject(account, account.items.at(-1)), + before: null, after: { name: args.name, checked: false } }); break; } case 'items:checkItem': case 'items:uncheckItem': if (!item) throw new Error('Item not found'); + await recordAction(account, endpoint === 'items:checkItem' + ? { action: 'item.completed', subject: itemSubject(account, item), before: { checked: item.checked }, + after: { checked: true, checkedAt: Number.isFinite(args.checkedAt) ? args.checkedAt : null } } + : { action: 'item.reopened', subject: itemSubject(account, item), before: { checked: item.checked }, after: { checked: false } }); item.checked = endpoint === 'items:checkItem'; item.updatedAt = Date.now(); break; - case 'items:removeItem': account.items = account.items.filter(i => i._id !== args.itemId); break; + case 'items:removeItem': + account.items = account.items.filter(i => i._id !== args.itemId); + account.actionRecords = account.actionRecords.filter(r => r.itemId !== args.itemId); + break; case 'publication:publishList': account.published = true; break; case 'publication:unpublishList': account.published = false; break; case 'referrals:getOrCreateReferralCode': result = 'e2e-code'; break; @@ -224,7 +288,7 @@ const backend = Bun.serve({ if (server.upgrade(request, { data: { queries: new Map(), version: 0, account: null, pending: Promise.resolve() } })) return; } if (url.pathname === '/__e2e/seed' && request.method === 'POST') { - const account = seed(await request.json()); + const account = await seed(await request.json()); return Response.json({ token: account.token, user }); } if (url.pathname === '/__e2e/start') { diff --git a/tests/share-modal.e2e.ts b/tests/share-modal.e2e.ts index f8c6512..edd488f 100644 --- a/tests/share-modal.e2e.ts +++ b/tests/share-modal.e2e.ts @@ -91,4 +91,23 @@ test.describe("Share / publish flow (POO-14)", () => { screen.getByText("public", { exact: true }), ).toBeVisible({ timeout: 10000 }); }); + + // Action records (#237): the list's creation record shows its true status. + for (const [status, label, detail] of [ + ["signed", "Signed", /not independently verified here/], + ["pending", "Pending signature", /Waiting for this record to be signed/], + ["failed", "Signing failed", /carries no signature/], + ["unsigned", "Unsigned", /No signature exists for this record/], + ] as const) { + test(`8. provenance shows a ${status} list creation record`, async ({ screen, scenario }) => { + await openList(scenario, screen, { actionRecordStatus: status }); + + await screen.getByRole("button", { name: "Share", exact: false }).click({ timeout: 10000 }); + await screen.getByRole("button", { name: /Originals Provenance/i }).click({ timeout: 5000 }); + + await expect(screen.getByText("List creation record")).toBeVisible({ timeout: 5000 }); + await expect(screen.getByText(label, { exact: true })).toBeVisible(); + await expect(screen.getByText(detail)).toBeVisible(); + }); + } });