Commits
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.
Florian's #20 (469bf65) renamed the space URI scheme from at:// to ats://
across the codebase but didn't update the regex in this assertion (added
in PR #18 after that rename was already in flight).
Verified: pnpm test:e2e in apps/contrail-e2e — 12/12 passing.
# Conflicts:
# pnpm-lock.yaml
add e2e tests for spaces auth path and privacy invariants
fix: resolve config defensively in raw runPersistent export
fix: flush buffered events while Jetstream is idle
Three new tests in apps/contrail-e2e, each running in-process against a
real devnet PDS + PLC + Postgres with spaces enabled:
- spaces-auth.test.ts: real service-auth JWT minted via
com.atproto.server.getServiceAuth is accepted; wrong aud and wrong
lxm binding are rejected. Exercises the full verifier path (PDS
signs with the user's PLC-published key, Contrail resolves the key
from devnet PLC, @atcute/xrpc-server verifies the signature) — the
existing package-level spaces tests all use a fakeAuth header and
never touch this path.
- spaces-firehose-invisibility.test.ts: records written via
{ns}.space.putRecord do not appear on the ATProto firehose. Uses a
direct-to-PDS createRecord as a positive control so a silently
broken subscriber can't make the negative assertion vacuous.
- spaces-table-isolation.test.ts: a space putRecord lands in
spaces_records_<collection> with the public records_<collection>
table empty for the same caller — proves the store-level separation
that the spaces privacy model depends on.
helpers.ts gains CONTRAIL_SERVICE_DID, createDevnetResolver (PlcDid-
DocumentResolver pointed at devnet PLC), and mintServiceAuthJwt. Adds
@atcute/identity-resolver, @atcute/jetstream, and @atcute/lexicons as
direct devDependencies.
verifier.verify returns a structured {error, description} AuthError on
failure, but the middleware stringified it as `String(result.error)`,
producing "[object Object]" in the 401 body. Real rejection reasons
("jwt lexicon method does not match", "invalid signature",
"could not resolve did document", etc.) were invisible to callers.
Surface the description (or the error name as fallback) so 401 bodies
carry a human-readable reason.
The exported runPersistent(db, config, options) from @atmo-dev/contrail
silently skipped grouped relation count updates when passed an
unresolved ContrailConfig. Internals (applyEvents' buildBatchCountStatements,
query planning, etc.) read `config._resolved?.relations[...]?.groups` —
if `_resolved` was missing, the grouped-counts loop never ran, so columns
like count_rsvp_going stayed at 0 while total count_rsvp updated normally.
The Contrail class constructor resolves the config, so callers going
through `new Contrail(...).runPersistent()` were fine. Only the raw
export was affected — a real footgun for library consumers.
Fix: mirror what Contrail's constructor does and defensively call
resolveConfig when `_resolved` is absent. resolveConfig is idempotent,
so this is a no-op for already-resolved configs.
Regression test in packages/contrail/tests/persistent.test.ts passes a
raw ContrailConfig with a grouped relation and asserts
count_rsvp_going = 1. Confirmed to fail without this patch.
Reverts the Contrail.runPersistent() workaround in the postgres-devnet
e2e tests — they now call the raw runPersistent export directly, same
as any external library consumer would.
New app under apps/contrail-e2e that brings up a full sealed local
ATProto network (PDS, PLC, Jetstream, TAP, Postgres, maildev) via
docker-compose and runs three end-to-end test files against it:
- health.test.ts — service health checks
- ingest-roundtrip.test.ts — publish → index roundtrip, verifying
a calendar event and an RSVP land in the DB and that the grouped
rsvpsGoingCount is populated via an in-process XRPC handler
- cursor-resume.test.ts — regression for runPersistent's durable
cursor. Publishes A, stops the ingester, publishes B + updates B
+ deletes A, restarts, and asserts the saved cursor replays the
gap. The basic roundtrip can't catch a broken cursor because
events always arrive while the ingester is live.
Each test stands up its own runPersistent and XRPC handler
in-process against an isolated pg schema (search_path pinned per
test), so the suite works with just pnpm stack:up and doesn't
fight a dogfooding ingester writing to public.
streamAndFlush only re-checked the flush timer inside the main loop
after iterator.next() resolved, so a trickle of events followed by a
quiet Jetstream would strand the buffer until the next event or abort.
The final flush in the finally block masked the bug in tests that
abort.
Replace the in-loop flush race with a setInterval-driven periodic
flush. The main loop now only races iterator.next() against abort,
and flushes are decoupled from loop tempo. Timer-flush errors log
and retry next interval rather than propagate — a transient DB
hiccup shouldn't force a Jetstream reconnect.
Adds a regression test that forces the timer path by setting
batchSize well above the event count and asserting records are
present BEFORE the controller aborts.
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.
# Conflicts:
# pnpm-lock.yaml
Three new tests in apps/contrail-e2e, each running in-process against a
real devnet PDS + PLC + Postgres with spaces enabled:
- spaces-auth.test.ts: real service-auth JWT minted via
com.atproto.server.getServiceAuth is accepted; wrong aud and wrong
lxm binding are rejected. Exercises the full verifier path (PDS
signs with the user's PLC-published key, Contrail resolves the key
from devnet PLC, @atcute/xrpc-server verifies the signature) — the
existing package-level spaces tests all use a fakeAuth header and
never touch this path.
- spaces-firehose-invisibility.test.ts: records written via
{ns}.space.putRecord do not appear on the ATProto firehose. Uses a
direct-to-PDS createRecord as a positive control so a silently
broken subscriber can't make the negative assertion vacuous.
- spaces-table-isolation.test.ts: a space putRecord lands in
spaces_records_<collection> with the public records_<collection>
table empty for the same caller — proves the store-level separation
that the spaces privacy model depends on.
helpers.ts gains CONTRAIL_SERVICE_DID, createDevnetResolver (PlcDid-
DocumentResolver pointed at devnet PLC), and mintServiceAuthJwt. Adds
@atcute/identity-resolver, @atcute/jetstream, and @atcute/lexicons as
direct devDependencies.
verifier.verify returns a structured {error, description} AuthError on
failure, but the middleware stringified it as `String(result.error)`,
producing "[object Object]" in the 401 body. Real rejection reasons
("jwt lexicon method does not match", "invalid signature",
"could not resolve did document", etc.) were invisible to callers.
Surface the description (or the error name as fallback) so 401 bodies
carry a human-readable reason.
The exported runPersistent(db, config, options) from @atmo-dev/contrail
silently skipped grouped relation count updates when passed an
unresolved ContrailConfig. Internals (applyEvents' buildBatchCountStatements,
query planning, etc.) read `config._resolved?.relations[...]?.groups` —
if `_resolved` was missing, the grouped-counts loop never ran, so columns
like count_rsvp_going stayed at 0 while total count_rsvp updated normally.
The Contrail class constructor resolves the config, so callers going
through `new Contrail(...).runPersistent()` were fine. Only the raw
export was affected — a real footgun for library consumers.
Fix: mirror what Contrail's constructor does and defensively call
resolveConfig when `_resolved` is absent. resolveConfig is idempotent,
so this is a no-op for already-resolved configs.
Regression test in packages/contrail/tests/persistent.test.ts passes a
raw ContrailConfig with a grouped relation and asserts
count_rsvp_going = 1. Confirmed to fail without this patch.
Reverts the Contrail.runPersistent() workaround in the postgres-devnet
e2e tests — they now call the raw runPersistent export directly, same
as any external library consumer would.
New app under apps/contrail-e2e that brings up a full sealed local
ATProto network (PDS, PLC, Jetstream, TAP, Postgres, maildev) via
docker-compose and runs three end-to-end test files against it:
- health.test.ts — service health checks
- ingest-roundtrip.test.ts — publish → index roundtrip, verifying
a calendar event and an RSVP land in the DB and that the grouped
rsvpsGoingCount is populated via an in-process XRPC handler
- cursor-resume.test.ts — regression for runPersistent's durable
cursor. Publishes A, stops the ingester, publishes B + updates B
+ deletes A, restarts, and asserts the saved cursor replays the
gap. The basic roundtrip can't catch a broken cursor because
events always arrive while the ingester is live.
Each test stands up its own runPersistent and XRPC handler
in-process against an isolated pg schema (search_path pinned per
test), so the suite works with just pnpm stack:up and doesn't
fight a dogfooding ingester writing to public.
streamAndFlush only re-checked the flush timer inside the main loop
after iterator.next() resolved, so a trickle of events followed by a
quiet Jetstream would strand the buffer until the next event or abort.
The final flush in the finally block masked the bug in tests that
abort.
Replace the in-loop flush race with a setInterval-driven periodic
flush. The main loop now only races iterator.next() against abort,
and flushes are decoupled from loop tempo. Timer-flush errors log
and retry next interval rather than propagate — a transient DB
hiccup shouldn't force a Jetstream reconnect.
Adds a regression test that forces the timer path by setting
batchSize well above the event count and asserting records are
present BEFORE the controller aborts.