diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index a9ce88c7..08f71e5a 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -222,12 +222,15 @@ services: # 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:-} + # Community keys + the one-shot Step-3 provision window + # (CONTRAIL_COMMUNITY_ENCRYPTION_KEY, CONTRAIL_AUTHORITY_SIGNING_KEY, + # CONTRAIL_PLC_URL, CONTRAIL_ALLOWED_PDS_ENDPOINTS, + # CONTRAIL_ALLOW_PROVISIONING) are injected straight from `.env` via + # env_file below — no `environment:` override here, so they don't get + # blanked when absent from the shell. Community routes mount only when + # BOTH keys are present; provisioning stays default-deny unless + # CONTRAIL_ALLOW_PROVISIONING=true. Close the window by removing those + # three lines from `.env` and recreating. env_file: - .env networks: diff --git a/env-example-relational-ci b/env-example-relational-ci index 263edc09..5382bdd1 100644 --- a/env-example-relational-ci +++ b/env-example-relational-ci @@ -206,3 +206,14 @@ CONTRAIL_SCHEMA=contrail # 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= +# Provisioning config — enables the community.provision body-forwarding e2e +# to actually RUN in CI (not skip). The resolver must point at the CI devnet +# PLC so caller DIDs minted there resolve (else auth 401s before the body is +# read); provisioning must be enabled to reach createAccount; the PDS endpoint +# must be allow-listed. aud alignment is automatic — both OM's did.json `id` +# and contrail's verifier serviceDid default to did:web:api.openmeet.net when +# SERVICE_DID is unset (as it is here). The e2e uses a bad invite, so it fails +# at createAccount and provisions nothing — no accumulating CI state. +CONTRAIL_PLC_URL=http://plc:2582 +CONTRAIL_ALLOWED_PDS_ENDPOINTS=http://pds:3000 +CONTRAIL_ALLOW_PROVISIONING=true diff --git a/src/contrail/contrail.config.spec.ts b/src/contrail/contrail.config.spec.ts index 009efd8f..247ab2d2 100644 --- a/src/contrail/contrail.config.spec.ts +++ b/src/contrail/contrail.config.spec.ts @@ -39,12 +39,13 @@ describe('buildContrailConfig community/spaces gating', () => { expect(config.spaces?.authority?.signing).toBeDefined(); }); - it('should add community with default-deny provisioning when BOTH keys are set', async () => { + it('should add community with default-deny provisioning when BOTH keys are set and CONTRAIL_ALLOW_PROVISIONING is unset', 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; + delete process.env.CONTRAIL_ALLOW_PROVISIONING; const config = await buildContrailConfig(); const community = config.community as Record; // `masterKey` is the vendor (contrail-community) config field name. @@ -52,6 +53,27 @@ describe('buildContrailConfig community/spaces gating', () => { expect(community.allowProvisioning).toBe(false); }); + it('should enable provisioning when CONTRAIL_ALLOW_PROVISIONING is exactly "true"', async () => { + // The Step-3 one-shot provision window opens by setting this env var, with + // no code edit. Strict `=== 'true'` so the route's default-deny posture is + // only lifted by an explicit, deliberate value. + process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY = FAKE_ENCRYPTION_KEY; + process.env.CONTRAIL_AUTHORITY_SIGNING_KEY = FAKE_SIGNING; + process.env.CONTRAIL_ALLOW_PROVISIONING = 'true'; + const config = await buildContrailConfig(); + const community = config.community as Record; + expect(community.allowProvisioning).toBe(true); + }); + + it('should keep provisioning disabled for a non-"true" CONTRAIL_ALLOW_PROVISIONING value', async () => { + process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY = FAKE_ENCRYPTION_KEY; + process.env.CONTRAIL_AUTHORITY_SIGNING_KEY = FAKE_SIGNING; + process.env.CONTRAIL_ALLOW_PROVISIONING = '1'; + const config = await buildContrailConfig(); + const community = config.community as Record; + 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; diff --git a/src/contrail/contrail.config.ts b/src/contrail/contrail.config.ts index b430b411..1679aba0 100644 --- a/src/contrail/contrail.config.ts +++ b/src/contrail/contrail.config.ts @@ -19,7 +19,11 @@ function parseAuthoritySigningKey( export async function buildContrailConfig(): Promise { const plcUrl = process.env.CONTRAIL_PLC_URL; - let resolver: unknown | undefined; + // Typed `unknown` because the ESM-only @atcute resolver type isn't cleanly + // importable under this project's moduleResolution; it's attached to the + // config below via spread (which skips excess-property checks) and consumed + // structurally at runtime by contrail-base's buildVerifier. + let resolver: unknown; if (plcUrl) { const ir = await esmImport( '@atcute/identity-resolver', @@ -51,6 +55,13 @@ export async function buildContrailConfig(): Promise { type: process.env.CONTRAIL_SPACE_TYPE ?? 'tools.atmo.event.space', serviceDid: process.env.SERVICE_DID ?? 'did:web:api.openmeet.net', signing: authoritySigningKey, + // Without this the spaces-authority verifier (buildVerifier in + // contrail-base) defaults to a public plc.directory resolver and + // can't resolve caller DIDs minted on a non-public PLC — the auth + // middleware then 401s with "failed to retrieve did document". + // Only set when CONTRAIL_PLC_URL is configured; otherwise the vendor + // default (public PLC) is correct. + ...(resolver ? { resolver } : {}), }, } : undefined; @@ -76,7 +87,15 @@ export async function buildContrailConfig(): Promise { ? { masterKey: encryptionKey, plcDirectory: plcUrl, - allowProvisioning: false, // default-deny until Step 3 + // Same rationale as spaces.authority.resolver above: the community + // adopt/identity-resolution paths use cfg.resolver; without it they + // fall back to public PLC and can't see non-public-PLC DIDs. + ...(resolver ? { resolver } : {}), + // Default-deny: the one-shot Step-3 provision window opens by setting + // CONTRAIL_ALLOW_PROVISIONING=true (no code edit), then unsetting it. + // Strict `=== 'true'` so only a deliberate value lifts the route's + // default-deny posture (router returns 403 ProvisioningDisabled). + allowProvisioning: process.env.CONTRAIL_ALLOW_PROVISIONING === 'true', allowedPdsEndpoints: process.env.CONTRAIL_ALLOWED_PDS_ENDPOINTS?.split(',') || undefined, } diff --git a/src/main.ts b/src/main.ts index f05a1f81..298aff1c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -112,9 +112,24 @@ async function bootstrap() { if (typeof v === 'string') headers[k] = v; else if (Array.isArray(v)) headers[k] = v.join(', '); } + // This middleware runs before Nest's body parsers and never calls + // next() for net.openmeet.*, so req is an unconsumed readable stream. + // Buffer it and forward as the request body — without this, every + // POST XRPC (provision, putRecord, space.grant, …) reaches the Hono + // handler empty and fails its required-fields validation. + let body: Buffer | undefined; + const method = req.method.toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + body = Buffer.concat(chunks); + } + // Drop content-length so undici recomputes it for the buffered body. + delete headers['content-length']; const fetchRequest = new globalThis.Request(url.toString(), { method: req.method, headers, + ...(body && body.length ? { body } : {}), }); const fetchResponse = await contrailProvider.handle(fetchRequest); res.status(fetchResponse.status); diff --git a/test/contrail/contrail-xrpc.e2e-spec.ts b/test/contrail/contrail-xrpc.e2e-spec.ts index 287260a7..6deda1af 100644 --- a/test/contrail/contrail-xrpc.e2e-spec.ts +++ b/test/contrail/contrail-xrpc.e2e-spec.ts @@ -1,5 +1,10 @@ import request from 'supertest'; -import { TESTING_APP_URL } from '../utils/constants'; +import { + TESTING_APP_URL, + TESTING_PDS_URL, + TESTING_PDS_HANDLE_DOMAIN, + TESTING_PDS_INVITE_CODE, +} from '../utils/constants'; /** * Curl-level proof that OM API's /xrpc/net.openmeet.* mount works. @@ -92,3 +97,137 @@ describeIfCommunity('Contrail community XRPC mount (e2e)', () => { expect(res.status).toBe(401); }); }); + +/** + * Regression guard for the nest→Hono bridge request-body forwarding + * (src/main.ts /xrpc middleware). That middleware runs before Nest's body + * parsers and never calls next() for net.openmeet.*, so `req` is an + * unconsumed readable stream that must be buffered and forwarded — otherwise + * every POST XRPC reaches the Hono handler with an empty body and fails its + * required-fields validation. GET routes have no body, which is why the + * getHealth probe above never caught this. + * + * The discriminator: with the body DROPPED, an authenticated provision call + * returns 400 InvalidRequest "...required". With the body FORWARDED, the same + * call carries handle/email/password/pdsEndpoint/rotationKey past that gate + * into the orchestrator, which reaches createAccount and fails only on the + * deliberately-invalid invite code (502 ProvisioningFailed). Reaching + * createAccount is positive proof the body crossed the bridge. A bad invite + * fails before any PLC op, so no community is provisioned — the test is + * repeatable with no accumulating PDS/PLC state. + * + * Preconditions (skips cleanly unless ALL are met — provisioning has to be + * configured and enabled, which is not the default config): + * - community env (mount): CONTRAIL_COMMUNITY_ENCRYPTION_KEY + + * CONTRAIL_AUTHORITY_SIGNING_KEY + CONTRAIL_DATABASE_URL + * - live PDS for account creation + service-auth mint: PDS_URL + + * PDS_INVITE_CODE + * - CONTRAIL_PLC_URL: the internal-PLC resolver must be wired, else the + * caller DID (minted on that PLC) can't resolve and auth 401s before the + * body is ever read. (This is also what fix #2 in this PR addresses.) + * - CONTRAIL_ALLOW_PROVISIONING === 'true': otherwise the route 403s at the + * gate before reaching createAccount, so we'd never observe the body. + * + * These are set in CI (env-example-relational-ci) so the test RUNS there — + * the resolver points at the CI devnet PLC and provisioning is enabled. aud + * alignment is automatic: both OM's did.json `id` and contrail's verifier + * serviceDid default to did:web:api.openmeet.net when SERVICE_DID is unset. + * The gate just makes the test skip cleanly where provisioning isn't enabled + * (e.g. a plain local run) rather than failing with a 401/403. It first + * caught this bridge regression during the local Step-3 rehearsal (verified + * RED without the main.ts fix). + */ +const describeIfProvisioningEnabled = + process.env.CONTRAIL_DATABASE_URL && + process.env.CONTRAIL_COMMUNITY_ENCRYPTION_KEY && + process.env.CONTRAIL_AUTHORITY_SIGNING_KEY && + process.env.PDS_URL && + process.env.PDS_INVITE_CODE && + process.env.CONTRAIL_PLC_URL && + process.env.CONTRAIL_ALLOW_PROVISIONING === 'true' + ? describe + : describe.skip; + +describeIfProvisioningEnabled( + 'Contrail community POST body forwarding (e2e)', + () => { + const app = TESTING_APP_URL; + const pdsUrl = TESTING_PDS_URL; + // Endpoint the OM API server (not this test process) uses to reach the PDS. + // On devnet that's the in-cluster address; CONTRAIL_ALLOWED_PDS_ENDPOINTS + // gates it, so it must match one of the allowed values. + const inContainerPdsEndpoint = + process.env.CONTRAIL_ALLOWED_PDS_ENDPOINTS?.split(',')[0].trim() || + 'http://pds:3000'; + + const shortId = () => Math.random().toString(36).substring(2, 8); + + /** Create a throwaway PDS account and mint a community.provision-scoped + * service-auth JWT (aud = OM's SERVICE_DID from did.json). */ + async function mintProvisionCaller(): Promise { + const id = shortId(); + // Use the domain the PDS actually serves (.env's PDS_SERVICE_HANDLE_DOMAINS + // can drift from the devnet PDS config — e.g. .pds.test vs .devnet.test). + const describe = await request(pdsUrl) + .get('/xrpc/com.atproto.server.describeServer') + .expect(200); + const handleDomain: string = + describe.body.availableUserDomains?.[0] ?? TESTING_PDS_HANDLE_DOMAIN; + + const create = await request(pdsUrl) + .post('/xrpc/com.atproto.server.createAccount') + .set('Content-Type', 'application/json') + .send({ + email: `bodyfwd-${id}@test.invalid`, + handle: `bodyfwd${id}${handleDomain}`, + password: 'test-password-123', + inviteCode: TESTING_PDS_INVITE_CODE, + }) + .expect(200); + const { accessJwt } = create.body; + + const didJson = await request(app) + .get('/.well-known/did.json') + .expect(200); + const serviceDid = didJson.body.id; + + const svc = await request(pdsUrl) + .get('/xrpc/com.atproto.server.getServiceAuth') + .query({ aud: serviceDid, lxm: 'net.openmeet.community.provision' }) + .set('Authorization', `Bearer ${accessJwt}`) + .expect(200); + return svc.body.token; + } + + it('should forward the POST body across the bridge (provision reaches createAccount, not the empty-body 400)', async () => { + const token = await mintProvisionCaller(); + const id = shortId(); + + const res = await request(app) + .post('/xrpc/net.openmeet.community.provision') + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'application/json') + .send({ + handle: `bodyfwd-tgt-${id}.devnet.test`, + email: `bodyfwd-tgt-${id}@test.invalid`, + password: `pw-${id}`, + pdsEndpoint: inContainerPdsEndpoint, + rotationKey: + 'did:key:zDnaeWVHmxYg3V8sBygjT64PRcQzCVjePBhmPrjEPngAvatvi', + inviteCode: 'deliberately-invalid-invite', + }); + + // The empty-body bug returned 400 "...required". The body crossed if we + // do NOT see that rejection. + const message: string = res.body?.message ?? ''; + expect(message).not.toMatch(/required/i); + expect(res.status).not.toBe(400); + + // Positive proof: the orchestrator received all fields and got as far as + // createAccount, failing only on the bad invite. + expect(res.status).toBe(502); + expect(res.body?.error).toBe('ProvisioningFailed'); + expect(message).toMatch(/createAccount/i); + }); + }, +);