Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions env-example-relational-ci
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 23 additions & 1 deletion src/contrail/contrail.config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,41 @@ 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<string, unknown>;
// `masterKey` is the vendor (contrail-community) config field name.
expect(community.masterKey).toBe(FAKE_ENCRYPTION_KEY);
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<string, unknown>;
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<string, unknown>;
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;
Expand Down
23 changes: 21 additions & 2 deletions src/contrail/contrail.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ function parseAuthoritySigningKey(
export async function buildContrailConfig(): Promise<ContrailConfig> {
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<typeof import('@atcute/identity-resolver')>(
'@atcute/identity-resolver',
Expand Down Expand Up @@ -51,6 +55,13 @@ export async function buildContrailConfig(): Promise<ContrailConfig> {
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;
Expand All @@ -76,7 +87,15 @@ export async function buildContrailConfig(): Promise<ContrailConfig> {
? {
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,
}
Expand Down
15 changes: 15 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
141 changes: 140 additions & 1 deletion test/contrail/contrail-xrpc.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string> {
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);
});
},
);
Loading