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
9 changes: 0 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
11 changes: 11 additions & 0 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 53 additions & 1 deletion env-example-relational
Original file line number Diff line number Diff line change
Expand Up @@ -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=
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=
14 changes: 14 additions & 0 deletions env-example-relational-ci
Original file line number Diff line number Diff line change
Expand Up @@ -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=
31 changes: 26 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions scripts/prepare-contrail-deps.sh
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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"
Expand Down
89 changes: 89 additions & 0 deletions src/contrail/contrail-community-wiring.spec.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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);
});
});
33 changes: 24 additions & 9 deletions src/contrail/contrail-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,34 @@ const esmImport = new Function('specifier', 'return import(specifier)') as <
specifier: string,
) => Promise<T>;

export async function loadContrailCommunity(): Promise<
typeof import('@atmo-dev/contrail-community')
> {
return esmImport<typeof import('@atmo-dev/contrail-community')>(
'@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<typeof import('@atmo-dev/contrail')>('@atmo-dev/contrail'),
esmImport<typeof import('@atmo-dev/contrail/server')>(
'@atmo-dev/contrail/server',
),
esmImport<typeof import('@atmo-dev/contrail/postgres')>(
'@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<typeof import('@atmo-dev/contrail')>('@atmo-dev/contrail');
const server = await esmImport<typeof import('@atmo-dev/contrail/server')>(
'@atmo-dev/contrail/server',
);
const postgres = await esmImport<
typeof import('@atmo-dev/contrail/postgres')
>('@atmo-dev/contrail/postgres');
return { pkg, server, postgres };
}
Loading
Loading