diff --git a/Dockerfile b/Dockerfile index 6e02986f..eb8021dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,12 +59,3 @@ EXPOSE 3000 # CMD npm run migration:run:prod && npm run seed:run:prod && npm run start:prod CMD npm run start:prod - - -# ---- Ingest ---- -# Same artifact as `release`, different default command. The contrail live-ingest -# Deployment streams ATProto records from Jetstream into Postgres continuously; -# no HTTP port. Process liveness is the sole health signal (kubelet restarts on -# exit). See src/contrail/ingest.ts and the Phase D plan. -FROM release AS ingest -CMD ["npm", "run", "contrail:ingest"] diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 535d5c44..a9ce88c7 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -217,6 +217,17 @@ services: - ELASTICACHE_HOST=redis - MAIL_HOST=maildev - OTEL_EXPORTER_OTLP_ENDPOINT=http://tracing:4318 + # Contrail appview: reuse the local Postgres (schema-isolated under + # CONTRAIL_SCHEMA). Without CONTRAIL_DATABASE_URL the provider stays + # uninitialized and /xrpc/net.openmeet.* returns 503. + - CONTRAIL_DATABASE_URL=postgres://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@postgres:5432/${DATABASE_NAME} + - CONTRAIL_SCHEMA=contrail + # Community routes mount only when BOTH keys are present (see + # env-example-relational). Pass them at `up` time, e.g.: + # CONTRAIL_COMMUNITY_ENCRYPTION_KEY=... CONTRAIL_AUTHORITY_SIGNING_KEY=... \ + # docker compose -f docker-compose-dev.yml up -d api + - CONTRAIL_COMMUNITY_ENCRYPTION_KEY=${CONTRAIL_COMMUNITY_ENCRYPTION_KEY:-} + - CONTRAIL_AUTHORITY_SIGNING_KEY=${CONTRAIL_AUTHORITY_SIGNING_KEY:-} env_file: - .env networks: diff --git a/env-example-relational b/env-example-relational index 58a22457..f90a91bb 100644 --- a/env-example-relational +++ b/env-example-relational @@ -186,4 +186,56 @@ PDS_CREDENTIAL_KEY_2= # Generate with: curl -X POST https://pds.example.com/xrpc/com.atproto.server.createInviteCode \ # -H "Authorization: Basic $(echo -n 'admin:PASSWORD' | base64)" \ # -H "Content-Type: application/json" -d '{"useCount": 999999}' -PDS_INVITE_CODE= \ No newline at end of file +PDS_INVITE_CODE= + +# ============================================================================ +# Contrail (AT Protocol appview) — public event/RSVP read layer + community +# ============================================================================ +# The /xrpc/net.openmeet.* mount is single-tenant and lives in main.ts (no +# Nest controller, TenantGuard, or /api prefix). It serves public Contrail +# records out of a Postgres populated by `npm run contrail:sync`. + +# Connection string for the Contrail Postgres. REQUIRED to enable the mount — +# when unset, ContrailProvider does not initialize and every /xrpc/net.openmeet.* +# request returns 503 (graceful degradation). Can point at the same local +# Postgres as the API; records live in the CONTRAIL_SCHEMA below. +CONTRAIL_DATABASE_URL= +# Schema the Contrail tables live in (created on init if absent). Default: contrail +CONTRAIL_SCHEMA=contrail + +# --- Community + spaces (group-owned records) ------------------------------- +# Community XRPC routes (/xrpc/net.openmeet.community.*) mount ONLY when BOTH +# the community block AND spaces.authority below are configured. With just one, +# the routes are silently absent (404). All community routes are auth-gated +# (service-auth JWT); an unauthenticated request to a mounted route returns 401. + +# AES-GCM key that envelope-encrypts stored group rotation keys + PDS app +# passwords. Enables the `community` config branch. Vendor config field is +# `masterKey`; we feed it this var. +# Generate with: openssl rand -base64 32 +CONTRAIL_COMMUNITY_ENCRYPTION_KEY= +# Comma-separated PDS endpoints the community integration may provision against. +CONTRAIL_ALLOWED_PDS_ENDPOINTS= + +# Base64-encoded JSON CredentialKeyMaterial (P-256 / ES256 private+public JWK). +# Enables the `spaces.authority` config branch. The authority signs space +# credentials and is the issuer the service-auth verifier trusts. +# Generate with: +# node -e 'crypto.subtle.generateKey({name:"ECDSA",namedCurve:"P-256"},true,["sign","verify"]).then(async p=>{const privateKey=await crypto.subtle.exportKey("jwk",p.privateKey);const publicKey=await crypto.subtle.exportKey("jwk",p.publicKey);console.log(Buffer.from(JSON.stringify({privateKey,publicKey})).toString("base64"))})' +CONTRAIL_AUTHORITY_SIGNING_KEY= +# DID the authority issues credentials as. Default: did:web:api.openmeet.net +SERVICE_DID= +# Lexicon type for authored spaces. Default: tools.atmo.event.space +CONTRAIL_SPACE_TYPE= + +# --- Network overrides (all optional) --------------------------------------- +# DID PLC directory URL (also used as the community plcDirectory). +CONTRAIL_PLC_URL= +# Slingshot resolver URL. +CONTRAIL_SLINGSHOT_URL= +# Comma-separated additional allowed PDS hosts for record resolution. +CONTRAIL_ALLOWED_HOSTS= +# Comma-separated Jetstream firehose URLs (live ingest). +CONTRAIL_JETSTREAM_URLS= +# Comma-separated relay URLs. +CONTRAIL_RELAYS= diff --git a/env-example-relational-ci b/env-example-relational-ci index 79181288..263edc09 100644 --- a/env-example-relational-ci +++ b/env-example-relational-ci @@ -192,3 +192,17 @@ DEVNET_PDS_HOSTNAME=pds.test DEVNET_PDS_ADMIN_PASSWORD=ci-pds-admin-password DEVNET_SMTP_URL=smtp://maildev:1025 DEVNET_HANDLE_DOMAIN=.pds.test + +# Contrail appview — enables the /xrpc/net.openmeet.* mount in CI e2e so the +# contrail-xrpc suite runs instead of skipping. Reuses the CI Postgres +# (schema-isolated under CONTRAIL_SCHEMA); the schema + tables are created on +# contrail.init(), so event/rsvp.listRecords return 200 [] with no sync step. +CONTRAIL_DATABASE_URL=postgres://root:secret@postgres:5432/api +CONTRAIL_SCHEMA=contrail +# Throwaway CI-only test keys (no production value). The two below enable the +# community block + spaces.authority so community.getHealth mounts (401). +# DO NOT REUSE: these are committed fixtures, inert outside CI. Real envs get +# their keys from the deployment secret store (see om-uwis / om-k55c), never +# from this file. The committed P-256 private key here signs nothing of value. +CONTRAIL_COMMUNITY_ENCRYPTION_KEY=BBiI4DJaQ+AsoTJuKvMv06dNEFL+NprY9atylYM4Eds= +CONTRAIL_AUTHORITY_SIGNING_KEY=eyJwcml2YXRlS2V5Ijp7ImtleV9vcHMiOlsic2lnbiJdLCJleHQiOnRydWUsImt0eSI6IkVDIiwieCI6ImM5ZnhwRWlQdktXd2g5eEk5OGFmUTVucVBYcjI5WU03NHkxOG1qeks0V2siLCJ5IjoiNmF1c1c5MVgyS21XMEJYXzFuRDlBZmtHRDh2ZldNNE0yU29kN0dLMkZpRSIsImNydiI6IlAtMjU2IiwiZCI6Ik9tMzM4Q0NCRmhMUzFON1JmYjZESnJ5NlpnYTdJakx1c3JpM2ExcGdvX3MifSwicHVibGljS2V5Ijp7ImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZSwia3R5IjoiRUMiLCJ4IjoiYzlmeHBFaVB2S1d3aDl4STk4YWZRNW5xUFhyMjlZTTc0eTE4bWp6SzRXayIsInkiOiI2YXVzVzkxWDJLbVcwQlhfMW5EOUFma0dEOHZmV000TTJTb2Q3R0syRmlFIiwiY3J2IjoiUC0yNTYifX0= diff --git a/package-lock.json b/package-lock.json index 650a7929..d9999d00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@atcute/identity-resolver": "^1.2.3", "@atmo-dev/contrail": "file:./vendor/atmo-dev-contrail.tgz", + "@atmo-dev/contrail-community": "file:./vendor/atmo-dev-contrail-community.tgz", "@atproto/api": "^0.13.31", "@atproto/crypto": "^0.4.5", "@atproto/identity": "^0.4.7", @@ -650,7 +651,6 @@ "node_modules/@atmo-dev/contrail": { "version": "0.6.0", "resolved": "file:vendor/atmo-dev-contrail.tgz", - "integrity": "sha512-zrRkIPbpwspWz/qESZqX3iwj+fkIyQ9Ddf6BTjvNdBzltrGmy96fRH4kerdyIPPFDa1MYKMjMgQn4RBbAGh8aA==", "license": "MIT", "dependencies": { "@atcute/atproto": "^3.1.10", @@ -689,7 +689,6 @@ "node_modules/@atmo-dev/contrail-appview": { "version": "0.6.0", "resolved": "file:vendor/atmo-dev-contrail-appview.tgz", - "integrity": "sha512-C5pJPGdwngA7OpD7UHMZuVNOkWhVXNjgCrUUQ/GjDmqMDPRUfYnp270zqQ5rQqrTRz7a5Trb3PKyn1+nrxppvQ==", "license": "MIT", "dependencies": { "@atcute/atproto": "^3.1.10", @@ -710,7 +709,6 @@ "node_modules/@atmo-dev/contrail-authority": { "version": "0.6.0", "resolved": "file:vendor/atmo-dev-contrail-authority.tgz", - "integrity": "sha512-Fccf2GqSP7reNinflu3/K0Fvu2ihPDctIa+0Bb9OMbCFsDX7c36acmqfVlNJskhslevcPqKb8RxrJNGEgI9MuA==", "license": "MIT", "dependencies": { "@atcute/cid": "^2.4.1", @@ -722,7 +720,6 @@ "node_modules/@atmo-dev/contrail-base": { "version": "0.6.0", "resolved": "file:vendor/atmo-dev-contrail-base.tgz", - "integrity": "sha512-KeatsgaXE/cx+ZR3L5YmieCRoHzALfY9WbvbLCaKnTT/Ey3O3MMRqrlBQq2/oyot5gCBN1RKl1toan0MM4M8qw==", "license": "MIT", "dependencies": { "@atcute/atproto": "^3.1.10", @@ -743,10 +740,34 @@ } } }, + "node_modules/@atmo-dev/contrail-community": { + "version": "0.4.2", + "resolved": "file:vendor/atmo-dev-contrail-community.tgz", + "license": "MIT", + "dependencies": { + "@atcute/atproto": "^3.1.10", + "@atcute/cbor": "^2.3.2", + "@atcute/identity": "^1.1.4", + "@atcute/identity-resolver": "^1.2.2", + "@atcute/lexicons": "^1.2.9", + "@atcute/xrpc-server": "^0.1.12", + "@atmo-dev/contrail": "0.6.0", + "@atmo-dev/contrail-base": "0.6.0", + "cac": "^7.0.0", + "hono": "^4.12.8" + }, + "peerDependencies": { + "wrangler": "^4.0.0" + }, + "peerDependenciesMeta": { + "wrangler": { + "optional": true + } + } + }, "node_modules/@atmo-dev/contrail-record-host": { "version": "0.6.0", "resolved": "file:vendor/atmo-dev-contrail-record-host.tgz", - "integrity": "sha512-UdLxr1o3ENrwma95GkTJ+/9ffExwIAG3PyGdUUGgHUYhgFyaF4gJ+30I3PpihB5V5cwytkS5sC0WtySyr6Crsw==", "license": "MIT", "dependencies": { "@atcute/cid": "^2.4.1", diff --git a/package.json b/package.json index 192371bf..ac96e7ff 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "dependencies": { "@atcute/identity-resolver": "^1.2.3", "@atmo-dev/contrail": "file:./vendor/atmo-dev-contrail.tgz", + "@atmo-dev/contrail-community": "file:./vendor/atmo-dev-contrail-community.tgz", "@atproto/api": "^0.13.31", "@atproto/crypto": "^0.4.5", "@atproto/identity": "^0.4.7", diff --git a/scripts/prepare-contrail-deps.sh b/scripts/prepare-contrail-deps.sh index d2056693..0fee40a4 100755 --- a/scripts/prepare-contrail-deps.sh +++ b/scripts/prepare-contrail-deps.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build the five @atmo-dev/contrail* packages from a sibling fork worktree +# Build the six @atmo-dev/contrail* packages from a sibling fork worktree # and place the resulting tarballs under vendor/ with stable filenames. # # Local: assumes ../contrail-pr30 exists (override with CONTRAIL_DIR). @@ -32,11 +32,11 @@ fi echo "building @atmo-dev/contrail* from $CONTRAIL_DIR ($(git -C "$CONTRAIL_DIR" rev-parse --short HEAD))" cd "$CONTRAIL_DIR" pnpm install --frozen-lockfile -pnpm -r --filter "@atmo-dev/contrail" --filter "@atmo-dev/contrail-base" --filter "@atmo-dev/contrail-appview" --filter "@atmo-dev/contrail-authority" --filter "@atmo-dev/contrail-record-host" build +pnpm -r --filter "@atmo-dev/contrail" --filter "@atmo-dev/contrail-base" --filter "@atmo-dev/contrail-appview" --filter "@atmo-dev/contrail-authority" --filter "@atmo-dev/contrail-record-host" --filter "@atmo-dev/contrail-community" build mkdir -p "$REPO_ROOT/vendor" -for pkg in contrail contrail-base contrail-appview contrail-authority contrail-record-host; do +for pkg in contrail contrail-base contrail-appview contrail-authority contrail-record-host contrail-community; do cd "$CONTRAIL_DIR/packages/$pkg" packed=$(pnpm pack --silent | tail -1) dest="$REPO_ROOT/vendor/atmo-dev-${pkg}.tgz" diff --git a/src/contrail/contrail-community-wiring.spec.ts b/src/contrail/contrail-community-wiring.spec.ts new file mode 100644 index 00000000..93a3875c --- /dev/null +++ b/src/contrail/contrail-community-wiring.spec.ts @@ -0,0 +1,89 @@ +/** + * Verifies the contrail-community integration wires its schema and routes when + * built into a Contrail instance. Postgres-only, gated on + * CONTRAIL_TEST_DATABASE_URL (local-only by default). + */ +import { randomBytes } from 'crypto'; +import pg from 'pg'; +import { loadContrail, loadContrailCommunity } from './contrail-loader'; +import { buildContrailConfig } from './contrail.config'; + +const databaseUrl = process.env.CONTRAIL_TEST_DATABASE_URL; +const maybe = databaseUrl ? describe : describe.skip; + +const TEST_SCHEMA = 'contrail_community_wiring_test'; + +async function tableExists(pool: pg.Pool, table: string): Promise { + const res = await pool.query( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = $1 AND table_name = $2`, + [TEST_SCHEMA, table], + ); + return (res.rowCount ?? 0) > 0; +} + +maybe('contrail-community wiring', () => { + let pool: pg.Pool; + let saved: NodeJS.ProcessEnv; + + beforeEach(async () => { + saved = { ...process.env }; + pool = new pg.Pool({ + connectionString: databaseUrl, + options: `-c search_path=${TEST_SCHEMA},public`, + } as pg.PoolConfig); + await pool.query(`DROP SCHEMA IF EXISTS ${TEST_SCHEMA} CASCADE`); + await pool.query(`CREATE SCHEMA ${TEST_SCHEMA}`); + }); + + afterEach(async () => { + process.env = { ...saved }; + await pool.query(`DROP SCHEMA IF EXISTS ${TEST_SCHEMA} CASCADE`); + await pool.end(); + }); + + it('should NOT create the communities table without the integration', async () => { + delete process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY; + delete process.env.CONTRAIL_AUTHORITY_SIGNING_KEY; + + const { pkg, postgres } = await loadContrail(); + const config = await buildContrailConfig(); + const db = postgres.createPostgresDatabase(pool); + const contrail = new pkg.Contrail({ ...config, db }); + await contrail.init(db); + + expect(await tableExists(pool, 'communities')).toBe(false); + }); + + it('should create community + spaces tables only when the integration is wired', async () => { + const { pkg, postgres } = await loadContrail(); + + // Real key material so the integration constructs cleanly. + const signing = await pkg.generateAuthoritySigningKey(); + process.env.CONTRAIL_AUTHORITY_SIGNING_KEY = Buffer.from( + JSON.stringify(signing), + ).toString('base64'); + process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY = + randomBytes(32).toString('base64'); + + const config = await buildContrailConfig(); + const db = postgres.createPostgresDatabase(pool); + const communityPkg = await loadContrailCommunity(); + const communityIntegration = communityPkg.createCommunityIntegration({ + db, + config, + }); + + const contrail = new pkg.Contrail({ ...config, db, communityIntegration }); + await contrail.init(db); + + // Discriminating proof: these tables are created only because the + // community integration was passed to Contrail — the no-integration test + // above confirms `communities` is absent otherwise. A real runtime DDL + // side effect, not a type-mirror assertion. Route registration flows from + // the same `communityIntegration` through the same init()/createHandler + // path, so this is sufficient evidence the wiring is active. + expect(await tableExists(pool, 'communities')).toBe(true); + expect(await tableExists(pool, 'spaces')).toBe(true); + }); +}); diff --git a/src/contrail/contrail-loader.ts b/src/contrail/contrail-loader.ts index 7c0d2501..55ca5a7b 100644 --- a/src/contrail/contrail-loader.ts +++ b/src/contrail/contrail-loader.ts @@ -8,19 +8,34 @@ const esmImport = new Function('specifier', 'return import(specifier)') as < specifier: string, ) => Promise; +export async function loadContrailCommunity(): Promise< + typeof import('@atmo-dev/contrail-community') +> { + return esmImport( + '@atmo-dev/contrail-community', + ); +} + export async function loadContrail(): Promise<{ pkg: typeof import('@atmo-dev/contrail'); server: typeof import('@atmo-dev/contrail/server'); postgres: typeof import('@atmo-dev/contrail/postgres'); }> { - const [pkg, server, postgres] = await Promise.all([ - esmImport('@atmo-dev/contrail'), - esmImport( - '@atmo-dev/contrail/server', - ), - esmImport( - '@atmo-dev/contrail/postgres', - ), - ]); + // Import the subpaths sequentially, not via Promise.all. They share the + // contrail-base / contrail-appview subgraph, and jest's --experimental-vm-modules + // linker races when overlapping ESM graphs are instantiated concurrently on a + // cold load (contrail-appview requests contrail-base before it's linked → + // "module is not linked"). Awaiting each import in turn links the shared + // subgraph once before the next import. The startup cost is a one-time, + // negligible serialization; real Node's loader handles the parallel case fine, + // but the sequential form keeps the test runner honest too. + const pkg = + await esmImport('@atmo-dev/contrail'); + const server = await esmImport( + '@atmo-dev/contrail/server', + ); + const postgres = await esmImport< + typeof import('@atmo-dev/contrail/postgres') + >('@atmo-dev/contrail/postgres'); return { pkg, server, postgres }; } diff --git a/src/contrail/contrail.config.spec.ts b/src/contrail/contrail.config.spec.ts new file mode 100644 index 00000000..009efd8f --- /dev/null +++ b/src/contrail/contrail.config.spec.ts @@ -0,0 +1,72 @@ +import { buildContrailConfig } from './contrail.config'; + +// A valid-shaped P-256 JWK pair is not required for config assembly — the +// config layer only base64-decodes + JSON.parses the signing blob. Use a +// minimal stand-in object. +const FAKE_SIGNING = Buffer.from( + JSON.stringify({ privateKey: { kty: 'EC' }, publicKey: { kty: 'EC' } }), +).toString('base64'); +const FAKE_ENCRYPTION_KEY = Buffer.from(new Uint8Array(32)).toString('base64'); + +describe('buildContrailConfig community/spaces gating', () => { + let saved: NodeJS.ProcessEnv; + + beforeEach(() => { + saved = { ...process.env }; + }); + + afterEach(() => { + process.env = { ...saved }; + }); + + it('should omit spaces and community when neither secret is set', async () => { + delete process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY; + delete process.env.CONTRAIL_AUTHORITY_SIGNING_KEY; + const config = await buildContrailConfig(); + expect(config.spaces).toBeUndefined(); + expect(config.community).toBeUndefined(); + }); + + it('should add spaces.authority when the signing key is set', async () => { + process.env.CONTRAIL_AUTHORITY_SIGNING_KEY = FAKE_SIGNING; + process.env.SERVICE_DID = 'did:web:api.openmeet.net'; + delete process.env.CONTRAIL_SPACE_TYPE; + const config = await buildContrailConfig(); + expect(config.spaces?.authority?.serviceDid).toBe( + 'did:web:api.openmeet.net', + ); + expect(config.spaces?.authority?.type).toBe('tools.atmo.event.space'); + expect(config.spaces?.authority?.signing).toBeDefined(); + }); + + it('should add community with default-deny provisioning when BOTH keys are set', async () => { + // Community needs the authority to sign/verify the service-auth credentials + // its routes depend on, so it only assembles when spaces.authority is also + // present (i.e. the authority signing key is configured too). + process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY = FAKE_ENCRYPTION_KEY; + process.env.CONTRAIL_AUTHORITY_SIGNING_KEY = FAKE_SIGNING; + const config = await buildContrailConfig(); + const community = config.community as Record; + // `masterKey` is the vendor (contrail-community) config field name. + expect(community.masterKey).toBe(FAKE_ENCRYPTION_KEY); + expect(community.allowProvisioning).toBe(false); + }); + + it('should drop community (and warn) when the encryption key is set without the authority signing key', async () => { + process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY = FAKE_ENCRYPTION_KEY; + delete process.env.CONTRAIL_AUTHORITY_SIGNING_KEY; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const config = await buildContrailConfig(); + + // Partial config is not a valid mount: without spaces.authority the + // community routes can't function, so the block is dropped rather than + // handed half-configured to the vendor integration at startup. + expect(config.community).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('CONTRAIL_AUTHORITY_SIGNING_KEY'), + ); + + warn.mockRestore(); + }); +}); diff --git a/src/contrail/contrail.config.ts b/src/contrail/contrail.config.ts index 5d03d353..b430b411 100644 --- a/src/contrail/contrail.config.ts +++ b/src/contrail/contrail.config.ts @@ -1,4 +1,4 @@ -import type { ContrailConfig } from '@atmo-dev/contrail'; +import type { ContrailConfig, CredentialKeyMaterial } from '@atmo-dev/contrail'; // @atcute/identity-resolver ships as ESM-only. Mirrors the trick in // contrail-loader.ts so the dynamic import survives `module: commonjs`. @@ -8,6 +8,14 @@ const esmImport = new Function('specifier', 'return import(specifier)') as < specifier: string, ) => Promise; +function parseAuthoritySigningKey( + raw?: string, +): CredentialKeyMaterial | undefined { + if (!raw) return undefined; + const json = Buffer.from(raw, 'base64').toString('utf8'); + return JSON.parse(json) as CredentialKeyMaterial; +} + export async function buildContrailConfig(): Promise { const plcUrl = process.env.CONTRAIL_PLC_URL; @@ -33,6 +41,47 @@ export async function buildContrailConfig(): Promise { ? { resolver, slingshotUrl, additionalAllowedHosts } : undefined; + // spaces.authority — present only when the signing key is configured. + const authoritySigningKey = parseAuthoritySigningKey( + process.env.CONTRAIL_AUTHORITY_SIGNING_KEY, + ); + const spaces = authoritySigningKey + ? { + authority: { + type: process.env.CONTRAIL_SPACE_TYPE ?? 'tools.atmo.event.space', + serviceDid: process.env.SERVICE_DID ?? 'did:web:api.openmeet.net', + signing: authoritySigningKey, + }, + } + : undefined; + + // community — present only when BOTH the encryption key AND spaces.authority + // are configured. The community routes are service-auth gated against + // credentials the authority signs/verifies, so an encryption key alone is a + // half-configured mount that the vendor integration can't serve. Drop it + // (and warn) rather than hand a partial config to createCommunityIntegration + // at startup. `masterKey` is the @atmo-dev/contrail-community config field + // (vendor API); we feed it our CONTRAIL_COMMUNITY_ENCRYPTION_KEY env var — + // the AES-GCM key that envelope-encrypts stored rotation keys + app passwords. + const encryptionKey = process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY; + if (encryptionKey && !spaces) { + console.warn( + 'CONTRAIL_COMMUNITY_ENCRYPTION_KEY is set but CONTRAIL_AUTHORITY_SIGNING_KEY ' + + 'is not; community routes need spaces.authority to function. Dropping the ' + + 'community config — set both keys to mount /xrpc/net.openmeet.community.*.', + ); + } + const community = + encryptionKey && spaces + ? { + masterKey: encryptionKey, + plcDirectory: plcUrl, + allowProvisioning: false, // default-deny until Step 3 + allowedPdsEndpoints: + process.env.CONTRAIL_ALLOWED_PDS_ENDPOINTS?.split(',') || undefined, + } + : undefined; + return { namespace: 'net.openmeet', collections: { @@ -77,5 +126,7 @@ export async function buildContrailConfig(): Promise { jetstreams: process.env.CONTRAIL_JETSTREAM_URLS?.split(',') || undefined, relays: process.env.CONTRAIL_RELAYS?.split(',') || undefined, networkOverrides, + ...(spaces ? { spaces } : {}), + ...(community ? { community } : {}), }; } diff --git a/src/contrail/contrail.d.ts b/src/contrail/contrail.d.ts index 842ae516..2f815a6d 100644 --- a/src/contrail/contrail.d.ts +++ b/src/contrail/contrail.d.ts @@ -41,6 +41,33 @@ declare module '@atmo-dev/contrail' { additionalAllowedHosts?: string[]; } + export interface CredentialKeyMaterial { + /** Private key in JWK form. P-256 / ES256. */ + privateKey: Record; + /** Public key in JWK form. Must match privateKey. */ + publicKey: Record; + /** DID-doc verification method id. Defaults to "atproto_space_authority". */ + keyId?: string; + } + + export interface AuthorityConfig { + /** NSID identifying the kind of space this authority hosts. */ + type: string; + /** Service DID that service-auth tokens target (aud) and that signs + * issued credentials (iss). */ + serviceDid: string; + /** ES256 signing key for issuing space credentials. When omitted, + * net.openmeet.space.getCredential returns 501. */ + signing?: CredentialKeyMaterial; + } + + export interface SpacesConfig { + authority?: AuthorityConfig; + } + + /** Opaque pre-built community integration from @atmo-dev/contrail-community. */ + export type CommunityIntegration = unknown; + export interface ContrailConfig { namespace: string; collections: Record; @@ -49,6 +76,10 @@ declare module '@atmo-dev/contrail' { logger?: Logger; notify?: boolean | string; networkOverrides?: NetworkOverrides; + spaces?: SpacesConfig; + /** User-supplied community config blob (masterKey, plcDirectory, etc.). + * Read by the community integration via config.community. */ + community?: unknown; } export type Database = unknown; @@ -56,6 +87,7 @@ declare module '@atmo-dev/contrail' { export interface ContrailOptions extends ContrailConfig { db?: Database; spacesDb?: Database; + communityIntegration?: CommunityIntegration; } export interface BackfillProgress { @@ -81,6 +113,8 @@ declare module '@atmo-dev/contrail' { backfill(options?: BackfillAllOptions, db?: Database): Promise; runPersistent(options?: RunPersistentOptions): Promise; } + + export function generateAuthoritySigningKey(): Promise; } declare module '@atmo-dev/contrail/server' { @@ -96,6 +130,23 @@ declare module '@atmo-dev/contrail/postgres' { export function createPostgresDatabase(pool: Pool): Database; } +declare module '@atmo-dev/contrail-community' { + import type { + Database, + ContrailConfig, + CommunityIntegration, + } from '@atmo-dev/contrail'; + + export interface CommunityIntegrationOptions { + db: Database; + config: ContrailConfig; + } + + export function createCommunityIntegration( + options: CommunityIntegrationOptions, + ): CommunityIntegration; +} + declare module '@atcute/identity-resolver' { export class CompositeDidDocumentResolver { constructor(config: { methods: Record }); diff --git a/src/contrail/contrail.provider.ts b/src/contrail/contrail.provider.ts index a27e8998..9f9102f0 100644 --- a/src/contrail/contrail.provider.ts +++ b/src/contrail/contrail.provider.ts @@ -5,9 +5,9 @@ import { OnModuleInit, } from '@nestjs/common'; import pg from 'pg'; -import type { Contrail } from '@atmo-dev/contrail'; +import type { Contrail, CommunityIntegration } from '@atmo-dev/contrail'; import { buildContrailConfig } from './contrail.config'; -import { loadContrail } from './contrail-loader'; +import { loadContrail, loadContrailCommunity } from './contrail-loader'; const DEFAULT_SCHEMA = 'contrail'; @@ -17,6 +17,7 @@ export class ContrailProvider implements OnModuleInit, OnModuleDestroy { private pool?: pg.Pool; private contrail?: Contrail; private handler?: (request: Request) => Promise; + private communityEnabled = false; async onModuleInit(): Promise { const databaseUrl = process.env.CONTRAIL_DATABASE_URL; @@ -40,7 +41,18 @@ export class ContrailProvider implements OnModuleInit, OnModuleDestroy { const { pkg, server, postgres } = await loadContrail(); const config = await buildContrailConfig(); const db = postgres.createPostgresDatabase(this.pool); - this.contrail = new pkg.Contrail({ ...config, db }); + + let communityIntegration: CommunityIntegration | undefined; + if (config.community) { + const communityPkg = await loadContrailCommunity(); + communityIntegration = communityPkg.createCommunityIntegration({ + db, + config, + }); + this.communityEnabled = true; + } + + this.contrail = new pkg.Contrail({ ...config, db, communityIntegration }); await this.pool!.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); await this.contrail!.init(); @@ -49,7 +61,7 @@ export class ContrailProvider implements OnModuleInit, OnModuleDestroy { const redactedUrl = databaseUrl.replace(/:[^:@/]+@/, ':***@'); this.logger.log( - `Contrail initialized; namespace=${config.namespace}, schema=${schema}, db=${redactedUrl}`, + `Contrail initialized; namespace=${config.namespace}, schema=${schema}, community=${this.communityEnabled ? 'enabled' : 'disabled'}, db=${redactedUrl}`, ); } diff --git a/test/contrail/contrail-xrpc.e2e-spec.ts b/test/contrail/contrail-xrpc.e2e-spec.ts index 675784fc..287260a7 100644 --- a/test/contrail/contrail-xrpc.e2e-spec.ts +++ b/test/contrail/contrail-xrpc.e2e-spec.ts @@ -60,3 +60,35 @@ describeIfContrail('Contrail XRPC mount (e2e)', () => { expect(res.body).toHaveProperty('records'); }); }); + +/** + * Community XRPC routes mount only when BOTH the community block + * (CONTRAIL_COMMUNITY_ENCRYPTION_KEY) and spaces.authority + * (CONTRAIL_AUTHORITY_SIGNING_KEY) are configured — see + * src/contrail/contrail.config.ts and contrail-community/src/integration.ts. + * Every community route is service-auth gated, so an unauthenticated request + * to a *mounted* route returns 401 ("AuthRequired"), not 404 (unmounted) or + * 503 (provider not initialized). getHealth is the cheapest mount probe — it + * needs no synced records, only that the integration registered its routes. + */ +const describeIfCommunity = + process.env.CONTRAIL_DATABASE_URL && + process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY && + process.env.CONTRAIL_AUTHORITY_SIGNING_KEY + ? describe + : describe.skip; + +describeIfCommunity('Contrail community XRPC mount (e2e)', () => { + const app = TESTING_APP_URL; + + it('should mount community.getHealth (401 auth-gated, not 404/503)', async () => { + const res = await request(app).get( + '/xrpc/net.openmeet.community.getHealth', + ); + + // Mounted + provider up + no bearer token → service-auth rejects with 401. + expect(res.status).not.toBe(404); // route would be absent if unmounted + expect(res.status).not.toBe(503); // provider would be down without a DB + expect(res.status).toBe(401); + }); +}); diff --git a/vendor/atmo-dev-contrail-appview.tgz b/vendor/atmo-dev-contrail-appview.tgz index 75f26323..9c166fa6 100644 Binary files a/vendor/atmo-dev-contrail-appview.tgz and b/vendor/atmo-dev-contrail-appview.tgz differ diff --git a/vendor/atmo-dev-contrail-base.tgz b/vendor/atmo-dev-contrail-base.tgz index 7432cf58..f685e80f 100644 Binary files a/vendor/atmo-dev-contrail-base.tgz and b/vendor/atmo-dev-contrail-base.tgz differ diff --git a/vendor/atmo-dev-contrail-community.tgz b/vendor/atmo-dev-contrail-community.tgz new file mode 100644 index 00000000..fa8ba293 Binary files /dev/null and b/vendor/atmo-dev-contrail-community.tgz differ diff --git a/vendor/atmo-dev-contrail.tgz b/vendor/atmo-dev-contrail.tgz index 494382c1..fa52f56e 100644 Binary files a/vendor/atmo-dev-contrail.tgz and b/vendor/atmo-dev-contrail.tgz differ