···66 * dogfooding ingester the developer might have running in another terminal.
77 */
88import pg from "pg";
99+import type { Client } from "@atcute/client";
1010+import {
1111+ CompositeDidDocumentResolver,
1212+ PlcDidDocumentResolver,
1313+} from "@atcute/identity-resolver";
1414+import type { Did as AtDid, Nsid } from "@atcute/lexicons";
9151016export type Did = `did:${string}:${string}`;
11171218export const PDS_PORT = Number(process.env.DEVNET_PDS_PORT ?? 4000);
1319export const PDS_URL = `http://localhost:${PDS_PORT}`;
2020+export const PLC_PORT = Number(process.env.DEVNET_PLC_PORT ?? 2582);
2121+export const PLC_URL = `http://localhost:${PLC_PORT}`;
1422export const HANDLE_DOMAIN = process.env.DEVNET_HANDLE_DOMAIN ?? ".devnet.test";
1523export const PDS_ADMIN_PASSWORD = process.env.DEVNET_PDS_ADMIN_PASSWORD ?? "devnet-admin-password";
1624export const DATABASE_URL =
1725 process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5433/contrail";
2626+2727+/**
2828+ * Arbitrary service DID used for the test Contrail deployment. The only
2929+ * requirements: (a) the JWTs we mint via getServiceAuth use this as their
3030+ * `aud` claim, and (b) Contrail's SpacesConfig.serviceDid matches. The DID
3131+ * itself doesn't need to be resolvable — the verifier only resolves issuers
3232+ * (users), not the audience.
3333+ */
3434+export const CONTRAIL_SERVICE_DID = "did:web:contrail-test.devnet.test";
3535+3636+/**
3737+ * Resolver that points the PLC method at the local devnet PLC on :2582.
3838+ * Without this, the default resolver hits plc.directory and 404s on every
3939+ * devnet DID.
4040+ */
4141+export function createDevnetResolver() {
4242+ return new CompositeDidDocumentResolver({
4343+ methods: {
4444+ plc: new PlcDidDocumentResolver({ apiUrl: PLC_URL }),
4545+ },
4646+ });
4747+}
4848+4949+/**
5050+ * Mint an atproto service-auth JWT via the PDS's getServiceAuth endpoint.
5151+ * Requires the client to already be authed for a user. Returns the raw JWT
5252+ * string suitable for `Authorization: Bearer <token>`.
5353+ */
5454+export async function mintServiceAuthJwt(
5555+ client: Client,
5656+ opts: { aud: string; lxm?: string; expSeconds?: number },
5757+): Promise<string> {
5858+ const params: { aud: AtDid; lxm?: Nsid; exp?: number } = {
5959+ aud: opts.aud as AtDid,
6060+ };
6161+ if (opts.lxm) params.lxm = opts.lxm as Nsid;
6262+ if (opts.expSeconds) params.exp = Math.floor(Date.now() / 1000) + opts.expSeconds;
6363+6464+ const res = await client.get("com.atproto.server.getServiceAuth", { params });
6565+ if (!res.ok) {
6666+ throw new Error(`getServiceAuth → ${res.status}: ${JSON.stringify(res.data)}`);
6767+ }
6868+ return res.data.token;
6969+}
18701971export type TestAccount = { handle: string; password: string; did: Did };
2072
+127
apps/contrail-e2e/tests/spaces-auth.test.ts
···11+/**
22+ * Service-auth JWT end-to-end against spaces XRPCs.
33+ *
44+ * 1. Alice mints a JWT via com.atproto.server.getServiceAuth, calls
55+ * {ns}.space.createSpace → verifier accepts, space is created.
66+ * 2. JWT with wrong audience → verifier rejects, 401.
77+ * 3. JWT with lxm bound to one method, used on a different method → 401.
88+ *
99+ * The full auth path is exercised: PDS signs with Alice's PLC-published
1010+ * key, Contrail's resolver reads that key from devnet PLC, real verifier
1111+ * checks the signature. No mocks on the auth path.
1212+ */
1313+import { describe, it, expect, beforeAll, afterAll } from "vitest";
1414+import pg from "pg";
1515+import { CredentialManager, Client } from "@atcute/client";
1616+import "@atcute/atproto";
1717+import { Contrail } from "@atmo-dev/contrail";
1818+import { createHandler } from "@atmo-dev/contrail/server";
1919+import { createPostgresDatabase } from "@atmo-dev/contrail/postgres";
2020+import { config as baseConfig } from "../config";
2121+import {
2222+ createTestAccount,
2323+ createIsolatedSchema,
2424+ createDevnetResolver,
2525+ mintServiceAuthJwt,
2626+ CONTRAIL_SERVICE_DID,
2727+ PDS_URL,
2828+ type TestAccount,
2929+} from "./helpers";
3030+3131+const SPACE_TYPE = "rsvp.atmo.event.space";
3232+3333+describe("spaces auth (devnet PDS JWT → Contrail verifier)", () => {
3434+ let alice: TestAccount;
3535+ let aliceClient: Client;
3636+ let pool: pg.Pool;
3737+ let cleanupSchema: () => Promise<void>;
3838+ let handle: (req: Request) => Promise<Response>;
3939+4040+ beforeAll(async () => {
4141+ alice = await createTestAccount();
4242+ const creds = new CredentialManager({ service: PDS_URL });
4343+ await creds.login({ identifier: alice.handle, password: alice.password });
4444+ aliceClient = new Client({ handler: creds });
4545+4646+ const iso = await createIsolatedSchema("test_spaces_auth");
4747+ pool = iso.pool;
4848+ cleanupSchema = iso.cleanup;
4949+ const db = createPostgresDatabase(pool);
5050+5151+ const contrail = new Contrail({
5252+ ...baseConfig,
5353+ db,
5454+ spaces: {
5555+ type: SPACE_TYPE,
5656+ serviceDid: CONTRAIL_SERVICE_DID,
5757+ resolver: createDevnetResolver(),
5858+ },
5959+ });
6060+ await contrail.init();
6161+ handle = createHandler(contrail);
6262+ });
6363+6464+ afterAll(async () => {
6565+ await cleanupSchema?.();
6666+ });
6767+6868+ async function callXrpc(
6969+ method: "GET" | "POST",
7070+ path: string,
7171+ opts: { token?: string; body?: unknown } = {},
7272+ ): Promise<Response> {
7373+ const headers: Record<string, string> = {};
7474+ if (opts.token) headers["authorization"] = `Bearer ${opts.token}`;
7575+ if (opts.body !== undefined) headers["content-type"] = "application/json";
7676+ return handle(
7777+ new Request(`http://test${path}`, {
7878+ method,
7979+ headers,
8080+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
8181+ }),
8282+ );
8383+ }
8484+8585+ it("accepts a real service-auth JWT and creates a space", async () => {
8686+ const token = await mintServiceAuthJwt(aliceClient, {
8787+ aud: CONTRAIL_SERVICE_DID,
8888+ lxm: "rsvp.atmo.space.createSpace",
8989+ });
9090+9191+ const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", {
9292+ token,
9393+ body: {},
9494+ });
9595+ const text = await res.clone().text().catch(() => "");
9696+ expect(res.status, `createSpace → ${res.status}: ${text}`).toBe(200);
9797+9898+ const data = (await res.json()) as { space: { uri: string; ownerDid: string } };
9999+ expect(data.space.uri).toMatch(/^at:\/\//);
100100+ expect(data.space.ownerDid).toBe(alice.did);
101101+ });
102102+103103+ it("rejects a JWT minted with the wrong audience", async () => {
104104+ const token = await mintServiceAuthJwt(aliceClient, {
105105+ aud: "did:web:not-contrail.devnet.test",
106106+ });
107107+108108+ const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", {
109109+ token,
110110+ body: {},
111111+ });
112112+ expect(res.status).toBe(401);
113113+ });
114114+115115+ it("rejects a JWT whose lxm binding mismatches the route", async () => {
116116+ const token = await mintServiceAuthJwt(aliceClient, {
117117+ aud: CONTRAIL_SERVICE_DID,
118118+ lxm: "rsvp.atmo.space.listSpaces",
119119+ });
120120+121121+ const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", {
122122+ token,
123123+ body: {},
124124+ });
125125+ expect(res.status).toBe(401);
126126+ });
127127+});