Commits
fix(spaces): owner-signed enroll, enrollment-only default binding, declaration validation
merge: resolve conflicts with main
Two trust assumptions in the binding layer cannot be closed at the
Contrail layer alone:
- DID-doc service-entry rebind: deployments using
createDidDocBindingResolver inherit PLC's rotation-key authorization
model. Any rotation key on the owner's account can rewrite the
#atproto_space_authority service entry.
- PDS app password held for managed communities: community.provision
and community.adopt hold unscoped app passwords that can write any
record on the user-owned PDS, including the binding declaration
read by createPdsBindingResolver.
Both are constraints of their respective binding sources and
onboarding flows, not bugs. Document each so operators picking a
binding strategy can size them against their own threat model, with
pointers to the canonical issues for the upstream/protocol-level
work that would close them: #38 (DID-doc), #39 (PDS app password).
The PDS binding resolver trusted body.value.authority on any record
returned for the space's URI, with only a startsWith('did:') sniff. A
malformed or attacker-shaped record at the same NSID could rebind
authority — record content alone, no signature verification.
Validate before trusting:
- $type must equal the URI's type (the space-type lexicon embeds the
declaration fields per tools.atmo.space.declaration's contract)
- createdAt must be a string
- authority must match a strict did:plc | did:web regex
Fail closed on any deviation. Adds three negative tests covering each
deviation path.
The default in-process credential verifier composed
[Enrollment, Local], so a never-enrolled space still resolved its
authority via LocalBindingResolver — collapsing the host-side consent
layer the enrollment table is meant to provide.
Default to enrollment-only. createLocalBindingResolver remains exported
for explicit single-tenant wiring via options.credentialVerifier.
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.
Main added record-filter, identity-events, follow-feed, and lex-gen-fix
features in `packages/contrail/src/core/*` that PR #30 had reorganized
into the new package split. Each conflict resolved by:
1. Keeping PR #30's re-export shims in `packages/contrail/src/core/*`
(these files now `export * from "@atmo-dev/contrail-appview"`)
2. Porting main's additions to the corresponding files in
`packages/contrail-appview/src/core/*`, which is the new home of the
actual implementation.
Files re-shimmed:
- backfill.ts, db/records.ts, db/schema.ts, identity.ts, jetstream.ts,
persistent.ts, router/feed.ts, types.ts
Ports landed in contrail-appview/src/core (or contrail-base where types
and identity helpers actually live):
- backfill.ts, db/records.ts, db/schema.ts, jetstream.ts, persistent.ts,
router/feed.ts (+1.0K lines from main)
- constellation.ts (new 181-line module)
- contrail-base/src/types.ts: FeedTargetConfig, ConstellationConfig,
buildFeedTargetCaps, normalizeFeedTarget, feedTargetMaxItems,
CollectionConfig.{timeField, subjectField, recordFilter}, and
resolveConfig defaulting `discover: false` for `app.bsky.*`
- contrail-base/src/identity.ts: applyIdentityEvent (handle change handler)
Test mock for `persistent.test.ts` updated to mock
`@atmo-dev/contrail-base` (post-split home of identity helpers) and
include both `refreshStaleIdentities` and `applyIdentityEvent`.
Test status post-merge: 314 passed, 7 failed in
`packages/contrail/tests/search.test.ts` — these 7 failures pre-exist on
plain `origin/main` and are unrelated to the merge.
No behavior changes intended beyond what main and PR #30 already deliver.
test(contrail-e2e): adopt post-PR30 spaces split + community integration
Bring the e2e suite back to green under the package split:
- makeSpacesConfig() wraps the old flat { type, serviceDid, resolver }
shape under authority / recordHost and generates a fresh signing key
per call. The config validator now requires spaces.authority whenever
community is set, so this is the minimum viable shape for any test
that uses spaces.
- setupCommunityContrail() wires the community module via
createCommunityIntegration({ db, config: resolveConfig(...) }) and
passes the result as communityIntegration to the Contrail constructor.
The community config field is now opaque to contrail core; routes are
registered through the integration option.
- 7 test files updated to use the helpers; 4 community tests pick up a
workspace dep on @atmo-dev/contrail-community.
35/35 e2e tests pass against devnet. No source-package changes.
feat: reject removing/demoting the last owner of a space
test: e2e suite for community.delete soft-delete + cascade
test: e2e suite for community.invite.* unified surface
test: e2e suite for community lifecycle + publishing
fix: update spaces-auth e2e assertion for ats:// scheme
space.revoke and space.setAccessLevel now return 409 Conflict with
reason "last-owner" when the operation would leave a space with zero
owners. On the $admin space this would otherwise leave the entire
community ownerless and unmanageable; on any space it leaves it without
the only role that can grant `owner`. Hand off ownership first by
promoting a successor.
Flips the two it.fails probes in community-lifecycle.test.ts to
passing, with assertions on the specific 409 / "last-owner" shape.
Covers <ns>.community.delete against real Postgres:
- Owner soft-deletes; communities.deleted_at + every owned space's
deleted_at flip together (verified by direct SQL on the test pool,
so the cascade is observed at the source-of-truth layer).
- <ns>.community.list filtering is real SQL — the deleted community
drops out of the listing without any in-memory bookkeeping.
- getCommunity's deleted_at filter blocks subsequent endpoints:
community.space.create returns 404 community-not-found.
- owner-required is enforced — an admin in $admin (strictly below
owner) gets 403, and so does a stranger; the community stays alive.
The router endpoint and softDeleteCommunity adapter method had no
unit-test coverage for delete; these e2e tests fill that gap and add
real-DB cascade verification on top.
The router-level unit tests in invite-unified.test.ts cover dispatch and
input validation against a fakeAuth middleware and mocked PDS. This file
exercises what those can't:
- Real service-auth JWTs minted via com.atproto.server.getServiceAuth on
the devnet PDS, verified through PLC-resolved keys by the real auth
middleware.
- The reconcile cascade. <ns>.invite.redeem on a community-owned space
fires community.grant + reconcile, and the resulting access must show
up in BOTH the community ACL and spaces.access_rows. Verified by
reading community.space.listMembers (ACL view) and the same endpoint
with flatten=true (spaces table view).
- Real Postgres enforcement of single-use (maxUses=1) and revoked
invites — the redeem query is the source of truth, not in-memory.
Both invite paths covered: community-owned (accessLevel) and user-owned
(kind=join, no community module touched).
4 tests, ~3s. Stacked on top of the lifecycle/publishing tests so the
shared helpers (login, createCaller, jsonOr) are reused without
duplication.
The space URI scheme moved from at:// to ats:// (Atmosphere Spaces) in the
spaces module. Update the assertion so the existing test passes against
current main without other changes.
Two new test files exercising the community module end-to-end against the
local devnet stack:
- community-lifecycle.test.ts: mint → bootstrap reserved spaces → grant →
list → setAccessLevel → revoke → ownership handoff. Pins the missing
last-owner guard with two it.fails probes that flip to passing once the
guard lands.
- community-publishing.test.ts: caller JWT → encrypted app-password decrypt
→ PDS session → com.atproto.repo.createRecord with the community DID as
repo → Jetstream → indexer. Each published record gets a 4-way check
(200 from putRecord, exists at PDS, indexed, indexed did is the community
DID). Plus authz (non-publisher 403), delete roundtrip, and minted
community returns NotSupported.
Shared infrastructure (login, callAs factory, jsonOr, app-password mint,
devnet-rewrite fetch, getRecordFromPds) lives in helpers.ts so each new
test file imports rather than copying.
Two trust assumptions in the binding layer cannot be closed at the
Contrail layer alone:
- DID-doc service-entry rebind: deployments using
createDidDocBindingResolver inherit PLC's rotation-key authorization
model. Any rotation key on the owner's account can rewrite the
#atproto_space_authority service entry.
- PDS app password held for managed communities: community.provision
and community.adopt hold unscoped app passwords that can write any
record on the user-owned PDS, including the binding declaration
read by createPdsBindingResolver.
Both are constraints of their respective binding sources and
onboarding flows, not bugs. Document each so operators picking a
binding strategy can size them against their own threat model, with
pointers to the canonical issues for the upstream/protocol-level
work that would close them: #38 (DID-doc), #39 (PDS app password).
The PDS binding resolver trusted body.value.authority on any record
returned for the space's URI, with only a startsWith('did:') sniff. A
malformed or attacker-shaped record at the same NSID could rebind
authority — record content alone, no signature verification.
Validate before trusting:
- $type must equal the URI's type (the space-type lexicon embeds the
declaration fields per tools.atmo.space.declaration's contract)
- createdAt must be a string
- authority must match a strict did:plc | did:web regex
Fail closed on any deviation. Adds three negative tests covering each
deviation path.
The default in-process credential verifier composed
[Enrollment, Local], so a never-enrolled space still resolved its
authority via LocalBindingResolver — collapsing the host-side consent
layer the enrollment table is meant to provide.
Default to enrollment-only. createLocalBindingResolver remains exported
for explicit single-tenant wiring via options.credentialVerifier.
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.
Main added record-filter, identity-events, follow-feed, and lex-gen-fix
features in `packages/contrail/src/core/*` that PR #30 had reorganized
into the new package split. Each conflict resolved by:
1. Keeping PR #30's re-export shims in `packages/contrail/src/core/*`
(these files now `export * from "@atmo-dev/contrail-appview"`)
2. Porting main's additions to the corresponding files in
`packages/contrail-appview/src/core/*`, which is the new home of the
actual implementation.
Files re-shimmed:
- backfill.ts, db/records.ts, db/schema.ts, identity.ts, jetstream.ts,
persistent.ts, router/feed.ts, types.ts
Ports landed in contrail-appview/src/core (or contrail-base where types
and identity helpers actually live):
- backfill.ts, db/records.ts, db/schema.ts, jetstream.ts, persistent.ts,
router/feed.ts (+1.0K lines from main)
- constellation.ts (new 181-line module)
- contrail-base/src/types.ts: FeedTargetConfig, ConstellationConfig,
buildFeedTargetCaps, normalizeFeedTarget, feedTargetMaxItems,
CollectionConfig.{timeField, subjectField, recordFilter}, and
resolveConfig defaulting `discover: false` for `app.bsky.*`
- contrail-base/src/identity.ts: applyIdentityEvent (handle change handler)
Test mock for `persistent.test.ts` updated to mock
`@atmo-dev/contrail-base` (post-split home of identity helpers) and
include both `refreshStaleIdentities` and `applyIdentityEvent`.
Test status post-merge: 314 passed, 7 failed in
`packages/contrail/tests/search.test.ts` — these 7 failures pre-exist on
plain `origin/main` and are unrelated to the merge.
No behavior changes intended beyond what main and PR #30 already deliver.
Bring the e2e suite back to green under the package split:
- makeSpacesConfig() wraps the old flat { type, serviceDid, resolver }
shape under authority / recordHost and generates a fresh signing key
per call. The config validator now requires spaces.authority whenever
community is set, so this is the minimum viable shape for any test
that uses spaces.
- setupCommunityContrail() wires the community module via
createCommunityIntegration({ db, config: resolveConfig(...) }) and
passes the result as communityIntegration to the Contrail constructor.
The community config field is now opaque to contrail core; routes are
registered through the integration option.
- 7 test files updated to use the helpers; 4 community tests pick up a
workspace dep on @atmo-dev/contrail-community.
35/35 e2e tests pass against devnet. No source-package changes.
space.revoke and space.setAccessLevel now return 409 Conflict with
reason "last-owner" when the operation would leave a space with zero
owners. On the $admin space this would otherwise leave the entire
community ownerless and unmanageable; on any space it leaves it without
the only role that can grant `owner`. Hand off ownership first by
promoting a successor.
Flips the two it.fails probes in community-lifecycle.test.ts to
passing, with assertions on the specific 409 / "last-owner" shape.
Covers <ns>.community.delete against real Postgres:
- Owner soft-deletes; communities.deleted_at + every owned space's
deleted_at flip together (verified by direct SQL on the test pool,
so the cascade is observed at the source-of-truth layer).
- <ns>.community.list filtering is real SQL — the deleted community
drops out of the listing without any in-memory bookkeeping.
- getCommunity's deleted_at filter blocks subsequent endpoints:
community.space.create returns 404 community-not-found.
- owner-required is enforced — an admin in $admin (strictly below
owner) gets 403, and so does a stranger; the community stays alive.
The router endpoint and softDeleteCommunity adapter method had no
unit-test coverage for delete; these e2e tests fill that gap and add
real-DB cascade verification on top.
The router-level unit tests in invite-unified.test.ts cover dispatch and
input validation against a fakeAuth middleware and mocked PDS. This file
exercises what those can't:
- Real service-auth JWTs minted via com.atproto.server.getServiceAuth on
the devnet PDS, verified through PLC-resolved keys by the real auth
middleware.
- The reconcile cascade. <ns>.invite.redeem on a community-owned space
fires community.grant + reconcile, and the resulting access must show
up in BOTH the community ACL and spaces.access_rows. Verified by
reading community.space.listMembers (ACL view) and the same endpoint
with flatten=true (spaces table view).
- Real Postgres enforcement of single-use (maxUses=1) and revoked
invites — the redeem query is the source of truth, not in-memory.
Both invite paths covered: community-owned (accessLevel) and user-owned
(kind=join, no community module touched).
4 tests, ~3s. Stacked on top of the lifecycle/publishing tests so the
shared helpers (login, createCaller, jsonOr) are reused without
duplication.
Two new test files exercising the community module end-to-end against the
local devnet stack:
- community-lifecycle.test.ts: mint → bootstrap reserved spaces → grant →
list → setAccessLevel → revoke → ownership handoff. Pins the missing
last-owner guard with two it.fails probes that flip to passing once the
guard lands.
- community-publishing.test.ts: caller JWT → encrypted app-password decrypt
→ PDS session → com.atproto.repo.createRecord with the community DID as
repo → Jetstream → indexer. Each published record gets a 4-way check
(200 from putRecord, exists at PDS, indexed, indexed did is the community
DID). Plus authz (non-publisher 403), delete roundtrip, and minted
community returns NotSupported.
Shared infrastructure (login, callAs factory, jsonOr, app-password mint,
devnet-rewrite fetch, getRecordFromPds) lives in helpers.ts so each new
test file imports rather than copying.