diff --git a/api/src/contrail.config.ts b/api/src/contrail.config.ts index 65c6cac..ff3d81a 100644 --- a/api/src/contrail.config.ts +++ b/api/src/contrail.config.ts @@ -13,7 +13,7 @@ export const config: ContrailConfig = { }, notify: true, serviceAuth: { - audience: 'did:web:api.atmo.watch', + audience: 'did:web:api.atmo.watch#contrail', methods: ['notifyOfUpdate'] }, maintenance: { optimize: true }, diff --git a/contrail.lock.json b/contrail.lock.json index 9cb4ecb..ceec9c1 100644 --- a/contrail.lock.json +++ b/contrail.lock.json @@ -3,7 +3,7 @@ "version": 1, "endpoint": "https://api.atmo.watch", "namespace": "watch.atmo", - "contractDigest": "sha256:13d95e3935100499b0d7d30deaa3daebff2150c6bdb118dc33015235897fb126", + "contractDigest": "sha256:331d01ac6beadd756e777004daf2a4e278bb3c864e91450bf7f974c4547dedd9", "lexiconDigest": "sha256:f4f7b4ddbb358fc1c701329b0bdde8f085bb17c238197c91f8a2a62233343afd", "methods": [ "watch.atmo.comment.getRecord", @@ -30,7 +30,7 @@ ], "serviceAuth": { "type": "atproto-service-auth", - "audience": "did:web:api.atmo.watch", + "audience": "did:web:api.atmo.watch#contrail", "methods": [ { "id": "watch.atmo.notifyOfUpdate", diff --git a/lex.config.js b/lex.config.js index 1ccbd00..53ceb6f 100644 --- a/lex.config.js +++ b/lex.config.js @@ -2,8 +2,8 @@ export default { contrail: { endpoint: "https://api.atmo.watch", - serviceDid: "did:web:api.atmo.watch", - scope: "rpc?lxm=*&aud=did:web:api.atmo.watch", + serviceDid: "did:web:api.atmo.watch#contrail", + scope: "rpc?lxm=watch.atmo.notifyOfUpdate&aud=did:web:api.atmo.watch%23contrail", collections: [ "app.bsky.actor.profile", "social.popfeed.actor.profile", diff --git a/patches/@atmo-dev__contrail@0.17.0.patch b/patches/@atmo-dev__contrail@0.17.0.patch new file mode 100644 index 0000000..3fb9bb8 --- /dev/null +++ b/patches/@atmo-dev__contrail@0.17.0.patch @@ -0,0 +1,207 @@ +diff --git a/dist/chunk-C5MWLAVY.js b/dist/chunk-C5MWLAVY.js +index f8b1fc4bdf6a4f40f6d183560dc3437523af095a..75b4580566a3b9b1498a185738e3bbec7566024c 100644 +--- a/dist/chunk-C5MWLAVY.js ++++ b/dist/chunk-C5MWLAVY.js +@@ -5717,16 +5717,17 @@ function createApp(db, config, options = {}) { + c.header("etag", `"${manifest.contract.digest}"`); + return c.json(manifest); + }); +- if (serviceAuth && serviceAuth.audience === `did:web:${new URL(options.publicService.endpoint).hostname}`) { ++ const serviceDid = serviceAuth?.audience.split("#", 1)[0]; ++ if (serviceAuth && serviceDid === `did:web:${new URL(options.publicService.endpoint).hostname}`) { + app.get("/.well-known/did.json", (c) => { + c.header("content-type", "application/did+ld+json; charset=UTF-8"); + c.header("cache-control", "public, max-age=300"); + return c.json({ + "@context": ["https://www.w3.org/ns/did/v1"], +- id: serviceAuth.audience, ++ id: serviceDid, + service: [ + { +- id: `${serviceAuth.audience}#contrail`, ++ id: serviceAuth.audience, + type: "ContrailService", + serviceEndpoint: options.publicService.endpoint + } +diff --git a/dist/chunk-FCTUTPWI.js b/dist/chunk-FCTUTPWI.js +index ff439ec205a76d85d93e1524b33d07d8aa1c9562..0318e60da81eda9fd10ca9ecbc1d9b760c904986 100644 +--- a/dist/chunk-FCTUTPWI.js ++++ b/dist/chunk-FCTUTPWI.js +@@ -1,5 +1,10 @@ + // src/core/types.ts + import { isDid } from "@atcute/lexicons/syntax"; ++function isServiceAudience(value) { ++ if (typeof value !== "string") return false; ++ const fragmentIndex = value.indexOf("#"); ++ return fragmentIndex > 0 && fragmentIndex < value.length - 1 && value.indexOf("#", fragmentIndex + 1) === -1 && isDid(value.slice(0, fragmentIndex)); ++} + var DEFAULT_FEED_MAX_ITEMS = 200; + var DEFAULT_FOLLOW_NSID = "app.bsky.graph.follow"; + var DEFAULT_FOLLOW_SHORT = "follow"; +@@ -72,8 +77,8 @@ function resolveConfig(config) { + throw new TypeError("orderedSource requires non-empty source and epoch values"); + } + if (config.serviceAuth) { +- if (!isDid(config.serviceAuth.audience)) { +- throw new TypeError("serviceAuth.audience must be a plain DID"); ++ if (!isServiceAudience(config.serviceAuth.audience)) { ++ throw new TypeError("serviceAuth.audience must be an absolute DID service reference"); + } + if (!Array.isArray(config.serviceAuth.methods) || new Set(config.serviceAuth.methods).size !== config.serviceAuth.methods.length) { + throw new TypeError("serviceAuth.methods must contain unique methods"); +diff --git a/dist/chunk-RWCV6ET2.js b/dist/chunk-RWCV6ET2.js +index 9dedd47bf752e9890333088517416b05b2daa4fa..f050adaaa8305b9c03771fbb0d4339d93b4b52f6 100644 +--- a/dist/chunk-RWCV6ET2.js ++++ b/dist/chunk-RWCV6ET2.js +@@ -6,6 +6,11 @@ import { + + // src/public-service.ts + import { isDid, isNsid } from "@atcute/lexicons/syntax"; ++function isServiceAudience(value) { ++ if (typeof value !== "string") return false; ++ const fragmentIndex = value.indexOf("#"); ++ return fragmentIndex > 0 && fragmentIndex < value.length - 1 && value.indexOf("#", fragmentIndex + 1) === -1 && isDid(value.slice(0, fragmentIndex)); ++} + function isLoopbackHostname(hostname) { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + } +@@ -271,7 +276,7 @@ function isPublicServiceAuthContract(value) { + if (value === null || value === void 0) return true; + if (!value || typeof value !== "object") return false; + const auth = value; +- return auth.type === "atproto-service-auth" && typeof auth.audience === "string" && isDid(auth.audience) && Array.isArray(auth.methods) && auth.methods.every( ++ return auth.type === "atproto-service-auth" && isServiceAudience(auth.audience) && Array.isArray(auth.methods) && auth.methods.every( + (method) => !!method && typeof method === "object" && typeof method.id === "string" && isNsid(method.id) && (method.type === "query" || method.type === "procedure") + ); + } +diff --git a/dist/cli.js b/dist/cli.js +index f3bc40ddf1dcc505da08f9d6646e8a3a1dcdf387..2ca7653e57a2e8ba41823e2db787e9bc4dc29d74 100755 +--- a/dist/cli.js ++++ b/dist/cli.js +@@ -383,6 +383,12 @@ function formatStringArray(values, indentation) { + ${items} + ${" ".repeat(indentation)}]`; + } ++function generatedServiceAuthScope(serviceAuth) { ++ if (!serviceAuth || serviceAuth.methods.length === 0) return null; ++ const methods = serviceAuth.methods.map(({ id }) => `lxm=${encodeURIComponent(id)}`).join("&"); ++ const audience = serviceAuth.audience.replace("#", "%23"); ++ return `rpc?${methods}&aud=${audience}`; ++} + var GENERATED_LEXICON_CONFIG_HEADER = "// Generated by `contrail connect`. Re-run the command to update; do not edit.\n"; + async function ensureConsumerLexiconConfig(options) { + const root = resolve(options.root); +@@ -402,7 +408,7 @@ async function ensureConsumerLexiconConfig(options) { + ); + const typesRoot = relative(root, dirname2(typesIndex)).replaceAll("\\", "/"); + const serviceDid = options.lock.serviceAuth?.audience ?? null; +- const scope = serviceDid ? `rpc?lxm=*&aud=${serviceDid}` : null; ++ const scope = generatedServiceAuthScope(options.lock.serviceAuth); + const source = `${GENERATED_LEXICON_CONFIG_HEADER}export default { + contrail: { + endpoint: ${JSON.stringify(options.lock.endpoint)}, +@@ -473,7 +479,7 @@ async function ensureConsumerClientModule(options) { + `; + } + const serviceDid = options.lock.serviceAuth?.audience; +- const scope = serviceDid ? `rpc?lxm=*&aud=${serviceDid}` : null; ++ const scope = generatedServiceAuthScope(options.lock.serviceAuth); + const protectedMethods = options.lock.serviceAuth?.methods.map(({ id }) => id) ?? []; + const serviceMethods = [ + .../* @__PURE__ */ new Set([...options.lock.methods, ...protectedMethods]) +diff --git a/dist/public-client.d.ts b/dist/public-client.d.ts +index c2f215fdde9a70b2f0b97757c8282260ef15571a..097a591bad439e29c1e19edb69c89c2291c1e0ce 100644 +--- a/dist/public-client.d.ts ++++ b/dist/public-client.d.ts +@@ -1,5 +1,5 @@ + import { Client, FetchHandler } from '@atcute/client'; +-import { Nsid, Did } from '@atcute/lexicons/syntax'; ++import { Nsid, AtprotoAudience } from '@atcute/lexicons/syntax'; + + interface PublicServiceClientOptions { + /** Canonical public Contrail HTTPS origin. */ +@@ -11,9 +11,9 @@ interface PublicServiceClientOptions { + contractDigest?: string; + /** Optional receiving-service DID from `lex.config.js`. Supplying it also + * makes the required OAuth permission available as `client.scope`. */ +- serviceDid?: Did; ++ serviceDid?: AtprotoAudience; + /** Optional precomputed OAuth permission. Must match `serviceDid`. */ +- scope?: `rpc?lxm=*&aud=${string}`; ++ scope?: `rpc?${string}`; + /** Exact XRPC methods served by this provider. Supplying the verified list + * lets authenticated clients route all other methods to the user's PDS. */ + serviceMethods?: readonly Nsid[]; +@@ -37,7 +37,7 @@ type PublicServiceClient = Client & { + /** Canonical public Contrail origin. */ + readonly endpoint: string; + /** OAuth permission required by protected methods, or null when unconfigured. */ +- readonly scope: `rpc?lxm=*&aud=${string}` | null; ++ readonly scope: `rpc?${string}` | null; + /** Record collections whose successful writes trigger notification. */ + readonly collections: readonly Nsid[]; + /** Combine this provider with an authenticated PDS client. Provider methods +@@ -45,7 +45,7 @@ type PublicServiceClient = Client & { + * record writes notify Contrail before returning their original response. */ + authenticated(authenticatedClient: Client, options?: PublicServiceAuthenticatedOptions): PublicServiceClient; + }; +-declare function publicServiceOAuthScope(audience: Did): `rpc?lxm=*&aud=${string}`; ++declare function publicServiceOAuthScope(audience: AtprotoAudience): `rpc?lxm=*&aud=${string}`; + /** Fetch handler that verifies an optional pinned contract once, then keeps + * unpinned anonymous reads cheap while automatically minting, caching, and + * attaching method-bound AT Protocol service tokens after a protected route +@@ -54,9 +54,9 @@ declare function publicServiceFetchHandler(options: PublicServiceClientOptions): + /** Create a typed Atcute client for anonymous and service-auth Contrail methods. + * Generated Lexicon imports still supply the method-specific TypeScript API. */ + declare function createPublicServiceClient(options: PublicServiceClientOptions & { +- serviceDid: Did; ++ serviceDid: AtprotoAudience; + }): PublicServiceClient & { +- readonly scope: `rpc?lxm=*&aud=${string}`; ++ readonly scope: `rpc?${string}`; + }; + declare function createPublicServiceClient(options: PublicServiceClientOptions): PublicServiceClient; + +diff --git a/dist/public-client.js b/dist/public-client.js +index 8c95c170d5946e52ebec9267a7d00a443c610ede..f0ded53c6fb0e5f483af73ea4f5369cbe91fe87b 100644 +--- a/dist/public-client.js ++++ b/dist/public-client.js +@@ -15,7 +15,12 @@ var TOKEN_EXPIRY_SKEW_MS = 5e3; + var PublicServiceContractError = class extends Error { + }; + function publicServiceOAuthScope(audience) { +- return `rpc?lxm=*&aud=${audience}`; ++ return `rpc?lxm=*&aud=${audience.replace("#", "%23")}`; ++} ++function scopeMatchesServiceAudience(scope, audience) { ++ if (!scope.startsWith("rpc?")) return false; ++ const params = new URLSearchParams(scope.slice(4)); ++ return params.getAll("aud").length === 1 && params.get("aud") === audience && params.getAll("lxm").length > 0; + } + function xrpcMethod(pathname) { + const path = pathname.startsWith("http") ? new URL(pathname).pathname : new URL(pathname, "https://contrail.invalid").pathname; +@@ -210,9 +215,9 @@ function createClient(options, authenticatedOptions = {}) { + handler: publicServiceFetchHandler({ ...options, endpoint }) + }); + const expectedScope = options.serviceDid ? publicServiceOAuthScope(options.serviceDid) : null; +- if (options.scope && options.scope !== expectedScope) { ++ if (options.scope && (!options.serviceDid || !scopeMatchesServiceAudience(options.scope, options.serviceDid))) { + throw new Error( +- `Contrail OAuth scope mismatch: expected ${expectedScope ?? "none"}, received ${options.scope}` ++ `Contrail OAuth scope audience mismatch: expected ${options.serviceDid ?? "none"}, received ${options.scope}` + ); + } + const scope = options.scope ?? expectedScope; +diff --git a/dist/types-BWmLk25T.d.ts b/dist/types-BWmLk25T.d.ts +index 17dbf762cee1c563435300cd1a9395acc3ec17a3..9404a89bc40c836e67cd81b7bd73ac032e960bc6 100644 +--- a/dist/types-BWmLk25T.d.ts ++++ b/dist/types-BWmLk25T.d.ts +@@ -252,7 +252,7 @@ interface OrderedSourceConfig { + } + type AtprotoServiceAuthMethod = "getFeed" | "notifyOfUpdate"; + interface AtprotoServiceAuthConfig { +- /** Plain service DID used as the exact JWT audience. */ ++ /** Absolute DID service reference used as the exact JWT audience. */ + audience: string; + /** Built-in methods that require a method-bound AT Protocol service token. */ + methods: AtprotoServiceAuthMethod[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0f75d4..75cd4d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@atmo-dev/contrail@0.17.0': + hash: 977e184cd1193436caebe37e5e995fd2ed43b5ec49fd751e1dd50222638a9326 + path: patches/@atmo-dev__contrail@0.17.0.patch + importers: .: @@ -19,7 +24,7 @@ importers: version: 2.0.3 '@atmo-dev/contrail': specifier: ^0.17.0 - version: 0.17.0(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1)) + version: 0.17.0(patch_hash=977e184cd1193436caebe37e5e995fd2ed43b5ec49fd751e1dd50222638a9326)(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1)) '@ethercorps/sveltekit-og': specifier: ^4.3.0 version: 4.3.0(@sveltejs/kit@2.70.2(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))) @@ -137,7 +142,7 @@ importers: dependencies: '@atmo-dev/contrail': specifier: ^0.17.0 - version: 0.17.0(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1)) + version: 0.17.0(patch_hash=977e184cd1193436caebe37e5e995fd2ed43b5ec49fd751e1dd50222638a9326)(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1)) devDependencies: '@atcute/lex-cli': specifier: ^3.2.1 @@ -3058,7 +3063,7 @@ snapshots: - '@atcute/cid' - typescript - '@atmo-dev/contrail@0.17.0(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1))': + '@atmo-dev/contrail@0.17.0(patch_hash=977e184cd1193436caebe37e5e995fd2ed43b5ec49fd751e1dd50222638a9326)(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(prettier@3.9.6)(typescript@6.0.3)(wrangler@4.123.0(@cloudflare/workers-types@5.20260815.1))': dependencies: '@atcute/atproto': 4.0.4(@atcute/lexicons@2.0.3) '@atcute/cbor': 2.3.6(@atcute/cid@2.4.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a44f21d..78fd92f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,6 @@ packages: allowBuilds: esbuild: true workerd: true + +patchedDependencies: + '@atmo-dev/contrail@0.17.0': patches/@atmo-dev__contrail@0.17.0.patch diff --git a/src/lib/contrail/index.ts b/src/lib/contrail/index.ts index 0a0af67..8f00605 100644 --- a/src/lib/contrail/index.ts +++ b/src/lib/contrail/index.ts @@ -4,9 +4,9 @@ import type {} from "./types/index.js"; export const contrail = createPublicServiceClient({ endpoint: "https://api.atmo.watch", - contractDigest: "sha256:13d95e3935100499b0d7d30deaa3daebff2150c6bdb118dc33015235897fb126", - serviceDid: "did:web:api.atmo.watch", - scope: "rpc?lxm=*&aud=did:web:api.atmo.watch", + contractDigest: "sha256:331d01ac6beadd756e777004daf2a4e278bb3c864e91450bf7f974c4547dedd9", + serviceDid: "did:web:api.atmo.watch#contrail", + scope: "rpc?lxm=*&aud=did:web:api.atmo.watch%23contrail", serviceMethods: [ "watch.atmo.comment.getRecord", "watch.atmo.comment.listRecords",