From 0aa42a2d1ed2984db5274fa90869df2d292da389 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Wed, 14 Oct 2026 20:44:45 -0700 Subject: [PATCH 1/6] feat: add canonical action record payload and independent verifier Defines the version-1 action record payload, its RFC 8785 canonical bytes and digest, the 64-byte domain-separated signing input, and a verifier that checks a record only against keys the caller already trusts for the payload's owner DID. Pure, so the server, the browser and outside verifiers share one definition. The payload names the authorizing account and credential but carries no signer or custody field: who holds the signing key is still open (#244). Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) --- shared/actionRecord.ts | 302 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 shared/actionRecord.ts 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"); +} From c481de5d39da900d5b62eff92a4d1f7bd7462cab Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Wed, 14 Oct 2026 20:44:45 -0700 Subject: [PATCH 2/6] feat: write unsigned action records through one shared path Every recorded transition now goes through recordActions: item create (single, template, recurring), complete and reopen (single and batch, which previously recorded nothing), and list create, copy and rename. Batch and individual calls write records of the same shape, bound to the authenticated session or API-key row separately from the authorizing account. Records are stored unsigned. No signer is wired up until the custody model from #244 is settled, so recording happens entirely inside the mutation, schedules nothing, and cannot fail the write. New unsigned vcProof/vcProofs placeholders are no longer written; existing ones stay readable and untouched. The list placeholder constructor moves into the celAssetDids migration, its only remaining user. Records are deleted with their item, list or account, and are kept out of the DID re-mint rewrite. Adds GET /api/v1/action-records and scripts/verify-action-records.mjs so records can be fetched and checked offline against pinned keys. Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) --- API.md | 14 +- convex/_generated/api.d.ts | 4 + convex/actionRecords.ts | 48 +++ convex/agentReadHttp.ts | 38 +++ convex/http.ts | 4 +- convex/items.ts | 159 ++-------- convex/lib/actionRecords.ts | 68 ++++ convex/lists.ts | 83 +---- convex/migrations/celAssetDidsDb.ts | 44 ++- convex/migrations/remintUserDidDb.ts | 1 + convex/schema.ts | 27 ++ convex/templates.ts | 16 +- convex/users.ts | 3 +- scripts/copy-list.test.mjs | 5 + scripts/offline-replay.test.mjs | 23 +- scripts/signed-action-records.test.mjs | 415 +++++++++++++++++++++++++ scripts/templates.test.mjs | 37 ++- scripts/verify-action-records.mjs | 30 ++ src/lib/authenticatedOperations.ts | 2 + 19 files changed, 782 insertions(+), 239 deletions(-) create mode 100644 convex/actionRecords.ts create mode 100644 convex/lib/actionRecords.ts create mode 100644 scripts/signed-action-records.test.mjs create mode 100644 scripts/verify-action-records.mjs 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..8abc1ce --- /dev/null +++ b/convex/actionRecords.ts @@ -0,0 +1,48 @@ +/** + * 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 type { Doc } from "./_generated/dataModel"; +import { actorQuery } from "./lib/authenticated"; + +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), +}); 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/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..5dbb07c --- /dev/null +++ b/convex/lib/actionRecords.ts @@ -0,0 +1,68 @@ +/** 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 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 name their subject, so they are removed with it like vcProofs were. */ +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)).collect() + : await ctx.db.query("actionRecords").withIndex("by_list", q => q.eq("listId", target.listId)).collect(); + for (const record of records) await ctx.db.delete(record._id); +} 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/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/signed-action-records.test.mjs b/scripts/signed-action-records.test.mjs new file mode 100644 index 0000000..5695263 --- /dev/null +++ b/scripts/signed-action-records.test.mjs @@ -0,0 +1,415 @@ +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', '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('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(), []); + + // 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/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, From 2d22a4972c0826856124a4adedd1143835dfa592 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Wed, 14 Oct 2026 20:44:45 -0700 Subject: [PATCH 3/6] feat: show action record signing status in provenance Lists an item's action records, and a list's list-level records, separately from historical placeholders, with a truthful status: unsigned, pending, failed or signed. A signed record is re-checked in the browser and only called verified when the owner DID itself encodes the signing key. Only unsigned occurs until a signer exists. Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/provenance-presentation.test.mjs | 101 +++++++++++- src/components/ProvenanceInfo.tsx | 191 ++++++++++++++++++++++- 2 files changed, 285 insertions(+), 7 deletions(-) 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/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) => { From 448ca94a78ff57520ecaafbc0acc5e4fcef818c1 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Wed, 14 Oct 2026 20:44:45 -0700 Subject: [PATCH 4/6] docs: describe action records and what they do not yet prove Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/action-records.md | 294 +++++++++++++++++++++++++++++++++++------ 1 file changed, 252 insertions(+), 42 deletions(-) diff --git a/docs/action-records.md b/docs/action-records.md index 27cba73..67f5eb5 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,230 @@ 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` | +| `item.reopened` | `uncheckItem`, `batchUncheckItems` | +| `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. + +## 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. From f20a4017c44526671269fa36485144220cf3efda Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Wed, 14 Oct 2026 20:44:45 -0700 Subject: [PATCH 5/6] test: serve action records from the E2E fixture The share modal's provenance panel now queries actionRecords:getListActionRecords; without it the fixture threw and the app error boundary replaced the page. The fixture now records list and item creation with real canonical payloads, unsigned by default, and an actionRecordStatus seed option covers all four rendered statuses. Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/fixtures/app.ts | 2 ++ tests/fixtures/server.mjs | 74 ++++++++++++++++++++++++++++++++++++--- tests/share-modal.e2e.ts | 19 ++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/app.ts b/tests/fixtures/app.ts index a9b033f..905c0b4 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 signed). */ + 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(); + }); + } }); From 9309db6f5ae609981a09658a446564acdb6e122d Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Wed, 14 Oct 2026 20:54:13 -0700 Subject: [PATCH 6/6] fix: record DID resource toggles and bound action record deletion checkSharedItem / uncheckSharedItem (the DID resource toggle over HTTP) completed and reopened items without writing action records, so provenance could show a reopen with no completion before it. They now record the same item.completed / item.reopened as checkItem / uncheckItem. Every toggle adds a record, so deleting a long-lived list or item could exceed Convex's per-mutation limits. deleteActionRecords now removes up to 500 records in the deleting mutation and schedules deleteRemaining for the rest. Also corrects the E2E fixture comment: the default status is unsigned. Co-Authored-By: Claude Opus 5.5 (1M context) --- convex/actionRecords.ts | 8 +++++++ convex/didResources.ts | 10 +++++++-- convex/lib/actionRecords.ts | 14 +++++++++--- docs/action-records.md | 8 +++++-- scripts/signed-action-records.test.mjs | 31 +++++++++++++++++++++++++- tests/fixtures/app.ts | 2 +- 6 files changed, 64 insertions(+), 9 deletions(-) diff --git a/convex/actionRecords.ts b/convex/actionRecords.ts index 8abc1ce..b0dfc7b 100644 --- a/convex/actionRecords.ts +++ b/convex/actionRecords.ts @@ -4,8 +4,10 @@ * 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; @@ -46,3 +48,9 @@ export const { public: getListActionRecords, internal: getListActionRecordsInter .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/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/lib/actionRecords.ts b/convex/lib/actionRecords.ts index 5dbb07c..f61cd64 100644 --- a/convex/lib/actionRecords.ts +++ b/convex/lib/actionRecords.ts @@ -2,6 +2,7 @@ * 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"; @@ -57,12 +58,19 @@ export async function recordActions(ctx: ActorCtx, changes: Recorde } } -/** Records name their subject, so they are removed with it like vcProofs were. */ +// 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)).collect() - : await ctx.db.query("actionRecords").withIndex("by_list", q => q.eq("listId", target.listId)).collect(); + ? 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/docs/action-records.md b/docs/action-records.md index 67f5eb5..fc08c7b 100644 --- a/docs/action-records.md +++ b/docs/action-records.md @@ -95,8 +95,8 @@ 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` | -| `item.reopened` | `uncheckItem`, `batchUncheckItems` | +| `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` | @@ -275,6 +275,10 @@ 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`, diff --git a/scripts/signed-action-records.test.mjs b/scripts/signed-action-records.test.mjs index 5695263..21fb478 100644 --- a/scripts/signed-action-records.test.mjs +++ b/scripts/signed-action-records.test.mjs @@ -17,7 +17,7 @@ import { } from '../shared/actionRecord.ts'; const modules = await loadReplayModules('signed-action-records'); -const extra = ['templates', 'actionRecords', 'agentReadHttp', 'users', 'migrations/remintUserDidDb']; +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' }, @@ -161,6 +161,19 @@ test('batch and individual actions produce equivalent records, including recurri } }); +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'); @@ -379,6 +392,22 @@ test('records are readable with the access of their subject and are removed with 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' }); diff --git a/tests/fixtures/app.ts b/tests/fixtures/app.ts index 905c0b4..552948a 100644 --- a/tests/fixtures/app.ts +++ b/tests/fixtures/app.ts @@ -14,7 +14,7 @@ export interface ScenarioOptions { authenticated?: boolean; onboarding?: boolean; inviteNudgeDone?: boolean; - /** Status every recorded action settles to (default signed). */ + /** Status every recorded action settles to (default unsigned, as in production). */ actionRecordStatus?: 'pending' | 'signed' | 'failed' | 'unsigned'; }