From 4b420c6d2aac1c286ab7544ea89dc966389d5906 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:32:49 +0200 Subject: [PATCH] service contract update --- .changeset/quiet-contracts-connect.md | 5 + README.md | 17 +- apps/atmo-rsvp/tests/contract.test.ts | 14 +- docs/public-services/api-atmo-rsvp.md | 3 +- docs/public-services/creating.md | 28 +- docs/public-services/using.md | 28 +- packages/contrail/README.md | 24 +- packages/contrail/src/cli-config.ts | 17 +- packages/contrail/src/cli/commands/connect.ts | 404 +++++++++++++++--- packages/contrail/src/cli/commands/dev.ts | 274 +++--------- packages/contrail/src/cli/dev-lexicons.ts | 90 ++++ packages/contrail/src/cli/shared.ts | 19 +- packages/contrail/src/core/router/index.ts | 3 +- packages/contrail/src/public-client.ts | 20 +- packages/contrail/src/public-service.ts | 93 ++-- packages/contrail/tests/cli-config.test.ts | 15 +- packages/contrail/tests/connect.test.ts | 364 +++++++++++++--- packages/contrail/tests/dev-lexicons.test.ts | 2 +- packages/contrail/tests/public-client.test.ts | 58 +-- .../contrail/tests/public-service-e2e.test.ts | 4 +- packages/contrail/tests/worker.test.ts | 16 +- 21 files changed, 960 insertions(+), 538 deletions(-) create mode 100644 .changeset/quiet-contracts-connect.md create mode 100644 packages/contrail/src/cli/dev-lexicons.ts diff --git a/.changeset/quiet-contracts-connect.md b/.changeset/quiet-contracts-connect.md new file mode 100644 index 0000000..b164758 --- /dev/null +++ b/.changeset/quiet-contracts-connect.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail": minor +--- + +Remove whole-service contract digests with clean version-2 service manifests and provider locks. Anonymous generated clients now call providers without a discovery preflight, while content-addressed Lexicon verification and service-auth discovery remain. `contrail connect` now accepts owned config files or directories to generate a local typed API without changing the deployment lock, generated clients expose local/target factories, and `contrail dev` no longer writes consumer connection artifacts. diff --git a/README.md b/README.md index c499592..75122f2 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,9 @@ Add a D1 binding and one-minute cron to `wrangler.jsonc`: { "main": "src/worker.ts", "d1_databases": [ - { "binding": "DB", "database_name": "contrail", "database_id": "..." } + { "binding": "DB", "database_name": "contrail", "database_id": "..." }, ], - "triggers": { "crons": ["*/1 * * * *"] } + "triggers": { "crons": ["*/1 * * * *"] }, } ``` @@ -96,7 +96,7 @@ Use `contrail lexicons all` to generate Contrail methods, pull referenced source ## Public read-through services -A deployment can publish a verified contract and Lexicon bundle for independent typed clients: +A deployment can publish a validated API description and Lexicon bundle for independent typed clients: ```ts export default createWorker(config, { @@ -113,7 +113,16 @@ Consumers connect and generate Atcute types with one command: pnpx @atmo-dev/contrail connect https://api.example.com ``` -The generated client pins and verifies the discovered contract digest before its first provider request; transient discovery failures can retry, while endpoint, service-DID, and contract mismatches fail closed. +The generated client sends anonymous requests directly. Protected methods lazily discover service auth and still fail closed on endpoint or service-DID mismatches. The content-addressed Lexicon digest remains in the version-2 provider lock; the complete provider method set is not pinned at runtime, so additive deployments do not interrupt existing calls. + +An application that owns the provider source can generate the same typed surface before deployment without creating a provider lock: + +```bash +pnpx @atmo-dev/contrail connect ../api/src/contrail.config.ts +pnpx @atmo-dev/contrail dev --config ../api/src/contrail.config.ts +``` + +The generated module exports `createLocalContrailClient()` for selecting the loopback service while retaining any existing production lock and default target. Public-service mode requires `orderedSource`; `getCursor` then returns the committed opaque `{ source, epoch, cursor }` position of that primary source. Compare complete positions for equality only; a source or epoch change requires a full client refetch. To avoid racing ingestion, read a position before and after a query and accept the query snapshot only when both positions match. Existing non-public deployments without `orderedSource` retain the legacy `time_us`, `date`, and `seconds_ago` response. diff --git a/apps/atmo-rsvp/tests/contract.test.ts b/apps/atmo-rsvp/tests/contract.test.ts index f8c305d..6e9039d 100644 --- a/apps/atmo-rsvp/tests/contract.test.ts +++ b/apps/atmo-rsvp/tests/contract.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - contractFromManifest, - describePublicService, - digestPublicContract, -} from "@atmo-dev/contrail"; +import { describePublicService } from "@atmo-dev/contrail"; import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; import { createWorker } from "@atmo-dev/contrail/worker"; import { lexicons } from "../lexicons/generated"; @@ -61,12 +57,8 @@ describe("api.atmo.rsvp public contract", () => { "rsvp.atmo.notifyOfUpdate", ]), ); - expect( - await digestPublicContract(contractFromManifest(service.manifest)), - ).toBe(service.manifest.contract.digest); - expect(service.manifest.contract.digest).not.toBe( - service.manifest.lexicons.digest, - ); + expect(service.manifest.version).toBe(2); + expect(service.manifest).not.toHaveProperty("contract"); }); it("passes synchronous Worker startup validation", () => { diff --git a/docs/public-services/api-atmo-rsvp.md b/docs/public-services/api-atmo-rsvp.md index ff3fb57..5a5cc92 100644 --- a/docs/public-services/api-atmo-rsvp.md +++ b/docs/public-services/api-atmo-rsvp.md @@ -238,8 +238,7 @@ The provider lock records: - `rsvp.atmo` namespace; - anonymous methods; - protected methods and their audience; -- the contract digest; -- the Lexicon digest; and +- the content-addressed Lexicon digest; and - the provider-owned Lexicon directory. See [Using a public Contrail service](./using.md) for a framework-neutral consumer walkthrough. diff --git a/docs/public-services/creating.md b/docs/public-services/creating.md index 8edc37d..a774789 100644 --- a/docs/public-services/creating.md +++ b/docs/public-services/creating.md @@ -1,6 +1,6 @@ # Creating a public Contrail service -A public Contrail service lets independent applications query one Contrail AppView from a stable HTTPS origin. The provider chooses the indexed collections, projections, query methods, and authentication policy. Consumers discover that contract, verify its Lexicons, generate local TypeScript types, and make ordinary XRPC requests. +A public Contrail service lets independent applications query one Contrail AppView from a stable HTTPS origin. The provider chooses the indexed collections, projections, query methods, and authentication policy. Consumers discover that API surface, verify its Lexicons, generate local TypeScript types, and make ordinary XRPC requests. Public service mode does not turn Contrail into a PDS. Records remain in their authors' repositories, and applications still authenticate users and publish writes through those users' PDSes. @@ -61,7 +61,7 @@ export default defineLexiconConfig({ }); ``` -Generate the provider contract, pull referenced record Lexicons, and generate TypeScript types: +Generate the provider API, pull referenced record Lexicons, and generate TypeScript types: ```bash pnpm contrail lexicons all --public @@ -102,7 +102,7 @@ GET /lexicons/ GET /status ``` -The discovery manifest contains separate contract and Lexicon digests. The immutable Lexicon URL is content-addressed. Startup fails when advertised methods, capabilities, and bundled Lexicons disagree. +The version-2 discovery manifest contains the endpoint, namespace, methods, collections, service-auth declaration, and a content-addressed Lexicon bundle. It does not hash the complete method set. Startup still fails when advertised methods, capabilities, and bundled Lexicons disagree, and the immutable Lexicon URL retains its digest. The public `/status` response contains aggregate readiness and freshness information. It omits DIDs, record bodies, source cursors, raw upstream errors, and other private operational details. @@ -132,9 +132,7 @@ export const config: ContrailConfig = { collections, feeds: { network: { - targets: [ - { collection: "event", maxItems: 100 }, - ], + targets: [{ collection: "event", maxItems: 100 }], }, }, }; @@ -154,6 +152,24 @@ The default PLC/`did:web` resolver keeps a bounded five-minute in-process cache The authenticated methods are listed separately from anonymous methods in discovery. Their query or procedure Lexicons remain in the provider bundle, so consumers still get generated types. +## Start from owned source + +A consumer developed alongside the provider can generate its initial API surface before any deployment exists: + +```bash +pnpx @atmo-dev/contrail connect ./src/contrail.config.ts +# or discover the standard config beneath another project directory +pnpx @atmo-dev/contrail connect ../api +``` + +This compiles the config directly, writes generated Lexicons and types, and exports local/target client factories. It does not create or modify `contrail.lock.json`. Run the service separately: + +```bash +pnpx @atmo-dev/contrail dev --config ../api/src/contrail.config.ts +``` + +After deploying, `contrail connect https://api.example.com` creates the version-2 provider lock and makes that deployment the generated default target. Future config-source connections can refresh local API types without changing the production lock. + ### OAuth permission versus token binding A client can request one OAuth permission for all methods at this service: diff --git a/docs/public-services/using.md b/docs/public-services/using.md index 2f67608..85c0332 100644 --- a/docs/public-services/using.md +++ b/docs/public-services/using.md @@ -9,7 +9,7 @@ pnpm add @atcute/client @atcute/lexicons @atmo-dev/contrail pnpx @atmo-dev/contrail connect https://api.atmo.rsvp ``` -`connect` verifies the provider's contract and Lexicons, writes `contrail.lock.json`, and generates: +`connect` validates the provider description and content-addressed Lexicon bundle, writes a version-2 `contrail.lock.json`, and generates: ```text lex.config.js @@ -50,9 +50,27 @@ pnpx @atmo-dev/contrail connect https://api.atmo.rsvp \ Commit `contrail.lock.json` and the generated files. -For a local service, run `contrail dev` in the consumer project. It automatically follows the same discovery, digest verification, Lexicon download, Atcute type generation, contract lock, and generated-client path against `http://127.0.0.1:8787`. Svelte projects use `src/lib/contrail/`; pass `--no-connect` to disable generation. The generated client includes `allowInsecureHttp: true`, an exception accepted only for `localhost`, `127.0.0.1`, or `[::1]`. +When the application owns or can read the provider config, connect directly to that source before a deployment exists: -When local dev supplies the otherwise omitted notify configuration, notification is an open loopback operation rather than fictitious AT Protocol service auth. The generated client's `scope` is therefore `null`; keep only normal repository permissions in local OAuth configuration. A deployed service still requires HTTPS and a real service DID for protected operations. +```bash +pnpx @atmo-dev/contrail connect ../api/src/contrail.config.ts +pnpx @atmo-dev/contrail dev --config ../api/src/contrail.config.ts +``` + +The config connection compiles Lexicons and types without creating or modifying `contrail.lock.json`. `contrail dev` only runs the loopback service. The generated module exports `createLocalContrailClient()`: + +```ts +import { + contrail as productionContrail, + createLocalContrailClient, +} from "./contrail/index.js"; + +export const contrail = process.env.CONTRAIL_URL + ? createLocalContrailClient(process.env.CONTRAIL_URL) + : productionContrail; +``` + +The helper permits HTTP only for `localhost`, `127.0.0.1`, or `[::1]` and does not inherit a production service-auth audience. Local notification and configured protected methods are loopback-only operations with a null OAuth scope. A deployed service still requires HTTPS and its real service auth. ## Query anonymous methods @@ -125,13 +143,13 @@ Contrail returns the original PDS response. A notification failure is reported t ## Update the connection -The generated client pins the lock's contract digest and verifies discovery before its first provider request. Transient discovery failures remain retryable; endpoint, service-DID, and contract mismatches fail closed. Update a changed contract deliberately at the same provider endpoint: +Anonymous generated clients call the endpoint directly without fetching discovery first. Protected calls lazily discover service auth; transient discovery failures remain retryable and endpoint or service-DID mismatches fail closed. Adding provider methods does not interrupt methods already known by a generated client. Regenerate when application code wants the new API surface: ```bash pnpx @atmo-dev/contrail connect https://api.atmo.rsvp --update ``` -Review changes to `contrail.lock.json`, `lex.config.js`, and `src/contrail/`, then run the application's typecheck and tests. `--update` cannot repoint an existing lock or abandon its provider-owned Lexicon root; remove the existing connection deliberately before switching providers or output roots. +Review changes to `contrail.lock.json`, `lex.config.js`, and `src/contrail/`, then run the application's typecheck and tests. `--update` cannot repoint an existing lock or abandon its provider-owned Lexicon root; remove the existing connection deliberately before switching providers or output roots. Version-1 provider locks are intentionally unsupported after the clean manifest-v2 cut and must be removed before reconnecting. ## Completeness diff --git a/packages/contrail/README.md b/packages/contrail/README.md index 317c9e6..2509a0c 100644 --- a/packages/contrail/README.md +++ b/packages/contrail/README.md @@ -80,7 +80,7 @@ A project containing only `contrail.config.ts` can start a complete local servic contrail dev ``` -Without a Wrangler config this creates or resumes `.contrail/dev.sqlite`, resolves missing configured record/ref Lexicons from the AT Protocol network without overwriting project-owned schemas, generates the public method Lexicons, runs PDS backfill, serves the complete public Contrail discovery/XRPC/Lexicon surface at `http://127.0.0.1:8787`, runs bounded Jetstream ingestion every minute, and connects the current project to that service. Svelte projects receive the lock, downloaded Lexicons, generated Atcute types, and client under `src/lib/contrail/`; other projects use `src/contrail/`. Add `.contrail/` to the project ignore file. Existing Wrangler projects retain the prior D1 development behavior automatically. Useful SQLite options include: +Without a Wrangler config this creates or resumes `.contrail/dev.sqlite`, resolves missing configured record/ref Lexicons from the AT Protocol network without overwriting project-owned schemas, runs PDS backfill, serves the complete public Contrail discovery/XRPC/Lexicon surface at `http://127.0.0.1:8787`, and runs bounded Jetstream ingestion every minute. It never creates or changes a deployment provider lock. Add `.contrail/` to the project ignore file. Existing Wrangler projects retain the prior D1 development behavior automatically. Useful SQLite options include: ```bash contrail dev --fresh # reset local SQLite first @@ -88,10 +88,18 @@ contrail dev --temporary # delete SQLite when stopped contrail dev --sqlite ./tmp/dev.sqlite # explicit durable path contrail dev --alluvium --allow-partial # fast base/archive bootstrap contrail dev --no-backfill # serve existing state only -contrail dev --no-connect # skip local client/type generation ``` -When `notify` is omitted, SQLite dev mode enables an open loopback-only `notifyOfUpdate` route in memory and includes it in the generated client. It does not invent a localhost service DID: `contrail.scope` remains `null`, so the application's OAuth scopes need only its normal repository permissions. Explicit `notify: false` disables this convenience; explicit production `serviceAuth` remains unchanged. The generated client sets `allowInsecureHttp: true`; plain HTTP remains rejected for every non-loopback hostname. +Generate the typed consumer API directly from an owned config or its containing directory: + +```bash +contrail connect ./contrail.config.ts +contrail connect ../api +``` + +A config source generates Lexicons, Atcute types, `contrailApi`, `createContrailClient()`, and `createLocalContrailClient()` without creating or changing `contrail.lock.json`. If a production lock already exists, the generated default client keeps that deployment target while the local factory uses the current source API. `contrail dev --config ../api/src/contrail.config.ts` can then run the same config locally. + +SQLite dev mode exposes configured protected methods only on loopback without inheriting a production service-auth audience. When `notify` is omitted, it also enables open loopback-only `notifyOfUpdate`. `createLocalContrailClient()` has no OAuth scope and permits plain HTTP only for a validated loopback endpoint. ## Fresh generations (experimental) @@ -169,7 +177,7 @@ export default createWorker(config, { }); ``` -Discovery at `/.well-known/contrail` advertises a canonical contract digest and a content-addressed Lexicon bundle. Anonymous collection reads, profiles, feeds, and authored custom queries may acquire public AT Protocol data and improve the cache behind the response. +Discovery at `/.well-known/contrail` advertises a validated API description and content-addressed Lexicon bundle. Anonymous collection reads, profiles, feeds, and authored custom queries may acquire public AT Protocol data and improve the cache behind the response. Personalized feeds and `notifyOfUpdate` can instead require method-bound AT Protocol service tokens: @@ -184,7 +192,7 @@ const config = { }; ``` -The protected contract advertises the audience and each query/procedure separately from anonymous methods. `contrail connect` generates `src/contrail/index.ts`; the module pins the discovered contract digest and verifies it once at runtime before sending provider requests. Its exported `contrail.scope` is the provider's verified OAuth permission, such as `rpc?lxm=*&aud=did:web:api.example.com`. `contrail.authenticated(authenticatedClient)` returns one Atcute client: advertised provider methods route to Contrail, ordinary methods route to the PDS, and successful tracked `createRecord`, `putRecord`, and `deleteRecord` calls automatically notify Contrail. Handle-form deletes are resolved to canonical DID URIs. Protected methods use cached exact method-bound tokens, while transient discovery failures remain retryable. Feed actors must resolve to the token issuer, and every notified AT URI must belong to the issuer. When the audience matches the public endpoint's `did:web`, the Worker publishes its service document at `/.well-known/did.json`. +The service description advertises the audience and each protected query/procedure separately from anonymous methods. `contrail connect` generates `src/contrail/index.ts`; anonymous calls go directly to the configured endpoint, while protected calls lazily discover and validate service auth. Its exported `contrail.scope` is the provider's verified OAuth permission, such as `rpc?lxm=*&aud=did:web:api.example.com`. `contrail.authenticated(authenticatedClient)` returns one Atcute client: advertised provider methods route to Contrail, ordinary methods route to the PDS, and successful tracked `createRecord`, `putRecord`, and `deleteRecord` calls automatically notify Contrail. Handle-form deletes are resolved to canonical DID URIs. Protected methods use cached exact method-bound tokens, while transient discovery failures remain retryable. Feed actors must resolve to the token issuer, and every notified AT URI must belong to the issuer. When the audience matches the public endpoint's `did:web`, the Worker publishes its service document at `/.well-known/did.json`. Public-service mode requires a primary ordered source so `getCursor` can expose its committed position. Existing non-public deployments without one retain the legacy ingestion-time cursor response: @@ -200,7 +208,7 @@ const config = { The returned cursor is opaque. Compare the complete `{ source, epoch, cursor }` value for equality; never order cursors from different epochs. Consumers can read the position before and after a query, retry if it changed, then poll it as a refetch/invalidation signal. -Connect an independent consumer with `contrail connect `. A repeated connection to the same endpoint and provider-owned output root requires `--update`; provider files and the lock are staged and swapped without deleting consumer-owned Lexicons. Switching providers or output roots requires removing the existing connection deliberately, so stale Lexicons cannot remain under a broad generator glob. +Connect an independent consumer with `contrail connect `. The version-2 provider lock records the deployment and exact Lexicon bundle, but generated clients do not pin the provider's complete method set at runtime. Existing anonymous methods therefore continue working when a provider adds methods. A repeated connection to the same endpoint and provider-owned output root requires `--update`; provider files and the lock are staged and swapped without deleting consumer-owned Lexicons. Version-1 locks must be removed and reconnected. See [Creating a public service](../../docs/public-services/creating.md), [Using a public service](../../docs/public-services/using.md), and [Example: api.atmo.rsvp](../../docs/public-services/api-atmo-rsvp.md) for complete provider and consumer walkthroughs. @@ -222,8 +230,8 @@ const config: ContrailConfig = { }, }, validation: { - strict: true, // default: enforce blob size/MIME constraints too - verifyCid: true, // default: canonical DAG-CBOR CID verification + strict: true, // default: enforce blob size/MIME constraints too + verifyCid: true, // default: canonical DAG-CBOR CID verification }, }; ``` diff --git a/packages/contrail/src/cli-config.ts b/packages/contrail/src/cli-config.ts index 1293869..f2ae4f6 100644 --- a/packages/contrail/src/cli-config.ts +++ b/packages/contrail/src/cli-config.ts @@ -7,7 +7,7 @@ * CLI tooling that has to discover + load a TS/JS config file off disk. */ import { existsSync } from "node:fs"; -import { resolve, join } from "node:path"; +import { dirname, resolve, join } from "node:path"; import { createJiti } from "jiti"; import type { ContrailConfig } from "./core/types.js"; @@ -51,6 +51,21 @@ export function findConfigFile(root: string, explicit?: string): string | null { return null; } +/** Infer the project root represented by a discovered config path. Exact + * standard candidate locations win; an arbitrary explicitly named config falls + * back to its containing directory. */ +export function configProjectRoot(path: string): string { + const target = resolve(path); + const matches = CONFIG_CANDIDATES.flatMap((candidate) => { + const depth = candidate.split("/").length; + let root = target; + for (let index = 0; index < depth; index++) root = dirname(root); + return resolve(root, candidate) === target ? [{ root, depth }] : []; + }); + matches.sort((left, right) => right.depth - left.depth); + return matches[0]?.root ?? dirname(target); +} + /** Load a config file via jiti — handles TS + ESM + CJS transparently, * no tsx/ts-node hook required. Accepts either a named export `config` or * a default export. Validates the result has the minimum `ContrailConfig` diff --git a/packages/contrail/src/cli/commands/connect.ts b/packages/contrail/src/cli/commands/connect.ts index 9bb5cfd..8c4cb11 100644 --- a/packages/contrail/src/cli/commands/connect.ts +++ b/packages/contrail/src/cli/commands/connect.ts @@ -5,6 +5,7 @@ import { readFile, rename, rm, + stat, writeFile, } from "node:fs/promises"; import { @@ -18,15 +19,25 @@ import { import { isNsid } from "@atcute/lexicons/syntax"; import type { CAC } from "cac"; import { - contractFromManifest, + describePublicService, digestLexiconDocuments, - digestPublicContract, isPublicServiceManifest, normalizePublicServiceEndpoint, - validateManifestContract, + validateServiceManifest, + type LexiconDocument, type PublicServiceAuthContract, type PublicServiceManifest, } from "../../public-service.js"; +import { + configProjectRoot, + findConfigFile, + loadConfig, +} from "../../cli-config.js"; +import type { ContrailConfig } from "../../core/types.js"; +import { + defaultConsumerLexiconRoot, + prepareDevLexicons, +} from "../dev-lexicons.js"; import { generateLexiconTypesWithAtcute } from "../atcute.js"; const MAX_DISCOVERY_BYTES = 10 * 1024 * 1024; @@ -53,12 +64,9 @@ interface ConnectOptions { allowInsecureHttp?: boolean; } -export interface ProviderLock { - format: "contrail.provider-lock"; - version: 1; +export interface ProviderDefinition { endpoint: string; namespace: string; - contractDigest: string; lexiconDigest: string; methods: string[]; collections: string[]; @@ -68,6 +76,11 @@ export interface ProviderLock { allowInsecureHttp?: true; } +export interface ProviderLock extends ProviderDefinition { + format: "contrail.provider-lock"; + version: 2; +} + async function readProviderLock(path: string): Promise { let source: string; try { @@ -87,11 +100,17 @@ async function readProviderLock(path: string): Promise { !value || typeof value !== "object" || lock.format !== "contrail.provider-lock" || - lock.version !== 1 || + lock.version !== 2 || + "contractDigest" in lock || typeof lock.endpoint !== "string" || typeof lock.lexiconRoot !== "string" || (lock.allowInsecureHttp !== undefined && lock.allowInsecureHttp !== true) ) { + if ((value as { version?: unknown }).version === 1) { + throw new Error( + "existing Contrail provider lock uses unsupported version 1; remove it and reconnect", + ); + } throw new Error("existing Contrail provider lock is malformed"); } return lock as ProviderLock; @@ -186,7 +205,10 @@ export async function ensureConsumerLexiconConfig(options: { root: string; out: string; types?: string; - lock: ProviderLock; + /** API definition whose Lexicons and collections are generated. */ + api: ProviderDefinition; + /** Runtime deployment target. Defaults to the API source itself. */ + target?: ProviderDefinition; }): Promise<{ path: string; created: boolean; updated: boolean }> { const root = resolve(options.root); let path = join(root, "lex.config.js"); @@ -205,9 +227,10 @@ export async function ensureConsumerLexiconConfig(options: { options.types ?? "src/contrail/types/index.ts", ); const typesRoot = relative(root, dirname(typesIndex)).replaceAll("\\", "/"); - const serviceDid = options.lock.serviceAuth?.audience ?? null; + const target = options.target ?? options.api; + const serviceDid = target.serviceAuth?.audience ?? null; const scope = serviceDid ? `rpc?lxm=*&aud=${serviceDid}` : null; - const source = `${GENERATED_LEXICON_CONFIG_HEADER}export default {\n contrail: {\n endpoint: ${JSON.stringify(options.lock.endpoint)},\n serviceDid: ${JSON.stringify(serviceDid)},\n scope: ${JSON.stringify(scope)},\n collections: ${formatStringArray(options.lock.collections, 4)},\n },\n generate: {\n files: [${JSON.stringify(`${patternRoot}/**/*.json`)}],\n outdir: ${JSON.stringify(`${typesRoot}/`)},\n },\n};\n`; + const source = `${GENERATED_LEXICON_CONFIG_HEADER}export default {\n contrail: {\n endpoint: ${JSON.stringify(target.endpoint)},\n serviceDid: ${JSON.stringify(serviceDid)},\n scope: ${JSON.stringify(scope)},\n collections: ${formatStringArray(target.collections, 4)},\n },\n generate: {\n files: [${JSON.stringify(`${patternRoot}/**/*.json`)}],\n outdir: ${JSON.stringify(`${typesRoot}/`)},\n },\n};\n`; if (await exists(path)) { const current = await readFile(path, "utf8"); @@ -217,7 +240,9 @@ export async function ensureConsumerLexiconConfig(options: { ) { return { path, created: false, updated: false }; } - const stagedDirectory = await mkdtemp(join(dirname(path), ".contrail-lex-")); + const stagedDirectory = await mkdtemp( + join(dirname(path), ".contrail-lex-"), + ); const staged = join(stagedDirectory, basename(path)); try { await writeFile(staged, source); @@ -248,7 +273,10 @@ export async function ensureConsumerClientModule(options: { root: string; file?: string; types?: string; - lock: ProviderLock; + /** API definition used for generated methods, collections, and types. */ + api: ProviderDefinition; + /** Default runtime deployment. Defaults to the API source itself. */ + target?: ProviderDefinition; /** Local-development notification method not advertised as a public query. */ notifyMethod?: string; }): Promise<{ path: string; created: boolean; updated: boolean }> { @@ -271,6 +299,7 @@ export async function ensureConsumerClientModule(options: { const path = resolveInsideRoot(root, selected); const isTypeScript = selected.endsWith(".ts"); let generatedImport = ""; + let generatedTypeImport = ""; if (isTypeScript) { const types = resolveInsideRoot( root, @@ -280,20 +309,43 @@ export async function ensureConsumerClientModule(options: { specifier = specifier.replace(/\.(?:ts|js)$/, ".js"); if (!specifier.startsWith(".")) specifier = `./${specifier}`; generatedImport = `import type {} from ${JSON.stringify(specifier)};\n`; + generatedTypeImport = + 'import type { PublicServiceClientOptions } from "@atmo-dev/contrail/client";\n'; } - const serviceDid = options.lock.serviceAuth?.audience; - const scope = serviceDid ? `rpc?lxm=*&aud=${serviceDid}` : null; - const protectedMethods = - options.lock.serviceAuth?.methods.map(({ id }) => id) ?? []; - const serviceMethods = [ - ...new Set([...options.lock.methods, ...protectedMethods]), - ].sort(); - const notifyMethod = + const target = options.target ?? options.api; + const targetServiceDid = target.serviceAuth?.audience; + const targetScope = targetServiceDid + ? `rpc?lxm=*&aud=${targetServiceDid}` + : null; + const apiProtectedMethods = + options.api.serviceAuth?.methods.map(({ id }) => id) ?? []; + const configuredNotifyMethod = options.notifyMethod ?? - serviceMethods.find( - (method) => method === `${options.lock.namespace}.notifyOfUpdate`, + [...options.api.methods, ...apiProtectedMethods].find( + (method) => method === `${options.api.namespace}.notifyOfUpdate`, ); - const source = `${GENERATED_CLIENT_HEADER}import { createPublicServiceClient } from "@atmo-dev/contrail/client";\n${generatedImport}\nexport const contrail = createPublicServiceClient({\n endpoint: ${JSON.stringify(options.lock.endpoint)},${options.lock.allowInsecureHttp ? "\n allowInsecureHttp: true," : ""}\n contractDigest: ${JSON.stringify(options.lock.contractDigest)},${serviceDid ? `\n serviceDid: ${JSON.stringify(serviceDid)},\n scope: ${JSON.stringify(scope)},` : ""}\n serviceMethods: ${formatStringArray(serviceMethods, 2)},\n collections: ${formatStringArray(options.lock.collections, 2)},${notifyMethod ? `\n notifyMethod: ${JSON.stringify(notifyMethod)},` : ""}\n});\n`; + const serviceMethods = [ + ...new Set([ + ...options.api.methods, + ...apiProtectedMethods, + ...(configuredNotifyMethod ? [configuredNotifyMethod] : []), + ]), + ].sort(); + const targetProtectedMethods = + target.serviceAuth?.methods.map(({ id }) => id) ?? []; + const targetMethods = [ + ...new Set([...target.methods, ...targetProtectedMethods]), + ].sort(); + const notifyMethod = configuredNotifyMethod; + const targetNotifyMethod = targetMethods.find( + (method) => method === `${target.namespace}.notifyOfUpdate`, + ); + const constAssertion = isTypeScript ? " as const" : ""; + const targetType = isTypeScript + ? `\nexport type ContrailTarget = Pick<\n PublicServiceClientOptions,\n "endpoint" | "allowInsecureHttp" | "serviceDid" | "scope" | "serviceMethods" | "collections"\n> & {\n notifyMethod?: PublicServiceClientOptions["notifyMethod"] | null;\n};\n` + : ""; + const targetAnnotation = isTypeScript ? ": ContrailTarget" : ""; + const source = `${GENERATED_CLIENT_HEADER}import { createPublicServiceClient } from "@atmo-dev/contrail/client";\n${generatedTypeImport}${generatedImport}\nexport const contrailApi = {\n namespace: ${JSON.stringify(options.api.namespace)},\n serviceMethods: ${formatStringArray(serviceMethods, 2)},\n collections: ${formatStringArray(options.api.collections, 2)},\n notifyMethod: ${JSON.stringify(notifyMethod ?? null)},\n}${constAssertion};\n\nexport const contrailTarget = {\n endpoint: ${JSON.stringify(target.endpoint)},${target.allowInsecureHttp ? "\n allowInsecureHttp: true," : ""}${targetServiceDid ? `\n serviceDid: ${JSON.stringify(targetServiceDid)},\n scope: ${JSON.stringify(targetScope)},` : ""}\n serviceMethods: ${formatStringArray(targetMethods, 2)},\n collections: ${formatStringArray(target.collections, 2)},\n notifyMethod: ${JSON.stringify(targetNotifyMethod ?? null)},\n}${constAssertion};\n\nexport const contrailMethods = contrailTarget.serviceMethods;\n${targetType}\nexport function createContrailClient(target${targetAnnotation} = contrailTarget) {\n const { notifyMethod: targetNotifyMethod, ...runtimeTarget } = target;\n const notifyMethod =\n targetNotifyMethod === undefined\n ? contrailApi.notifyMethod\n : targetNotifyMethod;\n return createPublicServiceClient({\n ...runtimeTarget,\n serviceMethods: target.serviceMethods ?? contrailApi.serviceMethods,\n collections: target.collections ?? contrailApi.collections,\n ...(notifyMethod ? { notifyMethod } : {}),\n });\n}\n\nexport function createLocalContrailClient(\n endpoint = "http://127.0.0.1:8787",\n) {\n return createContrailClient({\n endpoint,\n allowInsecureHttp: true,\n serviceMethods: contrailApi.serviceMethods,\n collections: contrailApi.collections,\n notifyMethod: contrailApi.notifyMethod,\n });\n}\n\nexport const contrail = createContrailClient();\n`; await mkdir(dirname(path), { recursive: true }); if (await exists(path)) { @@ -301,7 +353,9 @@ export async function ensureConsumerClientModule(options: { if (!current.startsWith(GENERATED_CLIENT_HEADER) || current === source) { return { path, created: false, updated: false }; } - const stagedDirectory = await mkdtemp(join(dirname(path), ".contrail-client-")); + const stagedDirectory = await mkdtemp( + join(dirname(path), ".contrail-client-"), + ); const staged = join(stagedDirectory, basename(path)); try { await writeFile(staged, source); @@ -323,6 +377,177 @@ export async function ensureConsumerClientModule(options: { } } +function allServiceMethods(lock: ProviderDefinition): string[] { + return [ + ...new Set([ + ...lock.methods, + ...(lock.serviceAuth?.methods.map(({ id }) => id) ?? []), + ]), + ].sort(); +} + +async function resolveConfigSource( + source: string, + consumerRoot: string, +): Promise { + const path = resolve(consumerRoot, source); + let info; + try { + info = await stat(path); + } catch { + throw new Error(`Contrail source does not exist: ${source}`); + } + if (info.isDirectory()) { + const config = findConfigFile(path); + if (!config) { + throw new Error(`Could not find a Contrail config under ${source}`); + } + return config; + } + if (!info.isFile()) { + throw new Error( + `Contrail source must be a config file or directory: ${source}`, + ); + } + return path; +} + +export async function replaceSourceLexicons( + outputRoot: string, + providerKey: string, + lexicons: readonly LexiconDocument[], +): Promise { + await mkdir(outputRoot, { recursive: true }); + const providerRoot = resolveInsideRoot(outputRoot, providerKey); + const stagedProvider = await mkdtemp(join(outputRoot, `.${providerKey}-`)); + const backupRoot = join( + outputRoot, + `.${providerKey}.backup-${process.pid}-${Date.now()}`, + ); + let backedUp = false; + let installed = false; + try { + for (const document of lexicons) { + const path = lexiconPath(stagedProvider, document.id); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(document, null, 2)}\n`); + } + if (await exists(providerRoot)) { + await rename(providerRoot, backupRoot); + backedUp = true; + } + await rename(stagedProvider, providerRoot); + installed = true; + await rm(backupRoot, { recursive: true, force: true }); + backedUp = false; + } catch (error) { + if (installed) await rm(providerRoot, { recursive: true, force: true }); + if (backedUp && (await exists(backupRoot))) { + await rename(backupRoot, providerRoot); + } + throw error; + } finally { + await rm(stagedProvider, { recursive: true, force: true }); + await rm(backupRoot, { recursive: true, force: true }); + } + return providerRoot; +} + +/** Compile an owned config into the same API artifacts as remote discovery. + * This deliberately does not create or mutate the deployment provider lock. */ +export async function connectConfigSource(options: { + source: string; + root: string; + out: string; + lock?: string; + endpoint?: string; +}): Promise<{ + manifest: PublicServiceManifest; + definition: ProviderDefinition; + target: ProviderDefinition; + config: ContrailConfig; + configPath: string; + written: number; +}> { + const projectRoot = resolve(options.root); + const configPath = await resolveConfigSource(options.source, projectRoot); + const config = await loadConfig(configPath); + const sourceConfig: ContrailConfig = { + ...config, + notify: config.notify ?? true, + orderedSource: config.orderedSource ?? { + source: "jetstream", + epoch: "contrail-local-jetstream-v1", + }, + }; + const endpoint = normalizePublicServiceEndpoint( + options.endpoint ?? "http://127.0.0.1:8787", + { allowInsecureHttp: true }, + ); + const lockPath = resolveInsideRoot( + projectRoot, + options.lock ?? "contrail.lock.json", + ); + const lockedTarget = await readProviderLock(lockPath); + if (lockedTarget && lockedTarget.namespace !== sourceConfig.namespace) { + throw new Error( + `provider lock namespace ${lockedTarget.namespace} does not match source namespace ${sourceConfig.namespace}`, + ); + } + const workspaceRoot = join(projectRoot, ".contrail", "source"); + await mkdir(workspaceRoot, { recursive: true }); + const outputRoot = resolveInsideRoot(projectRoot, options.out); + const providerKey = "source"; + const providerRoot = resolveInsideRoot(outputRoot, providerKey); + const lexicons = prepareDevLexicons( + sourceConfig, + projectRoot, + workspaceRoot, + providerRoot, + configProjectRoot(configPath), + ); + const description = await describePublicService( + sourceConfig, + { endpoint, allowInsecureHttp: true }, + lexicons, + ); + await replaceSourceLexicons(outputRoot, providerKey, description.lexicons); + + const definition: ProviderDefinition = { + endpoint, + namespace: description.manifest.namespace, + lexiconDigest: description.manifest.lexicons.digest, + methods: [...description.manifest.methods].sort(), + collections: [ + ...new Set(description.manifest.collections.map(({ nsid }) => nsid)), + ].sort(), + serviceAuth: description.manifest.serviceAuth ?? null, + lexiconRoot: relative(projectRoot, providerRoot), + allowInsecureHttp: true, + }; + const localTarget: ProviderDefinition = { + ...definition, + methods: [ + ...new Set([ + ...allServiceMethods(definition), + ...(sourceConfig.notify + ? [`${sourceConfig.namespace}.notifyOfUpdate`] + : []), + ]), + ].sort(), + serviceAuth: null, + }; + const target = lockedTarget ?? localTarget; + return { + manifest: description.manifest, + definition, + target, + config: sourceConfig, + configPath, + written: description.lexicons.length, + }; +} + export async function connectPublicService(options: { endpoint: string; root: string; @@ -420,15 +645,7 @@ export async function connectPublicService(options: { `Lexicon digest mismatch: manifest=${manifest.lexicons.digest}, fetched=${digest}`, ); } - validateManifestContract(manifest, lexicons); - const contractDigest = await digestPublicContract( - contractFromManifest(manifest), - ); - if (contractDigest !== manifest.contract.digest) { - throw new Error( - `Contract digest mismatch: manifest=${manifest.contract.digest}, computed=${contractDigest}`, - ); - } + validateServiceManifest(manifest, lexicons); await mkdir(outputRoot, { recursive: true }); const stagedProvider = await mkdtemp(join(outputRoot, `.${providerKey}-`)); @@ -440,10 +657,9 @@ export async function connectPublicService(options: { const lock: ProviderLock = { format: "contrail.provider-lock", - version: 1, + version: 2, endpoint, namespace: manifest.namespace, - contractDigest: manifest.contract.digest, lexiconDigest: manifest.lexicons.digest, methods: [...manifest.methods].sort(), collections: [ @@ -499,11 +715,22 @@ export async function connectPublicService(options: { return { manifest, lock, written: lexicons.length }; } +function endpointSource(source: string): string | null { + try { + const url = new URL(source); + return url.protocol === "https:" || url.protocol === "http:" + ? source + : null; + } catch { + return null; + } +} + export function registerConnect(cli: CAC): void { cli .command( - "connect ", - "Discover a public Contrail, lock its API, and generate a typed client", + "connect ", + "Generate a typed client from a Contrail config, directory, or deployment URL", ) .option("--root ", "Consumer project root", { default: process.cwd(), @@ -517,39 +744,86 @@ export function registerConnect(cli: CAC): void { .option("--client ", "Generated client module (.ts or .js)", { default: "src/contrail/index.ts", }) - .option("--client-types ", "Generated Lexicon index imported by a TypeScript client", { - default: "src/contrail/types/index.ts", - }) + .option( + "--client-types ", + "Generated Lexicon index imported by a TypeScript client", + { + default: "src/contrail/types/index.ts", + }, + ) .option("--skip-client", "Do not create a provider client module") .option( "--allow-insecure-http", "Permit HTTP only for a loopback development provider", ) - .option("--update", "Replace an existing provider lock and owned Lexicons") - .option("--no-generate", "Pull and lock without generating types or a client module") - .action(async (endpoint: string, options: ConnectOptions) => { - const result = await connectPublicService({ - endpoint, - root: options.root, - out: options.out, - lock: options.lock, - update: options.update, - allowInsecureHttp: options.allowInsecureHttp, - }); - console.log( - `connected ${result.lock.endpoint}: ${result.written} Lexicons, contract ${result.lock.contractDigest}`, - ); + .option( + "--update", + "Replace an existing deployment lock and owned Lexicons", + ) + .option( + "--no-generate", + "Resolve the API without generating types or a client module", + ) + .action(async (source: string, options: ConnectOptions) => { + const endpoint = endpointSource(source); + let api: ProviderDefinition; + let target: ProviderDefinition; + let written: number; + let notifyMethod: string | undefined; + let lexiconRoot: string; + + if (endpoint) { + const result = await connectPublicService({ + endpoint, + root: options.root, + out: options.out, + lock: options.lock, + update: options.update, + allowInsecureHttp: options.allowInsecureHttp, + }); + api = result.lock; + target = result.lock; + written = result.written; + lexiconRoot = options.out; + console.log( + `connected ${result.lock.endpoint}: ${written} Lexicons, bundle ${result.lock.lexiconDigest}`, + ); + } else { + const result = await connectConfigSource({ + source, + root: options.root, + out: options.out, + lock: options.lock, + }); + api = result.definition; + target = result.target; + written = result.written; + lexiconRoot = result.definition.lexiconRoot; + notifyMethod = result.config.notify + ? `${result.config.namespace}.notifyOfUpdate` + : undefined; + console.log( + `connected source ${relative(resolve(options.root), result.configPath)}: ` + + `${written} Lexicons, bundle ${api.lexiconDigest} (deployment lock unchanged)`, + ); + } + if (options.generate !== false) { const config = await ensureConsumerLexiconConfig({ root: options.root, - out: options.out, + out: lexiconRoot, types: options.clientTypes, - lock: result.lock, + api, + target, }); if (config.created) { - console.log(`created ${relative(resolve(options.root), config.path)}`); + console.log( + `created ${relative(resolve(options.root), config.path)}`, + ); } else if (config.updated) { - console.log(`updated ${relative(resolve(options.root), config.path)}`); + console.log( + `updated ${relative(resolve(options.root), config.path)}`, + ); } generateLexiconTypesWithAtcute(resolve(options.root)); if (!options.skipClient) { @@ -557,12 +831,18 @@ export function registerConnect(cli: CAC): void { root: options.root, file: options.client, types: options.clientTypes, - lock: result.lock, + api, + target, + notifyMethod, }); if (client.created) { - console.log(`created ${relative(resolve(options.root), client.path)}`); + console.log( + `created ${relative(resolve(options.root), client.path)}`, + ); } else if (client.updated) { - console.log(`updated ${relative(resolve(options.root), client.path)}`); + console.log( + `updated ${relative(resolve(options.root), client.path)}`, + ); } } } diff --git a/packages/contrail/src/cli/commands/dev.ts b/packages/contrail/src/cli/commands/dev.ts index d6322f1..1759c89 100644 --- a/packages/contrail/src/cli/commands/dev.ts +++ b/packages/contrail/src/cli/commands/dev.ts @@ -1,35 +1,27 @@ import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; import { tmpdir } from "node:os"; -import { basename, dirname, join, relative, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import type { CAC } from "cac"; import { Contrail } from "../../contrail.js"; import { createSqliteDatabase } from "../../adapters/sqlite.js"; import { getBackfillStatus } from "../../core/status.js"; import type { ContrailConfig, Database } from "../../core/types.js"; -import { generateLexicons } from "../../lexicons/generate.js"; +import { configProjectRoot } from "../../cli-config.js"; import { createHandler } from "../../server.js"; import { bootstrapAlluviumDatabase } from "../../workers/backfill.js"; import { - generateLexiconTypesWithAtcute, - pullLexiconsWithAtcute, -} from "../atcute.js"; -import { - connectPublicService, - ensureConsumerClientModule, - ensureConsumerLexiconConfig, -} from "./connect.js"; + defaultConsumerLexiconRoot, + prepareDevLexicons, +} from "../dev-lexicons.js"; import { promptYesNo, - resolveAndLoadConfig, + resolveConfig, resolveValidationLexicons, } from "../shared.js"; @@ -54,9 +46,6 @@ interface DevOpts { alluviumEpoch?: string; alluviumRetentionHours: number; allowPartial?: boolean; - connect?: boolean; - client?: string; - clientTypes?: string; clientLexicons?: string; } @@ -78,9 +67,11 @@ function devConfig(config: ContrailConfig, options: DevOpts): ContrailConfig { const notify = config.notify ?? true; return { ...config, - // Localhost notify is deliberately open and loopback-only. A real PDS - // should not be asked to authorize a fictitious localhost service DID. + // Localhost methods are deliberately open and loopback-only. A real PDS + // should not be asked to authorize a production service audience for a + // fictitious localhost deployment. notify, + serviceAuth: undefined, orderedSource: config.orderedSource ?? (options.alluvium @@ -92,7 +83,9 @@ function devConfig(config: ContrailConfig, options: DevOpts): ContrailConfig { }; } -async function requestBody(request: IncomingMessage): Promise { +async function requestBody( + request: IncomingMessage, +): Promise { if (request.method === "GET" || request.method === "HEAD") return undefined; const chunks: Buffer[] = []; for await (const chunk of request) { @@ -110,7 +103,10 @@ async function serveFetchResponse( try { const headers = new Headers(); for (let index = 0; index < request.rawHeaders.length; index += 2) { - headers.append(request.rawHeaders[index]!, request.rawHeaders[index + 1]!); + headers.append( + request.rawHeaders[index]!, + request.rawHeaders[index + 1]!, + ); } const body = await requestBody(request); const result = await handle( @@ -142,8 +138,7 @@ async function runWranglerDev( const { getPlatformProxy } = await import("wrangler"); const { env, dispose } = await getPlatformProxy(); const db = (env as Record)[options.binding] as - | Database - | undefined; + Database | undefined; if (db) { const contrail = new Contrail({ ...config, lexicons }); @@ -211,174 +206,11 @@ async function runWranglerDev( process.exitCode = code; } -function defaultDevClientLexiconRoot(projectRoot: string): string { - return existsSync(join(projectRoot, "src", "lib")) - ? "src/lib/contrail/lexicons" - : "src/contrail/lexicons"; -} - -export function prepareDevLexicons( - config: ContrailConfig, - projectRoot: string, - workspaceRoot: string, - clientLexiconRoot = resolve( - projectRoot, - defaultDevClientLexiconRoot(projectRoot), - ), -): object[] { - const pulled = join(workspaceRoot, "dev-pulled"); - const localSourceDirs = [ - join(projectRoot, "lexicons", "custom"), - join(projectRoot, "lexicons", "pulled"), - // A prior auto-connect places the complete localhost bundle here. Reuse - // exact local source documents from that stable path before attempting - // network resolution, so a developer can add an unpublished Lexicon where - // the generated consumer already expects it. - clientLexiconRoot, - ]; - const sourceDirs = [...localSourceDirs, pulled]; - const output = join(workspaceRoot, "dev-lexicons"); - const pullConfig = join(workspaceRoot, "dev-pull.config.mjs"); - let attempted: string | null = null; - - for (let pass = 0; pass < 5; pass++) { - const result = generateLexicons({ - config, - rootDir: workspaceRoot, - outputDir: output, - sourceDirs, - surface: "public", - writeAtcuteConfig: false, - quiet: true, - }); - const available = new Set( - result.lexicons - .map((document) => (document as { id?: unknown }).id) - .filter((id): id is string => typeof id === "string"), - ); - const missing = result.pullNsids.filter((nsid) => !available.has(nsid)); - if (missing.length === 0) return result.lexicons; - - const current = JSON.stringify(missing); - if (current === attempted) { - throw new Error( - `Could not resolve required development Lexicons: ${missing.join(", ")}`, - ); - } - attempted = current; - // Keep the complete remotely-resolved set in each clean Atcute pull, but - // do not ask the network for unpublished documents already supplied by the - // project or its stable generated-consumer root. - const remoteNsids = result.pullNsids.filter((nsid) => { - const relativePath = `${nsid.split(".").join("/")}.json`; - return !localSourceDirs.some((directory) => - existsSync(join(directory, relativePath)), - ); - }); - writeFileSync( - pullConfig, - `export default ${JSON.stringify({ - pull: { - outdir: "dev-pulled", - clean: true, - sources: [ - { type: "atproto", mode: "nsids", nsids: remoteNsids }, - ], - }, - }, null, 2)};\n`, - ); - console.log(`lexicons: resolving ${missing.join(", ")}`); - pullLexiconsWithAtcute(workspaceRoot, pullConfig); - } - throw new Error("Development Lexicon reference discovery did not converge"); -} - -function migrateLegacyDevConnection( - root: string, - endpoint: string, - desiredLexiconRoot: string, -): void { - const lockPath = join(root, "contrail.lock.json"); - if (!existsSync(lockPath)) return; - let lock: { - format?: unknown; - endpoint?: unknown; - allowInsecureHttp?: unknown; - lexiconRoot?: unknown; - }; - try { - lock = JSON.parse(readFileSync(lockPath, "utf8")) as typeof lock; - } catch { - return; - } - if ( - lock.format !== "contrail.provider-lock" || - lock.endpoint !== endpoint || - lock.allowInsecureHttp !== true || - typeof lock.lexiconRoot !== "string" || - lock.lexiconRoot === desiredLexiconRoot - ) { - return; - } - const desired = resolve(root, desiredLexiconRoot); - const prior = resolve(root, lock.lexiconRoot); - const child = relative(desired, prior); - if (!child || child === ".." || child.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) { - return; - } - rmSync(prior, { recursive: true, force: true }); - rmSync(lockPath, { force: true }); -} - -async function connectDevConsumer( +async function runSqliteDev( options: DevOpts, - root: string, - endpoint: string, - notifyMethod?: string, -): Promise { - if (options.connect === false) return null; - const svelteRoot = existsSync(join(root, "src", "lib")); - const lexiconRoot = - options.clientLexicons ?? defaultDevClientLexiconRoot(root); - migrateLegacyDevConnection(root, endpoint, lexiconRoot); - const out = dirname(lexiconRoot); - const providerKey = basename(lexiconRoot); - const client = - options.client ?? - (svelteRoot ? "src/lib/contrail/index.ts" : "src/contrail/index.ts"); - const clientTypes = - options.clientTypes ?? - (svelteRoot - ? "src/lib/contrail/types/index.ts" - : "src/contrail/types/index.ts"); - const lock = "contrail.lock.json"; - const result = await connectPublicService({ - endpoint, - root, - out, - lock, - update: existsSync(join(root, lock)), - allowInsecureHttp: true, - providerKey, - }); - await ensureConsumerLexiconConfig({ - root, - out: result.lock.lexiconRoot, - types: clientTypes, - lock: result.lock, - }); - generateLexiconTypesWithAtcute(root); - const generated = await ensureConsumerClientModule({ - root, - file: client, - types: clientTypes, - lock: result.lock, - notifyMethod, - }); - return generated.path; -} - -async function runSqliteDev(options: DevOpts, input: ContrailConfig) { + input: ContrailConfig, + sourceRoot: string, +) { const port = positiveNumber(options.port, "--port"); if (!Number.isSafeInteger(port) || port > 65_535) { throw new TypeError("--port must be an integer between 1 and 65535"); @@ -412,13 +244,14 @@ async function runSqliteDev(options: DevOpts, input: ContrailConfig) { mkdirSync(lexiconWorkspace, { recursive: true }); const clientLexiconRoot = resolve( root, - options.clientLexicons ?? defaultDevClientLexiconRoot(root), + options.clientLexicons ?? defaultConsumerLexiconRoot(root), ); const lexicons = prepareDevLexicons( config, root, lexiconWorkspace, clientLexiconRoot, + sourceRoot, ); const db = createSqliteDatabase(databasePath); const contrail = new Contrail({ ...config, db, lexicons }); @@ -457,7 +290,9 @@ async function runSqliteDev(options: DevOpts, input: ContrailConfig) { } else { const status = await getBackfillStatus(db, contrail.config); if (status.state !== "complete") { - console.log("backfill: discovering accounts and loading records from PDSes"); + console.log( + "backfill: discovering accounts and loading records from PDSes", + ); await contrail.backfillAll( { concurrency: Number(options.concurrency) }, db, @@ -478,29 +313,12 @@ async function runSqliteDev(options: DevOpts, input: ContrailConfig) { server.once("error", reject); server.listen(port, "127.0.0.1", () => done()); }); - let clientPath: string | null; - try { - clientPath = await connectDevConsumer( - options, - root, - endpoint, - contrail.config.notify - ? `${contrail.config.namespace}.notifyOfUpdate` - : undefined, - ); - } catch (error) { - server.close(); - throw error; - } - console.log(`\ncontrail dev ready: ${endpoint}`); console.log(` status: ${endpoint}/status`); console.log(` discovery: ${endpoint}/.well-known/contrail`); console.log(` sqlite: ${databasePath}`); console.log( - clientPath - ? ` client: ${clientPath}\n` - : " client: disabled (--no-connect)\n", + " client: run `contrail connect ` to generate\n", ); let ingesting = false; @@ -558,16 +376,17 @@ export function registerDev(cli: CAC): void { .option("--fresh", "Delete the selected SQLite database before starting") .option("--no-backfill", "Start without running or resuming backfill") .option( - "--no-connect", - "Do not generate a localhost consumer lock, Lexicons, types, and client", + "--client-lexicons ", + "Additional stable local Lexicon directory used during resolution", ) - .option("--client ", "Generated localhost client module") - .option("--client-types ", "Generated localhost type index") - .option("--client-lexicons ", "Downloaded localhost Lexicon directory") .option("--port ", "SQLite HTTP service port", { default: 8787 }) - .option("--ingest-interval ", "Seconds between live ingest cycles", { - default: 60, - }) + .option( + "--ingest-interval ", + "Seconds between live ingest cycles", + { + default: 60, + }, + ) .option("--ingest-timeout ", "Budget for each live ingest cycle", { default: 15_000, }) @@ -595,10 +414,13 @@ export function registerDev(cli: CAC): void { ) .option("--allow-partial", "Accept reported Alluvium historical omissions") .action(async (options: DevOpts) => { - const input = await resolveAndLoadConfig(options); + const resolved = await resolveConfig(options); + const input = resolved.config; const wrangler = options.wrangler === true || - (!options.sqlite && !options.temporary && hasWranglerConfig(options.root)); + (!options.sqlite && + !options.temporary && + hasWranglerConfig(options.root)); if (wrangler) { if (options.alluvium) { throw new Error( @@ -611,7 +433,7 @@ export function registerDev(cli: CAC): void { await resolveValidationLexicons(options, input), ); } else { - await runSqliteDev(options, input); + await runSqliteDev(options, input, configProjectRoot(resolved.path)); } }); } diff --git a/packages/contrail/src/cli/dev-lexicons.ts b/packages/contrail/src/cli/dev-lexicons.ts new file mode 100644 index 0000000..2af0f27 --- /dev/null +++ b/packages/contrail/src/cli/dev-lexicons.ts @@ -0,0 +1,90 @@ +import { existsSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { ContrailConfig } from "../core/types.js"; +import { generateLexicons } from "../lexicons/generate.js"; +import { pullLexiconsWithAtcute } from "./atcute.js"; + +export function defaultConsumerLexiconRoot(projectRoot: string): string { + return existsSync(join(projectRoot, "src", "lib")) + ? "src/lib/contrail/lexicons" + : "src/contrail/lexicons"; +} + +/** Resolve the exact public Lexicon bundle used by a config-backed local + * service. Project-owned documents win over remotely pulled dependencies. */ +export function prepareDevLexicons( + config: ContrailConfig, + projectRoot: string, + workspaceRoot: string, + clientLexiconRoot = resolve( + projectRoot, + defaultConsumerLexiconRoot(projectRoot), + ), + configRoot = projectRoot, +): object[] { + const pulled = join(workspaceRoot, "dev-pulled"); + const roots = [...new Set([projectRoot, configRoot])]; + const localSourceDirs = [ + ...roots.flatMap((root) => [ + join(root, "lexicons", "custom"), + join(root, "lexicons", "pulled"), + ]), + // A prior source connection places the complete local bundle here. Reuse + // unpublished documents from that stable path before network resolution. + clientLexiconRoot, + ]; + const sourceDirs = [...localSourceDirs, pulled]; + const output = join(workspaceRoot, "dev-lexicons"); + const pullConfig = join(workspaceRoot, "dev-pull.config.mjs"); + let attempted: string | null = null; + + for (let pass = 0; pass < 5; pass++) { + const result = generateLexicons({ + config, + rootDir: workspaceRoot, + outputDir: output, + sourceDirs, + surface: "public", + writeAtcuteConfig: false, + quiet: true, + }); + const available = new Set( + result.lexicons + .map((document) => (document as { id?: unknown }).id) + .filter((id): id is string => typeof id === "string"), + ); + const missing = result.pullNsids.filter((nsid) => !available.has(nsid)); + if (missing.length === 0) return result.lexicons; + + const current = JSON.stringify(missing); + if (current === attempted) { + throw new Error( + `Could not resolve required development Lexicons: ${missing.join(", ")}`, + ); + } + attempted = current; + const remoteNsids = result.pullNsids.filter((nsid) => { + const relativePath = `${nsid.split(".").join("/")}.json`; + return !localSourceDirs.some((directory) => + existsSync(join(directory, relativePath)), + ); + }); + writeFileSync( + pullConfig, + `export default ${JSON.stringify( + { + pull: { + outdir: "dev-pulled", + clean: true, + sources: [{ type: "atproto", mode: "nsids", nsids: remoteNsids }], + }, + }, + null, + 2, + )};\n`, + ); + console.log(`lexicons: resolving ${missing.join(", ")}`); + pullLexiconsWithAtcute(workspaceRoot, pullConfig); + } + throw new Error("Development Lexicon reference discovery did not converge"); +} diff --git a/packages/contrail/src/cli/shared.ts b/packages/contrail/src/cli/shared.ts index 60bdce2..4ca8b12 100644 --- a/packages/contrail/src/cli/shared.ts +++ b/packages/contrail/src/cli/shared.ts @@ -21,19 +21,24 @@ export interface ConfigOpts { * Resolve and load a ContrailConfig from CLI options. Exits with code 1 if no * config file is found, since every command except `append-scheduled` needs one. */ -export async function resolveAndLoadConfig( - opts: ConfigOpts -): Promise { +export async function resolveConfig( + opts: ConfigOpts, +): Promise<{ config: ContrailConfig; path: string }> { const root = opts.root ?? process.cwd(); const path = findConfigFile(root, opts.config); if (!path) { - console.error( + throw new Error( "Could not find a Contrail config. Pass --config or place one at\n" + - ` ${CONFIG_CANDIDATES_MESSAGE}` + ` ${CONFIG_CANDIDATES_MESSAGE}`, ); - process.exit(1); } - return loadConfig(path); + return { config: await loadConfig(path), path }; +} + +export async function resolveAndLoadConfig( + opts: ConfigOpts, +): Promise { + return (await resolveConfig(opts)).config; } /** Load the standard generated/pinned bundle only when collection policy needs diff --git a/packages/contrail/src/core/router/index.ts b/packages/contrail/src/core/router/index.ts index f188733..3c753c3 100644 --- a/packages/contrail/src/core/router/index.ts +++ b/packages/contrail/src/core/router/index.ts @@ -83,8 +83,7 @@ export function createApp( ); app.get("/.well-known/contrail", async (c) => { const { manifest } = await description; - c.header("cache-control", "no-cache"); - c.header("etag", `\"${manifest.contract.digest}\"`); + c.header("cache-control", "public, max-age=60"); return c.json(manifest); }); if ( diff --git a/packages/contrail/src/public-client.ts b/packages/contrail/src/public-client.ts index 46a812f..2fc903d 100644 --- a/packages/contrail/src/public-client.ts +++ b/packages/contrail/src/public-client.ts @@ -27,8 +27,6 @@ export interface PublicServiceClientOptions { /** Existing authenticated AT Protocol client used to mint service tokens. * Omit when the consumer only needs anonymous methods. */ authenticatedClient?: Client; - /** Optional contract pin from `contrail.lock.json`. */ - contractDigest?: string; /** Optional receiving-service DID from `lex.config.js`. Supplying it also * makes the required OAuth permission available as `client.scope`. */ serviceDid?: Did; @@ -121,10 +119,9 @@ function withBearer(init: RequestInit, token: string): RequestInit { return { ...init, headers }; } -/** 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 - * challenges the first request. */ +/** Fetch handler that keeps anonymous reads direct while automatically + * discovering service auth and minting, caching, and attaching method-bound AT + * Protocol service tokens after a protected route challenges the first request. */ export function publicServiceFetchHandler( options: PublicServiceClientOptions, ): FetchHandler { @@ -160,14 +157,6 @@ export function publicServiceFetchHandler( "Contrail manifest endpoint mismatch", ); } - if ( - options.contractDigest && - value.contract.digest !== options.contractDigest - ) { - throw new PublicServiceContractError( - `Contrail contract digest mismatch: expected ${options.contractDigest}, received ${value.contract.digest}`, - ); - } const serviceAuth = value.serviceAuth ?? null; if ( options.serviceDid && @@ -248,9 +237,6 @@ export function publicServiceFetchHandler( return async (pathname, init) => { const method = xrpcMethod(pathname); if (!method) return base(pathname, init); - // A generated lock pin is a runtime contract: verify it once even when the - // first operation is anonymous. Unpinned clients remain challenge-driven. - if (options.contractDigest) await discoverServiceAuth(); if (!options.authenticatedClient) return base(pathname, init); // Once discovery has been loaded, avoid the initial challenge on subsequent diff --git a/packages/contrail/src/public-service.ts b/packages/contrail/src/public-service.ts index 961559c..d07390b 100644 --- a/packages/contrail/src/public-service.ts +++ b/packages/contrail/src/public-service.ts @@ -34,22 +34,11 @@ export interface PublicServiceAuthContract { methods: PublicServiceProtectedMethod[]; } -export interface PublicContract { - format: "contrail.contract"; - version: 1; - namespace: string; - collections: PublicServiceCollection[]; - methods: string[]; - serviceAuth?: PublicServiceAuthContract | null; - lexiconDigest: string; -} - export interface PublicServiceManifest { format: "contrail.service"; - version: 1; + version: 2; endpoint: string; namespace: string; - contract: { digest: string }; lexicons: { url: string; digest: string }; status: { url: string }; collections: PublicServiceCollection[]; @@ -71,7 +60,9 @@ export interface PublicServiceDescription { } function isLoopbackHostname(hostname: string): boolean { - return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" + ); } export function normalizePublicServiceEndpoint( @@ -200,10 +191,16 @@ function publicServiceAuth( }; } -export function createPublicContract( +interface PublicServiceMetadata { + namespace: string; + collections: PublicServiceCollection[]; + methods: string[]; + serviceAuth: PublicServiceAuthContract | null; +} + +function createPublicServiceMetadata( config: ContrailConfig, - lexiconDigest: string, -): PublicContract { +): PublicServiceMetadata { const resolved = resolveConfig(config); const collections = publicCollections(resolved); const serviceAuth = publicServiceAuth(resolved); @@ -215,24 +212,15 @@ export function createPublicContract( ...collections.flatMap((collection) => collection.methods), ].filter((method) => !protectedMethods.has(method)); return { - format: "contrail.contract", - version: 1, namespace: resolved.namespace, collections, methods: [...new Set(methods)].sort(), serviceAuth, - lexiconDigest, }; } -export async function digestPublicContract( - contract: PublicContract, -): Promise { - return sha256(canonicalJson(contract)); -} - -export function validateContractLexicons( - contract: PublicContract, +function validateServiceLexicons( + service: Pick, values: readonly object[], ): LexiconDocument[] { const lexicons = normalizeLexiconDocuments(values); @@ -240,7 +228,7 @@ export function validateContractLexicons( throw new Error("public service requires a non-empty Lexicon bundle"); } const byId = new Map(lexicons.map((document) => [document.id, document])); - for (const method of contract.methods) { + for (const method of service.methods) { const document = byId.get(method) as { defs?: { main?: { type?: unknown } } } | undefined; if (document?.defs?.main?.type !== "query") { @@ -249,7 +237,7 @@ export function validateContractLexicons( ); } } - for (const method of contract.serviceAuth?.methods ?? []) { + for (const method of service.serviceAuth?.methods ?? []) { const document = byId.get(method.id) as { defs?: { main?: { type?: unknown } } } | undefined; if (document?.defs?.main?.type !== method.type) { @@ -274,11 +262,7 @@ export function validatePublicServiceLexicons( values: readonly object[], ): LexiconDocument[] { assertPublicServiceSource(config); - const placeholderDigest = `sha256:${"0".repeat(64)}`; - return validateContractLexicons( - createPublicContract(config, placeholderDigest), - values, - ); + return validateServiceLexicons(createPublicServiceMetadata(config), values); } export async function digestLexiconDocuments( @@ -309,22 +293,21 @@ export async function describePublicService( canonicalLexicons, digest: lexiconDigest, } = await digestLexiconDocuments(values); - const contract = createPublicContract(config, lexiconDigest); - validateContractLexicons(contract, lexicons); + const service = createPublicServiceMetadata(config); + validateServiceLexicons(service, lexicons); const manifest: PublicServiceManifest = { format: "contrail.service", - version: 1, + version: 2, endpoint, - namespace: contract.namespace, - contract: { digest: await digestPublicContract(contract) }, + namespace: service.namespace, lexicons: { url: `${endpoint}/lexicons/${lexiconDigest}`, digest: lexiconDigest, }, status: { url: `${endpoint}/status` }, - collections: contract.collections, - methods: contract.methods, - serviceAuth: contract.serviceAuth, + collections: service.collections, + methods: service.methods, + serviceAuth: service.serviceAuth, }; return { endpoint, lexicons, manifest, canonicalLexicons }; } @@ -333,21 +316,7 @@ function uniqueStrings(values: string[]): boolean { return new Set(values).size === values.length; } -export function contractFromManifest( - manifest: PublicServiceManifest, -): PublicContract { - return { - format: "contrail.contract", - version: 1, - namespace: manifest.namespace, - collections: manifest.collections, - methods: manifest.methods, - serviceAuth: manifest.serviceAuth, - lexiconDigest: manifest.lexicons.digest, - }; -} - -export function validateManifestContract( +export function validateServiceManifest( manifest: PublicServiceManifest, values: readonly object[], ): LexiconDocument[] { @@ -395,7 +364,10 @@ export function validateManifestContract( } } } - return validateContractLexicons(contractFromManifest(manifest), values); + return validateServiceLexicons( + { methods: manifest.methods, serviceAuth: manifest.serviceAuth ?? null }, + values, + ); } function isPublicServiceAuthContract( @@ -428,12 +400,11 @@ export function isPublicServiceManifest( const digest = /^sha256:[0-9a-f]{64}$/; if ( manifest.format !== "contrail.service" || - manifest.version !== 1 || + manifest.version !== 2 || + "contract" in manifest || typeof manifest.endpoint !== "string" || typeof manifest.namespace !== "string" || !isNsid(`${manifest.namespace}.method`) || - typeof manifest.contract?.digest !== "string" || - !digest.test(manifest.contract.digest) || typeof manifest.lexicons?.url !== "string" || typeof manifest.lexicons?.digest !== "string" || !digest.test(manifest.lexicons.digest) || diff --git a/packages/contrail/tests/cli-config.test.ts b/packages/contrail/tests/cli-config.test.ts index ad0b066..24e9176 100644 --- a/packages/contrail/tests/cli-config.test.ts +++ b/packages/contrail/tests/cli-config.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { + configProjectRoot, findConfigFile, findLexiconBundle, loadConfig, @@ -86,6 +87,18 @@ describe("findConfigFile", () => { }); }); +describe("configProjectRoot", () => { + it("prefers the deepest standard config location", () => { + expect(configProjectRoot("/api/src/contrail.config.ts")).toBe(resolve("/api")); + expect(configProjectRoot("/web/src/lib/contrail.config.ts")).toBe(resolve("/web")); + expect(configProjectRoot("/worker/app/contrail.config.js")).toBe(resolve("/worker")); + }); + + it("uses the containing directory for an arbitrary config name", () => { + expect(configProjectRoot("/api/config/custom.ts")).toBe(resolve("/api/config")); + }); +}); + describe("generated Lexicon bundles", () => { let root: string; diff --git a/packages/contrail/tests/connect.test.ts b/packages/contrail/tests/connect.test.ts index af3fef8..e92ae98 100644 --- a/packages/contrail/tests/connect.test.ts +++ b/packages/contrail/tests/connect.test.ts @@ -1,17 +1,17 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + connectConfigSource, connectPublicService, ensureConsumerClientModule, ensureConsumerLexiconConfig, + replaceSourceLexicons, type ProviderLock, } from "../src/cli/commands/connect"; import { - contractFromManifest, digestLexiconDocuments, - digestPublicContract, type PublicServiceManifest, } from "../src/public-service"; @@ -44,10 +44,9 @@ const notifyLexicon = { function providerLock(): ProviderLock { return { format: "contrail.provider-lock", - version: 1, + version: 2, endpoint, namespace: "atmo.rsvp", - contractDigest: `sha256:${"a".repeat(64)}`, lexiconDigest: `sha256:${"b".repeat(64)}`, methods: [method], collections: ["community.lexicon.calendar.event"], @@ -67,10 +66,9 @@ async function serviceFixture( const { digest } = await digestLexiconDocuments(values); const manifest: PublicServiceManifest = { format: "contrail.service", - version: 1, + version: 2, endpoint: serviceEndpoint, namespace: "atmo.rsvp", - contract: { digest: "" }, lexicons: { url: `${serviceEndpoint}/lexicons/${digest}`, digest }, status: { url: `${serviceEndpoint}/status` }, collections: [ @@ -87,9 +85,6 @@ async function serviceFixture( methods: [method], serviceAuth: null, }; - manifest.contract.digest = await digestPublicContract( - contractFromManifest(manifest), - ); const fetcher = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/.well-known/contrail")) { @@ -108,6 +103,187 @@ async function temporaryRoot() { } describe("contrail connect", () => { + it("generates an owned config source without creating a deployment lock", async () => { + const root = await temporaryRoot(); + const configPath = join(root, "src/contrail.config.ts"); + const sourcePath = join( + root, + "lexicons/custom/community/lexicon/calendar/event.json", + ); + await mkdir(join(root, "src"), { recursive: true }); + await mkdir(join(root, "lexicons/custom/community/lexicon/calendar"), { + recursive: true, + }); + await writeFile( + configPath, + `export const config = { + namespace: "atmo.rsvp", + profiles: [], + notify: true, + collections: { + event: { collection: "community.lexicon.calendar.event" }, + }, +};\n`, + ); + await writeFile(sourcePath, `${JSON.stringify(sourceLexicon, null, 2)}\n`); + for (const id of ["com.atproto.label.defs", "com.atproto.repo.strongRef"]) { + const path = join(root, "lexicons/custom", ...id.split(".")) + ".json"; + await mkdir(join(path, ".."), { recursive: true }); + await writeFile( + path, + `${JSON.stringify({ lexicon: 1, id, defs: { main: { type: "object", properties: {} } } }, null, 2)}\n`, + ); + } + const staleLexicon = join( + root, + "src/contrail/lexicons/old-provider/other/example/stale.json", + ); + await mkdir(join(staleLexicon, ".."), { recursive: true }); + await writeFile( + staleLexicon, + `${JSON.stringify({ lexicon: 1, id: "other.example.stale", defs: {} })}\n`, + ); + + const result = await connectConfigSource({ + source: "src/contrail.config.ts", + root, + out: "src/contrail/lexicons", + }); + + expect(result.definition).toMatchObject({ + endpoint: "http://127.0.0.1:8787", + namespace: "atmo.rsvp", + lexiconRoot: "src/contrail/lexicons/source", + }); + expect(result.definition).not.toHaveProperty("format"); + await expect( + readFile( + join( + root, + "src/contrail/lexicons/source/other/example/stale.json", + ), + ), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(result.target.serviceAuth).toBeNull(); + expect(result.target.methods).toContain("atmo.rsvp.notifyOfUpdate"); + await expect( + readFile(join(root, "contrail.lock.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + + const generated = await ensureConsumerClientModule({ + root, + api: result.definition, + target: result.target, + notifyMethod: "atmo.rsvp.notifyOfUpdate", + }); + const source = await readFile(generated.path, "utf8"); + expect(source).toContain("export function createLocalContrailClient"); + expect(source).toContain('endpoint: "http://127.0.0.1:8787"'); + expect(source).not.toContain("contractDigest"); + + await rm(join(root, "lexicons/custom"), { recursive: true }); + const deploymentLock = providerLock(); + await writeFile( + join(root, "contrail.lock.json"), + `${JSON.stringify(deploymentLock, null, 2)}\n`, + ); + const reconnected = await connectConfigSource({ + source: ".", + root, + out: "src/contrail/lexicons", + }); + expect(reconnected.target.endpoint).toBe(endpoint); + expect( + JSON.parse(await readFile(join(root, "contrail.lock.json"), "utf8")), + ).toEqual(deploymentLock); + + await writeFile( + join(root, "contrail.lock.json"), + `${JSON.stringify({ ...deploymentLock, namespace: "other.example" }, null, 2)}\n`, + ); + await expect( + connectConfigSource({ + source: ".", + root, + out: "src/contrail/lexicons", + }), + ).rejects.toThrow("does not match source namespace atmo.rsvp"); + }); + + it("resolves unpublished Lexicons from an external config project root", async () => { + const workspace = await temporaryRoot(); + const consumerRoot = join(workspace, "consumer"); + const apiRoot = join(workspace, "api"); + await mkdir(join(consumerRoot, "src"), { recursive: true }); + await mkdir(join(apiRoot, "src"), { recursive: true }); + await writeFile( + join(apiRoot, "src/contrail.config.ts"), + `export const config = { + namespace: "atmo.rsvp", + profiles: [], + collections: { + event: { collection: "community.lexicon.calendar.event" }, + }, +};\n`, + ); + for (const document of [ + sourceLexicon, + { + lexicon: 1, + id: "com.atproto.label.defs", + defs: { main: { type: "object", properties: {} } }, + }, + { + lexicon: 1, + id: "com.atproto.repo.strongRef", + defs: { main: { type: "object", properties: {} } }, + }, + ]) { + const path = + join(apiRoot, "lexicons/custom", ...document.id.split(".")) + ".json"; + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, `${JSON.stringify(document, null, 2)}\n`); + } + + const result = await connectConfigSource({ + source: "../api/src/contrail.config.ts", + root: consumerRoot, + out: "src/contrail/lexicons", + }); + + expect(result.configPath).toBe(join(apiRoot, "src/contrail.config.ts")); + expect( + JSON.parse( + await readFile( + join( + consumerRoot, + "src/contrail/lexicons/source/community/lexicon/calendar/event.json", + ), + "utf8", + ), + ), + ).toMatchObject({ id: sourceLexicon.id }); + }); + + it("preserves the prior source bundle when staging fails", async () => { + const root = await temporaryRoot(); + const outputRoot = join(root, "src/contrail/lexicons"); + const providerRoot = join(outputRoot, "source"); + await mkdir(providerRoot, { recursive: true }); + await writeFile(join(providerRoot, "previous.json"), "previous\n"); + + await expect( + replaceSourceLexicons(outputRoot, "source", [ + { id: "not a valid nsid" } as any, + ]), + ).rejects.toThrow("invalid Lexicon NSID"); + expect(await readFile(join(providerRoot, "previous.json"), "utf8")).toBe( + "previous\n", + ); + }); + it("connects to loopback HTTP only with an explicit development exception", async () => { const root = await temporaryRoot(); const localEndpoint = "http://127.0.0.1:8787"; @@ -137,7 +313,7 @@ describe("contrail connect", () => { endpoint: localEndpoint, allowInsecureHttp: true, }); - const client = await ensureConsumerClientModule({ root, lock }); + const client = await ensureConsumerClientModule({ root, api: lock }); expect(await readFile(client.path, "utf8")).toContain( "allowInsecureHttp: true", ); @@ -148,7 +324,7 @@ describe("contrail connect", () => { const generated = await ensureConsumerLexiconConfig({ root, out: "src/contrail/lexicons", - lock: providerLock(), + api: providerLock(), }); expect(generated.created).toBe(true); expect(await readFile(generated.path, "utf8")).toContain( @@ -172,19 +348,33 @@ describe("contrail connect", () => { const updated = await ensureConsumerLexiconConfig({ root, out: "src/contrail/lexicons", - lock: { ...providerLock(), endpoint: "https://next.example.com" }, + api: { ...providerLock(), collections: ["source.example.record"] }, + target: { + ...providerLock(), + endpoint: "https://next.example.com", + collections: ["deployed.example.record"], + }, }); expect(updated.updated).toBe(true); expect(await readFile(updated.path, "utf8")).toContain( 'endpoint: "https://next.example.com"', ); + expect(await readFile(updated.path, "utf8")).toContain( + '"deployed.example.record",', + ); + expect(await readFile(updated.path, "utf8")).not.toContain( + '"source.example.record",', + ); - await writeFile(join(root, "lex.config.ts"), "export default { mine: true }"); + await writeFile( + join(root, "lex.config.ts"), + "export default { mine: true }", + ); await rm(generated.path); const existing = await ensureConsumerLexiconConfig({ root, out: "src/other", - lock: providerLock(), + api: providerLock(), }); expect(existing.created).toBe(false); expect(existing.path).toBe(join(root, "lex.config.ts")); @@ -205,9 +395,6 @@ describe("contrail connect", () => { audience: "did:web:api.atmo.rsvp", methods: [{ id: notifyMethod, type: "procedure" }], }; - fixture.manifest.contract.digest = await digestPublicContract( - contractFromManifest(fixture.manifest), - ); const { lock } = await connectPublicService({ endpoint, root, @@ -216,30 +403,21 @@ describe("contrail connect", () => { fetcher: fixture.fetcher, }); - const generated = await ensureConsumerClientModule({ root, lock }); + const generated = await ensureConsumerClientModule({ root, api: lock }); expect(generated.created).toBe(true); const source = await readFile(generated.path, "utf8"); - expect(source).toContain( - 'import type {} from "./types/index.js"', - ); + expect(source).toContain('import type {} from "./types/index.js"'); expect(source).toContain(`endpoint: ${JSON.stringify(endpoint)}`); - expect(source).toContain( - `contractDigest: ${JSON.stringify(lock.contractDigest)}`, - ); - expect(source).toContain( - 'serviceDid: "did:web:api.atmo.rsvp"', - ); - expect(source).toContain( - 'scope: "rpc?lxm=*&aud=did:web:api.atmo.rsvp"', - ); + expect(source).not.toContain("contractDigest"); + expect(source).toContain("export function createLocalContrailClient"); + expect(source).toContain('serviceDid: "did:web:api.atmo.rsvp"'); + expect(source).toContain('scope: "rpc?lxm=*&aud=did:web:api.atmo.rsvp"'); expect(source).toContain('"community.lexicon.calendar.event",'); - expect(source).toContain( - `notifyMethod: ${JSON.stringify(notifyMethod)}`, - ); + expect(source).toContain(`notifyMethod: ${JSON.stringify(notifyMethod)}`); const updated = await ensureConsumerClientModule({ root, - lock: { ...lock, endpoint: "https://next.example.com" }, + api: { ...lock, endpoint: "https://next.example.com" }, }); expect(updated.updated).toBe(true); expect(await readFile(updated.path, "utf8")).toContain( @@ -247,7 +425,7 @@ describe("contrail connect", () => { ); await writeFile(generated.path, "export const mine = true;\n"); - const existing = await ensureConsumerClientModule({ root, lock }); + const existing = await ensureConsumerClientModule({ root, api: lock }); expect(existing.created).toBe(false); expect(await readFile(existing.path, "utf8")).toBe( "export const mine = true;\n", @@ -256,7 +434,7 @@ describe("contrail connect", () => { const javascript = await ensureConsumerClientModule({ root, file: "client/contrail.js", - lock, + api: lock, }); expect(javascript.created).toBe(true); expect(await readFile(javascript.path, "utf8")).not.toContain( @@ -264,6 +442,65 @@ describe("contrail connect", () => { ); }); + it("keeps default target notifications separate from the source API", async () => { + const root = await temporaryRoot(); + const sourceOnlyCollection = "source.example.newRecord"; + const deployedCollection = "community.lexicon.calendar.event"; + const sourceWithoutNotify = { + ...providerLock(), + collections: [sourceOnlyCollection], + serviceAuth: null, + }; + const deployedWithNotify = providerLock(); + const generated = await ensureConsumerClientModule({ + root, + file: "src/contrail/source-target.ts", + api: sourceWithoutNotify, + target: deployedWithNotify, + }); + const source = await readFile(generated.path, "utf8"); + const apiBlock = source.slice( + source.indexOf("export const contrailApi"), + source.indexOf("export const contrailTarget"), + ); + const targetBlock = source.slice( + source.indexOf("export const contrailTarget"), + source.indexOf("export const contrailMethods"), + ); + expect(apiBlock).toContain(sourceOnlyCollection); + expect(apiBlock).toContain("notifyMethod: null"); + expect(targetBlock).toContain(deployedCollection); + expect(targetBlock).not.toContain(sourceOnlyCollection); + expect(targetBlock).toContain(`notifyMethod: ${JSON.stringify(notifyMethod)}`); + expect(source).toContain( + "collections: target.collections ?? contrailApi.collections", + ); + + const targetWithoutNotify = { + ...providerLock(), + collections: [deployedCollection], + serviceAuth: null, + }; + const sourceWithNotify = { + ...providerLock(), + collections: [sourceOnlyCollection], + }; + const inverse = await ensureConsumerClientModule({ + root, + file: "src/contrail/inverse-target.ts", + api: sourceWithNotify, + target: targetWithoutNotify, + }); + const inverseSource = await readFile(inverse.path, "utf8"); + const inverseTarget = inverseSource.slice( + inverseSource.indexOf("export const contrailTarget"), + inverseSource.indexOf("export const contrailMethods"), + ); + expect(inverseTarget).toContain(deployedCollection); + expect(inverseTarget).not.toContain(sourceOnlyCollection); + expect(inverseTarget).toContain("notifyMethod: null"); + }); + it("verifies and atomically locks a discovered service", async () => { const root = await temporaryRoot(); const { fetcher, manifest, values } = await serviceFixture(); @@ -280,7 +517,7 @@ describe("contrail connect", () => { expect(result.lock).toMatchObject({ endpoint, namespace: "atmo.rsvp", - contractDigest: manifest.contract.digest, + lexiconDigest: manifest.lexicons.digest, lexiconRoot: "lexicons/pulled/api.atmo.rsvp", }); expect( @@ -321,10 +558,25 @@ describe("contrail connect", () => { ).toBe("keep"); }); - it("refuses to repoint an existing lock or abandon its owned output", async () => { + it("rejects v1 locks, repointing, and abandoned owned output", async () => { const root = await temporaryRoot(); const lockPath = join(root, "contrail.lock.json"); const fetcher = vi.fn(); + await writeFile( + lockPath, + `${JSON.stringify({ ...providerLock(), version: 1 })}\n`, + ); + await expect( + connectPublicService({ + endpoint, + root, + out: "src/contrail/lexicons", + lock: "contrail.lock.json", + fetcher, + update: true, + }), + ).rejects.toThrow("unsupported version 1"); + await writeFile( lockPath, `${JSON.stringify({ @@ -342,7 +594,9 @@ describe("contrail connect", () => { fetcher, update: true, }), - ).rejects.toThrow("remove the existing connection before switching endpoints"); + ).rejects.toThrow( + "remove the existing connection before switching endpoints", + ); expect(fetcher).not.toHaveBeenCalled(); await writeFile(lockPath, `${JSON.stringify(providerLock())}\n`); @@ -371,9 +625,6 @@ describe("contrail connect", () => { audience: "did:web:api.atmo.rsvp", methods: [{ id: notifyMethod, type: "procedure" }], }; - fixture.manifest.contract.digest = await digestPublicContract( - contractFromManifest(fixture.manifest), - ); const result = await connectPublicService({ endpoint, @@ -404,7 +655,7 @@ describe("contrail connect", () => { ); const previousLock = await readFile(lockPath, "utf8"); const previousDocument = await readFile(documentPath, "utf8"); - fixture.manifest.contract.digest = `sha256:${"f".repeat(64)}`; + fixture.manifest.methods.push("other.example.read"); await expect( connectPublicService({ @@ -415,12 +666,12 @@ describe("contrail connect", () => { fetcher: fixture.fetcher, update: true, }), - ).rejects.toThrow("Contract digest mismatch"); + ).rejects.toThrow("outside its namespace"); expect(await readFile(lockPath, "utf8")).toBe(previousLock); expect(await readFile(documentPath, "utf8")).toBe(previousDocument); }); - it("rejects Lexicon and contract digest mismatches", async () => { + it("rejects Lexicon digest mismatches", async () => { const root = await temporaryRoot(); const lexiconMismatch = await serviceFixture(); lexiconMismatch.manifest.lexicons.digest = `sha256:${"0".repeat(64)}`; @@ -433,18 +684,6 @@ describe("contrail connect", () => { fetcher: lexiconMismatch.fetcher, }), ).rejects.toThrow("Lexicon digest mismatch"); - - const contractMismatch = await serviceFixture(); - contractMismatch.manifest.contract.digest = `sha256:${"1".repeat(64)}`; - await expect( - connectPublicService({ - endpoint, - root, - out: "lexicons/pulled", - lock: "contrail.lock.json", - fetcher: contractMismatch.fetcher, - }), - ).rejects.toThrow("Contract digest mismatch"); }); it("rejects advertised methods without matching query Lexicons", async () => { @@ -476,9 +715,6 @@ describe("contrail connect", () => { audience: "did:web:api.atmo.rsvp", methods: [{ id: notifyMethod, type: "procedure" }], }; - fixture.manifest.contract.digest = await digestPublicContract( - contractFromManifest(fixture.manifest), - ); await expect( connectPublicService({ @@ -495,9 +731,6 @@ describe("contrail connect", () => { const root = await temporaryRoot(); const outside = await serviceFixture(); outside.manifest.methods.push("other.example.read"); - outside.manifest.contract.digest = await digestPublicContract( - contractFromManifest(outside.manifest), - ); await expect( connectPublicService({ endpoint, @@ -510,9 +743,6 @@ describe("contrail connect", () => { const unknown = await serviceFixture(); unknown.manifest.collections[0]!.methods.push("atmo.rsvp.event.getRecord"); - unknown.manifest.contract.digest = await digestPublicContract( - contractFromManifest(unknown.manifest), - ); await expect( connectPublicService({ endpoint, diff --git a/packages/contrail/tests/dev-lexicons.test.ts b/packages/contrail/tests/dev-lexicons.test.ts index 79c2c76..3f1c32b 100644 --- a/packages/contrail/tests/dev-lexicons.test.ts +++ b/packages/contrail/tests/dev-lexicons.test.ts @@ -8,7 +8,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; import type { ContrailConfig } from "../src/core/types"; -import { prepareDevLexicons } from "../src/cli/commands/dev"; +import { prepareDevLexicons } from "../src/cli/dev-lexicons"; const roots: string[] = []; diff --git a/packages/contrail/tests/public-client.test.ts b/packages/contrail/tests/public-client.test.ts index 89ce2d9..ad02329 100644 --- a/packages/contrail/tests/public-client.test.ts +++ b/packages/contrail/tests/public-client.test.ts @@ -25,10 +25,9 @@ function token() { function manifest(): PublicServiceManifest { return { format: "contrail.service", - version: 1, + version: 2, endpoint, namespace: "com.example", - contract: { digest }, lexicons: { url: `${endpoint}/lexicons/${digest}`, digest }, status: { url: `${endpoint}/status` }, collections: [], @@ -93,17 +92,13 @@ describe("public service client", () => { expect(fetcher).toHaveBeenCalledTimes(1); }); - it("verifies a pinned contract once before anonymous requests", async () => { + it("does not fetch discovery before anonymous requests", async () => { const fetcher = vi.fn(async (input: RequestInfo | URL) => String(input).endsWith("/.well-known/contrail") ? Response.json(manifest()) : Response.json({ records: [] }), ); - const handler = publicServiceFetchHandler({ - endpoint, - contractDigest: digest, - fetch: fetcher, - }); + const handler = publicServiceFetchHandler({ endpoint, fetch: fetcher }); expect( (await handler("/xrpc/com.example.getCursor", { method: "get" })).status, @@ -115,11 +110,12 @@ describe("public service client", () => { fetcher.mock.calls.filter(([input]) => String(input).endsWith("/.well-known/contrail"), ), - ).toHaveLength(1); + ).toHaveLength(0); }); - it("retries transient discovery failures", async () => { + it("retries transient discovery failures for protected calls", async () => { let discoveries = 0; + const pds = authenticatedClient(token()); const fetcher = vi.fn(async (input: RequestInfo | URL) => { if (String(input).endsWith("/.well-known/contrail")) { discoveries++; @@ -127,20 +123,20 @@ describe("public service client", () => { ? new Response(null, { status: 503 }) : Response.json(manifest()); } - return Response.json({ records: [] }); + return new Response(null, { status: 401 }); }); const handler = publicServiceFetchHandler({ endpoint, - contractDigest: digest, + authenticatedClient: pds.client, fetch: fetcher, }); - await expect( - handler("/xrpc/com.example.getCursor", { method: "get" }), - ).rejects.toThrow("discovery failed: 503"); - expect( - (await handler("/xrpc/com.example.getCursor", { method: "get" })).status, - ).toBe(200); + await expect(handler(`/xrpc/${method}`, { method: "get" })).rejects.toThrow( + "discovery failed: 503", + ); + expect((await handler(`/xrpc/${method}`, { method: "get" })).status).toBe( + 401, + ); expect(discoveries).toBe(2); }); @@ -164,7 +160,6 @@ describe("public service client", () => { }); const publicClient = createPublicServiceClient({ endpoint, - contractDigest: digest, serviceDid: "did:web:api.example.com", scope: "rpc?lxm=*&aud=did:web:api.example.com", serviceMethods: [method], @@ -191,7 +186,7 @@ describe("public service client", () => { requests.filter((request) => request.url.includes(`/xrpc/${method}`), ).map((request) => request.authorization), - ).toEqual([`Bearer ${jwt}`, `Bearer ${jwt}`]); + ).toEqual([null, `Bearer ${jwt}`, `Bearer ${jwt}`]); }); it("notifies an open local service without minting service auth", async () => { @@ -466,27 +461,4 @@ describe("public service client", () => { expect(pds.handler).not.toHaveBeenCalled(); }); - it("refuses runtime discovery that differs from an optional lock pin", async () => { - const pds = authenticatedClient(token()); - const fetcher = vi.fn(async (input: RequestInfo | URL) => - String(input).endsWith("/.well-known/contrail") - ? Response.json(manifest()) - : new Response(null, { status: 401 }), - ); - const handler = publicServiceFetchHandler({ - endpoint, - authenticatedClient: pds.client, - contractDigest: `sha256:${"b".repeat(64)}`, - fetch: fetcher, - }); - - await expect( - handler(`/xrpc/${method}`, { method: "get" }), - ).rejects.toThrow("contract digest mismatch"); - await expect( - handler(`/xrpc/${method}`, { method: "get" }), - ).rejects.toThrow("contract digest mismatch"); - expect(fetcher).toHaveBeenCalledTimes(1); - expect(pds.handler).not.toHaveBeenCalled(); - }); }); diff --git a/packages/contrail/tests/public-service-e2e.test.ts b/packages/contrail/tests/public-service-e2e.test.ts index 133e087..3829489 100644 --- a/packages/contrail/tests/public-service-e2e.test.ts +++ b/packages/contrail/tests/public-service-e2e.test.ts @@ -122,13 +122,13 @@ describe("public service consumer integration", () => { const generatedConfig = await ensureConsumerLexiconConfig({ root: consumerRoot, out: "src/contrail/lexicons", - lock: connection.lock, + api: connection.lock, }); expect(generatedConfig.created).toBe(true); generateLexiconTypesWithAtcute(consumerRoot); const generatedClient = await ensureConsumerClientModule({ root: consumerRoot, - lock: connection.lock, + api: connection.lock, notifyMethod: "com.example.notifyOfUpdate", }); expect(generatedClient.created).toBe(true); diff --git a/packages/contrail/tests/worker.test.ts b/packages/contrail/tests/worker.test.ts index 2adf7e5..eb42cfc 100644 --- a/packages/contrail/tests/worker.test.ts +++ b/packages/contrail/tests/worker.test.ts @@ -2,12 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { createWorker } from "../src/worker"; import { Contrail } from "../src/contrail"; import { createSqliteDatabase } from "../src/adapters/sqlite"; -import { - contractFromManifest, - digestPublicContract, - saveCursor, - type ContrailConfig, -} from "../src/index"; +import { saveCursor, type ContrailConfig } from "../src/index"; const MINIMAL_CONFIG: ContrailConfig = { namespace: "com.example", @@ -166,10 +161,9 @@ describe("createWorker", () => { const manifest = await manifestResponse.json(); expect(manifest).toMatchObject({ format: "contrail.service", - version: 1, + version: 2, endpoint: "https://api.example.com", namespace: "com.example", - contract: { digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/) }, lexicons: { url: expect.stringMatching( /^https:\/\/api\.example\.com\/lexicons\/sha256:[0-9a-f]{64}$/, @@ -196,10 +190,8 @@ describe("createWorker", () => { }, ]), }); - expect(manifest.contract.digest).not.toBe(manifest.lexicons.digest); - expect(await digestPublicContract(contractFromManifest(manifest))).toBe( - manifest.contract.digest, - ); + expect(manifest.contract).toBeUndefined(); + expect(manifestResponse.headers.get("etag")).toBeNull(); expect(manifest.lexicons.url).toBe( `https://api.example.com/lexicons/${manifest.lexicons.digest}`, ); -- 2.51.2