From d809d91180fb14d7a685971df11eb83d13b970ae Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Sun, 16 Aug 2026 00:42:23 -0700 Subject: [PATCH 1/5] chore(originals): upgrade to 3.0.0-next.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @originals/sdk and @originals/auth 3.0.0-next.0, which pulls in the newly split-out @originals/cel 0.2.0-next.0. Pinned exact rather than caret: npm writes ^3.0.0-next.0 for a prerelease, which would also match 3.0.0 final and every later 3.x, and the repo pins these exactly already. Nothing in our code had to change. `tsc -b --force`, `eslint`, the full bun suite (151 tests) and `npm run build` are all clean on the new version, and the import surface is small — src/lib/originals.ts, src/lib/celKeyStore.ts and convex/migrations/celAssetDids.ts. None of the breaking changes land on it: - `createAsset` now throws NO_CUSTODY without custody; config.keyStore is already set (localCelKeyStore), so it has some. 3.0 also fixes OriginalsSDK.create destructuring keyStore away before it reached the LifecycleManager, so our custody is now actually honoured downstream. - Buffer -> Uint8Array on the public surface: no Buffer in our SDK paths. - Deep imports like @originals/sdk/crypto/* no longer resolve: we had none. - @originals/auth's root no longer re-exports ./server: we never imported it. Adds a back-compat regression test, which is the part that could actually have bitten. 3.0 renames the CEL cryptosuite to originals-cel-ed25519-jcs-v1 and starts signing the proof configuration; logs sealed before it cannot be re-signed, so eddsa-jcs-2022 stays accepted on read. Every list envelope already in the database depends on that promise holding. The fixture is a genuine envelope minted by SDK 2.1.0 in a throwaway install, not a hand-written one, and it verifies clean under 3.0 with no warnings. The test asserts the fixture still carries the pre-3.0 suite, so it cannot quietly decay into proving nothing. If a later bump drops the legacy suite this fails here rather than in production, where the symptom would be every existing list going unverifiable at once. Note our did:webvh mint path is unaffected by 3.0's normalizeUpdateKey fix for the didwebvh-ts 2.8 bare-multibase requirement: src/lib/webvh.ts calls didwebvh-ts directly rather than through the SDK's WebVHManager, so #218's fix is still what is carrying us there. Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 24 +--- package-lock.json | 144 ++++++++++++------- package.json | 4 +- scripts/fixtures/cel-envelope-sdk-2.1.0.json | 1 + scripts/originals.test.mjs | 26 ++++ 5 files changed, 131 insertions(+), 68 deletions(-) create mode 100644 scripts/fixtures/cel-envelope-sdk-2.1.0.json diff --git a/bun.lock b/bun.lock index 2a0ec40..488ee9c 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.0", "@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.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=="], + + "@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": ["@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,10 +2241,6 @@ "@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=="], - "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@peculiar/asn1-cms/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -2281,8 +2275,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 +2401,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=="], diff --git a/package-lock.json b/package-lock.json index 8707028..d483942 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.0", "@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.0", + "resolved": "https://registry.npmjs.org/@originals/cel/-/cel-0.2.0-next.0.tgz", + "integrity": "sha512-sp5iIeOgz1JgOPhl0EYr2qNNvn+O9+ic9/oXmng2IYf+TE+7Pz/95dCGUqbMrTZphYRR8HGdJ2Lw4c4H5t4IyQ==", + "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.0", + "resolved": "https://registry.npmjs.org/@originals/sdk/-/sdk-3.0.0-next.0.tgz", + "integrity": "sha512-jbZa/TbLxfCwfM/Sp2ayQOWF0BNQkNyjdxavtPTUXb9azrJ77mpcToKctPN4SpcL7hlu1OHKAGlcl7Wus+dvWQ==", "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.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", @@ -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..fcacc78 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.0", "@sentry/react": "^10.42.0", "@sentry/vite-plugin": "^5.1.1", "@turnkey/core": "^1.11.0", 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"); From 65b11ea1fa30aca59f92b43bb1ccce1d4b1d9511 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Sun, 16 Aug 2026 02:12:09 -0700 Subject: [PATCH 2/5] fix(originals): the CEL migration mints ephemeral, not custody-less MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK 3.0 throws NO_CUSTODY when createAsset is called without a signer, a keyStore, or an explicit `{ controller: 'ephemeral' }`. celAssetDids.ts mints with none of them — deliberately, so the server never holds a list's controller key — so under 3.0 every call throws and the migration cannot run at all. `ephemeral` is the explicit spelling of exactly what it already did. The header has always said migrated lists are verifiable but not authorable; 3.0 simply gives that intent a name and refuses to infer it from an omission. I claimed in the upgrade commit that NO_CUSTODY missed us because config.keyStore is set. That holds for src/lib/originals.ts and not for this file, which is the one place we mint deliberately without custody. Nothing caught it. Typecheck cannot see a runtime throw, and cel-migration.test covered only celAssetDidsDb.ts — the database half — so no test had ever called createAsset on this path. The migration is an internalAction run by hand against prod, so the first signal would have been a failed run on real rows. Adds coverage for the minting half: a real mint, a real verification of the envelope it produces, and an assertion that the genesis resource still commits to the list's original createdAt rather than the migration's clock. Confirmed non-vacuous — reverting the fix fails it with NO_CUSTODY. Co-Authored-By: Claude Opus 5 (1M context) --- convex/migrations/celAssetDids.ts | 28 ++++++++----- scripts/cel-migration.test.mjs | 68 +++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) 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/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"); +}); From 21d9fc74df1cf7b2ee7a8f2dfe8f7ba0ab5b5bf2 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Sun, 16 Aug 2026 23:25:50 -0700 Subject: [PATCH 3/5] feat(lists): let owners make an authorable copy of a migrated list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every list in prod verifies — 28 of 28 have an envelope, checked by diffing list ids against listEnvelopes.listId, no gaps either way. The problem is not verifiability, it is custody: 22 of those 28 were given their genesis by the celAssetDids migration, server-side with an ephemeral controller. Nobody holds their key, so they can never record another CEL event. Only 6 are authorable. copyList mints a new list in the owner's browser, so the key lands in their keyStore, and moves the items across. The copy is honestly new — today's genesis, its own DID, no claim on the original's history — and the source list is left untouched. Details that matter: - Owner-only. A copy mints an identity naming the owner, so a viewer of a shared list must not be able to make one. Enforced server-side; the button only decides whether to offer it. - The free-plan cap is now a shared assertListQuota rather than inline in createList, because a copy is a new list and copying must not be a way around the limit. There is a test for that specifically. - parentId is remapped in a second pass. Copying it verbatim would leave sub-items in the new list pointing at rows in the old one. - vcProofs are dropped. They attest actions taken against the SOURCE asset's DID; carrying them would attribute one asset's provenance to another, which is the exact claim this feature exists to avoid. - Item fields are rest-spread with a four-field drop list, so a column added to items later is carried automatically instead of silently going missing. Also fixes the reason none of this was visible. EnvelopeVerificationRow returned null for the absent case, so a list with no log rendered nothing — indistinguishable from a list whose log just was not shown. Every state now says something, and a retroactive genesis says so rather than claiming a history it does not have. isRetroactiveGenesis compares the genesis proof's `created` against the list's createdAt. That gap is the only signal distinguishing a migrated list from a client-minted one; both commit to the list's real creation date. Validated against prod before relying on it: the split came out 22/6, matching the migration's own run. Co-Authored-By: Claude Opus 5 (1M context) --- convex/_generated/api.d.ts | 2 + convex/lists.ts | 184 +++++++++++++++++++----- convex/migrations/envelopeCoverage.ts | 65 +++++++++ scripts/copy-list.test.mjs | 199 ++++++++++++++++++++++++++ src/components/ProvenanceInfo.tsx | 105 ++++++++++++-- src/lib/originals.ts | 44 +++++- 6 files changed, 552 insertions(+), 47 deletions(-) create mode 100644 convex/migrations/envelopeCoverage.ts create mode 100644 scripts/copy-list.test.mjs 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/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/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/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/lib/originals.ts b/src/lib/originals.ts index 0d25b0c..25b0f3d 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,47 @@ export async function verifyListEnvelope(envelope: string): Promise 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 }; From 637a9269149c16e4d7d864f41a5104f464c5db8d Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Mon, 17 Aug 2026 00:35:18 -0700 Subject: [PATCH 4/5] chore(originals): sdk 3.0.0-next.1 (pulls cel 0.2.0-next.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both of next.1's changes are in surfaces we do not call: - getAnchoringsForDidCel, so production ord providers can verify btco-anchored did:cel assets. We do no Bitcoin anchoring — the SDK references in convex/bitcoinAnchors.ts are commented-out scaffolding. - addResourceVersion now hosts the new bytes for a published did:webvh asset. We never call addResourceVersion or publishToWeb. Verified rather than assumed, since "we don't use it" is exactly the reasoning that missed NO_CUSTODY in the migration: typecheck, 158 tests, a production build, and a headless run of the built bundle. Asset creation, fresh and 2.1.0-sealed verification, and tamper-rejection all behave identically to next.0, with no console errors. The bundle moves +0.8 kB. @originals/auth has no next.1 and stays on 3.0.0-next.0. Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 10 +++++++--- package-lock.json | 16 ++++++++-------- package.json | 2 +- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 488ee9c..49ffd12 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ "@capacitor/share": "^8.0.0", "@capacitor/status-bar": "^8.0.0", "@originals/auth": "3.0.0-next.0", - "@originals/sdk": "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", @@ -325,9 +325,9 @@ "@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/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=="], + "@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.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=="], + "@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=="], @@ -2241,6 +2241,8 @@ "@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@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=="], "@peculiar/asn1-cms/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -2597,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/package-lock.json b/package-lock.json index d483942..7e3dc60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@capacitor/share": "^8.0.0", "@capacitor/status-bar": "^8.0.0", "@originals/auth": "3.0.0-next.0", - "@originals/sdk": "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", @@ -2644,9 +2644,9 @@ } }, "node_modules/@originals/cel": { - "version": "0.2.0-next.0", - "resolved": "https://registry.npmjs.org/@originals/cel/-/cel-0.2.0-next.0.tgz", - "integrity": "sha512-sp5iIeOgz1JgOPhl0EYr2qNNvn+O9+ic9/oXmng2IYf+TE+7Pz/95dCGUqbMrTZphYRR8HGdJ2Lw4c4H5t4IyQ==", + "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", @@ -2685,9 +2685,9 @@ } }, "node_modules/@originals/sdk": { - "version": "3.0.0-next.0", - "resolved": "https://registry.npmjs.org/@originals/sdk/-/sdk-3.0.0-next.0.tgz", - "integrity": "sha512-jbZa/TbLxfCwfM/Sp2ayQOWF0BNQkNyjdxavtPTUXb9azrJ77mpcToKctPN4SpcL7hlu1OHKAGlcl7Wus+dvWQ==", + "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", @@ -2696,7 +2696,7 @@ "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.0.1", "@noble/secp256k1": "^3.1.0", - "@originals/cel": "^0.2.0-next.0", + "@originals/cel": "^0.2.0-next.1", "@scure/base": "^2.2.0", "@scure/btc-signer": "^2.2.0", "b58": "^4.0.3", diff --git a/package.json b/package.json index fcacc78..c6234b1 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@capacitor/share": "^8.0.0", "@capacitor/status-bar": "^8.0.0", "@originals/auth": "3.0.0-next.0", - "@originals/sdk": "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", From f4fb596c107931111ae9f26e37b4ada377f21be6 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Mon, 17 Aug 2026 00:55:28 -0700 Subject: [PATCH 5/5] feat(publish): record the published state in the list's own event log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing wrote a publications row with a hand-built did:webvh and never touched the list's CEL log. The chain that exists to record an asset's history was silent about the most significant thing that happens to one: 10 lists are published in prod and not one of them says so. recordPublishedVersion appends a signed `update` event carrying a content hash of what went public — name and items, via buildListSnapshot. The log then carries a verifiable version history: genesis is v1, the first publish v2, and a later publish of changed content v3. Deliberately NOT publishToWeb, despite that being the SDK's publish surface. publishToWeb migrates the asset did:cel -> did:webvh, which would change the assetDid every row is keyed by and move the public URL to a content-addressed key. boop serves published lists live at a stable path, with POST endpoints for checking items off — an immutable snapshot at a hash URL is a different product. addResourceVersion gets the provenance without touching identity or URLs. Two behaviours worth knowing: Re-publishing unchanged content is a no-op rather than an error. The SDK refuses a version identical to the current one, and a user re-publishing an unchanged list has not done anything wrong; the log simply must not grow. A list whose key was lost to the celAssetDids migration still publishes. It cannot sign the event, so it gets ListNotAuthorableError, and the modal says publishing succeeded but the history could not be recorded and points at the copy action. Failing the publish outright would regress the feature for the 5 published lists in that state. Known gap, not addressed here: checking an item off a published list goes through server-side POST endpoints, and the server holds no key, so live edits after publish are not recorded. Only publishes are. Co-Authored-By: Claude Opus 5 (1M context) --- convex/publication.ts | 12 ++ scripts/published-version.test.mjs | 186 ++++++++++++++++++++++++ src/components/publish/PublishModal.tsx | 45 +++++- src/lib/originals.ts | 100 +++++++++++++ 4 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 scripts/published-version.test.mjs 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/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/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 25b0f3d..29ea70f 100644 --- a/src/lib/originals.ts +++ b/src/lib/originals.ts @@ -126,6 +126,106 @@ 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. *