From a3c0fdada1865ff10db425652ca179b20a963862 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Mon, 5 Oct 2026 02:44:35 -0700 Subject: [PATCH 1/5] feat: add canonical action record payload and independent verifier Defines the version-1 action record payload, its RFC 8785 canonical bytes, the 64-byte domain-separated signing input, and verifyActionRecord: a pure check (no Convex, no network) that only accepts keys the caller already trusts for the payload's owner DID. A key carried by the record selects among those and is never trusted by itself. Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) --- shared/actionRecord.ts | 307 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 shared/actionRecord.ts diff --git a/shared/actionRecord.ts b/shared/actionRecord.ts new file mode 100644 index 0000000..3ca5077 --- /dev/null +++ b/shared/actionRecord.ts @@ -0,0 +1,307 @@ +/** 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 the DID-log proofs 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; + /** Binding the signature is expected under: the owner's own Turnkey-held key. */ + signer: { did: string; custody: "turnkey"; keyType: "Ed25519" }; +}; + +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 }, + signer: { did: context.owner.did, custody: "turnkey", keyType: "Ed25519" }, + }; + 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, signer, 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 (!isRecord(signer) || typeof signer.did !== "string" || signer.custody !== "turnkey" || signer.keyType !== "Ed25519") 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 (payload.signer.did !== payload.owner.did + || (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 fe8b0f3363b2356ad2b4fca626f646b913bfd9ba Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Mon, 5 Oct 2026 02:44:35 -0700 Subject: [PATCH 2/5] feat: record signed 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. Signing custody is author-held at Turnkey and asynchronous. The mutation stores the exact canonical payload as pending and schedules a Node action; it never contacts Turnkey, so signing cannot fail a write. The signer re-checks the persisted bytes and owner binding, signs with the owner's sub-org Ed25519 key, verifies the result, and stores it. Failures retry with backoff up to four attempts, then settle as failed with a reason code. Accounts without a sub-org get an explicit unsigned record. An hourly sweep re-queues runs that never reported back. 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 | 8 + convex/actionRecordSigning.ts | 18 + convex/actionRecords.ts | 166 ++++++++ convex/agentReadHttp.ts | 38 ++ convex/crons.ts | 6 + convex/http.ts | 4 +- convex/items.ts | 159 ++----- convex/lib/actionRecordSigner.ts | 84 ++++ convex/lib/actionRecords.ts | 75 ++++ convex/lists.ts | 83 +--- convex/migrations/celAssetDidsDb.ts | 44 +- convex/migrations/remintUserDidDb.ts | 1 + convex/schema.ts | 28 ++ convex/templates.ts | 16 +- convex/turnkeyHelpers.ts | 47 ++- convex/users.ts | 3 +- scripts/copy-list.test.mjs | 7 + scripts/offline-replay.test.mjs | 23 +- scripts/signed-action-records.test.mjs | 561 +++++++++++++++++++++++++ scripts/templates.test.mjs | 38 +- scripts/verify-action-records.mjs | 30 ++ src/lib/authenticatedOperations.ts | 2 + 23 files changed, 1214 insertions(+), 241 deletions(-) create mode 100644 convex/actionRecordSigning.ts create mode 100644 convex/actionRecords.ts create mode 100644 convex/lib/actionRecordSigner.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..2346b50 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=` — signed 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; it is signed asynchronously with the account's Turnkey-held key. 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..8bc3e93 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,6 +8,8 @@ * @module */ +import type * as actionRecordSigning from "../actionRecordSigning.js"; +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 +46,8 @@ 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_actionRecordSigner from "../lib/actionRecordSigner.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 +116,8 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + actionRecordSigning: typeof actionRecordSigning; + actionRecords: typeof actionRecords; activity: typeof activity; activityHttp: typeof activityHttp; actorSession: typeof actorSession; @@ -148,6 +154,8 @@ declare const fullApi: ApiFromModules<{ invitationMail: typeof invitationMail; items: typeof items; itemsHttp: typeof itemsHttp; + "lib/actionRecordSigner": typeof lib_actionRecordSigner; + "lib/actionRecords": typeof lib_actionRecords; "lib/actor": typeof lib_actor; "lib/analytics": typeof lib_analytics; "lib/apiKeyHelpers": typeof lib_apiKeyHelpers; diff --git a/convex/actionRecordSigning.ts b/convex/actionRecordSigning.ts new file mode 100644 index 0000000..110fd36 --- /dev/null +++ b/convex/actionRecordSigning.ts @@ -0,0 +1,18 @@ +"use node"; + +/** + * Asynchronous signer for action records. Scheduled by recordActions (and by + * retries); signs each pending record with its owner's Turnkey sub-org key, + * server-initiated like didCreation. All logic lives in lib/actionRecordSigner. + */ +import { v } from "convex/values"; +import { internalAction } from "./_generated/server"; +import { signActionRecords } from "./lib/actionRecordSigner"; +import { turnkeySigningKey } from "./turnkeyHelpers"; + +export const sign = internalAction({ + args: { recordIds: v.array(v.id("actionRecords")) }, + handler: async (ctx, args): Promise => { + await signActionRecords(ctx, args.recordIds, subOrgId => turnkeySigningKey(subOrgId)); + }, +}); diff --git a/convex/actionRecords.ts b/convex/actionRecords.ts new file mode 100644 index 0000000..80d2f49 --- /dev/null +++ b/convex/actionRecords.ts @@ -0,0 +1,166 @@ +/** + * Signed action records (#237): reads for provenance/verification and the + * database half of asynchronous signing. The Turnkey half is the "use node" + * action in actionRecordSigning.ts. See docs/action-records.md. + */ +import { v } from "convex/values"; +import { internalMutation, internalQuery } from "./_generated/server"; +import { internal } from "./_generated/api"; +import type { Doc, Id } from "./_generated/dataModel"; +import { actorQuery } from "./lib/authenticated"; + +// One initial try plus three retries, then the record is marked failed. +export const MAX_SIGNING_ATTEMPTS = 4; +const RETRY_DELAYS_MS = [60_000, 5 * 60_000, 25 * 60_000]; +// Longer than the largest backoff, so the sweep never races a scheduled retry. +const STALE_PENDING_MS = 60 * 60_000; +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), +}); + +/** Pending records with their owner's current signing identity. Already-settled + * or deleted records are skipped, which is what makes a repeated signer run a no-op. */ +export const loadForSigning = internalQuery({ + args: { recordIds: v.array(v.id("actionRecords")) }, + handler: async (ctx, args) => { + const owners = new Map, Doc<"users"> | null>(); + const pending = []; + for (const recordId of new Set(args.recordIds)) { + const record = await ctx.db.get(recordId); + if (record?.status !== "pending") continue; + if (!owners.has(record.ownerUserId)) owners.set(record.ownerUserId, await ctx.db.get(record.ownerUserId)); + const owner = owners.get(record.ownerUserId); + pending.push({ + recordId, payload: record.payload, digest: record.digest, ownerUserId: record.ownerUserId, + ownerDid: owner?.did, turnkeySubOrgId: owner?.turnkeySubOrgId, + }); + } + return pending; + }, +}); + +const recordId = v.id("actionRecords"); +const outcome = v.union( + v.object({ + kind: v.literal("signed"), recordId, digest: v.string(), signature: v.string(), + publicKeyMultibase: v.string(), verificationMethod: v.string(), + }), + v.object({ kind: v.literal("unsigned"), recordId }), + // `reason` is a fixed non-secret code, never a provider error message. + v.object({ kind: v.literal("failed"), recordId, reason: v.string(), retry: v.boolean() }), +); + +/** Applies signer results. Only pending records change, so duplicate or late + * deliveries cannot replace a stored signature. Retries are scheduled here so + * the attempt count and its retry commit together. */ +export const settle = internalMutation({ + args: { outcomes: v.array(outcome) }, + handler: async (ctx, args) => { + const now = Date.now(); + const retries = new Map[]>(); + for (const result of args.outcomes) { + const record = await ctx.db.get(result.recordId); + if (record?.status !== "pending") continue; + const attempts = record.attempts + 1; + if (result.kind === "unsigned") { + await ctx.db.patch(record._id, { status: "unsigned" }); + } else if (result.kind === "signed" && result.digest === record.digest) { + await ctx.db.patch(record._id, { + status: "signed", signature: result.signature, publicKeyMultibase: result.publicKeyMultibase, + verificationMethod: result.verificationMethod, signedAt: now, attempts, lastAttemptAt: now, + }); + } else if (result.kind === "failed" && result.retry && attempts < MAX_SIGNING_ATTEMPTS) { + await ctx.db.patch(record._id, { attempts, lastAttemptAt: now }); + const delay = RETRY_DELAYS_MS[attempts - 1]; + retries.set(delay, [...(retries.get(delay) ?? []), record._id]); + } else { + await ctx.db.patch(record._id, { + status: "failed", attempts, lastAttemptAt: now, + error: result.kind === "failed" ? result.reason : "payload_integrity", + }); + } + } + for (const [delay, recordIds] of retries) { + await ctx.scheduler.runAfter(delay, internal.actionRecordSigning.sign, { recordIds }); + } + }, +}); + +/** Scheduled actions run at most once. This cron re-queues records whose signer + * run never reported back, within the same attempt bound. */ +export const sweepStalePending = internalMutation({ + args: {}, + handler: async (ctx) => { + const now = Date.now(); + const pending = await ctx.db.query("actionRecords") + .withIndex("by_status", q => q.eq("status", "pending")).take(200); + const recordIds: Id<"actionRecords">[] = []; + for (const record of pending) { + if (now - (record.lastAttemptAt ?? record._creationTime) <= STALE_PENDING_MS) continue; + const attempts = record.attempts + 1; + if (attempts >= MAX_SIGNING_ATTEMPTS) { + await ctx.db.patch(record._id, { status: "failed", error: "signer_unresponsive", attempts, lastAttemptAt: now }); + } else { + await ctx.db.patch(record._id, { attempts, lastAttemptAt: now }); + recordIds.push(record._id); + } + } + if (recordIds.length > 0) { + await ctx.scheduler.runAfter(0, internal.actionRecordSigning.sign, { recordIds }); + } + }, +}); + +/** Operator recovery when an outage outlasted the retry window. */ +export const requeueFailed = internalMutation({ + args: { recordIds: v.array(v.id("actionRecords")) }, + handler: async (ctx, args) => { + const recordIds: Id<"actionRecords">[] = []; + for (const id of new Set(args.recordIds)) { + const record = await ctx.db.get(id); + if (record?.status !== "failed") continue; + await ctx.db.patch(id, { status: "pending", attempts: 0, error: undefined, lastAttemptAt: undefined }); + recordIds.push(id); + } + if (recordIds.length > 0) { + await ctx.scheduler.runAfter(0, internal.actionRecordSigning.sign, { recordIds }); + } + return recordIds.length; + }, +}); diff --git a/convex/agentReadHttp.ts b/convex/agentReadHttp.ts index 573d895..2b0f309 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) + * + * Signed 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/crons.ts b/convex/crons.ts index fcad098..9653b26 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -15,4 +15,10 @@ crons.interval( internal.siteActions.pollPendingCustomHostnames ); +crons.interval( + "re-queue stale pending action record signatures", + { hours: 1 }, + internal.actionRecords.sweepStalePending +); + export default crons; 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/actionRecordSigner.ts b/convex/lib/actionRecordSigner.ts new file mode 100644 index 0000000..4a2e276 --- /dev/null +++ b/convex/lib/actionRecordSigner.ts @@ -0,0 +1,84 @@ +/** Signing orchestration, free of Turnkey and Node imports so tests can drive + * it with a real keypair behind a stubbed client. */ +import type { ActionCtx } from "../_generated/server"; +import type { Id } from "../_generated/dataModel"; +import { internal } from "../_generated/api"; +import { + actionRecordDigest, actionRecordSigningInput, canonicalize, ed25519Multikey, + encodeSignature, parseActionRecordPayload, verifyActionSignature, +} from "../../shared/actionRecord"; + +export type SigningKey = { publicKey: Uint8Array; sign: (message: Uint8Array) => Promise }; +export type ResolveSigningKey = (turnkeySubOrgId: string) => Promise; + +type Pending = { + recordId: Id<"actionRecords">; payload: string; digest: string; + ownerUserId: Id<"users">; ownerDid?: string; turnkeySubOrgId?: string; +}; +type Outcome = + | { kind: "signed"; recordId: Id<"actionRecords">; digest: string; signature: string; publicKeyMultibase: string; verificationMethod: string } + | { kind: "unsigned"; recordId: Id<"actionRecords"> } + | { kind: "failed"; recordId: Id<"actionRecords">; reason: string; retry: boolean }; + +function isIntact(record: Pending): boolean { + try { + return canonicalize(JSON.parse(record.payload)) === record.payload + && actionRecordDigest(record.payload) === record.digest; + } catch { + return false; + } +} + +async function signOne(record: Pending, keyFor: ResolveSigningKey): Promise { + const { recordId } = record; + const fail = (reason: string, retry: boolean): Outcome => ({ kind: "failed", recordId, reason, retry }); + + // Sign only bytes that are still exactly what the mutation persisted. + const payload = isIntact(record) ? parseActionRecordPayload(record.payload) : null; + if (!payload) return fail("payload_integrity", false); + // The key must belong to the account the payload names, under the DID it names. + if (payload.owner.userId !== record.ownerUserId || payload.owner.did !== record.ownerDid) { + return fail("owner_binding_changed", false); + } + if (!record.turnkeySubOrgId) return { kind: "unsigned", recordId }; + + let key: SigningKey; + try { key = await keyFor(record.turnkeySubOrgId); } catch { return fail("signing_key_unavailable", true); } + let signature: Uint8Array; + try { signature = await key.sign(actionRecordSigningInput(record.payload)); } catch { return fail("signing_request_failed", true); } + // Never store a signature this deployment could not itself verify. + if (!await verifyActionSignature(record.payload, signature, key.publicKey)) return fail("signature_invalid", true); + + const publicKeyMultibase = ed25519Multikey(key.publicKey); + return { + kind: "signed", recordId, digest: record.digest, signature: encodeSignature(signature), + publicKeyMultibase, verificationMethod: `did:key:${publicKeyMultibase}#${publicKeyMultibase}`, + }; +} + +export async function signActionRecords( + ctx: Pick, + recordIds: Id<"actionRecords">[], + resolveKey: ResolveSigningKey, +): Promise { + const pending: Pending[] = await ctx.runQuery(internal.actionRecords.loadForSigning, { recordIds }); + // One key lookup per owner per run; a failed lookup fails that owner's records alike. + const keys = new Map>(); + const keyFor: ResolveSigningKey = (subOrgId) => { + if (!keys.has(subOrgId)) keys.set(subOrgId, resolveKey(subOrgId)); + return keys.get(subOrgId)!; + }; + + const unsettled: Outcome[] = []; + for (const record of pending) { + const outcome = await signOne(record, keyFor); + // Store each signature as it arrives so a later failure cannot lose it. + if (outcome.kind === "signed") await ctx.runMutation(internal.actionRecords.settle, { outcomes: [outcome] }); + else unsettled.push(outcome); + } + if (unsettled.length > 0) { + // Reason codes only: provider errors may carry identifiers we do not log. + console.warn("[actionRecords] not signed:", unsettled.map(o => `${o.recordId}:${o.kind === "failed" ? o.reason : o.kind}`).join(",")); + await ctx.runMutation(internal.actionRecords.settle, { outcomes: unsettled }); + } +} diff --git a/convex/lib/actionRecords.ts b/convex/lib/actionRecords.ts new file mode 100644 index 0000000..9c623cf --- /dev/null +++ b/convex/lib/actionRecords.ts @@ -0,0 +1,75 @@ +/** The single writer of action records. Every recorded transition goes through + * recordActions so individual, batch, template and recurring flows agree. */ +import type { MutationCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { internal } from "../_generated/api"; +import type { ActorCtx } from "./authenticated"; +import { buildActionRecord, type ActionChange } from "../../shared/actionRecord"; + +type RecordedChange = ActionChange & { subject: { listId: Id<"lists">; itemId?: Id<"items"> } }; +export type ListRef = { _id: Id<"lists">; assetDid?: string }; +type Origin = NonNullable["origin"]>; + +const subject = (list: ListRef) => ({ listId: list._id, ...(list.assetDid ? { listAssetDid: list.assetDid } : {}) }); + +export const itemCreated = (list: ListRef, itemId: Id<"items">, name: string, origin?: Origin<"item.created">): RecordedChange => ({ + action: "item.created", subject: { ...subject(list), itemId }, before: null, after: { name, checked: false }, + ...(origin ? { origin } : {}), +}); + +export const itemCompleted = (list: ListRef, item: Pick, "_id" | "checked">, checkedAt: number): RecordedChange => ({ + action: "item.completed", subject: { ...subject(list), itemId: item._id }, + // checkedAt can be client-supplied; a value JSON cannot carry must not fail the write. + before: { checked: item.checked }, after: { checked: true, checkedAt: Number.isFinite(checkedAt) ? checkedAt : null }, +}); + +export const itemReopened = (list: ListRef, item: Pick, "_id" | "checked">): RecordedChange => ({ + action: "item.reopened", subject: { ...subject(list), itemId: item._id }, + before: { checked: item.checked }, after: { checked: false }, +}); + +export const listCreated = (list: ListRef & { name: string; kind?: "note" }, origin?: Origin<"list.created">): RecordedChange => ({ + action: "list.created", subject: subject(list), before: null, after: { name: list.name, kind: list.kind ?? "list" }, + ...(origin ? { origin } : {}), +}); + +export const listRenamed = (list: ListRef & { name: string }, name: string): RecordedChange => ({ + action: "list.renamed", subject: subject(list), before: { name: list.name }, after: { name }, +}); + +/** + * Persists the exact canonical payloads for this mutation's actions and queues + * one signer run for them. Never signs inline: Turnkey is Node/network-only, so + * a signing outage cannot fail the write. Identity comes only from ctx.actor. + */ +export async function recordActions(ctx: ActorCtx, changes: RecordedChange[]): Promise { + if (changes.length === 0) return; + const owner = await ctx.db.get(ctx.actor.userId); + const status = owner?.turnkeySubOrgId ? "pending" as const : "unsigned" as const; + const occurredAt = Date.now(); + const recordIds: Id<"actionRecords">[] = []; + for (const change of changes) { + const { payload, digest } = buildActionRecord(change, { + occurredAt, + owner: { did: ctx.actor.did, userId: ctx.actor.userId }, + credential: ctx.actor.credential, + }); + recordIds.push(await ctx.db.insert("actionRecords", { + payload, digest, listId: change.subject.listId, itemId: change.subject.itemId, + ownerUserId: ctx.actor.userId, status, attempts: 0, + })); + } + if (status === "pending") { + await ctx.scheduler.runAfter(0, internal.actionRecordSigning.sign, { recordIds }); + } +} + +/** 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 ea43ecd..c206578 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. @@ -166,15 +117,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); @@ -242,9 +185,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); @@ -332,15 +278,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)]); }, }); @@ -575,6 +515,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 210ab32..2a1e7b1 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 9421cf3..f188f91 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -286,6 +286,34 @@ export default defineSchema({ .index("by_parent", ["parentId"]) .index("by_due_date", ["listId", "dueDate"]), + // Signed 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 whose Turnkey key signs this record + status: v.union( + v.literal("pending"), // Awaiting the asynchronous signer + v.literal("signed"), + v.literal("failed"), // Retries exhausted; see error + v.literal("unsigned"), // Owner has no Turnkey sub-org; nothing is fabricated + ), + attempts: v.number(), + lastAttemptAt: v.optional(v.number()), + 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"]) + .index("by_status", ["status"]), + // 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/turnkeyHelpers.ts b/convex/turnkeyHelpers.ts index cdb49e8..d1a27c5 100644 --- a/convex/turnkeyHelpers.ts +++ b/convex/turnkeyHelpers.ts @@ -6,7 +6,12 @@ * Provides wallet-account resolution logic for DID creation flows. */ +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { createTurnkeyClient } from "./lib/turnkeyClient"; +import type { SigningKey } from "./lib/actionRecordSigner"; +import { parseEd25519PublicKey } from "../shared/actionRecord"; + +type TurnkeyClient = ReturnType; /** * Look up the first Ed25519 wallet account for a Turnkey sub-org. @@ -14,8 +19,10 @@ import { createTurnkeyClient } from "./lib/turnkeyClient"; * Returns the Turnkey client, the Ed25519 account, and a convenience * `verificationMethodId` string (`did:key:
`). */ -export async function getEd25519Account(subOrgId: string) { - const turnkeyClient = createTurnkeyClient(); +export async function getEd25519Account( + subOrgId: string, + turnkeyClient: TurnkeyClient = createTurnkeyClient() +) { // Get wallets for the sub-org const walletsResponse = await turnkeyClient.apiClient().getWallets({ @@ -58,3 +65,39 @@ export async function getEd25519Account(subOrgId: string) { signingOrganizationId, }; } + +/** + * The sub-org's Ed25519 key as a raw signer, for action records. Same + * server-initiated signRawPayload call the did:webvh signer makes. + */ +export async function turnkeySigningKey( + subOrgId: string, + turnkeyClient: TurnkeyClient = createTurnkeyClient() +): Promise { + const { address, signingOrganizationId } = await getEd25519Account(subOrgId, turnkeyClient); + // A Turnkey Ed25519 (Solana-format) address is the base58 public key itself. + const publicKey = parseEd25519PublicKey(address); + if (!publicKey) throw new Error("Turnkey Ed25519 address is not a public key"); + + return { + publicKey, + sign: async (message) => { + const result = await turnkeyClient.apiClient().signRawPayload({ + organizationId: signingOrganizationId, + signWith: address, + payload: `0x${bytesToHex(message)}`, + encoding: "PAYLOAD_ENCODING_HEXADECIMAL", + hashFunction: "HASH_FUNCTION_NO_OP", + }); + const signRawResult = result.activity?.result?.signRawPayloadResult; + if (!signRawResult?.r || !signRawResult?.s) { + throw new Error("No signature returned from Turnkey"); + } + const signature = hexToBytes((signRawResult.r + signRawResult.s).replace(/^0x/, "")); + if (signature.length !== 64) { + throw new Error(`Invalid Ed25519 signature length: ${signature.length} (expected 64 bytes)`); + } + return signature; + }, + }; +} diff --git a/convex/users.ts b/convex/users.ts index 477bb77..700dc0a 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..eb15482 100644 --- a/scripts/copy-list.test.mjs +++ b/scripts/copy-list.test.mjs @@ -56,6 +56,8 @@ function makeCtx({ items = [], lists: extraLists = [], user = null, subscription let seq = 0; return { rows, + // copyList queues signing of its list.created action record. + scheduler: { runAfter: async () => {} }, db: { get: async (id) => byId.get(id) ?? null, patch: async (id, fields) => Object.assign(byId.get(id), fields), @@ -172,6 +174,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..067d511 --- /dev/null +++ b/scripts/signed-action-records.test.mjs @@ -0,0 +1,561 @@ +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', 'lib/actionRecordSigner', 'turnkeyHelpers', '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 { signActionRecords } = modules['lib/actionRecordSigner']; +const { turnkeySigningKey } = modules.turnkeyHelpers; +const digest = value => createHash('sha256').update(value).digest('hex'); +const hex = bytes => Buffer.from(bytes).toString('hex'); + +/** A stand-in for the Turnkey API holding real Ed25519 keys, one per sub-org. */ +async function fakeTurnkey() { + const keys = new Map(); + const calls = []; + let failing = false; + const keyFor = async subOrgId => { + if (!keys.has(subOrgId)) { + const secret = ed25519.utils.randomSecretKey(); + keys.set(subOrgId, { secret, publicKey: await ed25519.getPublicKeyAsync(secret) }); + } + return keys.get(subOrgId); + }; + const client = { apiClient: () => ({ + getWallets: async ({ organizationId }) => ({ wallets: [{ walletId: `wallet-${organizationId}` }] }), + getWalletAccounts: async ({ organizationId }) => ({ accounts: [ + { curve: 'CURVE_SECP256K1', address: '0xnot-this-one', organizationId }, + { curve: 'CURVE_ED25519', address: base58Encode((await keyFor(organizationId)).publicKey), organizationId }, + ] }), + signRawPayload: async request => { + calls.push(request); + if (failing) throw new Error('turnkey unavailable: api-secret-must-not-be-stored'); + const signature = hex(await ed25519.signAsync(Buffer.from(request.payload.slice(2), 'hex'), (await keyFor(request.organizationId)).secret)); + return { activity: { result: { signRawPayloadResult: { r: signature.slice(0, 64), s: signature.slice(64) } } } }; + }, + }) }; + return { + calls, keyFor, client, + fail(value) { failing = value; }, + trusted: async user => ({ [user.did]: [ed25519Multikey((await keyFor(user.turnkeySubOrgId)).publicKey)] }), + }; +} + +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 turnkey = await fakeTurnkey(); + const signerJobs = () => f.effects.filter(([, ref]) => getFunctionName(ref) === 'actionRecordSigning:sign'); + return Object.assign(f, { + turnkey, signerJobs, + records: () => f.rows.actionRecords ?? [], + payloads: (action) => (f.rows.actionRecords ?? []).map(r => JSON.parse(r.payload)).filter(p => !action || p.action === action), + /** Runs queued signer jobs the way the scheduled action would; returns how many ran. */ + async runSigner() { + const jobs = signerJobs().splice(0); + for (const job of jobs) f.effects.splice(f.effects.indexOf(job), 1); + for (const [, , args] of jobs) await signActionRecords(f.ctx, args.recordIds, subOrg => turnkeySigningKey(subOrg, turnkey.client)); + return jobs.length; + }, + 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","signer":{"custody":"turnkey","did":"did:owner","keyType":"Ed25519"},"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 pending 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.deepEqual(payload.signer, { did: f.owner.user.did, custody: 'turnkey', keyType: 'Ed25519' }); + 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.attempts, record.signature, record.ownerUserId, record.listId, record.itemId], + ['pending', 0, 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); + assert.equal(f.signerJobs().length, 5); + assert.deepEqual(f.signerJobs().flatMap(([delay, , args]) => [delay, args.recordIds.length]), [0, 1, 0, 1, 0, 1, 0, 1, 0, 1]); +}); + +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', signer: 'SIGNER', 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 }]); + } + // One mutation queues one signer run for all of its records. + assert.deepEqual(batch.signerJobs().map(([, , args]) => args.recordIds.length), [3, 2]); + assert.deepEqual(single.signerJobs().map(([, , args]) => args.recordIds.length), [2, 1, 1, 1]); +}); + +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 and signed 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.equal(editor.signer.did, f.collaborator.user.did); + 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('the signer signs the exact persisted bytes with the owner sub-org key and the result verifies independently', async () => { + const f = await fixture(); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await f.call('items', 'uncheckItem', { itemId: 'I1' }, f.collaborator); + assert.equal(await f.runSigner(), 2); + const [byOwner, byEditor] = f.records(); + for (const [record, user] of [[byOwner, f.owner.user], [byEditor, f.collaborator.user]]) { + const publicKey = (await f.turnkey.keyFor(user.turnkeySubOrgId)).publicKey; + assert.deepEqual([record.status, record.attempts, record.error], ['signed', 1, undefined]); + assert.equal(record.publicKeyMultibase, ed25519Multikey(publicKey)); + assert.equal(record.verificationMethod, `did:key:${record.publicKeyMultibase}#${record.publicKeyMultibase}`); + assert.ok(Math.abs(record.signedAt - Date.now()) < 5000); + const result = await verifyActionRecord(record, { trustedKeys: await f.turnkey.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 the Turnkey address form. + 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); + } + assert.deepEqual(f.turnkey.calls.map(c => [c.organizationId, c.encoding, c.hashFunction, c.payload.length]), [ + [f.owner.user.turnkeySubOrgId, 'PAYLOAD_ENCODING_HEXADECIMAL', 'HASH_FUNCTION_NO_OP', 130], + [f.collaborator.user.turnkeySubOrgId, 'PAYLOAD_ENCODING_HEXADECIMAL', 'HASH_FUNCTION_NO_OP', 130], + ]); + assert.equal(f.turnkey.calls[0].payload, `0x${hex(actionRecordSigningInput(byOwner.payload))}`); + assert.equal(f.turnkey.calls[0].signWith, base58Encode((await f.turnkey.keyFor(f.owner.user.turnkeySubOrgId)).publicKey)); + // The owner's key does not vouch for the editor's record, and vice versa. + assert.deepEqual(await verifyActionRecord(byEditor, { trustedKeys: await f.turnkey.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.runSigner(); + const [record] = structuredClone(f.records()); + const trustedKeys = await f.turnkey.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.runSigner(); + 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 signer's key attached. + const reattributed = rewrite(p => { p.owner = { did: f.collaborator.user.did, userId: f.collaborator.user._id }; }); + assert.equal(await verify({ ...reattributed, ownerUserId: f.collaborator.user._id }), 'binding_mismatch'); + const fully = rewrite(p => { p.owner = { did: f.collaborator.user.did, userId: f.collaborator.user._id }; p.signer.did = f.collaborator.user.did; }); + assert.equal(await verify({ ...fully, ownerUserId: f.collaborator.user._id }, { trustedKeys: { ...trustedKeys, ...await f.turnkey.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('signing failures never touch the write, retry with backoff, then settle as failed with a non-secret reason', async () => { + const f = await fixture(); + f.turnkey.fail(true); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + assert.equal(f.rows.items[0].checked, true, 'the mutation committed without any signing'); + const delays = []; + for (let attempt = 1; attempt <= 4; attempt++) { + const [[delay]] = f.signerJobs(); + delays.push(delay); + assert.equal(await f.runSigner(), 1); + assert.deepEqual([f.records()[0].status, f.records()[0].attempts], [attempt < 4 ? 'pending' : 'failed', attempt]); + } + assert.deepEqual(delays, [0, 60_000, 300_000, 1_500_000]); + assert.equal(f.signerJobs().length, 0, 'retries are bounded'); + const [failed] = f.records(); + assert.equal(failed.error, 'signing_request_failed'); + assert.deepEqual([failed.signature, failed.signedAt, failed.publicKeyMultibase], [undefined, undefined, undefined]); + assert.ok(!JSON.stringify(f.records()).includes('api-secret'), 'provider error text is never persisted'); + assert.equal(f.rows.items[0].checked, true); + assert.deepEqual(await verifyActionRecord(failed, { trustedKeys: await f.turnkey.trusted(f.owner.user) }), { verified: false, reason: 'not_signed' }); + + // A failed lookup of the key itself is retried the same way. + const lookup = await fixture(); + await lookup.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + const [[, , job]] = lookup.signerJobs().splice(0); + await signActionRecords(lookup.ctx, job.recordIds, async () => { throw new Error('No wallets found for sub-org'); }); + assert.deepEqual([lookup.records()[0].status, lookup.records()[0].attempts], ['pending', 1]); + + // Operator recovery once the outage is over. + f.turnkey.fail(false); + assert.equal(await modules.actionRecords.requeueFailed._handler(f.ctx, { recordIds: [failed._id] }), 1); + await f.runSigner(); + assert.deepEqual([f.records()[0].status, f.records()[0].error], ['signed', undefined]); + assert.equal((await verifyActionRecord(f.records()[0], { trustedKeys: await f.turnkey.trusted(f.owner.user) })).verified, true); +}); + +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]); + await f.runSigner(); + const trustedKeys = await f.turnkey.trusted(f.owner.user); + for (const record of f.records()) { + assert.equal(record.status, 'signed'); + assert.equal((await verifyActionRecord(JSON.parse(JSON.stringify(record)), { trustedKeys })).verified, true); + } +}); + +test('an account without a Turnkey sub-org gets an explicit unsigned record and no signer run', 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, record.attempts], ['unsigned', undefined, 0]); + assert.equal(f.signerJobs().length, 0); + assert.equal(f.rows.items[0].checked, true); + assert.deepEqual(await verifyActionRecord(record, { trustedKeys: { [f.owner.user.did]: ['z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'] } }), { verified: false, reason: 'not_signed' }); + + // A sub-org removed between the write and the signer run is also recorded as unsigned. + const g = await fixture(); + await g.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + delete g.rows.users[0].turnkeySubOrgId; + await g.runSigner(); + assert.deepEqual([g.records()[0].status, g.turnkey.calls.length], ['unsigned', 0]); +}); + +test('signing is idempotent: repeated and late runs never re-sign or replace a stored signature', async () => { + const f = await fixture(); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + const [[, , job]] = f.signerJobs(); + await f.runSigner(); + const signed = structuredClone(f.records()[0]); + const resolve = subOrg => turnkeySigningKey(subOrg, f.turnkey.client); + await signActionRecords(f.ctx, job.recordIds, resolve); + await signActionRecords(f.ctx, [...job.recordIds, ...job.recordIds], resolve); + assert.equal(f.turnkey.calls.length, 1); + assert.deepEqual(f.records()[0], signed); + // A duplicate delivery of a different result cannot overwrite the first. + await modules.actionRecords.settle._handler(f.ctx, { outcomes: [ + { kind: 'signed', recordId: signed._id, digest: signed.digest, signature: 'zOther', publicKeyMultibase: 'zOther', verificationMethod: 'did:key:zOther' }, + { kind: 'failed', recordId: signed._id, reason: 'late', retry: true }, + { kind: 'unsigned', recordId: signed._id }, + ] }); + assert.deepEqual(f.records()[0], signed); + assert.equal(f.signerJobs().length, 0); + // A record deleted before its signer runs is skipped. + await f.call('items', 'uncheckItem', { itemId: 'I1' }); + await f.call('items', 'removeItem', { itemId: 'I1' }); + assert.equal(await f.runSigner(), 1); + assert.equal(f.turnkey.calls.length, 1); +}); + +test('the signer refuses bytes or bindings that changed after the write, without calling Turnkey', async () => { + const f = await fixture(); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await f.call('items', 'uncheckItem', { itemId: 'I1' }); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 9 }); + await f.call('items', 'uncheckItem', { itemId: 'I1' }, f.collaborator); + const [edited, reserialized, misfiled, renamed] = f.rows.actionRecords; + edited.payload = edited.payload.replace('"checkedAt":7', '"checkedAt":8'); + reserialized.payload = JSON.stringify(JSON.parse(reserialized.payload), null, 2); + reserialized.digest = digest(reserialized.payload); + misfiled.ownerUserId = f.collaborator.user._id; + f.rows.users[1].did = 'did:collaborator-reminted'; + await f.runSigner(); + assert.deepEqual(f.records().map(r => [r.status, r.error, r.attempts]), [ + ['failed', 'payload_integrity', 1], ['failed', 'payload_integrity', 1], + ['failed', 'owner_binding_changed', 1], ['failed', 'owner_binding_changed', 1], + ]); + assert.equal(f.turnkey.calls.length, 0); + assert.equal(f.signerJobs().length, 0, 'integrity failures are not retried'); + // A signature that does not verify under the looked-up key is never stored. + const g = await fixture(); + await g.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + const [[, , job]] = g.signerJobs().splice(0); + await signActionRecords(g.ctx, job.recordIds, async () => ({ publicKey: (await g.turnkey.keyFor('org')).publicKey, sign: async () => new Uint8Array(64) })); + assert.deepEqual([g.records()[0].status, g.records()[0].signature, g.records()[0].attempts], ['pending', undefined, 1]); +}); + +test('the sweep re-queues signer runs that never reported back, within the same attempt bound', async () => { + const f = await fixture(); + await f.call('items', 'checkItem', { itemId: 'I1', checkedAt: 7 }); + await f.call('items', 'uncheckItem', { itemId: 'I1' }); + f.signerJobs().splice(0).forEach(job => f.effects.splice(f.effects.indexOf(job), 1)); // both signer runs are lost + const sweep = () => modules.actionRecords.sweepStalePending._handler(f.ctx, {}); + for (const row of f.rows.actionRecords) row._creationTime = Date.now(); // the fixture numbers rows instead of timing them + await sweep(); + assert.equal(f.signerJobs().length, 0, 'fresh pending records are left to their scheduled run'); + const hourAgo = Date.now() - 61 * 60_000; + f.rows.actionRecords[0]._creationTime = hourAgo; + f.rows.actionRecords[1]._creationTime = hourAgo; + f.rows.actionRecords[1].lastAttemptAt = Date.now(); // waiting on a backoff retry + await sweep(); + assert.deepEqual(f.signerJobs().map(([delay, , args]) => [delay, args.recordIds]), [[0, [f.rows.actionRecords[0]._id]]]); + assert.deepEqual(f.records().map(r => r.attempts), [1, 0]); + for (let i = 0; i < 3; i++) { f.rows.actionRecords[0].lastAttemptAt = hourAgo; await sweep(); } + assert.deepEqual([f.records()[0].status, f.records()[0].error, f.records()[0].attempts], ['failed', 'signer_unresponsive', 4]); + assert.equal(f.records()[1].status, 'pending'); +}); + +test('template creation records the list and every item through the same path and signs them in one run', 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 })); + assert.deepEqual(f.signerJobs().map(([, , args]) => args.recordIds.length), [3, 1]); + await f.runSigner(); + const trustedKeys = await f.turnkey.trusted(f.owner.user); + for (const record of f.records()) assert.equal((await verifyActionRecord(record, { trustedKeys })).verified, true); + assert.equal(f.turnkey.calls.length, 4); +}); + +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.runSigner(); + 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.turnkey.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.turnkey.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.turnkey.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.runSigner(); + 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.turnkey.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..ed0036e 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.equal(ctx.scheduled.length, 1, 'the retry queues no second signer run'); }); -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,27 @@ 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); + // One signer run covers the whole mutation's records. + assert.deepEqual(ctx.scheduled.map(job => job.args.recordIds.length), [items.length + 1]); + assert.ok(ctx.rows.actionRecords.every(r => r.status === 'pending' && 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 8968d08..02d4889 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.activity.recordActivity, api.assignees.assignItem, From 5d726b1c3b71aad0b706747cd33b2443917e5d1e Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Mon, 5 Oct 2026 02:44:36 -0700 Subject: [PATCH 3/5] feat: show action record signing status in provenance Item and list provenance list the new action records with a truthful status: Signed (Turnkey-held owner key), Pending signature, Signing failed or Unsigned, plus the authorizing account and whether a session or an API key was used. Historical placeholders keep their unsigned/unverified labels under a separate heading. The browser re-checks each signed record. It says "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 failing that check is shown as failed, not signed. 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..13cc027 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 \(Turnkey-held owner key\)/); + 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 Turnkey 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(), /at boop's request.*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 recordSigned \(Turnkey-held owner key\)Signature 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, /Signed \(Turnkey-held owner key\)|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 \(Turnkey-held owner key\)/); + 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..47a1db5 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 the account's Turnkey-held key to sign this record.", 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: "This account has no Turnkey signing key, so no signature exists.", 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 (Turnkey-held owner key)", + 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 account's Turnkey-held key signed this record at boop's + request, 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 27083a57facce736a8bb092e6c1ccba87a3bda8d Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Mon, 5 Oct 2026 02:44:36 -0700 Subject: [PATCH 4/5] docs: describe signed action records, custody and verification limits Covers Turnkey custody, the payload contract, the status lifecycle and failure policy, what independent verification does and does not prove, key rotation, and the remaining gaps, including that the Turnkey key is not published in the owner's DID document and live Turnkey has not been exercised. Part of #237. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/action-records.md | 329 +++++++++++++++++++++++++++++++++++------ 1 file changed, 287 insertions(+), 42 deletions(-) diff --git a/docs/action-records.md b/docs/action-records.md index 27cba73..889befd 100644 --- a/docs/action-records.md +++ b/docs/action-records.md @@ -1,7 +1,17 @@ # 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 +that the authorizing account's Turnkey-held key signs asynchronously (#237). +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, reads, signer bookkeeping | `convex/actionRecords.ts`, table `actionRecords` | +| Signing | `convex/actionRecordSigning.ts` → `convex/lib/actionRecordSigner.ts` | +| Standalone check | `scripts/verify-action-records.mjs` | ## Authenticated identity @@ -19,9 +29,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 +43,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). Signed action records are kept out +of every rewrite path. ## Identifier badges and evidence boundaries @@ -54,37 +69,267 @@ 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 + +Signing custody is **author-held at Turnkey**. Each record is signed by the +Ed25519 key in the authorizing account's own Turnkey sub-organization +(`users.turnkeySubOrgId`, resolved with `getEd25519Account`), through the same +server-initiated `signRawPayload` call `didCreation` uses. There is no boop +service key and no service attestation. + +"Author-held" describes where the key lives, not who pressed the button. boop's +server asks Turnkey to sign on the account's behalf; the account holder does not +approve each signature. See [what a signature proves](#what-a-signature-proves). + +## 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. A mutation's records are signed by one signer run. + +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 that gets signed. +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": "" }, + "signer": { "did": "did:webvh:…", "custody": "turnkey", "keyType": "Ed25519" } +} +``` + +| 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`. +- `signer.did` always equals `owner.did`: the signature is expected under that + account's own Turnkey-held Ed25519 key. +- `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 the did:webvh proofs the same key can +sign. `signature` is the Ed25519 signature, multibase base58btc (`z…`). +`publicKeyMultibase` (Multikey, `z6Mk…`) and `verificationMethod` +(`did:key:#`) record the key that signed. + +## Status lifecycle + +``` +recordActions ──► pending ──► signed + │ ▲ + │ └── retry (attempts < 4) + ├────► failed retries exhausted, or the record is no longer signable + └────► unsigned the account has no Turnkey sub-org +recordActions ──► unsigned same, known at write time +``` + +1. The mutation writes the record as `pending` and schedules + `actionRecordSigning.sign`. It does not contact Turnkey. If the account has no + sub-org it writes `unsigned` and schedules nothing. +2. The signer re-reads each record. It signs only if the stored payload is still + canonical, still hashes to the stored digest, and still names the record's + owner under that owner's current DID. It never rebuilds a payload from live + list or item state. +3. It checks the returned signature against the looked-up key before storing it. +4. `settle` changes only `pending` records, so repeated, duplicate or late runs + cannot re-sign or replace a stored signature. + +### Availability and failure policy + +- A write never fails or waits because of signing. A Turnkey outage delays + signatures; it does not block or roll back actions. +- Transient failures (key lookup, signing request, a signature that does not + verify) are retried after 1, 5 and 25 minutes. The fourth failure marks the + record `failed`. +- Integrity or binding failures (`payload_integrity`, `owner_binding_changed`) + mark the record `failed` immediately and are not retried. +- Scheduled actions run at most once. An hourly cron (`sweepStalePending`) + re-queues records whose signer run never reported back, counting against the + same attempt bound (`signer_unresponsive`). +- `error` holds a fixed reason code. Provider error text is never stored or + logged. +- After an outage longer than the retry window, an operator can run the internal + mutation `actionRecords.requeueFailed` with the affected record ids. Nothing + re-queues failed records automatically, and `unsigned` records are not signed + later if the account gains a sub-org. + +## 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 `signer.did === owner.did`, 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 + +This is the part boop does not yet solve for outside verifiers. An account's +did:webvh is minted in the browser with a browser-held key (`src/lib/webvh.ts`), +so its DID document does **not** list the Turnkey key that signs action records. +A verifier therefore has to pin "this owner DID → this Turnkey key" by a route +it trusts: the account holder publishing the key, a lookup of the Turnkey +sub-org by someone entitled to make it, or pinning on first use and watching for +change. 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 that same Turnkey 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 Turnkey 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 + +A verified record proves that the Ed25519 key in the named account's Turnkey +sub-organization signed exactly this payload, and so that the payload has not +changed since. boop requested that signature after an operation authenticated by +the named session or API key. + +It does **not** prove: + +- personal intent, or that the account holder saw or approved the action; +- 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. + +`pending`, `failed` and `unsigned` records carry no signature and prove nothing +cryptographically. They are shown as such. + +## 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 (Turnkey-held owner key)** | +| `pending` | **Pending signature** | +| `failed` | **Signing failed** | +| `unsigned` | **Unsigned** | + +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` and +`scripts/provenance-presentation.test.mjs`: canonical determinism; create, +complete, reopen, batch, recurring, template, copy and rename flows; individual +versus batch equivalence; session versus distinct API keys; signing through a +stubbed Turnkey client holding a real Ed25519 key; rejection of altered payloads, +signatures, keys and bindings and of every non-signed status; retry to failed; +no-sub-org to unsigned; idempotent re-signing; the stale-pending sweep; reads, +the HTTP export and the standalone verifier; deletion; the re-mint migration +leaving records untouched; rendered statuses. + +Not done or not verified: + +- **Live Turnkey has not been exercised.** The signer mirrors the request the + did:webvh signer makes (64-byte hex payload, `HASH_FUNCTION_NO_OP`), but no + real signature has been produced or checked against a real sub-org key. +- The Turnkey key is not published in the owner's DID document, so outside + verifiers must pin it themselves (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. +- Failed records need an operator to re-queue; there is no user-facing retry. +- Native builds and live flows have not been exercised. From 2352df9e7fc8c47e41ab47e5320eae6a18d9edc2 Mon Sep 17 00:00:00 2001 From: Brian Date: Thu, 8 Oct 2026 00:26:58 -0700 Subject: [PATCH 5/5] test: serve signed action records from the E2E fixture The share modal renders ListProvenanceInfo, which now queries actionRecords:getListActionRecords. The fixture threw "Unimplemented fixture query", the app error boundary replaced the page, and every test that waited for the share modal heading failed. The fixture now records actions the way recordActions does (real canonical payloads; Ed25519-signed with a fixed test key when signed) and serves both getListActionRecords and getItemActionRecords. A new actionRecordStatus seed option settles records to pending, signed, failed or unsigned, and share-modal tests cover each status. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/fixtures/app.ts | 2 ++ tests/fixtures/server.mjs | 73 ++++++++++++++++++++++++++++++++++++--- tests/share-modal.e2e.ts | 19 ++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/app.ts b/tests/fixtures/app.ts index 2cc19c3..dcb5843 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 b6dd22d..d1697dc 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 { revision } 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 stand-in for the account's Turnkey-held Ed25519 key. +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 signer settles +// at once to options.actionRecordStatus. Shape matches convex/actionRecords.ts evidence(). +async function recordAction(account, change) { + const status = account.options.actionRecordStatus ?? 'signed'; + 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,11 @@ 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 }; + sequence: 0, receipts: [], calls: [], errors: [], nextId: 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; } @@ -68,6 +105,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': @@ -113,6 +152,14 @@ async function mutation(account, path, 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': { @@ -120,27 +167,43 @@ async function mutation(account, path, 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; @@ -178,7 +241,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..e3af161 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 }); }); + + // Signed action records (#237): the list's creation record shows its true status. + for (const [status, label, detail] of [ + ["signed", "Signed (Turnkey-held owner key)", /not independently verified here/], + ["pending", "Pending signature", /Waiting for the account's Turnkey-held key/], + ["failed", "Signing failed", /carries no signature/], + ["unsigned", "Unsigned", /no Turnkey signing key/], + ] 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(); + }); + } });