···11+---
22+"@atmo-dev/contrail": minor
33+"@atmo-dev/contrail-sync": minor
44+---
55+66+unify the per-space marker field on records as `space` everywhere. previously `listRecords` / `getRecord` HTTP responses used `space: <spaceUri>` while watch events and `WatchRecord` exposed it as `_space`. the underscored form was inconsistent with the surrounding fields (`uri`, `cid`, `did`, etc.) and forced consumers to remember which path produced which name.
77+88+**breaking.** anywhere you read `r._space` on a `WatchRecord` (or a watch event payload's `record._space` / `child._space`), rename to `r.space`. drop-in.
99+1010+```ts
1111+// before
1212+if (record._space) ...
1313+1414+// after
1515+if (record.space) ...
1616+```
1717+1818+no migration needed for `listRecords` / `getRecord` consumers — that path was already `space`.
+20
.changeset/space-uri-scheme.md
···11+---
22+"@atmo-dev/contrail": minor
33+"@atmo-dev/contrail-sync": minor
44+"@atmo-dev/contrail-lexicons": minor
55+---
66+77+permissioned spaces now use the `ats://` scheme instead of `at://`. tracks the [permissioned data spec](https://dholms.leaflet.pub/3mhj6bcqats2o), which floats `ats://` as a distinct scheme so spaces can't be confused with atproto record URIs at any layer (logs, query params, dispatch, error messages).
88+99+```
1010+- at://did:plc:alice/com.example.event.space/birthday
1111++ ats://did:plc:alice/com.example.event.space/birthday
1212+```
1313+1414+what changed:
1515+- `buildSpaceUri` / `parseSpaceUri` (`@atmo-dev/contrail`) emit / accept `ats://`. anything else returns `null` from `parseSpaceUri`.
1616+- generated lexicons no longer claim `format: "at-uri"` on `spaceUri` params, on the `space` record-output field, or on `spaceView.uri` — they're plain `string`. (atproto's `at-uri` format would reject `ats://`.) regenerate committed `lexicons/generated/*` with `contrail-lex generate`; downstream `lex-cli generate` then emits `v.string()` instead of `v.resourceUriString()` for those fields.
1717+- realtime topics are unchanged in shape (`space:<uri>`), but `<uri>` is now an `ats://` URI.
1818+- record URIs (the `uri` on a record, the `appPolicyRef` field, `notifyOfUpdate` payloads) keep `at://` — those are still atproto record URIs.
1919+2020+**breaking.** anywhere you build a space URI by string concatenation (`` `at://${did}/${type}/${key}` ``), switch to `ats://` or call `buildSpaceUri()`. anywhere you persist space URIs in your own DB, migrate (`UPDATE … SET space_uri = REPLACE(space_uri, 'at://', 'ats://') WHERE space_uri LIKE 'at://%'`).
···203203| `spaces` | — | See [Spaces](./05-spaces.md) |
204204| `community` | — | See [Communities](./06-communities.md) |
205205| `realtime` | — | See [Sync](./07-sync.md) |
206206+| `labels` | — | See [Labels](./08-labels.md) |
+23-11
docs/02-querying.md
···77| `{namespace}.{short}.listRecords` | Paginated list with filters, sorts, hydration |
88| `{namespace}.{short}.getRecord?uri=…` | Single record by AT-URI |
991010-Plus a few top-level ones: `{namespace}.getProfile`, `{namespace}.getCursor`, `{namespace}.getOverview`, `{namespace}.notifyOfUpdate`, `{namespace}.permissionSet`.
1010+Plus a few top-level ones: `{namespace}.getProfile`, `{namespace}.getCursor`, `{namespace}.getOverview`, `{namespace}.notifyOfUpdate`, `{namespace}.permissionSet`, `{namespace}.lexicons`.
11111212## HTTP (what most callers use)
1313···7373{
7474 "uri": "at://did:plc:.../community.lexicon.calendar.event/...",
7575 "cid": "...",
7676- "record": { "name": "Rust meetup", "startsAt": "2026-03-16T...", ... },
7676+ "value": { "name": "Rust meetup", "startsAt": "2026-03-16T...", ... },
7777 "rsvpsCount": 42, // from relations
7878 "rsvpsGoingCount": 30,
7979 // relations + references appear here only when hydrated
8080}
8181```
8282+8383+The `value` field carries the record body — same shape as atproto's `com.atproto.repo.listRecords#record`. `did`, `collection`, `rkey`, and `time_us` are also returned alongside as optional extras.
82848385### `?hydrateRel=N` (relations)
8486···9496{
9597 "records": [{
9698 "uri": "at://.../event/...",
9797- "record": { "name": "..." },
9999+ "value": { "name": "..." },
98100 "rsvpsCount": 42,
99101 "rsvps": {
100100- "going": [ {uri, record}, ... 5 items ],
101101- "interested":[ {uri, record}, ... 5 items ]
102102+ "going": [ {uri, cid, value}, ... 5 items ],
103103+ "interested":[ {uri, cid, value}, ... 5 items ]
102104 }
103105 }]
104106}
···114116/xrpc/com.example.rsvp.listRecords?subjectUri=at://.../event/...&hydrateEvent=true
115117```
116118117117-Each RSVP record in the response gains an `event: {uri, cid, record}` field.
119119+Each RSVP record in the response gains an `event: {uri, cid, value}` field.
118120119121### `?profiles=true`
120122···124126/xrpc/com.example.event.listRecords?profiles=true
125127```
126128127127-Response grows a top-level `profiles` map keyed by DID:
129129+Response grows a top-level `profiles` array, one entry per (DID, configured profile NSID):
128130129131```jsonc
130132{
131133 "records": [...],
132132- "profiles": {
133133- "did:plc:alice...": { "handle": "alice.bsky.social", "record": {...} }
134134- }
134134+ "profiles": [
135135+ {
136136+ "did": "did:plc:alice...",
137137+ "handle": "alice.bsky.social",
138138+ "uri": "at://did:plc:alice.../app.bsky.actor.profile/self",
139139+ "cid": "...",
140140+ "collection": "app.bsky.actor.profile",
141141+ "rkey": "self",
142142+ "value": { /* profile record body */ }
143143+ }
144144+ ]
135145}
136146```
137147138138-Which profile NSID to hydrate from is configured at the top level of Contrail's config (`profiles`, defaults to `["app.bsky.actor.profile"]`).
148148+A DID with no profile record (or whose handle resolved but profile didn't) shows up as a bare `{ did, handle }` entry — `uri`/`cid`/`value` are omitted. With multiple profile NSIDs configured, you'll see one entry per (DID × NSID) that resolved.
149149+150150+Which profile NSID(s) to hydrate from is configured at the top level of Contrail's config (`profiles`, defaults to `["app.bsky.actor.profile"]`).
139151140152## Full-text search
141153
+5-7
docs/04-auth.md
···77777878Realtime subscriptions (`watchRecords`) can't use regular service-auth JWTs for two reasons: the WebSocket upgrade can't carry arbitrary headers, and an open socket would outlive a 60s JWT TTL. So contrail uses separate short-lived tickets.
79798080-Server-side minting:
8080+Server-side minting comes in two flavours:
81818282-```
8383-com.example.realtime.ticket { spaceUri }
8484- → { ticket: "...", expiresAt: 1234567890 }
8585-```
8282+- `com.example.realtime.ticket` — POST `{ topic }` (e.g. `"space:ats://..."`) → `{ ticket, topics, expiresAt }`. Bare topic-list ticket, used with the generic `<ns>.realtime.subscribe` endpoint.
8383+- `<collection>.watchRecords?mode=ws&spaceUri=…` (or `&actor=…`) handshake — returns `{ snapshot, ticket, wsUrl, sinceTs, ticketTtlMs, querySpec }`. The ticket is bound to `(did, topics, querySpec)` and is the one to use for the per-collection `watchRecords` stream — both for SSE (`?ticket=…`) and the subsequent WS upgrade.
86848787-The ticket is signed by `realtime.ticketSecret` (a 32-byte random, configured once), bound to `(did, spaceUri, querySpec)`. Client uses it on the SSE or WS handshake via `?ticket=...`.
8585+Both flavours are signed by `realtime.ticketSecret` (a 32-byte random, configured once). Clients hand the ticket off via `?ticket=...` on connect.
88868987In the `@atmo-dev/contrail-sync` client:
90889189```ts
9290createWatchStore({
9393- url: "/xrpc/com.example.message.watchRecords?spaceUri=at://...",
9191+ url: "/xrpc/com.example.message.watchRecords?spaceUri=ats://...",
9492 mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()),
9593});
9694```
+2-2
docs/05-spaces.md
···6677> A **space** is a bag of records with one lock. The **member list** says who has the key.
8899-- One owner (DID), one type (NSID), one key. Identified by `at://<owner>/<type>/<key>`.
99+- One owner (DID), one type (NSID), one key. Identified by `ats://<owner>/<type>/<key>` — distinct scheme from atproto record URIs (`at://`) so the two can't be confused at any layer.
1010- Every member (including owner) has read + write inside the space. Delete is scoped to your own records — no one can remove records they didn't author, owner included. To wipe everything, delete the space.
1111- Optional **app policy** gates which OAuth clients can act in the space.
1212···5151| `?spaceUri=…` + JWT | one space (ACL-gated) |
5252| JWT, no `spaceUri` | public **unioned** with every space the caller is a member of |
53535454-Filters, sorts, hydration, and references work across all three. Records from a space carry a `space: <spaceUri>` field.
5454+Filters, sorts, hydration, and references work across all three. Records from a space carry a `space: <spaceUri>` field — same on `listRecords`/`getRecord` responses and `watchRecords` stream events.
55555656## Invites
5757
+3-3
docs/06-communities.md
···8899## Two modes
10101111-- **Minted** — contrail creates a fresh `did:plc` for the community, holds the signing + rotation keys, publishes from it.
1212-- **Adopted** — contrail takes over an existing DID whose rotation keys were handed over by the owner.
1111+- **Minted** — contrail creates a fresh `did:plc` for the community, holds the signing key plus one rotation key (a second rotation key is returned to the creator once for recovery), and publishes from it.
1212+- **Adopted** — contrail takes over an existing account by holding an **app password** issued from its PDS. The owner's identity, signing key, and rotation keys are unchanged; contrail just gets PDS write access via the app password.
13131414Either way, the result is the same: a DID that multiple members can act through, gated by access levels.
1515···49495050- No per-record per-level ACLs. Model as spaces.
5151- No auto-rotation on key compromise yet.
5252-- Adoption is irreversible without manual key surrender back to the owner.
5252+- Adoption can be revoked unilaterally by the owner — they revoke the app password on their PDS and contrail loses write access. (Mint mode is the irreversible one: the creator's recovery rotation key, returned once at mint time, is the only path back if contrail's signing/rotation key is compromised.)
53535454The design follows zicklag's [Arbiter design sketch](https://zicklag.leaflet.pub/3mjrvb5pul224) for group management on atproto. The post is an early design note; our implementation will track it as the spec firms up.
···11+# Labels
22+33+Atproto-native moderation hydration. Subscribe to one or more labelers, index their labels, and attach them to records and profiles in your XRPC responses. Opt-in; zero cost if you don't enable it.
44+55+## Mental model
66+77+> A **label** is a `(src, uri, val)` triple authored by a labeler DID. A **labeler** is a regular atproto account that publishes signed annotations about other accounts and records via `com.atproto.label.subscribeLabels`.
88+99+- One contrail deployment can subscribe to many labelers.
1010+- The caller of your XRPC picks which subset to honor per request via the `atproto-accept-labelers` header (or `?labelers=` query param when headers are awkward — SSE/WS).
1111+- Labels hydrate onto every `listRecords`, `getRecord`, `getProfile`, and `?profiles=true` response without changing your collection config.
1212+- This module only consumes labels. Producing them — your appview emitting its own labels — is a separate question. See *Future work* below.
1313+1414+## Enable
1515+1616+```ts
1717+import type { ContrailConfig } from "@atmo-dev/contrail";
1818+1919+const config: ContrailConfig = {
2020+ namespace: "com.example",
2121+ collections: { /* ... */ },
2222+ labels: {
2323+ sources: [
2424+ { did: "did:plc:ar7c4by46qjdydhdevvrndac" }, // bsky moderation
2525+ { did: "did:plc:newsmast" },
2626+ ],
2727+ },
2828+};
2929+```
3030+3131+`initSchema` creates a `labels` table and a `labeler_cursors` table. Both live on the main DB; nothing per-collection.
3232+3333+## Caller selection
3434+3535+Per request, contrail picks accepted labelers in this order:
3636+3737+1. `atproto-accept-labelers: did:plc:a, did:plc:b` — the spec's HTTP header.
3838+2. `?labelers=did:plc:a,did:plc:b` — fallback for transports that can't set headers easily.
3939+3. `config.labels.defaults` — operator policy.
4040+4. Every entry in `config.labels.sources`.
4141+4242+The list is intersected with what's actually configured (unknowns dropped — see [`allowUserSupplied`](#allowusersupplied) below) and capped at `maxPerRequest` (default 20). Contrail echoes the applied set back via `atproto-content-labelers`.
4343+4444+```
4545+GET /xrpc/com.example.event.listRecords
4646+ atproto-accept-labelers: did:plc:ar7c4by46qjdydhdevvrndac
4747+```
4848+4949+→
5050+5151+```jsonc
5252+// Response: atproto-content-labelers: did:plc:ar7c4by46qjdydhdevvrndac
5353+{
5454+ "records": [
5555+ {
5656+ "uri": "at://did:plc:.../com.example.event/...",
5757+ "value": { /* ... */ },
5858+ "labels": [
5959+ {
6060+ "src": "did:plc:ar7c4by46qjdydhdevvrndac",
6161+ "uri": "at://did:plc:.../com.example.event/...",
6262+ "val": "spam",
6363+ "cts": "2026-04-25T00:00:00.000Z"
6464+ }
6565+ ]
6666+ }
6767+ ]
6868+}
6969+```
7070+7171+`labels` matches `com.atproto.label.defs#label` field-for-field — pass it straight to atproto SDK moderation helpers.
7272+7373+### `allowUserSupplied`
7474+7575+Default: `false` — caller-supplied DIDs that aren't in `sources` are silently dropped. Set `true` to honor them anyway. The current request still only returns labels for already-indexed sources; lazy registration of new labelers is future work.
7676+7777+### `defaults: []`
7878+7979+Set defaults to an empty array if you want strict opt-in: callers that send no header / param see no labels at all.
8080+8181+## Hydration semantics
8282+8383+For each `(src, uri, val)` tuple visible to the caller, hydration picks the row with the highest `cts`. If that row has `neg=true`, the label is treated as retracted and dropped. Expired rows (`exp` past `now`) are filtered at the SQL level. CID-pinned labels apply only when the indexed record's CID matches.
8484+8585+Account-level labels (subject = bare DID) hydrate onto profiles. They appear inside each `ProfileEntry.labels` of the `profiles` array on `?profiles=true` responses, and on `getProfile`.
8686+8787+## Ingestion
8888+8989+`com.atproto.label.subscribeLabels` is a per-labeler WebSocket firehose with a CBOR frame envelope. Contrail mirrors its existing Jetstream pipeline:
9090+9191+| Mode | Function | When |
9292+|---|---|---|
9393+| Cron-driven | `contrail.ingestLabels()` | Cloudflare Workers — one drain per cron tick |
9494+| Persistent | `contrail.runPersistentLabels()` | Node / long-lived servers — one socket per labeler, auto-reconnect |
9595+| One-shot backfill | `pnpm contrail labels-backfill [--remote]` | Local script, drains until each labeler reports caught up |
9696+9797+When `config.labels` is set, the bundled `createWorker` already calls `ingestLabels()` from `scheduled()` alongside `ingest()` — no boilerplate.
9898+9999+```ts
100100+// node / long-lived
101101+const ac = new AbortController();
102102+await Promise.all([
103103+ contrail.runPersistent({ signal: ac.signal }),
104104+ contrail.runPersistentLabels({ signal: ac.signal }),
105105+]);
106106+```
107107+108108+Per-labeler cursors live in `labeler_cursors` (`{did, cursor, endpoint, resolved_at}`). Endpoints are resolved from the DID doc's `service[id="#atproto_labeler"]` and cached for 6h. On `#info { name: "OutdatedCursor" }` frames, contrail resets the cursor to `0` so the next cycle re-backfills.
109109+110110+### `backfill: false`
111111+112112+Per source. Default: backfill from `cursor=0` on first sight. Set `false` to start at "now" — useful for very chatty labelers where you don't need history.
113113+114114+```ts
115115+labels: {
116116+ sources: [{ did: "did:plc:somenoisylabeler", backfill: false }],
117117+}
118118+```
119119+120120+## Storage
121121+122122+```sql
123123+CREATE TABLE labels (
124124+ src TEXT NOT NULL, -- labeler DID
125125+ uri TEXT NOT NULL, -- subject: at://... or did:...
126126+ val TEXT NOT NULL, -- label value
127127+ cid TEXT, -- optional record-version pin
128128+ neg INTEGER NOT NULL DEFAULT 0,
129129+ exp INTEGER, -- expiry, unix sec
130130+ cts INTEGER NOT NULL, -- creation time, unix sec
131131+ sig BLOB, -- signature bytes (stored, not verified in v1)
132132+ PRIMARY KEY (src, uri, val, cts)
133133+);
134134+```
135135+136136+The PK includes `cts`, so a `neg=true` retraction is a *new row* that replaces the previous decision via the read-time collapse rule above — never an in-place mutation. This matches the spec, tolerates out-of-order delivery, and survives a labeler that flip-flops.
137137+138138+## What's not here
139139+140140+- **Signature verification.** `sig` is stored if the labeler supplies it, but contrail does not verify it in v1. Document as TODO; most appviews skip it.
141141+- **Live label updates on `watchRecords`.** The realtime stream snapshots labels with the initial query but does not push label deltas. Adding this means publishing `labels:<src>` topic events from the ingest worker and merging them in `runQueryStream` — future work.
142142+- **Spaces / community labels.** Hydration already runs on the spaces read paths, so labels emitted by a labeler-DID member of a space (or under a community DID) will show up if you write them to the `labels` table. The auth surface for "make this DID a labeler in this space" is not yet exposed as XRPCs.
143143+- **Outbound `subscribeLabels`.** Contrail does not republish labels. Communities-as-labelers (using the community DID as `src`) is a natural extension once you want to act as a labeler instead of just consume.
144144+- **Label definitions / preferences UX.** Custom label names, blur behaviors, severity, and per-user preference state belong on the *client*, fetched directly from each labeler. Contrail intentionally stays out of this.
145145+146146+## Design
147147+148148+Follows the [atproto label spec](https://atproto.com/specs/label) literally. The wire format on responses matches `com.atproto.label.defs#label` so existing atproto SDKs can consume it directly. Storage is the data model normalized into rows; ingestion mirrors Jetstream both in code shape and in operator UX.
+1
todo/other-stuff.md
···11+- allow running custom functions before ingestion (e.g. for filtering out unallowed writes)
···278278 expect(createRes.status).toBe(200);
279279 const bridged = ((await createRes.json()) as any).space.uri as string;
280280281281- const firstAdmin = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`;
281281+ const firstAdmin = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`;
282282 expect((await grant(app, DIANA, bridged, { spaceUri: firstAdmin }, "member")).status).toBe(200);
283283284284 // Alice has no direct grant in the second community, but is reachable via
+8-8
packages/contrail/tests/community-e2e.test.ts
···131131 it("adopts a community and creates reserved spaces with creator as owner", async () => {
132132 const did = await adopt(app, ALICE);
133133134134- const adminUri = `at://${did}/tools.atmo.event.space/$admin`;
135135- const publishersUri = `at://${did}/tools.atmo.event.space/$publishers`;
134134+ const adminUri = `ats://${did}/tools.atmo.event.space/$admin`;
135135+ const publishersUri = `ats://${did}/tools.atmo.event.space/$publishers`;
136136137137 // whoami in both reserved spaces → owner
138138 for (const uri of [adminUri, publishersUri]) {
···171171 });
172172 expect(res.status).toBe(200);
173173 const body = (await res.json()) as any;
174174- expect(body.space.uri).toBe(`at://${COMMUNITY_DID}/tools.atmo.event.space/general`);
174174+ expect(body.space.uri).toBe(`ats://${COMMUNITY_DID}/tools.atmo.event.space/general`);
175175 expect(body.space.ownerDid).toBe(COMMUNITY_DID);
176176 });
177177···193193 });
194194195195 it("owner grants Bob member access to #general; reconciler populates spaces_members", async () => {
196196- const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`;
196196+ const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`;
197197 const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, {
198198 spaceUri,
199199 subject: { did: BOB },
···214214 });
215215216216 it("manager cannot grant higher than own level", async () => {
217217- const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`;
217217+ const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`;
218218 // Promote Bob to manager
219219 await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, {
220220 spaceUri,
···232232 });
233233234234 it("grant cannot downgrade a subject who outranks the caller", async () => {
235235- const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`;
235235+ const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`;
236236 // Bob is currently manager (promoted earlier in this describe block).
237237 // Alice is owner of the space. Bob tries to downgrade Alice to member via grant.
238238 const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", BOB, {
···245245 });
246246247247 it("revokes and reconciler removes from spaces_members", async () => {
248248- const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`;
248248+ const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`;
249249 const res = await call(app, "POST", "/xrpc/test.comm.community.space.revoke", ALICE, {
250250 spaceUri,
251251 subject: { did: BOB },
···262262 });
263263264264 it("cannot delete a reserved space", async () => {
265265- const adminUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`;
265265+ const adminUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`;
266266 const res = await call(app, "POST", "/xrpc/test.comm.community.space.delete", ALICE, {
267267 spaceUri: adminUri,
268268 });
+1-1
packages/contrail/tests/community-mint.test.ts
···149149 expect(ourCalls[0]!.body.rotationKeys).toHaveLength(2);
150150151151 // Reserved spaces exist with the caller as owner.
152152- const adminUri = `at://${body.communityDid}/tools.atmo.event.space/$admin`;
152152+ const adminUri = `ats://${body.communityDid}/tools.atmo.event.space/$admin`;
153153 const whoami = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(adminUri)}`, ALICE);
154154 expect(((await whoami.json()) as any).accessLevel).toBe("owner");
155155 });
···220220 ? {
221221 space: {
222222 type: "string",
223223- format: "at-uri",
224224- description: "Present when the record was read from a permissioned space; its value is the space URI.",
223223+ description: "Present when the record was read from a permissioned space; its value is the `ats://` space URI.",
225224 },
226225 }
227226 : {}),
···279278 ? {
280279 space: {
281280 type: "string",
282282- format: "at-uri",
283283- description: "Present when the record was read from a permissioned space.",
281281+ description: "Present when the record was read from a permissioned space; `ats://` URI.",
284282 },
285283 }
286284 : {}),
···320318 ? {
321319 space: {
322320 type: "string",
323323- format: "at-uri",
324324- description: "Present when the record was read from a permissioned space.",
321321+ description: "Present when the record was read from a permissioned space; `ats://` URI.",
325322 },
326323 }
327324 : {}),
···591588 ? {
592589 spaceUri: {
593590 type: "string",
594594- format: "at-uri",
595595- description: "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token).",
591591+ description: "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token). `ats://` URI.",
596592 },
597593 byUser: {
598594 type: "string",
···725721 ? {
726722 spaceUri: {
727723 type: "string",
728728- format: "at-uri",
729729- description: "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token).",
724724+ description: "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token). `ats://` URI.",
730725 },
731726 inviteToken: {
732727 type: "string",
+1-1
packages/sync/src/index.ts
···2222 time_us?: number;
2323 indexed_at?: number;
2424 /** Set when the record originates from a per-space table. */
2525- _space?: string;
2525+ space?: string;
2626 /** Present on optimistic entries added via `addOptimistic` — not set by
2727 * records arriving from the stream. Auto-dropped when a real record
2828 * with the same rkey arrives via `record.created`. */
···161161 /** Realtime module configuration. When set, the service exposes ticket + SSE/WS
162162 * subscribe XRPCs, and wraps the spaces adapter to publish events after writes. */
163163 realtime?: import("./realtime/types").RealtimeConfig;
164164+ /** Labels module configuration. When set, contrail subscribes to the
165165+ * configured labelers, indexes their labels into a single `labels` table,
166166+ * and hydrates `record.labels` onto `listRecords` / `getRecord` / profile
167167+ * responses gated by the caller's `atproto-accept-labelers` header. */
168168+ labels?: import("./labels/types").LabelsConfig;
164169 /** Customize the auto-generated `<namespace>.permissionSet` lexicon. */
165170 permissionSet?: PermissionSetConfig;
166171}
···292297 /** Set when the row originates from a per-space table. Used by the
293298 * pipeline/hydration/response layers to route child queries to the same
294299 * space and tag the output. */
295295- _space?: string;
300300+ space?: string;
296301}
297302298303export interface IngestEvent {
+6
packages/contrail/src/worker/index.ts
···6464 const db = env[binding] as Database;
6565 await ensureReady(env, db);
6666 ctx.waitUntil(contrail.ingest({}, db));
6767+ // Run label ingest alongside the jetstream catch-up so a single cron
6868+ // tick keeps both data streams fresh. Only scheduled when configured —
6969+ // skipping the no-op promise keeps the worker task list tight.
7070+ if (config.labels) {
7171+ ctx.waitUntil(contrail.ingestLabels({}, db));
7272+ }
6773 },
6874 };
6975}
···22 *
33 * The URIs are returned as `ResourceUri` (branded) so they flow into
44 * lexicon-typed XRPC params without per-call casts. We apply the brand
55- * once here — structurally these are just `at://…` strings. */
55+ * once here — structurally these are just `ats://…` strings. */
6677import type { ResourceUri } from '@atcute/lexicons';
8899const SPACE_TYPE = 'tools.atmo.chat.space';
10101111export function buildSpaceUri(communityDid: string, key: string): ResourceUri {
1212- return `at://${communityDid}/${SPACE_TYPE}/${key}` as ResourceUri;
1212+ return `ats://${communityDid}/${SPACE_TYPE}/${key}` as ResourceUri;
1313}
14141515export function buildMembersUri(communityDid: string): ResourceUri {
···2020 return buildSpaceUri(communityDid, '$admin');
2121}
22222323-const SPACE_URI_RE = new RegExp(`^at://([^/]+)/${SPACE_TYPE.replace(/\./g, '\\.')}/([^/]+)$`);
2323+const SPACE_URI_RE = new RegExp(`^ats://([^/]+)/${SPACE_TYPE.replace(/\./g, '\\.')}/([^/]+)$`);
24242525export function parseSpaceUri(uri: string): { communityDid: string; key: string } | null {
2626 const m = SPACE_URI_RE.exec(uri);
+3-3
apps/group-chat/src/lib/rooms/watch.svelte.ts
···5959 : Record<string, unknown>
6060 : Record<string, unknown>;
61616262-/** WatchRecord with a typed `record` payload. Mirrors WatchRecord's explicit
6262+/** WatchRecord with a typed `value` payload. Mirrors WatchRecord's explicit
6363 * fields but without the `[k: string]: unknown` catchall (which would
6464 * poison property access under `Omit`). */
6565export interface TypedWatchRecord<R> {
···6767 did: string;
6868 rkey: string;
6969 collection: string;
7070- record: R;
7070+ value: R;
7171 time_us?: number;
7272 indexed_at?: number;
7373 cid?: string | null;
7474- _space?: string;
7474+ space?: string;
7575 /** Present on optimistic entries added via `addOptimistic`. */
7676 optimistic?: 'pending' | 'failed';
7777 /** Error attached via `markFailed`. */
···1212import { getSearchableFields } from "../search";
1313import { buildSpacesBaseSchema } from "../spaces/schema";
1414import { buildCommunitySchema } from "../community/schema";
1515+import { buildLabelsSchema } from "../labels/schema";
15161617function getResolved(config: ContrailConfig): ResolvedMaps {
1718 return (config as ResolvedContrailConfig)._resolved ?? resolveConfig(config)._resolved;
···315316 const target = spacesSharesMainDb ? db : spacesDb!;
316317 const communityStmts = buildCommunitySchema(dialect);
317318 await target.batch(communityStmts.map((s) => target.prepare(s)));
319319+ }
320320+321321+ if (config.labels) {
322322+ // Labels tables live on the main DB — they're keyed by at-URI / DID and
323323+ // are read alongside public records during hydration.
324324+ const labelsStmts = buildLabelsSchema(dialect);
325325+ await db.batch(labelsStmts.map((s) => db.prepare(s)));
318326 }
319327320328 // FTS5 may not be available (e.g. node:sqlite) — skip gracefully
+64
packages/contrail/src/core/labels/apply.ts
···11+import type { Database, Statement } from "../types";
22+33+/** Wire shape of a single `com.atproto.label.defs#label` entry. Field names
44+ * match the spec exactly. We accept the spec's ISO-8601 strings and
55+ * convert to unix seconds at the storage boundary. */
66+export interface IncomingLabel {
77+ src: string;
88+ uri: string;
99+ val: string;
1010+ cid?: string;
1111+ neg?: boolean;
1212+ exp?: string;
1313+ cts: string;
1414+ sig?: Uint8Array;
1515+}
1616+1717+/** Upsert a batch of labels. Idempotent on `(src, uri, val, cts)`. Bad rows
1818+ * (missing required fields, unparseable timestamps) are dropped silently;
1919+ * we don't want one malformed label to abort an entire labeler frame. */
2020+export async function applyLabels(
2121+ db: Database,
2222+ labels: IncomingLabel[],
2323+): Promise<number> {
2424+ if (labels.length === 0) return 0;
2525+ const stmts: Statement[] = [];
2626+ let kept = 0;
2727+ for (const l of labels) {
2828+ if (!l.src || !l.uri || !l.val || !l.cts) continue;
2929+ const cts = isoToUnixSec(l.cts);
3030+ if (cts == null) continue;
3131+ const exp = l.exp ? isoToUnixSec(l.exp) : null;
3232+ stmts.push(
3333+ db
3434+ .prepare(
3535+ `INSERT INTO labels (src, uri, val, cid, neg, exp, cts, sig)
3636+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3737+ ON CONFLICT(src, uri, val, cts) DO UPDATE SET
3838+ cid = excluded.cid,
3939+ neg = excluded.neg,
4040+ exp = excluded.exp,
4141+ sig = excluded.sig`,
4242+ )
4343+ .bind(
4444+ l.src,
4545+ l.uri,
4646+ l.val,
4747+ l.cid ?? null,
4848+ l.neg ? 1 : 0,
4949+ exp,
5050+ cts,
5151+ l.sig ?? null,
5252+ ),
5353+ );
5454+ kept++;
5555+ }
5656+ if (stmts.length > 0) await db.batch(stmts);
5757+ return kept;
5858+}
5959+6060+function isoToUnixSec(iso: string): number | null {
6161+ const ms = Date.parse(iso);
6262+ if (!Number.isFinite(ms)) return null;
6363+ return Math.floor(ms / 1000);
6464+}
packages/contrail/src/core/labels/hydrate.ts
This is a binary file and will not be displayed.
+134
packages/contrail/src/core/labels/resolve.ts
···11+import {
22+ CompositeDidDocumentResolver,
33+ PlcDidDocumentResolver,
44+ WebDidDocumentResolver,
55+} from "@atcute/identity-resolver";
66+import type { Did } from "@atcute/lexicons";
77+import type { Database } from "../types";
88+99+/** Reject endpoint URLs that point to private/internal addresses or non-HTTPS.
1010+ * Mirrors the validator in core/client.ts — labeler endpoints should be
1111+ * publicly reachable for the same reasons PDS endpoints should. */
1212+function validateEndpointUrl(url: string): boolean {
1313+ try {
1414+ const parsed = new URL(url);
1515+ if (parsed.protocol !== "https:") return false;
1616+ const host = parsed.hostname;
1717+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return false;
1818+ if (host.startsWith("10.")) return false;
1919+ if (host.startsWith("192.168.")) return false;
2020+ if (host.startsWith("169.254.")) return false;
2121+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false;
2222+ return true;
2323+ } catch {
2424+ return false;
2525+ }
2626+}
2727+2828+const didResolver = new CompositeDidDocumentResolver({
2929+ methods: {
3030+ plc: new PlcDidDocumentResolver(),
3131+ web: new WebDidDocumentResolver(),
3232+ },
3333+});
3434+3535+/** Look up the labeler service endpoint from a DID.
3636+ * Reads the DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. */
3737+export async function resolveLabelerEndpoint(did: string): Promise<string | null> {
3838+ if (!did.startsWith("did:plc:") && !did.startsWith("did:web:")) return null;
3939+ try {
4040+ const doc = await didResolver.resolve(did as Did<"plc"> | Did<"web">);
4141+ const endpoint = doc.service
4242+ ?.find((s) => s.id === "#atproto_labeler")
4343+ ?.serviceEndpoint?.toString();
4444+ if (!endpoint) return null;
4545+ if (!validateEndpointUrl(endpoint)) return null;
4646+ return endpoint;
4747+ } catch {
4848+ return null;
4949+ }
5050+}
5151+5252+/** State row for a labeler — the per-DID equivalent of the singleton
5353+ * jetstream `cursor` table, with cached endpoint to avoid repeated DID-doc
5454+ * fetches. */
5555+export interface LabelerState {
5656+ did: string;
5757+ cursor: number;
5858+ endpoint: string | null;
5959+ resolved_at: number | null;
6060+}
6161+6262+const ENDPOINT_TTL_MS = 6 * 60 * 60 * 1000; // 6h, matches the recommended client cache for label-defs
6363+6464+/** Get cached `(endpoint, cursor)` for a labeler. Resolves endpoint on
6565+ * cache miss or staleness; persists endpoint + resolved_at back to the DB
6666+ * so subsequent ingest cycles avoid the network round-trip. */
6767+export async function getLabelerState(
6868+ db: Database,
6969+ did: string,
7070+ endpointOverride: string | undefined,
7171+): Promise<LabelerState | null> {
7272+ const row = await db
7373+ .prepare(
7474+ "SELECT did, cursor, endpoint, resolved_at FROM labeler_cursors WHERE did = ?",
7575+ )
7676+ .bind(did)
7777+ .first<LabelerState>();
7878+7979+ let endpoint = endpointOverride ?? row?.endpoint ?? null;
8080+ const stale =
8181+ !row?.resolved_at || Date.now() - row.resolved_at > ENDPOINT_TTL_MS;
8282+8383+ if (!endpoint || (!endpointOverride && stale)) {
8484+ endpoint = await resolveLabelerEndpoint(did);
8585+ if (!endpoint) return null;
8686+ const now = Date.now();
8787+ await db
8888+ .prepare(
8989+ `INSERT INTO labeler_cursors (did, cursor, endpoint, resolved_at)
9090+ VALUES (?, ?, ?, ?)
9191+ ON CONFLICT(did) DO UPDATE SET endpoint = excluded.endpoint, resolved_at = excluded.resolved_at`,
9292+ )
9393+ .bind(did, row?.cursor ?? 0, endpoint, now)
9494+ .run();
9595+ return {
9696+ did,
9797+ cursor: row?.cursor ?? 0,
9898+ endpoint,
9999+ resolved_at: now,
100100+ };
101101+ }
102102+103103+ return row ?? { did, cursor: 0, endpoint, resolved_at: null };
104104+}
105105+106106+/** Persist the highest seen seq number for a labeler. Idempotent;
107107+ * the next ingest cycle resumes from `cursor + 1` via the `?cursor=` param. */
108108+export async function saveLabelerCursor(
109109+ db: Database,
110110+ did: string,
111111+ cursor: number,
112112+): Promise<void> {
113113+ await db
114114+ .prepare(
115115+ `INSERT INTO labeler_cursors (did, cursor)
116116+ VALUES (?, ?)
117117+ ON CONFLICT(did) DO UPDATE SET cursor = excluded.cursor`,
118118+ )
119119+ .bind(did, cursor)
120120+ .run();
121121+}
122122+123123+/** Reset cursor to 0 — used in response to `#info { name: "OutdatedCursor" }`
124124+ * frames, which signal that the labeler's seq history was rewound. */
125125+export async function resetLabelerCursor(db: Database, did: string): Promise<void> {
126126+ await db
127127+ .prepare(
128128+ `INSERT INTO labeler_cursors (did, cursor)
129129+ VALUES (?, 0)
130130+ ON CONFLICT(did) DO UPDATE SET cursor = 0`,
131131+ )
132132+ .bind(did)
133133+ .run();
134134+}
+30
packages/contrail/src/core/labels/schema.ts
···11+import type { SqlDialect } from "../dialect";
22+33+/** DDL for the labels module. Single `labels` table covers record-level
44+ * (uri starts with `at://`) and account-level (uri is a bare DID) entries —
55+ * the spec collapses both into the same row shape. `labeler_cursors`
66+ * mirrors the role of the singleton `cursor` table for jetstream, but
77+ * per-labeler. */
88+export function buildLabelsSchema(dialect: SqlDialect): string[] {
99+ return [
1010+ `CREATE TABLE IF NOT EXISTS labels (
1111+ src TEXT NOT NULL,
1212+ uri TEXT NOT NULL,
1313+ val TEXT NOT NULL,
1414+ cid TEXT,
1515+ neg INTEGER NOT NULL DEFAULT 0,
1616+ exp ${dialect.bigintType},
1717+ cts ${dialect.bigintType} NOT NULL,
1818+ sig BLOB,
1919+ PRIMARY KEY (src, uri, val, cts)
2020+ )`,
2121+ `CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri)`,
2222+ `CREATE INDEX IF NOT EXISTS idx_labels_src_cts ON labels(src, cts DESC)`,
2323+ `CREATE TABLE IF NOT EXISTS labeler_cursors (
2424+ did TEXT PRIMARY KEY,
2525+ cursor ${dialect.bigintType} NOT NULL DEFAULT 0,
2626+ endpoint TEXT,
2727+ resolved_at ${dialect.bigintType}
2828+ )`,
2929+ ];
3030+}
+75
packages/contrail/src/core/labels/select.ts
···11+import type { LabelsConfig } from "./types";
22+import { DEFAULT_LABELS_MAX_PER_REQUEST } from "./types";
33+44+/** Pick which labelers to honor for this request.
55+ *
66+ * Order of precedence:
77+ * 1. `atproto-accept-labelers` header (atproto spec)
88+ * 2. `?labelers=` query param (fallback for SSE/WS where headers are awkward)
99+ * 3. `config.defaults` (operator policy)
1010+ * 4. every entry in `config.sources`
1111+ *
1212+ * Each candidate DID is checked against `config.sources`. Unknown DIDs are
1313+ * dropped unless `allowUserSupplied: true`, in which case they're returned
1414+ * in `lazyAdd` for the caller to schedule a registration. The active
1515+ * request gets results only from already-indexed sources.
1616+ *
1717+ * Header values can carry `;param` modifiers (e.g. `did:plc:...;redact`);
1818+ * v1 strips and ignores those — only the bare DID is honored. */
1919+export interface SelectedLabelers {
2020+ /** DIDs to use for hydration this request. */
2121+ accepted: string[];
2222+ /** DIDs the caller asked for that aren't configured (only populated when
2323+ * `allowUserSupplied: true`). The hydrator ignores these for the current
2424+ * request — the caller should enqueue registration as a follow-up. */
2525+ lazyAdd: string[];
2626+}
2727+2828+export function selectAcceptedLabelers(
2929+ headerValue: string | null | undefined,
3030+ paramValue: string | null | undefined,
3131+ cfg: LabelsConfig,
3232+): SelectedLabelers {
3333+ const cap = cfg.maxPerRequest ?? DEFAULT_LABELS_MAX_PER_REQUEST;
3434+ const known = new Set(cfg.sources.map((s) => s.did));
3535+3636+ const fromCaller = parseLabelerList(headerValue) ?? parseLabelerList(paramValue);
3737+3838+ let candidates: string[];
3939+ if (fromCaller && fromCaller.length > 0) {
4040+ candidates = fromCaller;
4141+ } else {
4242+ candidates = (cfg.defaults ?? cfg.sources.map((s) => s.did)).slice();
4343+ }
4444+4545+ const accepted: string[] = [];
4646+ const lazyAdd: string[] = [];
4747+ const seen = new Set<string>();
4848+ for (const did of candidates) {
4949+ if (seen.has(did)) continue;
5050+ seen.add(did);
5151+ if (known.has(did)) {
5252+ accepted.push(did);
5353+ } else if (cfg.allowUserSupplied) {
5454+ lazyAdd.push(did);
5555+ }
5656+ if (accepted.length >= cap) break;
5757+ }
5858+5959+ return { accepted, lazyAdd };
6060+}
6161+6262+/** Parse a comma-separated DID list. Returns null when the input is empty
6363+ * or undefined so callers can distinguish "absent" from "empty list" (the
6464+ * latter — `atproto-accept-labelers: ` — is technically valid and means
6565+ * "no labelers"; we treat it the same as absent for ergonomics). */
6666+function parseLabelerList(value: string | null | undefined): string[] | null {
6767+ if (!value) return null;
6868+ const out: string[] = [];
6969+ for (const raw of value.split(",")) {
7070+ // Drop `;param` modifiers from the spec (e.g. `;redact`). v1 ignores them.
7171+ const head = raw.split(";")[0]!.trim();
7272+ if (head.startsWith("did:")) out.push(head);
7373+ }
7474+ return out.length > 0 ? out : null;
7575+}
+315
packages/contrail/src/core/labels/subscribe.ts
···11+import { decodeFirst } from "@atcute/cbor";
22+import type { ContrailConfig, Database, Logger } from "../types";
33+import type { LabelerSource } from "./types";
44+import { applyLabels, type IncomingLabel } from "./apply";
55+import {
66+ getLabelerState,
77+ resetLabelerCursor,
88+ saveLabelerCursor,
99+} from "./resolve";
1010+1111+const DEFAULT_CYCLE_TIMEOUT_MS = 25_000;
1212+const DEFAULT_BATCH_SIZE = 100;
1313+const DEFAULT_FLUSH_INTERVAL_MS = 5_000;
1414+1515+function getLogger(config: ContrailConfig): Logger {
1616+ return config.logger ?? console;
1717+}
1818+1919+/** One catch-up cycle for every configured labeler. Designed to fit inside a
2020+ * Cloudflare Workers cron tick — we drain frames until the labeler has no
2121+ * more buffered events for us, or `timeoutMs` is reached, then save cursor
2222+ * and disconnect. Mirrors the shape of `runIngestCycle` for jetstream. */
2323+export async function runLabelIngestCycle(
2424+ db: Database,
2525+ config: ContrailConfig,
2626+ timeoutMs = DEFAULT_CYCLE_TIMEOUT_MS,
2727+): Promise<void> {
2828+ if (!config.labels) return;
2929+ const log = getLogger(config);
3030+ const deadline = Date.now() + timeoutMs;
3131+3232+ for (const source of config.labels.sources) {
3333+ if (Date.now() >= deadline) {
3434+ log.log(`[labels] cycle deadline hit before processing ${source.did}`);
3535+ break;
3636+ }
3737+ const remaining = Math.max(2_000, deadline - Date.now());
3838+ try {
3939+ await pumpOneLabeler(db, source, log, remaining, /* persistent */ false);
4040+ } catch (err) {
4141+ log.warn(`[labels] cycle for ${source.did} failed: ${err}`);
4242+ }
4343+ }
4444+}
4545+4646+export interface PersistentLabelsOptions {
4747+ signal?: AbortSignal;
4848+ batchSize?: number;
4949+ flushIntervalMs?: number;
5050+ logger?: Logger;
5151+}
5252+5353+/** Long-lived equivalent — keeps one socket per labeler open forever, with
5454+ * exponential backoff reconnect. Mirrors `runPersistent` for jetstream. */
5555+export async function runPersistentLabels(
5656+ db: Database,
5757+ config: ContrailConfig,
5858+ options: PersistentLabelsOptions = {},
5959+): Promise<void> {
6060+ if (!config.labels) return;
6161+ const log = options.logger ?? config.logger ?? console;
6262+ const signal = options.signal;
6363+6464+ const tasks = config.labels.sources.map((source) =>
6565+ runOneLabelerForever(db, source, log, signal, options),
6666+ );
6767+ await Promise.all(tasks);
6868+}
6969+7070+async function runOneLabelerForever(
7171+ db: Database,
7272+ source: LabelerSource,
7373+ log: Logger,
7474+ signal: AbortSignal | undefined,
7575+ options: PersistentLabelsOptions,
7676+): Promise<void> {
7777+ let attempts = 0;
7878+ while (!signal?.aborted) {
7979+ try {
8080+ await pumpOneLabeler(db, source, log, /* timeoutMs */ Infinity, true, {
8181+ signal,
8282+ batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
8383+ flushIntervalMs: options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
8484+ });
8585+ attempts = 0;
8686+ } catch (err) {
8787+ if (signal?.aborted) break;
8888+ log.error(`[labels] ${source.did} stream error: ${err}`);
8989+ const delay = Math.min(1_000 * 2 ** attempts, 30_000);
9090+ attempts++;
9191+ log.log(`[labels] ${source.did} reconnecting in ${delay}ms (attempt ${attempts})`);
9292+ await new Promise((r) => setTimeout(r, delay));
9393+ }
9494+ }
9595+}
9696+9797+interface PumpOptions {
9898+ signal?: AbortSignal;
9999+ batchSize?: number;
100100+ flushIntervalMs?: number;
101101+}
102102+103103+/** Open a `subscribeLabels` WebSocket, drain frames into a buffer, flush
104104+ * the buffer to `labels` in batches, and persist the seq cursor. Returns
105105+ * when:
106106+ * - the labeler closes the socket cleanly (caught up + no more events)
107107+ * - `timeoutMs` is reached (cron mode)
108108+ * - `signal` is aborted (persistent mode)
109109+ * - an error tears the socket down (caller may retry) */
110110+async function pumpOneLabeler(
111111+ db: Database,
112112+ source: LabelerSource,
113113+ log: Logger,
114114+ timeoutMs: number,
115115+ persistent: boolean,
116116+ pumpOpts: PumpOptions = {},
117117+): Promise<void> {
118118+ const state = await getLabelerState(db, source.did, source.endpoint);
119119+ if (!state) {
120120+ log.warn(`[labels] could not resolve labeler endpoint for ${source.did}; skipping`);
121121+ return;
122122+ }
123123+124124+ // First-time policy: cursor 0 = "from the beginning" if backfill is on
125125+ // (default), null = "from now" otherwise. After the first cycle we always
126126+ // resume from the saved cursor — `backfill` only flips the start point.
127127+ const isFirstRun = state.cursor === 0 && state.resolved_at === null;
128128+ const backfill = source.backfill !== false;
129129+ const startCursor = isFirstRun && !backfill ? null : state.cursor;
130130+131131+ const url = buildWsUrl(state.endpoint!, startCursor);
132132+ log.log(`[labels] connecting to ${source.did} (cursor=${startCursor ?? "now"})`);
133133+134134+ const ws = new WebSocket(url);
135135+ ws.binaryType = "arraybuffer";
136136+137137+ const buffer: IncomingLabel[] = [];
138138+ let highestSeq = state.cursor;
139139+ let flushing = false;
140140+ let resolveDone!: () => void;
141141+ let rejectDone!: (err: unknown) => void;
142142+ const done = new Promise<void>((res, rej) => {
143143+ resolveDone = res;
144144+ rejectDone = rej;
145145+ });
146146+147147+ const flush = async () => {
148148+ if (buffer.length === 0 || flushing) return;
149149+ flushing = true;
150150+ const batch = buffer.splice(0);
151151+ try {
152152+ const kept = await applyLabels(db, batch);
153153+ if (highestSeq > state.cursor) {
154154+ await saveLabelerCursor(db, source.did, highestSeq);
155155+ state.cursor = highestSeq;
156156+ }
157157+ log.log(
158158+ `[labels] ${source.did} flushed ${kept}/${batch.length} labels, cursor=${highestSeq}`,
159159+ );
160160+ } catch (err) {
161161+ log.error(`[labels] ${source.did} flush failed: ${err}`);
162162+ } finally {
163163+ flushing = false;
164164+ }
165165+ };
166166+167167+ const batchSize = pumpOpts.batchSize ?? DEFAULT_BATCH_SIZE;
168168+ const flushInterval = pumpOpts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
169169+ const flushTimer = setInterval(() => {
170170+ flush().catch(() => {});
171171+ }, flushInterval);
172172+173173+ const cleanup = () => {
174174+ clearInterval(flushTimer);
175175+ try {
176176+ ws.close();
177177+ } catch {
178178+ /* already closed */
179179+ }
180180+ };
181181+182182+ // External abort (persistent mode) — close socket gracefully.
183183+ const abortHandler = () => {
184184+ cleanup();
185185+ flush().finally(() => resolveDone());
186186+ };
187187+ pumpOpts.signal?.addEventListener("abort", abortHandler, { once: true });
188188+189189+ // Cron-mode time budget — close socket gracefully when reached.
190190+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
191191+ if (Number.isFinite(timeoutMs)) {
192192+ deadlineTimer = setTimeout(() => {
193193+ log.log(`[labels] ${source.did} cycle deadline reached, closing`);
194194+ cleanup();
195195+ flush().finally(() => resolveDone());
196196+ }, timeoutMs);
197197+ }
198198+199199+ ws.addEventListener("error", (ev) => {
200200+ cleanup();
201201+ if (deadlineTimer) clearTimeout(deadlineTimer);
202202+ pumpOpts.signal?.removeEventListener("abort", abortHandler);
203203+ rejectDone(new Error(`WebSocket error: ${(ev as ErrorEvent)?.message ?? "unknown"}`));
204204+ });
205205+206206+ ws.addEventListener("close", () => {
207207+ if (deadlineTimer) clearTimeout(deadlineTimer);
208208+ pumpOpts.signal?.removeEventListener("abort", abortHandler);
209209+ flush().finally(() => {
210210+ clearInterval(flushTimer);
211211+ resolveDone();
212212+ });
213213+ });
214214+215215+ ws.addEventListener("message", async (ev) => {
216216+ let bytes: Uint8Array;
217217+ if (ev.data instanceof ArrayBuffer) {
218218+ bytes = new Uint8Array(ev.data);
219219+ } else if (ev.data instanceof Uint8Array) {
220220+ bytes = ev.data;
221221+ } else {
222222+ // Binary-only protocol — text frames shouldn't arrive.
223223+ return;
224224+ }
225225+ const frame = decodeFrame(bytes);
226226+ if (!frame) return;
227227+228228+ if (frame.t === "#labels") {
229229+ const seq = Number(frame.payload?.seq ?? 0);
230230+ const rawLabels = Array.isArray(frame.payload?.labels) ? frame.payload.labels : [];
231231+ for (const raw of rawLabels) {
232232+ const lab = normalizeLabel(raw, source.did);
233233+ if (lab) buffer.push(lab);
234234+ }
235235+ if (Number.isFinite(seq) && seq > highestSeq) highestSeq = seq;
236236+ if (buffer.length >= batchSize) {
237237+ flush().catch(() => {});
238238+ }
239239+ } else if (frame.t === "#info") {
240240+ const name = String(frame.payload?.name ?? "");
241241+ log.log(`[labels] ${source.did} info: ${name}`);
242242+ if (name === "OutdatedCursor") {
243243+ // Labeler rewound its log — discard our cursor and let the next
244244+ // run start from the beginning. We don't reconnect here; the
245245+ // caller (or the persistent loop) will pick up the reset on retry.
246246+ await resetLabelerCursor(db, source.did);
247247+ cleanup();
248248+ }
249249+ } else if (frame.op === -1) {
250250+ log.warn(`[labels] ${source.did} error frame: ${JSON.stringify(frame.payload)}`);
251251+ cleanup();
252252+ }
253253+ });
254254+255255+ // Workers WebSocket doesn't always emit `open`; just await `done` directly.
256256+ await done;
257257+}
258258+259259+function buildWsUrl(httpEndpoint: string, cursor: number | null): string {
260260+ const u = new URL("/xrpc/com.atproto.label.subscribeLabels", httpEndpoint);
261261+ // wss:// for HTTPS endpoints — the protocol on the labeler service is
262262+ // expected to be HTTPS already (validated at resolution time).
263263+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
264264+ if (cursor !== null) u.searchParams.set("cursor", String(cursor));
265265+ return u.toString();
266266+}
267267+268268+interface DecodedFrame {
269269+ op: number;
270270+ t: string | undefined;
271271+ payload: Record<string, unknown>;
272272+}
273273+274274+/** Decode an atproto subscription frame: two consecutive CBOR objects.
275275+ * Header `{ op, t? }`, payload — shape depends on `t`. Returns null on
276276+ * decode failure or non-object frames. */
277277+function decodeFrame(bytes: Uint8Array): DecodedFrame | null {
278278+ try {
279279+ const [header, rest] = decodeFirst(bytes);
280280+ if (!header || typeof header !== "object") return null;
281281+ const op = typeof (header as { op?: number }).op === "number" ? (header as { op: number }).op : 1;
282282+ const t = typeof (header as { t?: string }).t === "string" ? (header as { t: string }).t : undefined;
283283+ const [payload] = decodeFirst(rest);
284284+ if (!payload || typeof payload !== "object") return null;
285285+ return { op, t, payload: payload as Record<string, unknown> };
286286+ } catch {
287287+ return null;
288288+ }
289289+}
290290+291291+/** Coerce a wire `Label` object into our `IncomingLabel` shape. Returns
292292+ * null when required fields are missing — we'd rather skip a row than
293293+ * insert one with placeholder values. */
294294+function normalizeLabel(raw: unknown, expectedSrc: string): IncomingLabel | null {
295295+ if (!raw || typeof raw !== "object") return null;
296296+ const r = raw as Record<string, unknown>;
297297+ const src = typeof r.src === "string" ? r.src : null;
298298+ const uri = typeof r.uri === "string" ? r.uri : null;
299299+ const val = typeof r.val === "string" ? r.val : null;
300300+ const cts = typeof r.cts === "string" ? r.cts : null;
301301+ if (!src || !uri || !val || !cts) return null;
302302+ // A labeler shouldn't emit labels under a different `src` than its own
303303+ // DID — drop them rather than poison our table with cross-issuer rows.
304304+ if (src !== expectedSrc) return null;
305305+ return {
306306+ src,
307307+ uri,
308308+ val,
309309+ cts,
310310+ cid: typeof r.cid === "string" ? r.cid : undefined,
311311+ neg: r.neg === true,
312312+ exp: typeof r.exp === "string" ? r.exp : undefined,
313313+ sig: r.sig instanceof Uint8Array ? r.sig : undefined,
314314+ };
315315+}
+63
packages/contrail/src/core/labels/types.ts
···11+import type { Database } from "../types";
22+33+/** A labeler the operator wants contrail to track. */
44+export interface LabelerSource {
55+ /** Labeler DID — `did:plc:...` or `did:web:...`. */
66+ did: string;
77+ /** Override the service endpoint resolution. Otherwise resolved from the
88+ * DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. */
99+ endpoint?: string;
1010+ /** Backfill from `cursor=0` on first sight. Defaults to true. Set false
1111+ * for "start from now" — useful for very chatty labelers. */
1212+ backfill?: boolean;
1313+}
1414+1515+export interface LabelsConfig {
1616+ /** Labelers to subscribe to and index. */
1717+ sources: LabelerSource[];
1818+ /** DIDs honored when the caller sends no `atproto-accept-labelers` /
1919+ * `?labelers=`. Defaults to every entry in `sources`. Set `[]` for
2020+ * opt-in-only — clients see no labels unless they ask. */
2121+ defaults?: string[];
2222+ /** Honor caller-supplied DIDs that aren't in `sources`. Default: false.
2323+ * When true, unknown DIDs in the request are accepted (and the labeler
2424+ * may be lazily registered for ingest in a later release). */
2525+ allowUserSupplied?: boolean;
2626+ /** Per-request cap. Default: 20 (matches Bluesky). */
2727+ maxPerRequest?: number;
2828+}
2929+3030+export const DEFAULT_LABELS_MAX_PER_REQUEST = 20;
3131+3232+/** A single label as stored. Matches `com.atproto.label.defs#label`. */
3333+export interface LabelRow {
3434+ /** Issuing labeler DID. */
3535+ src: string;
3636+ /** Subject — at-URI for record labels, plain DID for account labels. */
3737+ uri: string;
3838+ /** Label value — kebab-case, ≤128 bytes per spec. */
3939+ val: string;
4040+ /** Optional CID pin to a specific record version. */
4141+ cid: string | null;
4242+ /** When true, retracts a previously-emitted label for the same (src, uri, val). */
4343+ neg: boolean;
4444+ /** Expiry, unix seconds. Past this, hydration drops the row. */
4545+ exp: number | null;
4646+ /** Creation timestamp, unix seconds — what we collapse on. */
4747+ cts: number;
4848+ /** Raw signature bytes. Stored when present so we can re-emit later;
4949+ * not verified in v1. */
5050+ sig: Uint8Array | null;
5151+}
5252+5353+/** Per-labeler state row — endpoint cache and last-seen seq cursor. */
5454+export interface LabelerCursorRow {
5555+ did: string;
5656+ cursor: number;
5757+ endpoint: string | null;
5858+ resolved_at: number | null;
5959+}
6060+6161+export interface AdapterContext {
6262+ db: Database;
6363+}
···1010import { batchedInQuery, formatRecord } from "./helpers";
11111212/** Group rows by their origin: public (undefined key) or a specific spaceUri. */
1313-function groupBySource<T extends { _space?: string }>(rows: T[]): Map<string | undefined, T[]> {
1313+function groupBySource<T extends { space?: string }>(rows: T[]): Map<string | undefined, T[]> {
1414 const groups = new Map<string | undefined, T[]>();
1515 for (const r of rows) {
1616- const key = r._space;
1616+ const key = r.space;
1717 const g = groups.get(key);
1818 if (g) g.push(r);
1919 else groups.set(key, [r]);
···130130 formatRecord({
131131 ...(row as any),
132132 collection: childNsid,
133133- ...(sourceSpace ? { _space: sourceSpace } : {}),
133133+ ...(sourceSpace ? { space: sourceSpace } : {}),
134134 } as RecordRow)
135135 );
136136 }
···212212 result[parentUri][refName] = formatRecord({
213213 ...(row as any),
214214 collection: refNsid,
215215- ...(sourceSpace ? { _space: sourceSpace } : {}),
215215+ ...(sourceSpace ? { space: sourceSpace } : {}),
216216 } as RecordRow);
217217 }
218218 }
+21
packages/contrail/src/core/router/index.ts
···2424import { resolveActor } from "../identity";
2525import { resolveProfiles } from "./profiles";
2626import { backfillUser } from "../backfill";
2727+import { selectAcceptedLabelers } from "../labels/select";
2828+import { hydrateLabels } from "../labels/hydrate";
27292830export interface SpacesContext {
2931 adapter: StorageAdapter;
···8890 const profileMap = await resolveProfiles(db, config, [did]);
8991 const profiles = profileMap[did];
9092 if (!profiles || profiles.length === 0) return c.json({ error: "Profile not found" }, 404);
9393+9494+ if (config.labels) {
9595+ const params = new URL(c.req.url).searchParams;
9696+ const sel = selectAcceptedLabelers(
9797+ c.req.raw.headers.get("atproto-accept-labelers"),
9898+ params.get("labelers"),
9999+ config.labels,
100100+ );
101101+ if (sel.accepted.length > 0) {
102102+ const labelsByUri = await hydrateLabels(db, [did], sel.accepted);
103103+ const ls = labelsByUri[did];
104104+ if (ls && ls.length > 0) {
105105+ for (const entry of profiles) {
106106+ entry.labels = ls;
107107+ }
108108+ }
109109+ c.header("atproto-content-labelers", sel.accepted.join(","));
110110+ }
111111+ }
9111292113 return c.json({ profiles });
93114 });
+3
packages/contrail/src/core/router/profiles.ts
···1313 value?: unknown;
1414 collection?: string;
1515 rkey?: string;
1616+ /** Hydrated by the labels module when the caller has accepted-labelers
1717+ * active and there are matching labels on this DID. */
1818+ labels?: unknown;
1619}
17201821export function collectDids(
+7-7
packages/contrail/src/core/spaces/uri.ts
···11/** Centralized space URI construction / parsing.
22 *
33- * Permissioned spaces are addressed by (ownerDid, type, key). The rough spec
44- * (https://dholms.leaflet.pub/3mhj6bcqats2o) floats `ats://` as a likely
55- * distinct scheme for permissioned data, but that's unresolved — we keep
66- * `at://` today and isolate the format here so swapping is a one-liner.
33+ * Permissioned spaces are addressed by (ownerDid, type, key) and use the
44+ * `ats://` scheme — distinct from atproto record URIs (`at://`) so the two
55+ * can't be confused at any layer (logs, params, dispatch). Tracks the rough
66+ * spec at https://dholms.leaflet.pub/3mhj6bcqats2o.
77 *
88 * Record URIs inside a space are minted by authorDid for index purposes
99 * (`at://<authorDid>/<collection>/<rkey>`); the spec is explicitly undecided
···18181919/** Build a space URI from its three addressing components. */
2020export function buildSpaceUri(parts: SpaceUriParts): string {
2121- return `at://${parts.ownerDid}/${parts.type}/${parts.key}`;
2121+ return `ats://${parts.ownerDid}/${parts.type}/${parts.key}`;
2222}
23232424/** Parse a space URI into its components, or null if malformed. */
2525export function parseSpaceUri(uri: string): SpaceUriParts | null {
2626- if (!uri.startsWith("at://")) return null;
2727- const rest = uri.slice("at://".length);
2626+ if (!uri.startsWith("ats://")) return null;
2727+ const rest = uri.slice("ats://".length);
2828 const [ownerDid, type, key, ...extra] = rest.split("/");
2929 if (!ownerDid || !type || !key || extra.length > 0) return null;
3030 return { ownerDid, type, key };
···2222 },
2323 "spaceUri": {
2424 "type": "string",
2525- "format": "at-uri",
2625 "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)."
2726 },
2827 "inviteToken": {
···7271 },
7372 "space": {
7473 "type": "string",
7575- "format": "at-uri",
7674 "description": "Present when the record was read from a permissioned space; its value is the space URI."
7775 },
7876 "profiles": {
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···135134 },
136135 "space": {
137136 "type": "string",
138138- "format": "at-uri",
139137 "description": "Present when the record was read from a permissioned space; its value is the space URI."
140138 }
141139 }
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···1515 "properties": {
1616 "spaceUri": {
1717 "type": "string",
1818- "format": "at-uri",
1919- "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check."
1818+ "description": "Optional \u2014 ownership is inferred from the invite row; required for user-owned spaces for a sanity check."
2019 },
2120 "tokenHash": {
2221 "type": "string"
···2222 },
2323 "spaceUri": {
2424 "type": "string",
2525- "format": "at-uri",
2625 "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)."
2726 },
2827 "inviteToken": {
···7271 },
7372 "space": {
7473 "type": "string",
7575- "format": "at-uri",
7674 "description": "Present when the record was read from a permissioned space; its value is the space URI."
7775 },
7876 "profiles": {
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···134133 },
135134 "space": {
136135 "type": "string",
137137- "format": "at-uri",
138136 "description": "Present when the record was read from a permissioned space; its value is the space URI."
139137 }
140138 }
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···44 "defs": {
55 "main": {
66 "type": "procedure",
77- "description": "Mint a short-lived subscription ticket for the given topic. The caller's access is checked and — for `community:<did>` topics — expanded to the concrete list of `space:<uri>` topics the caller can see. Server-side callers may skip tickets and use their JWT directly on `.subscribe`.",
77+ "description": "Mint a short-lived subscription ticket for the given topic. The caller's access is checked and \u2014 for `community:<did>` topics \u2014 expanded to the concrete list of `space:<uri>` topics the caller can see. Server-side callers may skip tickets and use their JWT directly on `.subscribe`.",
88 "input": {
99 "encoding": "application/json",
1010 "schema": {
···2222 },
2323 "spaceUri": {
2424 "type": "string",
2525- "format": "at-uri",
2625 "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)."
2726 },
2827 "inviteToken": {
···7271 },
7372 "space": {
7473 "type": "string",
7575- "format": "at-uri",
7674 "description": "Present when the record was read from a permissioned space; its value is the space URI."
7775 },
7876 "profiles": {
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···130129 },
131130 "space": {
132131 "type": "string",
133133- "format": "at-uri",
134132 "description": "Present when the record was read from a permissioned space; its value is the space URI."
135133 }
136134 }
···2828 },
2929 "spaceUri": {
3030 "type": "string",
3131- "format": "at-uri",
3231 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)."
3332 },
3433 "byUser": {
3534 "type": "string",
3635 "format": "did",
3737- "description": "Only used with spaceUri — filter to records authored by this DID."
3636+ "description": "Only used with spaceUri \u2014 filter to records authored by this DID."
3837 },
3938 "inviteToken": {
4039 "type": "string",
···44 "defs": {
55 "main": {
66 "type": "procedure",
77- "description": "Remove the caller from a space's member list. The owner cannot leave — they must delete the space instead.",
77+ "description": "Remove the caller from a space's member list. The owner cannot leave \u2014 they must delete the space instead.",
88 "input": {
99 "encoding": "application/json",
1010 "schema": {
···1414 ],
1515 "properties": {
1616 "spaceUri": {
1717- "type": "string",
1818- "format": "at-uri"
1717+ "type": "string"
1918 }
2019 }
2120 }
···44 "defs": {
55 "main": {
66 "type": "query",
77- "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns. When scope='member', the optional 'owner' param narrows to spaces owned by that DID — useful for listing all channels in a specific community the caller can access.",
77+ "description": "List spaces the caller has access to. Default scope is 'member' (spaces the caller is a member of, including owned); 'owner' lists only spaces the caller owns. When scope='member', the optional 'owner' param narrows to spaces owned by that DID \u2014 useful for listing all channels in a specific community the caller can access.",
88 "parameters": {
99 "type": "params",
1010 "properties": {