[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(record-host): require owner-signed enrollment

The recordHost.enroll endpoint accepted either an owner-signed call OR
a self-attested authority call. The latter let any DID claim to be the
authority for any space's URI and rebind that space's authority.

Drop the self-attesting-authority branch. Require the caller to be the
space owner (sa.issuer === parts.ownerDid). The owner is still free to
designate any authority via body.authority — only the legitimacy of the
caller changes.

Updates the existing test that codified the old behavior to assert the
new rejection, and adds a positive test for the canonical owner-driven
split-deployment enrollment flow.

Tom Scanlan (May 8, 2026, 9:05 AM EDT) 00094ee2 c9425dca

+41 -11
+2 -4
packages/contrail-record-host/src/routes.ts
··· 107 107 if (!parts) { 108 108 return c.json({ error: "InvalidRequest", reason: "malformed-uri" }, 400); 109 109 } 110 - const callerIsOwner = sa.issuer === parts.ownerDid; 111 - const callerIsAuthority = sa.issuer === body.authority; 112 - if (!callerIsOwner && !callerIsAuthority) { 110 + if (sa.issuer !== parts.ownerDid) { 113 111 return c.json( 114 - { error: "Forbidden", reason: "not-owner-or-authority" }, 112 + { error: "Forbidden", reason: "not-owner" }, 115 113 403 116 114 ); 117 115 }
+39 -7
packages/contrail/tests/spaces-enrollment.test.ts
··· 127 127 expect(((await reenroll.json()) as any).ok).toBe(true); 128 128 }); 129 129 130 - it("non-owner / non-authority callers cannot enroll", async () => { 130 + it("non-owner callers cannot enroll", async () => { 131 131 const app = await makeApp(); 132 132 const create = await call(app, "POST", "/xrpc/test.enroll.space.createSpace", ALICE, {}); 133 133 const uri = ((await create.json()) as any).space.uri; ··· 137 137 authority: SERVICE_DID, 138 138 }); 139 139 expect(res.status).toBe(403); 140 - expect((await res.json()).reason).toBe("not-owner-or-authority"); 140 + expect((await res.json()).reason).toBe("not-owner"); 141 141 }); 142 142 }); 143 143 ··· 369 369 expect((await list.json()).reason).toBe("not-enrolled"); 370 370 }); 371 371 372 - it("the declared authority can enroll a space without the owner's involvement", async () => { 373 - // Scenario: the authority service ('Contrail' in our naming) is a 374 - // separate DID and acts on behalf of an owner. The authority can enroll 375 - // because phase 5 accepts either owner-or-authority. 372 + it("a third party cannot enroll a space by claiming to be the authority", async () => { 373 + // Regression: the enroll handler used to accept either owner-signed 374 + // OR authority-self-attested calls. That let any DID claim "I am the 375 + // authority for ats://<victim>/..." and rebind the space. 376 376 const authorityDb = createSqliteDatabase(":memory:"); 377 377 const hostDb = createSqliteDatabase(":memory:"); 378 378 const cfg: ContrailConfig = { ··· 392 392 const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); 393 393 const uri = ((await create.json()) as any).space.uri; 394 394 395 - // Authority service identifies itself via the JWT issuer matching its DID. 395 + // SERVICE_DID self-attests as the authority for Alice's space. 396 + // Must be rejected — only the owner can enroll. 396 397 const enroll = await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", SERVICE_DID, { 397 398 spaceUri: uri, 398 399 authority: SERVICE_DID, 399 400 }); 401 + expect(enroll.status).toBe(403); 402 + expect((await enroll.json()).reason).toBe("not-owner"); 403 + }); 404 + 405 + it("the owner can enroll their space designating a separate authority", async () => { 406 + // Positive: the owner-signed path lets Alice point her space at the 407 + // configured authority service (the canonical split-deployment flow). 408 + const authorityDb = createSqliteDatabase(":memory:"); 409 + const hostDb = createSqliteDatabase(":memory:"); 410 + const cfg: ContrailConfig = { 411 + namespace: "test.split", 412 + collections: { message: { collection: "app.event.message" } }, 413 + spaces: { 414 + authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, 415 + recordHost: {}, 416 + }, 417 + }; 418 + const resolved = resolveConfig(cfg); 419 + await initSchema(authorityDb, resolved); 420 + await initSchema(hostDb, resolved); 421 + 422 + const authorityApp = buildAuthorityApp(authorityDb); 423 + const hostApp = buildRecordHostApp(hostDb); 424 + const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); 425 + const uri = ((await create.json()) as any).space.uri; 426 + 427 + const enroll = await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", ALICE, { 428 + spaceUri: uri, 429 + authority: SERVICE_DID, 430 + }); 400 431 expect(enroll.status).toBe(200); 432 + expect(((await enroll.json()) as any).ok).toBe(true); 401 433 }); 402 434 });