diff --git a/bun.lock b/bun.lock index 2a0ec40..49ffd12 100644 --- a/bun.lock +++ b/bun.lock @@ -18,8 +18,8 @@ "@capacitor/push-notifications": "^8.0.0", "@capacitor/share": "^8.0.0", "@capacitor/status-bar": "^8.0.0", - "@originals/auth": "2.0.0", - "@originals/sdk": "2.1.0", + "@originals/auth": "3.0.0-next.0", + "@originals/sdk": "3.0.0-next.1", "@sentry/react": "^10.42.0", "@sentry/vite-plugin": "^5.1.1", "@turnkey/core": "^1.11.0", @@ -291,7 +291,7 @@ "@noble/ed25519": ["@noble/ed25519@3.1.0", "", {}, "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g=="], - "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@noble/secp256k1": ["@noble/secp256k1@3.1.0", "", {}, "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g=="], @@ -323,9 +323,11 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@originals/auth": ["@originals/auth@2.0.0", "", { "dependencies": { "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@originals/sdk": "^2.0.0", "@turnkey/crypto": "^2.10.0", "@turnkey/sdk-server": "^7.0.0", "jsonwebtoken": "^9.0.2" }, "peerDependencies": { "express": ">=4.0.0" }, "optionalPeers": ["express"] }, "sha512-KiG+QKn5XzV6IoOxxVOSDyo4sAvl+A+WuKkNB1iScl2t3fAnJSaw+tRixOpwbiEQuXSaufmSHN0XJIESTPQyRQ=="], + "@originals/auth": ["@originals/auth@3.0.0-next.0", "", { "dependencies": { "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@originals/sdk": "^3.0.0-next.0", "@turnkey/crypto": "^2.10.0", "@turnkey/sdk-server": "^7.0.0", "jsonwebtoken": "^9.0.2" }, "peerDependencies": { "express": ">=4.0.0" }, "optionalPeers": ["express"] }, "sha512-fbWfYKBcGaODG65fpyuwf0/B+K8Ue88G8FcVdYyNJujisZMRtYzmafxLCtGdbktN6bx+WRzGEPTlRrPPVv7LsA=="], - "@originals/sdk": ["@originals/sdk@2.1.0", "", { "dependencies": { "@aviarytech/did-peer": "^1.1.2", "@digitalbazaar/bbs-signatures": "^3.1.0", "@noble/curves": "^2.2.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@noble/secp256k1": "^3.1.0", "@scure/base": "^2.2.0", "@scure/btc-signer": "^2.2.0", "b58": "^4.0.3", "bitcoinjs-lib": "^6.1.0", "cborg": "^5.1.6", "didwebvh-ts": "^2.8.0", "fflate": "^0.8.3", "jsonld": "^8.3.3", "micro-ordinals": "^0.2.2", "uuid": "^14.0.1" }, "bin": { "originals-cel": "dist/cel/cli/index.js" } }, "sha512-VUfqbj+AaBEhLj5eb3UwCItQWaH/jB4zRUrSYN0rSAHZMBa44aMi9/GZl6Q9VVX5IQ5FpDtOS3wWozLf/qLKVA=="], + "@originals/cel": ["@originals/cel@0.2.0-next.1", "", { "dependencies": { "@aviarytech/did-peer": "^1.1.2", "@noble/curves": "^2.2.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@scure/base": "^2.2.0", "cborg": "^5.1.6" } }, "sha512-xR2xrGAuNlcEUIr4pp3/ApNpsYVWW0SZsS/28KbPOL5t8xVFJ8bbhkB85nTfZ0VsAjTCb3ZwdjAGO8e+69IkxA=="], + + "@originals/sdk": ["@originals/sdk@3.0.0-next.1", "", { "dependencies": { "@aviarytech/did-peer": "^1.1.2", "@digitalbazaar/bbs-signatures": "^3.1.0", "@noble/curves": "^2.2.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@noble/secp256k1": "^3.1.0", "@originals/cel": "^0.2.0-next.1", "@scure/base": "^2.2.0", "@scure/btc-signer": "^2.2.0", "b58": "^4.0.3", "bitcoinjs-lib": "^6.1.0", "didwebvh-ts": "^2.8.0", "fflate": "^0.8.3", "jsonld": "^8.3.3", "micro-ordinals": "^0.2.2", "uuid": "^14.0.1" }, "bin": { "originals-cel": "dist/cel/cli/index.js" } }, "sha512-giauv9topg2yeLDFVGEj8Dw58PJbrUTifXNAx+JZ7cSY1y4/CrUT+3zKTuvE/Shv9TF7Gx9umZzC6+SirQTVuQ=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], @@ -2187,8 +2189,6 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@digitalbazaar/bbs-signatures/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/config-array/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -2231,8 +2231,6 @@ "@ionic/utils-terminal/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@noble/curves/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], @@ -2243,9 +2241,7 @@ "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - "@originals/auth/@originals/sdk": ["@originals/sdk@2.0.0", "", { "dependencies": { "@aviarytech/did-peer": "^1.1.2", "@digitalbazaar/bbs-signatures": "^3.1.0", "@noble/curves": "^2.2.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@noble/secp256k1": "^3.1.0", "@scure/base": "^2.2.0", "@scure/btc-signer": "^2.2.0", "b58": "^4.0.3", "bitcoinjs-lib": "^6.1.0", "cborg": "^5.1.6", "didwebvh-ts": "^2.8.0", "jsonld": "^8.3.3", "micro-ordinals": "^0.2.2", "uuid": "^14.0.1" }, "bin": { "originals-cel": "dist/cel/cli/index.js" } }, "sha512-e11HzaWQj+0yaaBdHFHnLafozl4YSh99Kv4f+RdFhsu8zrsSIGs9IC4qCa8w/NAsDhi1Y/LItEMffJhTDoE6tw=="], - - "@originals/sdk/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@originals/auth/@originals/sdk": ["@originals/sdk@3.0.0-next.0", "", { "dependencies": { "@aviarytech/did-peer": "^1.1.2", "@digitalbazaar/bbs-signatures": "^3.1.0", "@noble/curves": "^2.2.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@noble/secp256k1": "^3.1.0", "@originals/cel": "^0.2.0-next.0", "@scure/base": "^2.2.0", "@scure/btc-signer": "^2.2.0", "b58": "^4.0.3", "bitcoinjs-lib": "^6.1.0", "didwebvh-ts": "^2.8.0", "fflate": "^0.8.3", "jsonld": "^8.3.3", "micro-ordinals": "^0.2.2", "uuid": "^14.0.1" }, "bin": { "originals-cel": "dist/cel/cli/index.js" } }, "sha512-jbZa/TbLxfCwfM/Sp2ayQOWF0BNQkNyjdxavtPTUXb9azrJ77mpcToKctPN4SpcL7hlu1OHKAGlcl7Wus+dvWQ=="], "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -2281,8 +2277,6 @@ "@scure/bip39/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], - "@scure/btc-signer/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@sentry/bundler-plugin-core/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -2409,8 +2403,6 @@ "del/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "didwebvh-ts/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "didwebvh-ts/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "dir-glob/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], @@ -2607,6 +2599,8 @@ "@ionic/utils-terminal/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "@originals/auth/@originals/sdk/@originals/cel": ["@originals/cel@0.2.0-next.0", "", { "dependencies": { "@aviarytech/did-peer": "^1.1.2", "@noble/curves": "^2.2.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@scure/base": "^2.2.0", "cborg": "^5.1.6" } }, "sha512-sp5iIeOgz1JgOPhl0EYr2qNNvn+O9+ic9/oXmng2IYf+TE+7Pz/95dCGUqbMrTZphYRR8HGdJ2Lw4c4H5t4IyQ=="], + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "@sentry/bundler-plugin-core/glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 5b9ddbe..9e1a728 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -58,6 +58,7 @@ import type * as listsHttp from "../listsHttp.js"; import type * as migrations_bucketBackfill from "../migrations/bucketBackfill.js"; import type * as migrations_celAssetDids from "../migrations/celAssetDids.js"; import type * as migrations_celAssetDidsDb from "../migrations/celAssetDidsDb.js"; +import type * as migrations_envelopeCoverage from "../migrations/envelopeCoverage.js"; import type * as migrations_remintUserDidDb from "../migrations/remintUserDidDb.js"; import type * as notificationActions from "../notificationActions.js"; import type * as notifications from "../notifications.js"; @@ -136,6 +137,7 @@ declare const fullApi: ApiFromModules<{ "migrations/bucketBackfill": typeof migrations_bucketBackfill; "migrations/celAssetDids": typeof migrations_celAssetDids; "migrations/celAssetDidsDb": typeof migrations_celAssetDidsDb; + "migrations/envelopeCoverage": typeof migrations_envelopeCoverage; "migrations/remintUserDidDb": typeof migrations_remintUserDidDb; notificationActions: typeof notificationActions; notifications: typeof notifications; diff --git a/convex/lists.ts b/convex/lists.ts index cc32498..1f688cf 100644 --- a/convex/lists.ts +++ b/convex/lists.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { mutation, query, internalQuery } from "./_generated/server"; +import type { MutationCtx } from "./_generated/server"; import type { Doc, Id } from "./_generated/dataModel"; import { withMutationObservability } from "./lib/observability"; import { canUserViewList } from "./lib/permissions"; @@ -58,6 +59,47 @@ export function createListOwnershipVC( /** * Create a new list. */ +/** + * Free-plan list cap, shared by every path that inserts a list. + * + * Extracted rather than inlined so copyList cannot become a way around the + * limit — a copy is a new list and counts like one. + */ +async function assertListQuota( + ctx: MutationCtx, + ownerDid: string +): Promise<{ owner: Doc<"users"> | null; isFirstList: boolean }> { + const owner = await ctx.db + .query("users") + .withIndex("by_did", (q) => q.eq("did", ownerDid)) + .first(); + + if (!owner) return { owner: null, isFirstList: false }; + + const sub = await ctx.db + .query("subscriptions") + .withIndex("by_user", (q) => q.eq("userId", owner._id)) + .first(); + const hasPaidSub = sub && (sub.status === "active" || sub.status === "trialing"); + const hasReferralPro = !hasPaidSub && owner.referralProUntil != null && owner.referralProUntil > Date.now(); + const plan = hasPaidSub ? sub.plan : (hasReferralPro ? "pro" : "free"); + + const existingLists = await ctx.db + .query("lists") + .withIndex("by_owner", (q) => q.eq("ownerDid", ownerDid)) + .collect(); + + if (plan === "free") { + const bonusLists = owner.bonusLists ?? 0; + const maxLists = 5 + bonusLists; + if (existingLists.length >= maxLists) { + throw new Error("PLAN_LIMIT: You've reached the free plan limit of 5 lists. Upgrade at /pricing to create unlimited lists."); + } + } + + return { owner, isFirstList: existingLists.length === 0 }; +} + export const createList = mutation({ args: { assetDid: v.string(), @@ -75,38 +117,7 @@ export const createList = mutation({ if (args.name.trim().length === 0) throw new Error("List name cannot be empty"); if (args.name.length > 200) throw new Error("List name cannot exceed 200 characters"); - // Plan enforcement: free tier allows up to 5 lists - const owner = await ctx.db - .query("users") - .withIndex("by_did", (q) => q.eq("did", args.ownerDid)) - .first(); - - let isFirstList = false; - - if (owner) { - const sub = await ctx.db - .query("subscriptions") - .withIndex("by_user", (q) => q.eq("userId", owner._id)) - .first(); - const hasPaidSub = sub && (sub.status === "active" || sub.status === "trialing"); - const hasReferralPro = !hasPaidSub && owner.referralProUntil != null && owner.referralProUntil > Date.now(); - const plan = hasPaidSub ? sub.plan : (hasReferralPro ? "pro" : "free"); - - const existingLists = await ctx.db - .query("lists") - .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) - .collect(); - - isFirstList = existingLists.length === 0; - - if (plan === "free") { - const bonusLists = owner.bonusLists ?? 0; - const maxLists = 5 + bonusLists; - if (existingLists.length >= maxLists) { - throw new Error("PLAN_LIMIT: You've reached the free plan limit of 5 lists. Upgrade at /pricing to create unlimited lists."); - } - } - } + const { owner, isFirstList } = await assertListQuota(ctx, args.ownerDid); const listId = await ctx.db.insert("lists", { assetDid: args.assetDid, @@ -148,6 +159,115 @@ export const createList = mutation({ }), }); +/** + * Copy a list's contents into a brand-new list. + * + * This exists for provenance, not convenience. Lists re-minted by the + * celAssetDids migration were given their genesis server-side with an ephemeral + * controller, so no one holds their signing key and they can never record + * another CEL event — verifiable, but not authorable. A copy is minted in the + * owner's browser, so its key lands in their keyStore and the new list can + * author events for the rest of its life. + * + * The copy is honestly new: it gets today's genesis and its own DID, and makes + * no claim to the original's history. The source list is left untouched. + */ +export const copyList = mutation({ + args: { + sourceListId: v.id("lists"), + // Minted client-side by createListAsset — that is the whole point, so both + // are required here rather than optional as they are on createList. + assetDid: v.string(), + celEnvelope: v.string(), + name: v.string(), + ownerDid: v.string(), + createdAt: v.number(), + }, + handler: async (ctx, args) => withMutationObservability("lists.copyList", async () => { + if (args.name.trim().length === 0) throw new Error("List name cannot be empty"); + if (args.name.length > 200) throw new Error("List name cannot exceed 200 characters"); + + const source = await ctx.db.get(args.sourceListId); + if (!source) throw new Error("List not found"); + // Copying mints a new identity naming this owner, so viewers who can merely + // read a shared list must not be able to do it. + if (source.ownerDid !== args.ownerDid) { + throw new Error("Only the list's owner can copy it"); + } + + const { owner, isFirstList } = await assertListQuota(ctx, args.ownerDid); + + const listId = await ctx.db.insert("lists", { + assetDid: args.assetDid, + name: args.name, + ownerDid: args.ownerDid, + categoryId: source.categoryId, + createdAt: args.createdAt, + // Presentation settings belong to the list, so the copy should look like + // the original rather than reverting to the built-in defaults. + customAisles: source.customAisles, + itemCategories: source.itemCategories, + itemViewMode: source.itemViewMode, + }); + + await ctx.db.patch(listId, { + vcProof: createListOwnershipVC(listId, args.assetDid, args.ownerDid, args.name, args.createdAt), + }); + + await upsertListEnvelope(ctx, listId, args.assetDid, args.celEnvelope); + + const items = await ctx.db + .query("items") + .withIndex("by_list", (q) => q.eq("listId", args.sourceListId)) + .collect(); + + // Two passes: parentId points at a sibling item, so every row needs an id + // before any parent link can be rewritten. + const idMap = new Map, Id<"items">>(); + + // Rest-spread rather than an explicit field list, so a column added to items + // later is carried by a copy without anyone remembering to update this. + // Only these four must not cross: two are system-owned, parentId is rewritten + // in the second pass below, and vcProofs attest actions taken against the + // SOURCE asset's DID — carrying them would attribute one asset's provenance + // to another, the exact claim this copy exists to avoid making. + const DROP = ["_id", "_creationTime", "parentId", "vcProofs"] as const; + + for (const item of items) { + const payload: Record = { ...item }; + for (const field of DROP) delete payload[field]; + const newId = await ctx.db.insert( + "items", + { ...payload, listId } as Omit, "_id" | "_creationTime"> + ); + idMap.set(item._id, newId); + } + + for (const item of items) { + if (!item.parentId) continue; + const newParent = idMap.get(item.parentId); + // A parent outside this list would be corrupt data; drop the link rather + // than point the copy back into the original list. + if (newParent) await ctx.db.patch(idMap.get(item._id)!, { parentId: newParent }); + } + + if (owner && isFirstList) { + const referral = await ctx.db + .query("referrals") + .withIndex("by_referee", (q) => q.eq("refereeId", owner._id)) + .first(); + if (referral && !referral.proGrantedAt) { + const proUntil = Date.now() + 30 * 24 * 60 * 60 * 1000; + await ctx.db.patch(owner._id, { referralProUntil: proUntil }); + await ctx.db.patch(referral.referrerId, { referralProUntil: proUntil }); + await ctx.db.patch(referral._id, { proGrantedAt: Date.now() }); + } + } + + return { listId, itemsCopied: items.length }; + }), +}); + /** * Rename a list. Only the owner can rename. */ diff --git a/convex/migrations/celAssetDids.ts b/convex/migrations/celAssetDids.ts index 0f5b8f7..00c471d 100644 --- a/convex/migrations/celAssetDids.ts +++ b/convex/migrations/celAssetDids.ts @@ -14,7 +14,8 @@ * a "will be replaced with proper DID" comment. It never replaced them, so * every list made from a *saved* template carries a DID that was never real. * - * Genesis is minted server-side here, with NO keyStore configured. That is + * Genesis is minted server-side here, as an explicitly `ephemeral` controller — + * no key is retained. That is * deliberate: the genesis controller key would otherwise be held by the server * rather than the owner, which is a custody change this migration has no mandate * to make. The consequence is that migrated lists are VERIFIABLE (the envelope @@ -50,7 +51,7 @@ async function sha256Hex(value: string): Promise { * Mint a did:cel for an existing list, using the same genesis resource shape as * buildListResource in src/lib/originals.ts so migrated and new lists agree. */ -async function mintCelGenesis( +export async function mintCelGenesis( name: string, ownerDid: string, createdAt: number @@ -61,15 +62,20 @@ async function mintCelGenesis( createdAt: new Date(createdAt).toISOString(), }); const sdk = OriginalsSDK.create(config); - const asset = await sdk.lifecycle.createAsset([ - { - id: "list-metadata", - type: "ListMetadata", - contentType: "application/json", - content, - hash: await sha256Hex(content), - }, - ]); + // `ephemeral` is the explicit spelling of what this migration always did: mint + // without retaining the key. SDK 3.0 throws NO_CUSTODY on the implicit form. + const asset = await sdk.lifecycle.createAsset( + [ + { + id: "list-metadata", + type: "ListMetadata", + contentType: "application/json", + content, + hash: await sha256Hex(content), + }, + ], + { controller: "ephemeral" } + ); return { assetDid: asset.id, envelope: JSON.stringify(asset.serialize()) }; } diff --git a/convex/migrations/envelopeCoverage.ts b/convex/migrations/envelopeCoverage.ts new file mode 100644 index 0000000..6accce8 --- /dev/null +++ b/convex/migrations/envelopeCoverage.ts @@ -0,0 +1,65 @@ +/** + * Diagnostic: how many lists have a stored CEL envelope, and how many don't. + * + * A list with no `listEnvelopes` row has nothing to verify, and the provenance + * UI renders nothing for it — so this is the population the "copy to a new list" + * flow exists to serve. + * + * npx convex run migrations/envelopeCoverage:count + * npx convex run --prod migrations/envelopeCoverage:count + * + * Read-only. Both tables are scanned in full, so `truncated` reports whether the + * numbers are complete rather than letting a read limit look like a real answer. + */ + +import { internalQuery } from "../_generated/server"; + +const SCAN_LIMIT = 8000; + +export const count = internalQuery({ + args: {}, + handler: async (ctx) => { + const lists = await ctx.db.query("lists").take(SCAN_LIMIT); + const envelopes = await ctx.db.query("listEnvelopes").take(SCAN_LIMIT); + + const envelopeFor = new Map(envelopes.map((e) => [e.listId, e])); + const missing = lists.filter((l) => !envelopeFor.has(l._id)); + + // A genesis proof signed well after the list already existed is one the + // celAssetDids migration minted, with an ephemeral controller — verifiable, + // but nobody holds its key, so it can never author another event. Mirrors + // isRetroactiveGenesis in src/lib/originals.ts. + let retroactive = 0; + for (const list of lists) { + const row = envelopeFor.get(list._id); + if (!row) continue; + try { + const proof = JSON.parse(row.envelope)?.eventLog?.events?.[0]?.proof; + const created = (Array.isArray(proof) ? proof[0] : proof)?.created; + const sealedAt = created ? Date.parse(created) : NaN; + if (!Number.isNaN(sealedAt) && sealedAt - list.createdAt > 60_000) retroactive += 1; + } catch { + // An unparseable envelope is a verification problem, not a custody one. + } + } + + // Grouped by DID scheme: a did:peer row means the earlier celAssetDids + // migration never ran on it, which is a different fix from a missing envelope. + const byScheme: Record = {}; + for (const l of missing) { + const scheme = l.assetDid?.split(":").slice(0, 2).join(":") ?? "(none)"; + byScheme[scheme] = (byScheme[scheme] ?? 0) + 1; + } + + return { + lists: lists.length, + withEnvelope: lists.length - missing.length, + missingEnvelope: missing.length, + missingByDidScheme: byScheme, + // The population the "make an authorable copy" action exists to serve. + retroactiveGenesis: retroactive, + authorable: lists.length - missing.length - retroactive, + truncated: lists.length === SCAN_LIMIT || envelopes.length === SCAN_LIMIT, + }; + }, +}); diff --git a/convex/publication.ts b/convex/publication.ts index 8937688..c1a5a22 100644 --- a/convex/publication.ts +++ b/convex/publication.ts @@ -7,6 +7,7 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; +import { upsertListEnvelope } from "./lib/listEnvelope"; import { internal } from "./_generated/api"; /** @@ -20,6 +21,10 @@ export const publishList = mutation({ didDocument: v.optional(v.string()), didLog: v.optional(v.string()), publisherDid: v.string(), + // The asset envelope after appending the published-version event. Optional: + // a list whose signing key was lost to the celAssetDids migration can still + // be published, it just cannot record the fact in its own log. + celEnvelope: v.optional(v.string()), }, handler: async (ctx, args) => { // Verify user is owner @@ -41,6 +46,13 @@ export const publishList = mutation({ throw new Error("List is already published"); } + // The log is the record of what was published, so store it before the + // publication row — a publications row without its log would claim a + // provenance entry that does not exist. + if (args.celEnvelope) { + await upsertListEnvelope(ctx, args.listId, list.assetDid, args.celEnvelope); + } + if (existing) { // Re-publish (update existing record) await ctx.db.patch(existing._id, { diff --git a/package-lock.json b/package-lock.json index 8707028..7e3dc60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,8 @@ "@capacitor/push-notifications": "^8.0.0", "@capacitor/share": "^8.0.0", "@capacitor/status-bar": "^8.0.0", - "@originals/auth": "2.0.0", - "@originals/sdk": "2.1.0", + "@originals/auth": "3.0.0-next.0", + "@originals/sdk": "3.0.0-next.1", "@sentry/react": "^10.42.0", "@sentry/vite-plugin": "^5.1.1", "@turnkey/core": "^1.11.0", @@ -1008,12 +1008,12 @@ } }, "node_modules/@digitalbazaar/bbs-signatures/node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", + "integrity": "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.2.0" + "@noble/hashes": "2.3.0" }, "engines": { "node": ">= 20.19.0" @@ -2246,9 +2246,9 @@ } }, "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -2552,14 +2552,14 @@ } }, "node_modules/@originals/auth": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@originals/auth/-/auth-2.0.0.tgz", - "integrity": "sha512-KiG+QKn5XzV6IoOxxVOSDyo4sAvl+A+WuKkNB1iScl2t3fAnJSaw+tRixOpwbiEQuXSaufmSHN0XJIESTPQyRQ==", + "version": "3.0.0-next.0", + "resolved": "https://registry.npmjs.org/@originals/auth/-/auth-3.0.0-next.0.tgz", + "integrity": "sha512-fbWfYKBcGaODG65fpyuwf0/B+K8Ue88G8FcVdYyNJujisZMRtYzmafxLCtGdbktN6bx+WRzGEPTlRrPPVv7LsA==", "license": "MIT", "dependencies": { "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", - "@originals/sdk": "^2.0.0", + "@originals/sdk": "^3.0.0-next.0", "@turnkey/crypto": "^2.10.0", "@turnkey/sdk-server": "^7.0.0", "jsonwebtoken": "^9.0.2" @@ -2643,10 +2643,51 @@ "node": ">=18.0.0" } }, + "node_modules/@originals/cel": { + "version": "0.2.0-next.1", + "resolved": "https://registry.npmjs.org/@originals/cel/-/cel-0.2.0-next.1.tgz", + "integrity": "sha512-xR2xrGAuNlcEUIr4pp3/ApNpsYVWW0SZsS/28KbPOL5t8xVFJ8bbhkB85nTfZ0VsAjTCb3ZwdjAGO8e+69IkxA==", + "license": "MIT", + "dependencies": { + "@aviarytech/did-peer": "^1.1.2", + "@noble/curves": "^2.2.0", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.0.1", + "@scure/base": "^2.2.0", + "cborg": "^5.1.6" + }, + "engines": { + "node": ">=20.10.0" + } + }, + "node_modules/@originals/cel/node_modules/@noble/curves": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", + "integrity": "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.3.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@originals/cel/node_modules/@scure/base": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz", + "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@originals/sdk": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@originals/sdk/-/sdk-2.1.0.tgz", - "integrity": "sha512-VUfqbj+AaBEhLj5eb3UwCItQWaH/jB4zRUrSYN0rSAHZMBa44aMi9/GZl6Q9VVX5IQ5FpDtOS3wWozLf/qLKVA==", + "version": "3.0.0-next.1", + "resolved": "https://registry.npmjs.org/@originals/sdk/-/sdk-3.0.0-next.1.tgz", + "integrity": "sha512-giauv9topg2yeLDFVGEj8Dw58PJbrUTifXNAx+JZ7cSY1y4/CrUT+3zKTuvE/Shv9TF7Gx9umZzC6+SirQTVuQ==", "license": "MIT", "dependencies": { "@aviarytech/did-peer": "^1.1.2", @@ -2655,11 +2696,11 @@ "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@noble/secp256k1": "^3.1.0", + "@originals/cel": "^0.2.0-next.1", "@scure/base": "^2.2.0", "@scure/btc-signer": "^2.2.0", "b58": "^4.0.3", "bitcoinjs-lib": "^6.1.0", - "cborg": "^5.1.6", "didwebvh-ts": "^2.8.0", "fflate": "^0.8.3", "jsonld": "^8.3.3", @@ -2674,12 +2715,12 @@ } }, "node_modules/@originals/sdk/node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", + "integrity": "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.2.0" + "@noble/hashes": "2.3.0" }, "engines": { "node": ">= 20.19.0" @@ -2689,9 +2730,9 @@ } }, "node_modules/@originals/sdk/node_modules/@scure/base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", - "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz", + "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -3374,27 +3415,27 @@ } }, "node_modules/@scure/btc-signer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz", - "integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.3.0.tgz", + "integrity": "sha512-05oEjx2l4VhT+CBGKqGqE+4R2n+XN7+z+vC5I0edzymHfmTZ8nrHvl0WGgPVaNPkOg/+elEhIB1gRROkunxCRg==", "license": "MIT", "dependencies": { - "@noble/curves": "~2.2.0", - "@noble/hashes": "~2.2.0", - "@scure/base": "~2.2.0", - "micro-packed": "~0.9.0" + "@noble/curves": "~2.3.0", + "@noble/hashes": "~2.3.0", + "@scure/base": "~2.3.0", + "micro-packed": "~0.11.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/btc-signer/node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", + "integrity": "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.2.0" + "@noble/hashes": "2.3.0" }, "engines": { "node": ">= 20.19.0" @@ -3404,9 +3445,9 @@ } }, "node_modules/@scure/btc-signer/node_modules/@scure/base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", - "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz", + "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" @@ -10466,10 +10507,15 @@ "license": "MIT" }, "node_modules/json-canonicalize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/json-canonicalize/-/json-canonicalize-2.0.0.tgz", - "integrity": "sha512-yyrnK/mEm6Na3ChbJUWueXdapueW0p380RUyTW87XGb1ww8l8hU0pRrGC3vSWHe9CxrbPHX2fGUOZpNiHR0IIg==", - "license": "MIT" + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/json-canonicalize/-/json-canonicalize-2.0.1.tgz", + "integrity": "sha512-TuGrLM400c1VN8bQsSOiyDEEmRjIVgFvcSehWC+0JJaV4BGl1vYNxHBPVsN5ADUZIcWp75IN1yf+vCZyDjPt1Q==", + "license": "MIT", + "engines": { + "node": ">=8.5", + "npm": ">=6.0", + "yarn": "^1.0" + } }, "node_modules/json-parse-better-errors": { "version": "1.0.2", @@ -11811,12 +11857,12 @@ } }, "node_modules/micro-packed": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz", - "integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.11.0.tgz", + "integrity": "sha512-rq9RzPpmlgt3YaW2RrtCG9Q2iydX2+KZnL/Vhik8KKdsKwR5FvLXJzqp5IhXqD67XjYeZEMCrG6B28Mq9uC8Tw==", "license": "MIT", "dependencies": { - "@scure/base": "~2.2.0" + "@scure/base": "~2.3.0" }, "engines": { "node": ">= 20.19.0" @@ -11826,9 +11872,9 @@ } }, "node_modules/micro-packed/node_modules/@scure/base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", - "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz", + "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" diff --git a/package.json b/package.json index b69d564..c6234b1 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "@capacitor/push-notifications": "^8.0.0", "@capacitor/share": "^8.0.0", "@capacitor/status-bar": "^8.0.0", - "@originals/auth": "2.0.0", - "@originals/sdk": "2.1.0", + "@originals/auth": "3.0.0-next.0", + "@originals/sdk": "3.0.0-next.1", "@sentry/react": "^10.42.0", "@sentry/vite-plugin": "^5.1.1", "@turnkey/core": "^1.11.0", diff --git a/scripts/cel-migration.test.mjs b/scripts/cel-migration.test.mjs index c4f950f..cc84817 100644 --- a/scripts/cel-migration.test.mjs +++ b/scripts/cel-migration.test.mjs @@ -162,3 +162,71 @@ test("setListAssetDid throws on a missing list rather than silently no-oping", a /not found/ ); }); + +// The minting half of the migration. Until now only celAssetDidsDb.ts (the +// database half) was covered, which is why SDK 3.0's NO_CUSTODY throw slipped +// through typecheck and the whole suite: nothing here ever called createAsset. +async function loadMinter() { + const dir = "tmp/cel-migration-mint-test"; + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); + await build({ + entryPoints: ["./convex/migrations/celAssetDids.ts"], + outfile: `${dir}/celAssetDids.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + // Convex's generated server module needs a real deployment; the mint path + // under test never touches it, so stub it rather than stand one up. + plugins: [ + { + name: "stub-convex-generated", + setup(b) { + b.onResolve({ filter: /_generated\// }, () => ({ path: "gen", namespace: "g" })); + b.onLoad({ filter: /.*/, namespace: "g" }, () => ({ + contents: + "export const internalAction=(d)=>d; export const internalMutation=(d)=>d; export const internalQuery=(d)=>d; export const internal=new Proxy({},{get:()=>new Proxy({},{get:()=>undefined})});", + loader: "js", + })); + }, + }, + ], + banner: { + js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);", + }, + }); + return import( + `${pathToFileURL(`${process.cwd()}/${dir}/celAssetDids.mjs`).href}?t=${Date.now()}` + ); +} + +test("mintCelGenesis produces a verifiable envelope", async () => { + const minter = await loadMinter(); + + // A real mint. Under SDK 3.0 an implicit no-custody createAsset throws + // NO_CUSTODY, so this fails here rather than mid-migration against prod data. + const { assetDid, envelope } = await minter.mintCelGenesis( + "Groceries", + "did:webvh:example:alice", + Date.parse("2026-02-01T00:00:00.000Z") + ); + + assert.match(assetDid, /^did:cel:/); + + const parsed = JSON.parse(envelope); + assert.equal(parsed.assetDid, assetDid); + + // The migration's whole point is that migrated lists are verifiable. Replay + // the log the same way the client does rather than trusting it parses. + const { OriginalsSDK } = await import("@originals/sdk"); + const sdk = OriginalsSDK.create({ network: "signet", defaultKeyType: "Ed25519" }); + const { verification } = await sdk.lifecycle.loadAsset(envelope); + assert.equal(verification?.verified, true, "a migrated list must verify"); + + // The list's own createdAt is what the genesis resource commits to, so a + // migrated list keeps its real creation date even though the log is sealed now. + const resource = parsed.resources.find((r) => r.id === "list-metadata"); + assert.equal(JSON.parse(resource.content).createdAt, "2026-02-01T00:00:00.000Z"); +}); diff --git a/scripts/copy-list.test.mjs b/scripts/copy-list.test.mjs new file mode 100644 index 0000000..9a1cef6 --- /dev/null +++ b/scripts/copy-list.test.mjs @@ -0,0 +1,199 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/copy-list-test"; + +async function loadModule() { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["./convex/lists.ts"], + outfile: `${outdir}/lists.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + }); + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/lists.mjs`).href}?t=${Date.now()}` + ); +} + +const mod = await loadModule(); +const unwrap = (fn) => fn._handler ?? fn.handler; + +const OWNER = "did:webvh:QmS:boop.ad:user-owner"; +const STRANGER = "did:webvh:QmS:boop.ad:user-stranger"; + +/** + * The source list is a migrated one: its envelope exists but nobody holds the + * key, which is the situation copyList is for. + */ +function makeCtx({ items = [], lists: extraLists = [], user = null, subscription = null } = {}) { + const rows = { + lists: [ + { _id: "L1", ownerDid: OWNER, name: "Camping", createdAt: 1000, assetDid: "did:cel:old", itemViewMode: "categorized", itemCategories: [{ id: "c1", name: "Gear", emoji: "🎒", order: 0 }] }, + ...extraLists, + ], + items: items.map((i, n) => ({ _id: `I${n}`, listId: "L1", ...i })), + users: user ? [user] : [], + subscriptions: subscription ? [subscription] : [], + referrals: [], + listEnvelopes: [], + }; + + const byId = new Map(); + for (const table of Object.values(rows)) for (const r of table) byId.set(r._id, r); + + let seq = 0; + return { + rows, + db: { + get: async (id) => byId.get(id) ?? null, + patch: async (id, fields) => Object.assign(byId.get(id), fields), + insert: async (table, doc) => { + const _id = `${table}-${++seq}`; + const row = { _id, _creationTime: Date.now(), ...doc }; + (rows[table] ??= []).push(row); + byId.set(_id, row); + return _id; + }, + query: (table) => { + let rowsFor = () => rows[table] ?? []; + const result = { + withIndex: (_name, fn) => { + // Emulate just enough index filtering for by_list / by_owner. + const captured = {}; + if (fn) fn({ eq: (field, value) => { captured[field] = value; return captured; } }); + const base = rowsFor; + rowsFor = () => + base().filter((r) => Object.entries(captured).every(([k, v]) => r[k] === v)); + return result; + }, + collect: async () => rowsFor(), + first: async () => rowsFor()[0] ?? null, + }; + return result; + }, + }, + }; +} + +const MINTED = { + assetDid: "did:cel:fresh", + celEnvelope: '{"format":"originals/asset"}', + name: "Camping (copy)", + ownerDid: OWNER, + createdAt: 5000, +}; + +test("copies items into a new list and leaves the source alone", async () => { + const ctx = makeCtx({ + items: [ + { name: "Tent", checked: true, createdByDid: OWNER, createdAt: 1 }, + { name: "Stove", checked: false, createdByDid: OWNER, createdAt: 2 }, + ], + }); + + const { listId, itemsCopied } = await unwrap(mod.copyList)(ctx, { + sourceListId: "L1", + ...MINTED, + }); + + assert.equal(itemsCopied, 2); + + const copied = ctx.rows.items.filter((i) => i.listId === listId); + assert.deepEqual(copied.map((i) => i.name).sort(), ["Stove", "Tent"]); + // Checked state is content, so a faithful copy keeps it. + assert.equal(copied.find((i) => i.name === "Tent").checked, true); + + // The source keeps its own items and its own identity. + assert.equal(ctx.rows.items.filter((i) => i.listId === "L1").length, 2); + assert.equal((await ctx.db.get("L1")).assetDid, "did:cel:old"); + + const fresh = await ctx.db.get(listId); + assert.equal(fresh.assetDid, "did:cel:fresh"); + // Presentation settings belong to the list and should survive the copy. + assert.equal(fresh.itemViewMode, "categorized"); + assert.equal(fresh.itemCategories[0].name, "Gear"); +}); + +test("writes the new list's envelope so the copy is verifiable", async () => { + const ctx = makeCtx({ items: [{ name: "Tent", checked: false, createdByDid: OWNER, createdAt: 1 }] }); + const { listId } = await unwrap(mod.copyList)(ctx, { sourceListId: "L1", ...MINTED }); + + const env = ctx.rows.listEnvelopes.find((e) => e.listId === listId); + assert.ok(env, "a copy with no stored envelope would show as unverifiable"); + assert.equal(env.assetDid, "did:cel:fresh"); +}); + +test("sub-item links point inside the copy, never back at the source", async () => { + const ctx = makeCtx({ + items: [ + { name: "Shelter", checked: false, createdByDid: OWNER, createdAt: 1 }, + { name: "Pegs", checked: false, createdByDid: OWNER, createdAt: 2, parentId: "I0" }, + ], + }); + + const { listId } = await unwrap(mod.copyList)(ctx, { sourceListId: "L1", ...MINTED }); + + const copied = ctx.rows.items.filter((i) => i.listId === listId); + const shelter = copied.find((i) => i.name === "Shelter"); + const pegs = copied.find((i) => i.name === "Pegs"); + + assert.equal(pegs.parentId, shelter._id, "parentId must be remapped to the copied parent"); + assert.notEqual(pegs.parentId, "I0", "a copy pointing at the source list's item is corrupt"); +}); + +test("drops vcProofs — they attest actions against the source asset", async () => { + const ctx = makeCtx({ + items: [ + { + name: "Tent", + checked: false, + createdByDid: OWNER, + createdAt: 1, + vcProofs: [{ type: "ItemCreation", issuer: "did:cel:old", issuanceDate: 1, action: "created", actorDid: OWNER }], + }, + ], + }); + + const { listId } = await unwrap(mod.copyList)(ctx, { sourceListId: "L1", ...MINTED }); + + const copied = ctx.rows.items.find((i) => i.listId === listId); + assert.equal(copied.vcProofs, undefined, "carrying these would claim another asset's provenance"); +}); + +test("only the owner can copy a list", async () => { + const ctx = makeCtx({ items: [] }); + await assert.rejects( + () => unwrap(mod.copyList)(ctx, { sourceListId: "L1", ...MINTED, ownerDid: STRANGER }), + /owner/i + ); +}); + +test("copying respects the free-plan list cap", async () => { + // Five lists already, free plan: a copy is a new list and must count. + const extra = Array.from({ length: 4 }, (_, n) => ({ + _id: `X${n}`, + ownerDid: OWNER, + name: `Other ${n}`, + createdAt: 1, + assetDid: "did:cel:x", + })); + + const ctx = makeCtx({ + items: [], + lists: extra, + user: { _id: "U1", did: OWNER }, + }); + + await assert.rejects( + () => unwrap(mod.copyList)(ctx, { sourceListId: "L1", ...MINTED }), + /PLAN_LIMIT/ + ); +}); diff --git a/scripts/fixtures/cel-envelope-sdk-2.1.0.json b/scripts/fixtures/cel-envelope-sdk-2.1.0.json new file mode 100644 index 0000000..e27c3cc --- /dev/null +++ b/scripts/fixtures/cel-envelope-sdk-2.1.0.json @@ -0,0 +1 @@ +{"format":"originals/asset","version":1,"assetDid":"did:cel:uEiCMiVNnDVTBrbKCCmACFaUp7lIp5usuXDNJRd8dtr15dA","eventLog":{"events":[{"type":"create","data":{"controller":"did:key:z6MktsmAGYjzCiDSHPu9snQ5yxWAQU42CfAVj6jhrwsKvRaQ","createdAt":"2026-08-16T07:39:26.567Z","name":"list-metadata","nonce":"uf4QTXmcRac42HKJC-67F2w","resources":[{"digestMultibase":"uEiDZWpT6rYddd36hRdqIBR-HeSBuljszcibps6CRfJqatg","id":"list-metadata","mediaType":"application/json"}]},"proof":[{"created":"2026-08-16T07:39:26.580Z","cryptosuite":"eddsa-jcs-2022","proofPurpose":"assertionMethod","proofValue":"z3ewAcn1jDf1RQUG9rbUbgLDbcVG7kn9bujRnQbqihT2JwSE1vURWJzGiggGHn899EXiHogpH9Tcn7E6iEwv7B6QF","type":"DataIntegrityProof","verificationMethod":"did:key:z6MktsmAGYjzCiDSHPu9snQ5yxWAQU42CfAVj6jhrwsKvRaQ#z6MktsmAGYjzCiDSHPu9snQ5yxWAQU42CfAVj6jhrwsKvRaQ"}]}]},"didDocuments":{"did:cel":{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1"],"id":"did:cel:uEiCMiVNnDVTBrbKCCmACFaUp7lIp5usuXDNJRd8dtr15dA","verificationMethod":[{"id":"did:cel:uEiCMiVNnDVTBrbKCCmACFaUp7lIp5usuXDNJRd8dtr15dA#key-0","type":"Multikey","controller":"did:cel:uEiCMiVNnDVTBrbKCCmACFaUp7lIp5usuXDNJRd8dtr15dA","publicKeyMultibase":"z6MktsmAGYjzCiDSHPu9snQ5yxWAQU42CfAVj6jhrwsKvRaQ"}],"authentication":["did:cel:uEiCMiVNnDVTBrbKCCmACFaUp7lIp5usuXDNJRd8dtr15dA#key-0"],"assertionMethod":["did:cel:uEiCMiVNnDVTBrbKCCmACFaUp7lIp5usuXDNJRd8dtr15dA#key-0"],"alsoKnownAs":["did:key:z6MktsmAGYjzCiDSHPu9snQ5yxWAQU42CfAVj6jhrwsKvRaQ"]}},"resources":[{"id":"list-metadata","type":"ListMetadata","contentType":"application/json","content":"{\"name\":\"legacy list\",\"createdBy\":\"did:webvh:x\",\"createdAt\":\"2026-01-01T00:00:00Z\"}","hash":"d95a94faad875d777ea145da88051f8779206e963b337226e9b3a0917c9a9ab6"}]} \ No newline at end of file diff --git a/scripts/originals.test.mjs b/scripts/originals.test.mjs index f82070b..9d14bb8 100644 --- a/scripts/originals.test.mjs +++ b/scripts/originals.test.mjs @@ -85,4 +85,30 @@ const originals = await loadOriginalsModule(); assert.ok(garbage.error, "malformed input should report an error, not throw"); } +// --- envelopes sealed before the CEL cryptosuite rename still verify ------- + +// SDK 3.0 renamed the CEL cryptosuite to `originals-cel-ed25519-jcs-v1` and +// started signing the proof configuration. Logs sealed earlier cannot be +// re-signed, so `eddsa-jcs-2022` stays accepted on READ — every list already in +// the database depends on that. The fixture is a real envelope minted by SDK +// 2.1.0; if a future bump drops the legacy suite, this fails instead of every +// existing list silently going unverifiable. +{ + const { readFile } = await import("node:fs/promises"); + const legacy = await readFile("scripts/fixtures/cel-envelope-sdk-2.1.0.json", "utf8"); + + assert.ok( + legacy.includes('"cryptosuite":"eddsa-jcs-2022"'), + "fixture must carry the pre-3.0 suite, else it proves nothing" + ); + + const result = await originals.verifyListEnvelope(legacy); + assert.equal( + result.verified, + true, + `2.x-sealed envelope must still verify, got: ${result.error ?? ""}` + ); + assert.deepEqual(result.warnings, []); +} + console.log("originals helper tests passed"); diff --git a/scripts/published-version.test.mjs b/scripts/published-version.test.mjs new file mode 100644 index 0000000..5d7ff1e --- /dev/null +++ b/scripts/published-version.test.mjs @@ -0,0 +1,186 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/published-version-test"; + +/** + * Recording a published version signs a CEL event, which needs the genesis key + * out of localStorage — so these run with browser globals installed, unlike the + * rest of the originals tests. + */ +async function loadOriginals() { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["src/lib/originals.ts"], + outfile: `${outdir}/originals.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + banner: { + js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);", + }, + }); + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/originals.mjs`).href}?t=${Date.now()}` + ); +} + +/** + * defineProperty, not assignment: bun exposes a readonly `localStorage`. + * Restoring matters because bun shares globals across test files. + */ +function withLocalStorage(store) { + const saved = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + }, + configurable: true, + writable: true, + }); + return () => { + if (saved) Object.defineProperty(globalThis, "localStorage", saved); + else delete globalThis.localStorage; + }; +} + +const SNAPSHOT = { + name: "Camping", + items: [ + { name: "Tent", checked: false }, + { name: "Stove", checked: true }, + ], +}; + +test("records a published version the log still verifies", async () => { + const store = new Map(); + const restore = withLocalStorage(store); + try { + const originals = await loadOriginals(); + const asset = await originals.createListAsset("Camping", "did:webvh:example:alice"); + + const recorded = await originals.recordPublishedVersion(asset.envelope, SNAPSHOT); + + assert.equal(recorded.appended, true); + // Genesis is version 1, so the first published snapshot is 2. + assert.equal(recorded.version, 2); + assert.ok(recorded.hash); + + const events = JSON.parse(recorded.envelope).eventLog.events.map((e) => e.type); + assert.deepEqual(events, ["create", "update"]); + + // An unverifiable log would be worse than no log at all. + const check = await originals.verifyListEnvelope(recorded.envelope); + assert.equal(check.verified, true, `must still verify, got: ${check.error ?? ""}`); + assert.deepEqual(check.warnings, []); + } finally { + restore(); + } +}); + +test("the snapshot commits to what was published", async () => { + const store = new Map(); + const restore = withLocalStorage(store); + try { + const originals = await loadOriginals(); + const asset = await originals.createListAsset("Camping", "did:webvh:example:alice"); + const recorded = await originals.recordPublishedVersion(asset.envelope, SNAPSHOT); + + const resources = JSON.parse(recorded.envelope).resources; + const published = resources.filter((r) => r.id === "list-metadata").at(-1); + assert.deepEqual(JSON.parse(published.content), SNAPSHOT); + } finally { + restore(); + } +}); + +test("re-publishing unchanged content is a no-op, not a failure", async () => { + const store = new Map(); + const restore = withLocalStorage(store); + try { + const originals = await loadOriginals(); + const asset = await originals.createListAsset("Camping", "did:webvh:example:alice"); + + const first = await originals.recordPublishedVersion(asset.envelope, SNAPSHOT); + const again = await originals.recordPublishedVersion(first.envelope, SNAPSHOT); + + assert.equal(again.appended, false, "an unchanged re-publish must not append"); + assert.equal( + JSON.parse(again.envelope).eventLog.events.length, + 2, + "the log must not grow when nothing changed" + ); + } finally { + restore(); + } +}); + +test("a changed list appends a further version", async () => { + const store = new Map(); + const restore = withLocalStorage(store); + try { + const originals = await loadOriginals(); + const asset = await originals.createListAsset("Camping", "did:webvh:example:alice"); + + const first = await originals.recordPublishedVersion(asset.envelope, SNAPSHOT); + const changed = originals.buildListSnapshot("Camping", [ + { name: "Tent", checked: true }, + { name: "Stove", checked: true }, + ]); + const second = await originals.recordPublishedVersion(first.envelope, changed); + + assert.equal(second.appended, true); + assert.equal(second.version, 3); + assert.equal(JSON.parse(second.envelope).eventLog.events.length, 3); + + const check = await originals.verifyListEnvelope(second.envelope); + assert.equal(check.verified, true); + } finally { + restore(); + } +}); + +test("a list whose key is gone reports it rather than throwing a raw SDK error", async () => { + const store = new Map(); + const restore = withLocalStorage(store); + try { + const originals = await loadOriginals(); + const asset = await originals.createListAsset("Camping", "did:webvh:example:alice"); + + // Exactly the migrated case: the log exists, the key does not. + store.clear(); + + await assert.rejects( + () => originals.recordPublishedVersion(asset.envelope, SNAPSHOT), + (err) => { + assert.equal(err.name, "ListNotAuthorableError"); + assert.match(err.message, /signing key/i); + return true; + } + ); + } finally { + restore(); + } +}); + +test("buildListSnapshot keeps only what a version should commit to", async () => { + const restore = withLocalStorage(new Map()); + try { + const originals = await loadOriginals(); + const snapshot = originals.buildListSnapshot("Camping", [ + { name: "Tent", checked: false, _id: "I1", assigneeDid: "did:webvh:someone" }, + ]); + // Ids and assignees are local bookkeeping, not published content — including + // them would churn the hash on changes nobody published. + assert.deepEqual(snapshot, { name: "Camping", items: [{ name: "Tent", checked: false }] }); + } finally { + restore(); + } +}); diff --git a/src/components/ProvenanceInfo.tsx b/src/components/ProvenanceInfo.tsx index 52075d8..c53eddd 100644 --- a/src/components/ProvenanceInfo.tsx +++ b/src/components/ProvenanceInfo.tsx @@ -11,11 +11,13 @@ */ import { useEffect, useState } from "react"; -import { useQuery } from "convex/react"; +import { useQuery, useMutation } from "convex/react"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; +import { useNavigate } from "react-router-dom"; import { useSettings } from "../hooks/useSettings"; -import { verifyListEnvelope } from "../lib/originals"; +import { useCurrentUser } from "../hooks/useCurrentUser"; +import { verifyListEnvelope, isRetroactiveGenesis, createListAsset } from "../lib/originals"; interface ListProvenanceProps { list: Doc<"lists">; @@ -167,14 +169,17 @@ function TimestampRow({ label, timestamp }: { label: string; timestamp: number } type EnvelopeState = | { status: "absent" } | { status: "checking" } - | { status: "verified" } + | { status: "verified"; retroactive: boolean } | { status: "failed"; detail: string }; /** * Replays the list's signed CEL log client-side. Verification is real work * (signature checks + hashing), so it runs in an effect rather than on render. */ -function useEnvelopeVerification(listId: Doc<"lists">["_id"]): EnvelopeState { +function useEnvelopeVerification( + listId: Doc<"lists">["_id"], + listCreatedAt: number +): EnvelopeState { const stored = useQuery(api.lists.getListEnvelope, { listId }); const envelope = stored?.envelope ?? null; // Keyed by the envelope it describes, so a stale result is never shown for a @@ -189,7 +194,7 @@ function useEnvelopeVerification(listId: Doc<"lists">["_id"]): EnvelopeState { setResult({ envelope, state: verification.verified - ? { status: "verified" } + ? { status: "verified", retroactive: isRetroactiveGenesis(envelope, listCreatedAt) } : { status: "failed", detail: verification.error ?? verification.warnings.join("; "), @@ -199,7 +204,7 @@ function useEnvelopeVerification(listId: Doc<"lists">["_id"]): EnvelopeState { return () => { cancelled = true; }; - }, [envelope]); + }, [envelope, listCreatedAt]); if (stored === undefined) return { status: "checking" }; if (stored === null) return { status: "absent" }; @@ -207,14 +212,22 @@ function useEnvelopeVerification(listId: Doc<"lists">["_id"]): EnvelopeState { } function EnvelopeVerificationRow({ state }: { state: EnvelopeState }) { - if (state.status === "absent") return null; - + // Every state says something. Rendering nothing for "absent" made a list with + // no log indistinguishable from one whose log simply wasn't shown. const [icon, text, tone] = - state.status === "checking" - ? ["⏳", "Verifying event log…", "text-gray-500 dark:text-gray-400"] - : state.status === "verified" - ? ["✅", "Event log verified", "text-green-600 dark:text-green-400"] - : ["⚠️", `Event log failed verification: ${state.detail}`, "text-red-600 dark:text-red-400"]; + state.status === "absent" + ? ["—", "No event log recorded for this list", "text-gray-500 dark:text-gray-400"] + : state.status === "checking" + ? ["⏳", "Verifying event log…", "text-gray-500 dark:text-gray-400"] + : state.status === "verified" + ? [ + "✅", + state.retroactive + ? "Event log verified — sealed when this list was migrated, not when it was created" + : "Event log verified", + "text-green-600 dark:text-green-400", + ] + : ["⚠️", `Event log failed verification: ${state.detail}`, "text-red-600 dark:text-red-400"]; return (
@@ -228,6 +241,67 @@ function EnvelopeVerificationRow({ state }: { state: EnvelopeState }) { ); } +/** + * Offers an authorable copy of a list whose log was sealed by the migration. + * + * Those lists were given genesis server-side with an ephemeral controller, so + * nobody holds their key and they can never record another event. A copy is + * minted here in the browser, so its key lands in this device's keyStore. + * + * Owner-only: copying mints an identity naming the owner, so someone who can + * merely view a shared list must not be able to do it. The server enforces this + * too — this only decides whether to show the button. + */ +function CopyForProvenance({ list }: { list: Doc<"lists"> }) { + const { did } = useCurrentUser(); + const copyList = useMutation(api.lists.copyList); + const navigate = useNavigate(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + if (!did || did !== list.ownerDid) return null; + + const handleCopy = async () => { + setBusy(true); + setError(null); + try { + const name = `${list.name} (copy)`; + const asset = await createListAsset(name, did); + const { listId } = await copyList({ + sourceListId: list._id, + assetDid: asset.assetDid, + celEnvelope: asset.envelope, + name, + ownerDid: did, + createdAt: Date.now(), + }); + navigate(`/list/${listId}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not copy this list"); + setBusy(false); + } + }; + + return ( +
+ +
+ Copies the items into a new list that can record its own history. This + list is left as it is. +
+ {error && ( +
{error}
+ )} +
+ ); +} + function ProvenanceSection({ title, children, @@ -508,7 +582,7 @@ export function ListProvenanceInfo({ list }: ListProvenanceProps) { const ownerName = userInfo?.[list.ownerDid]?.displayName ?? null; - const envelopeState = useEnvelopeVerification(list._id); + const envelopeState = useEnvelopeVerification(list._id, list.createdAt); // Build timeline events const timelineEvents: Array<{ @@ -575,6 +649,9 @@ export function ListProvenanceInfo({ list }: ListProvenanceProps) { /> + {envelopeState.status === "verified" && envelopeState.retroactive && ( + + )} {/* Ownership VC */} {list.vcProof && ( diff --git a/src/components/publish/PublishModal.tsx b/src/components/publish/PublishModal.tsx index 06dc944..9db7720 100644 --- a/src/components/publish/PublishModal.tsx +++ b/src/components/publish/PublishModal.tsx @@ -13,6 +13,11 @@ import type { Doc } from "../../../convex/_generated/dataModel"; import { useCurrentUser } from "../../hooks/useCurrentUser"; import { useSettings } from "../../hooks/useSettings"; import { buildListResourceDid, buildListResourceUrl } from "../../lib/webvh"; +import { + buildListSnapshot, + recordPublishedVersion, + ListNotAuthorableError, +} from "../../lib/originals"; import { Panel } from "../ui/Panel"; interface PublishModalProps { @@ -29,11 +34,16 @@ export function PublishModal({ list, onClose }: PublishModalProps) { const publicationStatus = useQuery(api.publication.getPublicationStatus, { listId: list._id, }); + // Both feed the signed snapshot the publish records in the list's own log. + const storedEnvelope = useQuery(api.lists.getListEnvelope, { listId: list._id }); + const items = useQuery(api.items.getListItems, { listId: list._id }); const [isPublishing, setIsPublishing] = useState(false); const [isUnpublishing, setIsUnpublishing] = useState(false); const [isCopied, setIsCopied] = useState(false); const [error, setError] = useState(null); + // Published, but the list could not sign the event recording it. + const [unrecorded, setUnrecorded] = useState(false); const isPublished = publicationStatus?.status === "active"; const publicUrl = isPublished && did @@ -54,13 +64,36 @@ export function PublishModal({ list, onClose }: PublishModalProps) { // The list is a resource under the user's DID — no separate DID needed. const listResourceDid = buildListResourceDid(did, list._id); - // Record publication in Convex + // Record the published state in the list's own event log first, so the + // publication row and the log cannot disagree about what went public. + // A list whose key was lost to the migration still publishes; it just + // cannot sign the event, and we say so rather than failing the publish. + let celEnvelope: string | undefined; + setUnrecorded(false); + + if (storedEnvelope?.envelope) { + try { + const recorded = await recordPublishedVersion( + storedEnvelope.envelope, + buildListSnapshot(list.name, items ?? []) + ); + celEnvelope = recorded.envelope; + } catch (err) { + if (err instanceof ListNotAuthorableError) { + setUnrecorded(true); + } else { + throw err; + } + } + } + await publishListMutation({ listId: list._id, webvhDid: listResourceDid, publisherDid: did, + celEnvelope, }); - + haptic('success'); } catch (err) { console.error("[PublishModal] Failed to publish:", err); @@ -308,6 +341,14 @@ export function PublishModal({ list, onClose }: PublishModalProps) { )} + {unrecorded && ( +
+ Published, but this list couldn't sign the event recording it — its + signing key isn't on this device. Make an authorable copy from the + list's provenance details to keep a verifiable history. +
+ )} + {error && (
diff --git a/src/lib/originals.ts b/src/lib/originals.ts index 0d25b0c..29ea70f 100644 --- a/src/lib/originals.ts +++ b/src/lib/originals.ts @@ -16,7 +16,7 @@ import type { KeyPair, OriginalsConfig, } from "@originals/sdk"; -import { localCelKeyStore } from "./celKeyStore"; +import { localCelKeyStore, hasCelKey } from "./celKeyStore"; // SDK configuration for testnet/development const config: OriginalsConfig = { @@ -126,5 +126,147 @@ export async function verifyListEnvelope(envelope: string): Promise; +} + +export interface RecordedVersion { + /** The asset envelope after the append. Persist it — it is the new log. */ + envelope: string; + /** 1 is genesis, so a first published version is 2. */ + version: number; + hash: string; + /** False when the content matched the current version and nothing was appended. */ + appended: boolean; +} + +/** + * Thrown when a list cannot sign its own events, which is not a bug the user + * can act on except by copying the list. + */ +export class ListNotAuthorableError extends Error { + constructor(message: string) { + super(message); + this.name = "ListNotAuthorableError"; + } +} + +/** + * Build the content a published version commits to. Separate from + * buildListResource because genesis commits to the list's identity while a + * version commits to what was actually published. + */ +export function buildListSnapshot( + name: string, + items: Array<{ name: string; checked: boolean }> +): ListSnapshot { + return { name, items: items.map(({ name, checked }) => ({ name, checked })) }; +} + +/** + * Append a signed snapshot of the list's published state to its CEL log. + * + * Publishing used to leave no trace in the chain that exists to record an + * asset's history — the `publications` row knew, the log did not. This appends + * a content-addressed `update` event so the published state is verifiable. + * + * Stays on did:cel deliberately: publishToWeb would migrate the asset to + * did:webvh, changing its DID and moving its public URL to a content-addressed + * key, and boop serves published lists live at a stable path. + * + * Signing needs the genesis controller key, which lives in this device's + * keyStore. Lists re-minted by the celAssetDids migration have no key at all — + * they get ListNotAuthorableError rather than the SDK's CEL_APPEND_FAILED, so + * the caller can point at the copy action instead of showing a raw SDK error. + */ +export async function recordPublishedVersion( + envelope: string, + snapshot: ListSnapshot, + changes = "Published to the web" +): Promise { + const sdk = OriginalsSDK.create(config); + const { asset } = await sdk.lifecycle.loadAsset(envelope); + + if (!(await canAuthorList(asset.id))) { + throw new ListNotAuthorableError( + "This list's signing key isn't on this device, so its history can't be updated." + ); + } + + const content = JSON.stringify(snapshot); + + try { + const resource = await asset.addResourceVersion( + "list-metadata", + content, + "application/json", + changes + ); + return { + envelope: JSON.stringify(asset.serialize()), + version: resource.version ?? 0, + hash: resource.hash, + appended: true, + }; + } catch (err) { + // Re-publishing an unchanged list is a no-op, not a failure. The SDK + // refuses a version identical to the current one. + if (err instanceof Error && /unchanged|identical|same content/i.test(err.message)) { + const current = asset.resources.find((r) => r.id === "list-metadata"); + return { + envelope, + version: current?.version ?? 0, + hash: current?.hash ?? "", + appended: false, + }; + } + throw err; + } +} + +/** + * When the genesis event's proof was signed. + * + * Distinct from the list's own createdAt: the celAssetDids migration minted + * genesis for existing lists, so it committed to the list's real creation date + * while signing the log on the migration's clock. That gap is the only + * server-observable difference between a migrated list and a client-minted one. + */ +export function genesisSealedAt(envelope: string): number | null { + try { + const proof = JSON.parse(envelope)?.eventLog?.events?.[0]?.proof; + const created = (Array.isArray(proof) ? proof[0] : proof)?.created; + const ms = created ? Date.parse(created) : NaN; + return Number.isNaN(ms) ? null : ms; + } catch { + return null; + } +} + +/** Genesis sealed this long after list creation counts as a re-genesis. */ +const RETROACTIVE_GENESIS_MS = 60_000; + +/** + * True when this list's log was sealed well after the list already existed — + * i.e. the migration minted it, so no one holds its controller key and it can + * never author another event. Copying is the only way to get an authorable list. + */ +export function isRetroactiveGenesis(envelope: string, listCreatedAt: number): boolean { + const sealed = genesisSealedAt(envelope); + return sealed !== null && sealed - listCreatedAt > RETROACTIVE_GENESIS_MS; +} + +/** + * True when THIS DEVICE holds the list's genesis key. Custody is per-origin + * localStorage, so a client-minted list is unauthorable from a second device + * too — which is a different problem from a migrated list, and not one copying + * should be offered for. + */ +export async function canAuthorList(assetDid: string): Promise { + return hasCelKey(`${assetDid}#key-0`); +} + // Re-export types that consumers might need export type { DIDDocument, VerifiableCredential, KeyPair };