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

Configure Feed

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

Merge pull request #20 from flo-bit/fix/update-docs

update docs, add labels

authored by

Florian and committed by
GitHub
(Apr 25, 2026, 3:49 PM +0200) 3b794639 fdd32bdc

+1616 -235
+1
README.md
··· 86 86 - [Spaces](./docs/05-spaces.md) — permissioned records stored by the appview 87 87 - [Communities](./docs/06-communities.md) — group-controlled atproto DIDs 88 88 - [Sync](./docs/07-sync.md) — reactive client-side store over `watchRecords` 89 + - [Labels](./docs/08-labels.md) — atproto-native moderation hydration from external labelers 89 90 - Frameworks: [SvelteKit + Cloudflare](./docs/frameworks/sveltekit-cloudflare.md) 90 91 91 92 ## Packages
+3
pnpm-lock.yaml
··· 349 349 '@atcute/atproto': 350 350 specifier: ^3.1.10 351 351 version: 3.1.11 352 + '@atcute/cbor': 353 + specifier: ^2.3.2 354 + version: 2.3.2 352 355 '@atcute/cid': 353 356 specifier: ^2.4.1 354 357 version: 2.4.1
+18
.changeset/space-no-underscore.md
··· 1 + --- 2 + "@atmo-dev/contrail": minor 3 + "@atmo-dev/contrail-sync": minor 4 + --- 5 + 6 + 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. 7 + 8 + **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. 9 + 10 + ```ts 11 + // before 12 + if (record._space) ... 13 + 14 + // after 15 + if (record.space) ... 16 + ``` 17 + 18 + no migration needed for `listRecords` / `getRecord` consumers — that path was already `space`.
+20
.changeset/space-uri-scheme.md
··· 1 + --- 2 + "@atmo-dev/contrail": minor 3 + "@atmo-dev/contrail-sync": minor 4 + "@atmo-dev/contrail-lexicons": minor 5 + --- 6 + 7 + 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). 8 + 9 + ``` 10 + - at://did:plc:alice/com.example.event.space/birthday 11 + + ats://did:plc:alice/com.example.event.space/birthday 12 + ``` 13 + 14 + what changed: 15 + - `buildSpaceUri` / `parseSpaceUri` (`@atmo-dev/contrail`) emit / accept `ats://`. anything else returns `null` from `parseSpaceUri`. 16 + - 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. 17 + - realtime topics are unchanged in shape (`space:<uri>`), but `<uri>` is now an `ats://` URI. 18 + - record URIs (the `uri` on a record, the `appPolicyRef` field, `notifyOfUpdate` payloads) keep `at://` — those are still atproto record URIs. 19 + 20 + **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://%'`).
+3 -1
.claude/settings.local.json
··· 10 10 "Bash(npm test:*)", 11 11 "Bash(npx lex-cli:*)", 12 12 "Bash(pnpm vitest:*)", 13 - "WebFetch(domain:flo-bit.dev)" 13 + "WebFetch(domain:flo-bit.dev)", 14 + "WebFetch(domain:atproto.com)", 15 + "WebFetch(domain:docs.bsky.app)" 14 16 ] 15 17 } 16 18 }
+1
docs/01-indexing.md
··· 203 203 | `spaces` | — | See [Spaces](./05-spaces.md) | 204 204 | `community` | — | See [Communities](./06-communities.md) | 205 205 | `realtime` | — | See [Sync](./07-sync.md) | 206 + | `labels` | — | See [Labels](./08-labels.md) |
+23 -11
docs/02-querying.md
··· 7 7 | `{namespace}.{short}.listRecords` | Paginated list with filters, sorts, hydration | 8 8 | `{namespace}.{short}.getRecord?uri=…` | Single record by AT-URI | 9 9 10 - Plus a few top-level ones: `{namespace}.getProfile`, `{namespace}.getCursor`, `{namespace}.getOverview`, `{namespace}.notifyOfUpdate`, `{namespace}.permissionSet`. 10 + Plus a few top-level ones: `{namespace}.getProfile`, `{namespace}.getCursor`, `{namespace}.getOverview`, `{namespace}.notifyOfUpdate`, `{namespace}.permissionSet`, `{namespace}.lexicons`. 11 11 12 12 ## HTTP (what most callers use) 13 13 ··· 73 73 { 74 74 "uri": "at://did:plc:.../community.lexicon.calendar.event/...", 75 75 "cid": "...", 76 - "record": { "name": "Rust meetup", "startsAt": "2026-03-16T...", ... }, 76 + "value": { "name": "Rust meetup", "startsAt": "2026-03-16T...", ... }, 77 77 "rsvpsCount": 42, // from relations 78 78 "rsvpsGoingCount": 30, 79 79 // relations + references appear here only when hydrated 80 80 } 81 81 ``` 82 + 83 + 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. 82 84 83 85 ### `?hydrateRel=N` (relations) 84 86 ··· 94 96 { 95 97 "records": [{ 96 98 "uri": "at://.../event/...", 97 - "record": { "name": "..." }, 99 + "value": { "name": "..." }, 98 100 "rsvpsCount": 42, 99 101 "rsvps": { 100 - "going": [ {uri, record}, ... 5 items ], 101 - "interested":[ {uri, record}, ... 5 items ] 102 + "going": [ {uri, cid, value}, ... 5 items ], 103 + "interested":[ {uri, cid, value}, ... 5 items ] 102 104 } 103 105 }] 104 106 } ··· 114 116 /xrpc/com.example.rsvp.listRecords?subjectUri=at://.../event/...&hydrateEvent=true 115 117 ``` 116 118 117 - Each RSVP record in the response gains an `event: {uri, cid, record}` field. 119 + Each RSVP record in the response gains an `event: {uri, cid, value}` field. 118 120 119 121 ### `?profiles=true` 120 122 ··· 124 126 /xrpc/com.example.event.listRecords?profiles=true 125 127 ``` 126 128 127 - Response grows a top-level `profiles` map keyed by DID: 129 + Response grows a top-level `profiles` array, one entry per (DID, configured profile NSID): 128 130 129 131 ```jsonc 130 132 { 131 133 "records": [...], 132 - "profiles": { 133 - "did:plc:alice...": { "handle": "alice.bsky.social", "record": {...} } 134 - } 134 + "profiles": [ 135 + { 136 + "did": "did:plc:alice...", 137 + "handle": "alice.bsky.social", 138 + "uri": "at://did:plc:alice.../app.bsky.actor.profile/self", 139 + "cid": "...", 140 + "collection": "app.bsky.actor.profile", 141 + "rkey": "self", 142 + "value": { /* profile record body */ } 143 + } 144 + ] 135 145 } 136 146 ``` 137 147 138 - Which profile NSID to hydrate from is configured at the top level of Contrail's config (`profiles`, defaults to `["app.bsky.actor.profile"]`). 148 + 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. 149 + 150 + Which profile NSID(s) to hydrate from is configured at the top level of Contrail's config (`profiles`, defaults to `["app.bsky.actor.profile"]`). 139 151 140 152 ## Full-text search 141 153
+5 -7
docs/04-auth.md
··· 77 77 78 78 Realtime 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. 79 79 80 - Server-side minting: 80 + Server-side minting comes in two flavours: 81 81 82 - ``` 83 - com.example.realtime.ticket { spaceUri } 84 - → { ticket: "...", expiresAt: 1234567890 } 85 - ``` 82 + - `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. 83 + - `<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. 86 84 87 - 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=...`. 85 + Both flavours are signed by `realtime.ticketSecret` (a 32-byte random, configured once). Clients hand the ticket off via `?ticket=...` on connect. 88 86 89 87 In the `@atmo-dev/contrail-sync` client: 90 88 91 89 ```ts 92 90 createWatchStore({ 93 - url: "/xrpc/com.example.message.watchRecords?spaceUri=at://...", 91 + url: "/xrpc/com.example.message.watchRecords?spaceUri=ats://...", 94 92 mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()), 95 93 }); 96 94 ```
+2 -2
docs/05-spaces.md
··· 6 6 7 7 > A **space** is a bag of records with one lock. The **member list** says who has the key. 8 8 9 - - One owner (DID), one type (NSID), one key. Identified by `at://<owner>/<type>/<key>`. 9 + - 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. 10 10 - 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. 11 11 - Optional **app policy** gates which OAuth clients can act in the space. 12 12 ··· 51 51 | `?spaceUri=…` + JWT | one space (ACL-gated) | 52 52 | JWT, no `spaceUri` | public **unioned** with every space the caller is a member of | 53 53 54 - Filters, sorts, hydration, and references work across all three. Records from a space carry a `space: <spaceUri>` field. 54 + 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. 55 55 56 56 ## Invites 57 57
+3 -3
docs/06-communities.md
··· 8 8 9 9 ## Two modes 10 10 11 - - **Minted** — contrail creates a fresh `did:plc` for the community, holds the signing + rotation keys, publishes from it. 12 - - **Adopted** — contrail takes over an existing DID whose rotation keys were handed over by the owner. 11 + - **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. 12 + - **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. 13 13 14 14 Either way, the result is the same: a DID that multiple members can act through, gated by access levels. 15 15 ··· 49 49 50 50 - No per-record per-level ACLs. Model as spaces. 51 51 - No auto-rotation on key compromise yet. 52 - - Adoption is irreversible without manual key surrender back to the owner. 52 + - 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.) 53 53 54 54 The 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.
+1 -1
docs/07-sync.md
··· 42 42 ## Optimistic updates 43 43 44 44 ```ts 45 - store.addOptimistic({ rkey, did, record: { text: "hi" } }); 45 + store.addOptimistic({ rkey, did, value: { text: "hi" } }); 46 46 // later, on mutation failure: 47 47 store.markFailed(rkey, err); 48 48 // or explicit rollback:
+148
docs/08-labels.md
··· 1 + # Labels 2 + 3 + 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. 4 + 5 + ## Mental model 6 + 7 + > 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`. 8 + 9 + - One contrail deployment can subscribe to many labelers. 10 + - 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). 11 + - Labels hydrate onto every `listRecords`, `getRecord`, `getProfile`, and `?profiles=true` response without changing your collection config. 12 + - This module only consumes labels. Producing them — your appview emitting its own labels — is a separate question. See *Future work* below. 13 + 14 + ## Enable 15 + 16 + ```ts 17 + import type { ContrailConfig } from "@atmo-dev/contrail"; 18 + 19 + const config: ContrailConfig = { 20 + namespace: "com.example", 21 + collections: { /* ... */ }, 22 + labels: { 23 + sources: [ 24 + { did: "did:plc:ar7c4by46qjdydhdevvrndac" }, // bsky moderation 25 + { did: "did:plc:newsmast" }, 26 + ], 27 + }, 28 + }; 29 + ``` 30 + 31 + `initSchema` creates a `labels` table and a `labeler_cursors` table. Both live on the main DB; nothing per-collection. 32 + 33 + ## Caller selection 34 + 35 + Per request, contrail picks accepted labelers in this order: 36 + 37 + 1. `atproto-accept-labelers: did:plc:a, did:plc:b` — the spec's HTTP header. 38 + 2. `?labelers=did:plc:a,did:plc:b` — fallback for transports that can't set headers easily. 39 + 3. `config.labels.defaults` — operator policy. 40 + 4. Every entry in `config.labels.sources`. 41 + 42 + 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`. 43 + 44 + ``` 45 + GET /xrpc/com.example.event.listRecords 46 + atproto-accept-labelers: did:plc:ar7c4by46qjdydhdevvrndac 47 + ``` 48 + 49 + 50 + 51 + ```jsonc 52 + // Response: atproto-content-labelers: did:plc:ar7c4by46qjdydhdevvrndac 53 + { 54 + "records": [ 55 + { 56 + "uri": "at://did:plc:.../com.example.event/...", 57 + "value": { /* ... */ }, 58 + "labels": [ 59 + { 60 + "src": "did:plc:ar7c4by46qjdydhdevvrndac", 61 + "uri": "at://did:plc:.../com.example.event/...", 62 + "val": "spam", 63 + "cts": "2026-04-25T00:00:00.000Z" 64 + } 65 + ] 66 + } 67 + ] 68 + } 69 + ``` 70 + 71 + `labels` matches `com.atproto.label.defs#label` field-for-field — pass it straight to atproto SDK moderation helpers. 72 + 73 + ### `allowUserSupplied` 74 + 75 + 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. 76 + 77 + ### `defaults: []` 78 + 79 + Set defaults to an empty array if you want strict opt-in: callers that send no header / param see no labels at all. 80 + 81 + ## Hydration semantics 82 + 83 + 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. 84 + 85 + 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`. 86 + 87 + ## Ingestion 88 + 89 + `com.atproto.label.subscribeLabels` is a per-labeler WebSocket firehose with a CBOR frame envelope. Contrail mirrors its existing Jetstream pipeline: 90 + 91 + | Mode | Function | When | 92 + |---|---|---| 93 + | Cron-driven | `contrail.ingestLabels()` | Cloudflare Workers — one drain per cron tick | 94 + | Persistent | `contrail.runPersistentLabels()` | Node / long-lived servers — one socket per labeler, auto-reconnect | 95 + | One-shot backfill | `pnpm contrail labels-backfill [--remote]` | Local script, drains until each labeler reports caught up | 96 + 97 + When `config.labels` is set, the bundled `createWorker` already calls `ingestLabels()` from `scheduled()` alongside `ingest()` — no boilerplate. 98 + 99 + ```ts 100 + // node / long-lived 101 + const ac = new AbortController(); 102 + await Promise.all([ 103 + contrail.runPersistent({ signal: ac.signal }), 104 + contrail.runPersistentLabels({ signal: ac.signal }), 105 + ]); 106 + ``` 107 + 108 + 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. 109 + 110 + ### `backfill: false` 111 + 112 + 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. 113 + 114 + ```ts 115 + labels: { 116 + sources: [{ did: "did:plc:somenoisylabeler", backfill: false }], 117 + } 118 + ``` 119 + 120 + ## Storage 121 + 122 + ```sql 123 + CREATE TABLE labels ( 124 + src TEXT NOT NULL, -- labeler DID 125 + uri TEXT NOT NULL, -- subject: at://... or did:... 126 + val TEXT NOT NULL, -- label value 127 + cid TEXT, -- optional record-version pin 128 + neg INTEGER NOT NULL DEFAULT 0, 129 + exp INTEGER, -- expiry, unix sec 130 + cts INTEGER NOT NULL, -- creation time, unix sec 131 + sig BLOB, -- signature bytes (stored, not verified in v1) 132 + PRIMARY KEY (src, uri, val, cts) 133 + ); 134 + ``` 135 + 136 + 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. 137 + 138 + ## What's not here 139 + 140 + - **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. 141 + - **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. 142 + - **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. 143 + - **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. 144 + - **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. 145 + 146 + ## Design 147 + 148 + 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
··· 1 + - allow running custom functions before ingestion (e.g. for filtering out unallowed writes)
+1
packages/contrail/package.json
··· 65 65 }, 66 66 "dependencies": { 67 67 "@atcute/atproto": "^3.1.10", 68 + "@atcute/cbor": "^2.3.2", 68 69 "@atcute/cid": "^2.4.1", 69 70 "@atcute/client": "^4.2.1", 70 71 "@atcute/identity": "^1.1.4",
+61 -5
packages/contrail/src/cli.ts
··· 19 19 import type { CollectionStats, RefreshResult } from "./core/refresh.js"; 20 20 import type { Database } from "./core/types.js"; 21 21 22 - type Subcommand = "backfill" | "refresh" | "dev" | "help"; 22 + type Subcommand = "backfill" | "refresh" | "labels-backfill" | "dev" | "help"; 23 23 24 24 const USAGE = `contrail <subcommand> [options] 25 25 26 26 Subcommands: 27 - backfill One-time bulk load from each known DID's PDS (resumable) 28 - refresh Fresh sweep: reconcile PDS vs DB, report missing + stale 29 - dev Local wrangler dev + auto-trigger cron + backfill/refresh prompts 30 - help Print this message 27 + backfill One-time bulk load from each known DID's PDS (resumable) 28 + refresh Fresh sweep: reconcile PDS vs DB, report missing + stale 29 + labels-backfill One-shot drain per configured labeler — runs catch-up cycles 30 + until each labeler has no more pending events (resumable) 31 + dev Local wrangler dev + auto-trigger cron + backfill/refresh prompts 32 + help Print this message 31 33 32 34 Options (backfill): 33 35 --config <path> Path to Contrail config file (TS or JS). ··· 285 287 }); 286 288 printRefreshReport(result, opts.byCollection); 287 289 return 0; 290 + } 291 + 292 + if (opts.cmd === "labels-backfill") { 293 + const configPath = resolveConfigPath(opts); 294 + if (!configPath) return 1; 295 + const config = await loadConfig(configPath); 296 + if (!config.labels || config.labels.sources.length === 0) { 297 + console.error("No labels configured (config.labels.sources is empty)."); 298 + return 1; 299 + } 300 + const { getPlatformProxy } = await import("wrangler"); 301 + const { env, dispose } = await getPlatformProxy({ 302 + environment: opts.remote ? "production" : undefined, 303 + }); 304 + try { 305 + const db = (env as Record<string, unknown>)[opts.binding] as Database | undefined; 306 + if (!db) { 307 + console.error(`No binding named "${opts.binding}" in wrangler env.`); 308 + return 1; 309 + } 310 + const contrail = new Contrail(config); 311 + await contrail.init(db); 312 + 313 + // Run cycles until each cycle drains nothing new — measured by the 314 + // labeler_cursors not advancing across two consecutive cycles. 315 + const before = new Map<string, number>(); 316 + let stable = 0; 317 + while (stable < 2) { 318 + const rows = ( 319 + await db 320 + .prepare("SELECT did, cursor FROM labeler_cursors") 321 + .all<{ did: string; cursor: number }>() 322 + ).results ?? []; 323 + for (const r of rows) before.set(r.did, r.cursor); 324 + await contrail.ingestLabels({ timeoutMs: 60_000 }, db); 325 + const after = ( 326 + await db 327 + .prepare("SELECT did, cursor FROM labeler_cursors") 328 + .all<{ did: string; cursor: number }>() 329 + ).results ?? []; 330 + let advanced = false; 331 + for (const r of after) { 332 + if ((before.get(r.did) ?? -1) !== r.cursor) { 333 + advanced = true; 334 + break; 335 + } 336 + } 337 + stable = advanced ? 0 : stable + 1; 338 + } 339 + console.log("labels-backfill: caught up"); 340 + return 0; 341 + } finally { 342 + await dispose(); 343 + } 288 344 } 289 345 290 346 if (opts.cmd === "dev") return cmdDev(opts);
+29
packages/contrail/src/contrail.ts
··· 13 13 import type { NotifyResult } from "./core/router/notify"; 14 14 import { runPersistent as runPersistentIngestion } from "./core/persistent"; 15 15 import type { PersistentIngestOptions } from "./core/persistent"; 16 + import { 17 + runLabelIngestCycle, 18 + runPersistentLabels, 19 + type PersistentLabelsOptions, 20 + } from "./core/labels/subscribe"; 16 21 import type { PubSub } from "./core/realtime/types"; 17 22 import { InMemoryPubSub } from "./core/realtime/in-memory"; 18 23 import { createApp, type CreateAppOptions } from "./core/router"; ··· 96 101 ...options, 97 102 logger: this.config.logger, 98 103 pubsub: this._pubsub ?? undefined, 104 + }); 105 + } 106 + 107 + /** Run one labeler ingestion cycle — for every labeler in `config.labels.sources`, 108 + * drains pending `subscribeLabels` frames and persists them to the `labels` 109 + * table. No-op when `config.labels` is unset. Mirrors `ingest()`. */ 110 + async ingestLabels( 111 + options?: { timeoutMs?: number }, 112 + db?: Database, 113 + ): Promise<void> { 114 + if (!this.config.labels) return; 115 + await runLabelIngestCycle(this.getDb(db), this.config, options?.timeoutMs); 116 + } 117 + 118 + /** Long-lived label ingestion — one socket per labeler, auto-reconnect on drop. 119 + * No-op when `config.labels` is unset. Mirrors `runPersistent()`. */ 120 + async runPersistentLabels( 121 + options?: Omit<PersistentLabelsOptions, "logger">, 122 + db?: Database, 123 + ): Promise<void> { 124 + if (!this.config.labels) return; 125 + await runPersistentLabels(this.getDb(db), this.config, { 126 + ...options, 127 + logger: this.config.logger, 99 128 }); 100 129 } 101 130
+19
packages/contrail/src/index.ts
··· 94 94 DurableObjectState, 95 95 } from "./core/realtime/durable-object"; 96 96 97 + // Labels 98 + export type { 99 + LabelsConfig, 100 + LabelerSource, 101 + LabelRow, 102 + LabelerCursorRow, 103 + } from "./core/labels/types"; 104 + export type { HydratedLabel } from "./core/labels/hydrate"; 105 + export { hydrateLabels } from "./core/labels/hydrate"; 106 + export { selectAcceptedLabelers } from "./core/labels/select"; 107 + export { applyLabels } from "./core/labels/apply"; 108 + export type { IncomingLabel } from "./core/labels/apply"; 109 + export { 110 + runLabelIngestCycle, 111 + runPersistentLabels, 112 + } from "./core/labels/subscribe"; 113 + export type { PersistentLabelsOptions } from "./core/labels/subscribe"; 114 + export { resolveLabelerEndpoint } from "./core/labels/resolve"; 115 + 97 116 // Community 98 117 export { 99 118 CommunityAdapter,
+1 -1
packages/contrail/tests/community-delegation.test.ts
··· 278 278 expect(createRes.status).toBe(200); 279 279 const bridged = ((await createRes.json()) as any).space.uri as string; 280 280 281 - const firstAdmin = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; 281 + const firstAdmin = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; 282 282 expect((await grant(app, DIANA, bridged, { spaceUri: firstAdmin }, "member")).status).toBe(200); 283 283 284 284 // Alice has no direct grant in the second community, but is reachable via
+8 -8
packages/contrail/tests/community-e2e.test.ts
··· 131 131 it("adopts a community and creates reserved spaces with creator as owner", async () => { 132 132 const did = await adopt(app, ALICE); 133 133 134 - const adminUri = `at://${did}/tools.atmo.event.space/$admin`; 135 - const publishersUri = `at://${did}/tools.atmo.event.space/$publishers`; 134 + const adminUri = `ats://${did}/tools.atmo.event.space/$admin`; 135 + const publishersUri = `ats://${did}/tools.atmo.event.space/$publishers`; 136 136 137 137 // whoami in both reserved spaces → owner 138 138 for (const uri of [adminUri, publishersUri]) { ··· 171 171 }); 172 172 expect(res.status).toBe(200); 173 173 const body = (await res.json()) as any; 174 - expect(body.space.uri).toBe(`at://${COMMUNITY_DID}/tools.atmo.event.space/general`); 174 + expect(body.space.uri).toBe(`ats://${COMMUNITY_DID}/tools.atmo.event.space/general`); 175 175 expect(body.space.ownerDid).toBe(COMMUNITY_DID); 176 176 }); 177 177 ··· 193 193 }); 194 194 195 195 it("owner grants Bob member access to #general; reconciler populates spaces_members", async () => { 196 - const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`; 196 + const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`; 197 197 const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, { 198 198 spaceUri, 199 199 subject: { did: BOB }, ··· 214 214 }); 215 215 216 216 it("manager cannot grant higher than own level", async () => { 217 - const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`; 217 + const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`; 218 218 // Promote Bob to manager 219 219 await call(app, "POST", "/xrpc/test.comm.community.space.grant", ALICE, { 220 220 spaceUri, ··· 232 232 }); 233 233 234 234 it("grant cannot downgrade a subject who outranks the caller", async () => { 235 - const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`; 235 + const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`; 236 236 // Bob is currently manager (promoted earlier in this describe block). 237 237 // Alice is owner of the space. Bob tries to downgrade Alice to member via grant. 238 238 const res = await call(app, "POST", "/xrpc/test.comm.community.space.grant", BOB, { ··· 245 245 }); 246 246 247 247 it("revokes and reconciler removes from spaces_members", async () => { 248 - const spaceUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/general`; 248 + const spaceUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/general`; 249 249 const res = await call(app, "POST", "/xrpc/test.comm.community.space.revoke", ALICE, { 250 250 spaceUri, 251 251 subject: { did: BOB }, ··· 262 262 }); 263 263 264 264 it("cannot delete a reserved space", async () => { 265 - const adminUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; 265 + const adminUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; 266 266 const res = await call(app, "POST", "/xrpc/test.comm.community.space.delete", ALICE, { 267 267 spaceUri: adminUri, 268 268 });
+1 -1
packages/contrail/tests/community-mint.test.ts
··· 149 149 expect(ourCalls[0]!.body.rotationKeys).toHaveLength(2); 150 150 151 151 // Reserved spaces exist with the caller as owner. 152 - const adminUri = `at://${body.communityDid}/tools.atmo.event.space/$admin`; 152 + const adminUri = `ats://${body.communityDid}/tools.atmo.event.space/$admin`; 153 153 const whoami = await call(app, "GET", `/xrpc/test.comm.spaceExt.whoami?spaceUri=${encodeURIComponent(adminUri)}`, ALICE); 154 154 expect(((await whoami.json()) as any).accessLevel).toBe("owner"); 155 155 });
+2 -2
packages/contrail/tests/community-publishing.test.ts
··· 132 132 133 133 describe("community publishing + reauth — stage 3", () => { 134 134 let app: Hono; 135 - const publishers = `at://${COMMUNITY_DID}/tools.atmo.event.space/$publishers`; 136 - const admin = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; 135 + const publishers = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$publishers`; 136 + const admin = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; 137 137 138 138 beforeAll(async () => { 139 139 app = await makeApp();
+1 -1
packages/contrail/tests/invite-unified.test.ts
··· 129 129 appPassword: "anything", 130 130 }); 131 131 expect(adopt.status).toBe(200); 132 - adminUri = `at://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; 132 + adminUri = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; 133 133 134 134 // Alice (owner in $admin) creates a child space. 135 135 const create = await call(app, "POST", "/xrpc/test.inv.community.space.create", ALICE, {
+141
packages/contrail/tests/labels-router.test.ts
··· 1 + /** End-to-end router checks for label hydration: 2 + * - `listRecords` attaches `record.labels` and echoes `atproto-content-labelers` 3 + * - `getRecord` does the same for a single record 4 + * - caller-provided `?labelers=` overrides config defaults 5 + * - unaccepted labelers are dropped */ 6 + import { describe, it, expect } from "vitest"; 7 + import { Contrail } from "../src/contrail"; 8 + import { createSqliteDatabase } from "../src/adapters/sqlite"; 9 + import { applyEvents } from "../src/core/db/records"; 10 + import { applyLabels } from "../src/core/labels/apply"; 11 + import type { IngestEvent } from "../src/core/types"; 12 + 13 + const COLL = "com.example.event"; 14 + const SRC_A = "did:plc:labelerA"; 15 + const SRC_B = "did:plc:labelerB"; 16 + const AUTHOR = "did:plc:author"; 17 + const RKEY = "abc"; 18 + const URI = `at://${AUTHOR}/${COLL}/${RKEY}`; 19 + 20 + function ev(): IngestEvent { 21 + const now = Date.now() * 1000; 22 + return { 23 + uri: URI, 24 + did: AUTHOR, 25 + collection: COLL, 26 + rkey: RKEY, 27 + operation: "create", 28 + cid: "bafy-1", 29 + record: JSON.stringify({ name: "test" }), 30 + time_us: now, 31 + indexed_at: now, 32 + }; 33 + } 34 + 35 + async function setup() { 36 + const db = createSqliteDatabase(":memory:"); 37 + const contrail = new Contrail({ 38 + namespace: "ex", 39 + collections: { event: { collection: COLL } }, 40 + labels: { 41 + sources: [{ did: SRC_A }, { did: SRC_B }], 42 + defaults: [SRC_A], // SRC_B is opt-in via caller 43 + }, 44 + db, 45 + }); 46 + await contrail.init(); 47 + await applyEvents(db, [ev()], contrail.config); 48 + await applyLabels(db, [ 49 + { src: SRC_A, uri: URI, val: "spam", cts: new Date().toISOString() }, 50 + { src: SRC_B, uri: URI, val: "porn", cts: new Date().toISOString() }, 51 + ]); 52 + return { db, contrail }; 53 + } 54 + 55 + describe("labels router integration", () => { 56 + it("listRecords attaches labels and echoes atproto-content-labelers", async () => { 57 + const { contrail } = await setup(); 58 + const app = contrail.app(); 59 + 60 + const res = await app.fetch(new Request(`http://localhost/xrpc/ex.event.listRecords`)); 61 + expect(res.status).toBe(200); 62 + 63 + expect(res.headers.get("atproto-content-labelers")).toBe(SRC_A); 64 + 65 + const body = (await res.json()) as { records: Array<{ uri: string; labels?: any[] }> }; 66 + expect(body.records).toHaveLength(1); 67 + expect(body.records[0]!.labels).toBeDefined(); 68 + expect(body.records[0]!.labels!.map((l: any) => l.val)).toEqual(["spam"]); 69 + }); 70 + 71 + it("getRecord attaches labels for a single record", async () => { 72 + const { contrail } = await setup(); 73 + const app = contrail.app(); 74 + 75 + const res = await app.fetch( 76 + new Request(`http://localhost/xrpc/ex.event.getRecord?uri=${encodeURIComponent(URI)}`), 77 + ); 78 + expect(res.status).toBe(200); 79 + expect(res.headers.get("atproto-content-labelers")).toBe(SRC_A); 80 + 81 + const body = (await res.json()) as { uri: string; labels?: any[] }; 82 + expect(body.labels?.map((l: any) => l.val)).toEqual(["spam"]); 83 + }); 84 + 85 + it("?labelers= override pulls from a non-default source", async () => { 86 + const { contrail } = await setup(); 87 + const app = contrail.app(); 88 + 89 + const res = await app.fetch( 90 + new Request(`http://localhost/xrpc/ex.event.listRecords?labelers=${SRC_B}`), 91 + ); 92 + expect(res.headers.get("atproto-content-labelers")).toBe(SRC_B); 93 + const body = (await res.json()) as { records: Array<{ labels?: any[] }> }; 94 + expect(body.records[0]!.labels?.map((l: any) => l.val)).toEqual(["porn"]); 95 + }); 96 + 97 + it("atproto-accept-labelers header takes precedence over query param", async () => { 98 + const { contrail } = await setup(); 99 + const app = contrail.app(); 100 + 101 + const res = await app.fetch( 102 + new Request(`http://localhost/xrpc/ex.event.listRecords?labelers=${SRC_B}`, { 103 + headers: { "atproto-accept-labelers": SRC_A }, 104 + }), 105 + ); 106 + expect(res.headers.get("atproto-content-labelers")).toBe(SRC_A); 107 + const body = (await res.json()) as { records: Array<{ labels?: any[] }> }; 108 + expect(body.records[0]!.labels?.map((l: any) => l.val)).toEqual(["spam"]); 109 + }); 110 + 111 + it("when no labels are configured, no header and no field", async () => { 112 + const db = createSqliteDatabase(":memory:"); 113 + const contrail = new Contrail({ 114 + namespace: "ex", 115 + collections: { event: { collection: COLL } }, 116 + // no labels: {} block 117 + db, 118 + }); 119 + await contrail.init(); 120 + await applyEvents(db, [ev()], contrail.config); 121 + const app = contrail.app(); 122 + 123 + const res = await app.fetch(new Request(`http://localhost/xrpc/ex.event.listRecords`)); 124 + expect(res.status).toBe(200); 125 + expect(res.headers.get("atproto-content-labelers")).toBeNull(); 126 + const body = (await res.json()) as { records: Array<{ labels?: any[] }> }; 127 + expect(body.records[0]!.labels).toBeUndefined(); 128 + }); 129 + 130 + it("unknown caller-supplied DIDs are dropped (allowUserSupplied off)", async () => { 131 + const { contrail } = await setup(); 132 + const app = contrail.app(); 133 + 134 + const res = await app.fetch( 135 + new Request(`http://localhost/xrpc/ex.event.listRecords?labelers=did:plc:bogus`), 136 + ); 137 + expect(res.headers.get("atproto-content-labelers")).toBeNull(); 138 + const body = (await res.json()) as { records: Array<{ labels?: any[] }> }; 139 + expect(body.records[0]!.labels).toBeUndefined(); 140 + }); 141 + });
+188
packages/contrail/tests/labels.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { createSqliteDatabase } from "../src/adapters/sqlite"; 3 + import { initSchema } from "../src/core/db/schema"; 4 + import { resolveConfig, type ContrailConfig } from "../src/core/types"; 5 + import { applyLabels } from "../src/core/labels/apply"; 6 + import { hydrateLabels } from "../src/core/labels/hydrate"; 7 + import { selectAcceptedLabelers } from "../src/core/labels/select"; 8 + import type { LabelsConfig } from "../src/core/labels/types"; 9 + 10 + const SRC_A = "did:plc:labelerA"; 11 + const SRC_B = "did:plc:labelerB"; 12 + const URI_X = "at://did:plc:author/com.example.event/1"; 13 + const URI_Y = "at://did:plc:author/com.example.event/2"; 14 + const ACC_DID = "did:plc:author"; 15 + 16 + function ctsIso(deltaSec = 0): string { 17 + return new Date(Date.now() + deltaSec * 1000).toISOString(); 18 + } 19 + 20 + async function setup() { 21 + const db = createSqliteDatabase(":memory:"); 22 + const config: ContrailConfig = { 23 + namespace: "com.example", 24 + collections: { 25 + event: { collection: "com.example.event" }, 26 + }, 27 + labels: { 28 + sources: [{ did: SRC_A }, { did: SRC_B }], 29 + }, 30 + }; 31 + const resolved = resolveConfig(config); 32 + await initSchema(db, resolved); 33 + return { db, config: resolved }; 34 + } 35 + 36 + describe("labels: applyLabels + hydrate", () => { 37 + it("upserts and hydrates a basic label", async () => { 38 + const { db } = await setup(); 39 + await applyLabels(db, [ 40 + { src: SRC_A, uri: URI_X, val: "spam", cts: ctsIso() }, 41 + ]); 42 + const out = await hydrateLabels(db, [URI_X], [SRC_A]); 43 + expect(out[URI_X]).toHaveLength(1); 44 + expect(out[URI_X][0]!.val).toBe("spam"); 45 + expect(out[URI_X][0]!.src).toBe(SRC_A); 46 + }); 47 + 48 + it("filters by accepted labelers — unaccepted source drops out", async () => { 49 + const { db } = await setup(); 50 + await applyLabels(db, [ 51 + { src: SRC_A, uri: URI_X, val: "a", cts: ctsIso() }, 52 + { src: SRC_B, uri: URI_X, val: "b", cts: ctsIso() }, 53 + ]); 54 + const out = await hydrateLabels(db, [URI_X], [SRC_A]); 55 + expect(out[URI_X]).toHaveLength(1); 56 + expect(out[URI_X][0]!.val).toBe("a"); 57 + }); 58 + 59 + it("collapses (src, uri, val) by latest cts and drops neg=true winners", async () => { 60 + const { db } = await setup(); 61 + await applyLabels(db, [ 62 + { src: SRC_A, uri: URI_X, val: "spam", cts: ctsIso(-10) }, 63 + // Negation arrives later — should retract. 64 + { src: SRC_A, uri: URI_X, val: "spam", cts: ctsIso(0), neg: true }, 65 + ]); 66 + const out = await hydrateLabels(db, [URI_X], [SRC_A]); 67 + expect(out[URI_X] ?? []).toHaveLength(0); 68 + }); 69 + 70 + it("re-positive after a negation wins again", async () => { 71 + const { db } = await setup(); 72 + await applyLabels(db, [ 73 + { src: SRC_A, uri: URI_X, val: "spam", cts: ctsIso(-20) }, 74 + { src: SRC_A, uri: URI_X, val: "spam", cts: ctsIso(-10), neg: true }, 75 + { src: SRC_A, uri: URI_X, val: "spam", cts: ctsIso(0) }, 76 + ]); 77 + const out = await hydrateLabels(db, [URI_X], [SRC_A]); 78 + expect(out[URI_X]).toHaveLength(1); 79 + }); 80 + 81 + it("expired labels are dropped", async () => { 82 + const { db } = await setup(); 83 + const yesterday = new Date(Date.now() - 86400_000).toISOString(); 84 + await applyLabels(db, [ 85 + { src: SRC_A, uri: URI_X, val: "x", cts: ctsIso(-3600), exp: yesterday }, 86 + ]); 87 + const out = await hydrateLabels(db, [URI_X], [SRC_A]); 88 + expect(out[URI_X] ?? []).toHaveLength(0); 89 + }); 90 + 91 + it("CID pin filters to matching record version", async () => { 92 + const { db } = await setup(); 93 + await applyLabels(db, [ 94 + { src: SRC_A, uri: URI_X, val: "v1-only", cts: ctsIso(), cid: "bafy-old" }, 95 + { src: SRC_A, uri: URI_X, val: "any-version", cts: ctsIso() }, 96 + ]); 97 + const cidByUri = new Map([[URI_X, "bafy-new"]]); 98 + const out = await hydrateLabels(db, [URI_X], [SRC_A], cidByUri); 99 + expect(out[URI_X]?.map((l) => l.val).sort()).toEqual(["any-version"]); 100 + }); 101 + 102 + it("account-level label keyed by bare DID hydrates fine", async () => { 103 + const { db } = await setup(); 104 + await applyLabels(db, [ 105 + { src: SRC_A, uri: ACC_DID, val: "!hide", cts: ctsIso() }, 106 + ]); 107 + const out = await hydrateLabels(db, [ACC_DID], [SRC_A]); 108 + expect(out[ACC_DID]).toHaveLength(1); 109 + expect(out[ACC_DID][0]!.val).toBe("!hide"); 110 + }); 111 + 112 + it("cross-subject hydration: many uris in one query", async () => { 113 + const { db } = await setup(); 114 + await applyLabels(db, [ 115 + { src: SRC_A, uri: URI_X, val: "a", cts: ctsIso() }, 116 + { src: SRC_A, uri: URI_Y, val: "b", cts: ctsIso() }, 117 + ]); 118 + const out = await hydrateLabels(db, [URI_X, URI_Y], [SRC_A]); 119 + expect(out[URI_X]).toHaveLength(1); 120 + expect(out[URI_Y]).toHaveLength(1); 121 + }); 122 + }); 123 + 124 + describe("labels: selectAcceptedLabelers", () => { 125 + const cfg: LabelsConfig = { 126 + sources: [{ did: SRC_A }, { did: SRC_B }], 127 + }; 128 + 129 + it("falls back to defaults (= sources) when caller sends nothing", () => { 130 + const sel = selectAcceptedLabelers(null, null, cfg); 131 + expect(sel.accepted).toEqual([SRC_A, SRC_B]); 132 + expect(sel.lazyAdd).toEqual([]); 133 + }); 134 + 135 + it("honors header before query param", () => { 136 + const sel = selectAcceptedLabelers(SRC_A, SRC_B, cfg); 137 + expect(sel.accepted).toEqual([SRC_A]); 138 + }); 139 + 140 + it("falls through to query param when header is empty", () => { 141 + const sel = selectAcceptedLabelers("", SRC_B, cfg); 142 + expect(sel.accepted).toEqual([SRC_B]); 143 + }); 144 + 145 + it("drops unknown DIDs by default", () => { 146 + const sel = selectAcceptedLabelers("did:plc:strangerlabeler", null, cfg); 147 + expect(sel.accepted).toEqual([]); 148 + expect(sel.lazyAdd).toEqual([]); 149 + }); 150 + 151 + it("collects unknowns as lazyAdd when allowUserSupplied is true", () => { 152 + const sel = selectAcceptedLabelers( 153 + `did:plc:strangerlabeler,${SRC_A}`, 154 + null, 155 + { ...cfg, allowUserSupplied: true }, 156 + ); 157 + expect(sel.accepted).toEqual([SRC_A]); 158 + expect(sel.lazyAdd).toEqual(["did:plc:strangerlabeler"]); 159 + }); 160 + 161 + it("strips ;param modifiers from header values", () => { 162 + const sel = selectAcceptedLabelers( 163 + `${SRC_A};redact, ${SRC_B} ; foo`, 164 + null, 165 + cfg, 166 + ); 167 + expect(sel.accepted).toEqual([SRC_A, SRC_B]); 168 + }); 169 + 170 + it("caps at maxPerRequest", () => { 171 + const sel = selectAcceptedLabelers( 172 + `${SRC_A},${SRC_B}`, 173 + null, 174 + { ...cfg, maxPerRequest: 1 }, 175 + ); 176 + expect(sel.accepted).toEqual([SRC_A]); 177 + }); 178 + 179 + it("empty defaults => no labelers when caller is silent (opt-in policy)", () => { 180 + const sel = selectAcceptedLabelers(null, null, { ...cfg, defaults: [] }); 181 + expect(sel.accepted).toEqual([]); 182 + }); 183 + 184 + it("dedupes repeated DIDs in caller list", () => { 185 + const sel = selectAcceptedLabelers(`${SRC_A},${SRC_A},${SRC_A}`, null, cfg); 186 + expect(sel.accepted).toEqual([SRC_A]); 187 + }); 188 + });
+1 -1
packages/contrail/tests/spaces-acl.test.ts
··· 4 4 5 5 function mkSpace(overrides: Partial<SpaceRow> = {}): SpaceRow { 6 6 return { 7 - uri: "at://did:plc:alice/tools.atmo.event.space/s1", 7 + uri: "ats://did:plc:alice/tools.atmo.event.space/s1", 8 8 ownerDid: "did:plc:alice", 9 9 type: "tools.atmo.event.space", 10 10 key: "s1",
+1 -1
packages/contrail/tests/spaces-e2e.test.ts
··· 96 96 expect(res.status).toBe(200); 97 97 const { space } = await asJson(res); 98 98 spaceUri = space.uri; 99 - expect(spaceUri).toBe(`at://${ALICE}/tools.atmo.event.space/birthday-2026`); 99 + expect(spaceUri).toBe(`ats://${ALICE}/tools.atmo.event.space/birthday-2026`); 100 100 }); 101 101 102 102 it("owner can write a location record", async () => {
+5 -10
packages/lexicons/src/generate.ts
··· 220 220 ? { 221 221 space: { 222 222 type: "string", 223 - format: "at-uri", 224 - description: "Present when the record was read from a permissioned space; its value is the space URI.", 223 + description: "Present when the record was read from a permissioned space; its value is the `ats://` space URI.", 225 224 }, 226 225 } 227 226 : {}), ··· 279 278 ? { 280 279 space: { 281 280 type: "string", 282 - format: "at-uri", 283 - description: "Present when the record was read from a permissioned space.", 281 + description: "Present when the record was read from a permissioned space; `ats://` URI.", 284 282 }, 285 283 } 286 284 : {}), ··· 320 318 ? { 321 319 space: { 322 320 type: "string", 323 - format: "at-uri", 324 - description: "Present when the record was read from a permissioned space.", 321 + description: "Present when the record was read from a permissioned space; `ats://` URI.", 325 322 }, 326 323 } 327 324 : {}), ··· 591 588 ? { 592 589 spaceUri: { 593 590 type: "string", 594 - format: "at-uri", 595 - description: "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token).", 591 + description: "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token). `ats://` URI.", 596 592 }, 597 593 byUser: { 598 594 type: "string", ··· 725 721 ? { 726 722 spaceUri: { 727 723 type: "string", 728 - format: "at-uri", 729 - description: "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token).", 724 + description: "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token). `ats://` URI.", 730 725 }, 731 726 inviteToken: { 732 727 type: "string",
+1 -1
packages/sync/src/index.ts
··· 22 22 time_us?: number; 23 23 indexed_at?: number; 24 24 /** Set when the record originates from a per-space table. */ 25 - _space?: string; 25 + space?: string; 26 26 /** Present on optimistic entries added via `addOptimistic` — not set by 27 27 * records arriving from the stream. Auto-dropped when a real record 28 28 * with the same rkey arrives via `record.created`. */
+1 -1
apps/group-chat/src/routes/+layout.server.ts
··· 23 23 profiles?: Array<{ 24 24 did: string; 25 25 handle?: string | null; 26 - record?: { displayName?: string; avatar?: string }; 26 + value?: { displayName?: string; avatar?: string }; 27 27 }>; 28 28 }; 29 29 const entry = data.profiles?.[0];
+1 -1
apps/group-chat/src/routes/+page.server.ts
··· 28 28 records: Array<{ 29 29 did: string; 30 30 rkey: string; 31 - record: { 31 + value: { 32 32 communityDid?: string; 33 33 name?: string; 34 34 description?: string;
+6 -1
packages/contrail/src/core/types.ts
··· 161 161 /** Realtime module configuration. When set, the service exposes ticket + SSE/WS 162 162 * subscribe XRPCs, and wraps the spaces adapter to publish events after writes. */ 163 163 realtime?: import("./realtime/types").RealtimeConfig; 164 + /** Labels module configuration. When set, contrail subscribes to the 165 + * configured labelers, indexes their labels into a single `labels` table, 166 + * and hydrates `record.labels` onto `listRecords` / `getRecord` / profile 167 + * responses gated by the caller's `atproto-accept-labelers` header. */ 168 + labels?: import("./labels/types").LabelsConfig; 164 169 /** Customize the auto-generated `<namespace>.permissionSet` lexicon. */ 165 170 permissionSet?: PermissionSetConfig; 166 171 } ··· 292 297 /** Set when the row originates from a per-space table. Used by the 293 298 * pipeline/hydration/response layers to route child queries to the same 294 299 * space and tag the output. */ 295 - _space?: string; 300 + space?: string; 296 301 } 297 302 298 303 export interface IngestEvent {
+6
packages/contrail/src/worker/index.ts
··· 64 64 const db = env[binding] as Database; 65 65 await ensureReady(env, db); 66 66 ctx.waitUntil(contrail.ingest({}, db)); 67 + // Run label ingest alongside the jetstream catch-up so a single cron 68 + // tick keeps both data streams fresh. Only scheduled when configured — 69 + // skipping the no-op promise keeps the worker task list tight. 70 + if (config.labels) { 71 + ctx.waitUntil(contrail.ingestLabels({}, db)); 72 + } 67 73 }, 68 74 }; 69 75 }
+3 -3
packages/lexicons/lexicon-templates/community/defs.json
··· 22 22 "type": "object", 23 23 "required": ["uri", "ownerDid", "type", "key", "serviceDid", "createdAt"], 24 24 "properties": { 25 - "uri": { "type": "string", "format": "at-uri" }, 25 + "uri": { "type": "string" }, 26 26 "ownerDid": { "type": "string", "format": "did" }, 27 27 "type": { "type": "string", "format": "nsid" }, 28 28 "key": { "type": "string" }, ··· 35 35 "description": "Exactly one of `did` or `spaceUri` must be set.", 36 36 "properties": { 37 37 "did": { "type": "string", "format": "did" }, 38 - "spaceUri": { "type": "string", "format": "at-uri" } 38 + "spaceUri": { "type": "string" } 39 39 } 40 40 }, 41 41 "memberRow": { ··· 61 61 "required": ["tokenHash", "spaceUri", "accessLevel", "createdBy", "createdAt", "usedCount"], 62 62 "properties": { 63 63 "tokenHash": { "type": "string", "description": "SHA-256 of the raw token. Stable id for list/revoke; never grants access on its own." }, 64 - "spaceUri": { "type": "string", "format": "at-uri" }, 64 + "spaceUri": { "type": "string" }, 65 65 "accessLevel": { "type": "ref", "ref": "#accessLevel" }, 66 66 "createdBy": { "type": "string", "format": "did" }, 67 67 "createdAt": { "type": "integer" },
+1 -1
packages/lexicons/lexicon-templates/invite/create.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "kind": { "type": "string", "knownValues": ["join", "read", "read-join"], "description": "For user-owned spaces. join: redeem to become a member. read: bearer-only read access, no membership. read-join: anonymous read + signed-in redeem to join." }, 16 16 "accessLevel": { "type": "string", "knownValues": ["member", "manager", "admin", "owner"], "description": "For community-owned spaces. The access level granted on redemption — the creator's own level caps what they can grant." }, 17 17 "expiresAt": { "type": "integer", "description": "Unix ms timestamp. Omit for no expiry." },
+1 -1
packages/lexicons/lexicon-templates/invite/defs.json
··· 8 8 "required": ["tokenHash", "spaceUri", "createdBy", "createdAt", "usedCount"], 9 9 "properties": { 10 10 "tokenHash": { "type": "string", "description": "Stable identifier for list/revoke operations." }, 11 - "spaceUri": { "type": "string", "format": "at-uri" }, 11 + "spaceUri": { "type": "string" }, 12 12 "kind": { "type": "string", "knownValues": ["join", "read", "read-join"], "description": "Set for user-owned spaces. Absent for community-owned." }, 13 13 "accessLevel": { "type": "string", "knownValues": ["member", "manager", "admin", "owner"], "description": "Set for community-owned spaces. Absent for user-owned." }, 14 14 "createdBy": { "type": "string", "format": "did" },
+1 -1
packages/lexicons/lexicon-templates/invite/list.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" }, 12 + "spaceUri": { "type": "string" }, 13 13 "includeRevoked": { "type": "boolean", "default": false } 14 14 } 15 15 },
+1 -1
packages/lexicons/lexicon-templates/invite/redeem.json
··· 21 21 "type": "object", 22 22 "required": ["spaceUri"], 23 23 "properties": { 24 - "spaceUri": { "type": "string", "format": "at-uri" }, 24 + "spaceUri": { "type": "string" }, 25 25 "kind": { "type": "string", "description": "Set for user-owned spaces — echoes the invite kind consumed." }, 26 26 "accessLevel": { "type": "string", "description": "Set for community-owned spaces — the level granted." }, 27 27 "communityDid": { "type": "string", "format": "did", "description": "Set for community-owned spaces." }
+1 -1
packages/lexicons/lexicon-templates/invite/revoke.json
··· 11 11 "type": "object", 12 12 "required": ["tokenHash"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri", "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check." }, 14 + "spaceUri": { "type": "string", "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check." }, 15 15 "tokenHash": { "type": "string" } 16 16 } 17 17 }
+1 -1
packages/lexicons/lexicon-templates/spaces/addMember.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri", "did"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "did": { "type": "string", "format": "did" } 16 16 } 17 17 }
+3 -3
packages/lexicons/lexicon-templates/spaces/defs.json
··· 7 7 "type": "object", 8 8 "required": ["uri", "ownerDid", "type", "key", "serviceDid", "createdAt"], 9 9 "properties": { 10 - "uri": { "type": "string", "format": "at-uri" }, 10 + "uri": { "type": "string" }, 11 11 "ownerDid": { "type": "string", "format": "did" }, 12 12 "type": { "type": "string", "format": "nsid" }, 13 13 "key": { "type": "string" }, ··· 30 30 "type": "object", 31 31 "required": ["spaceUri", "collection", "authorDid", "rkey", "record", "createdAt"], 32 32 "properties": { 33 - "spaceUri": { "type": "string", "format": "at-uri" }, 33 + "spaceUri": { "type": "string" }, 34 34 "collection": { "type": "string", "format": "nsid" }, 35 35 "authorDid": { "type": "string", "format": "did" }, 36 36 "rkey": { "type": "string" }, ··· 63 63 "required": ["tokenHash", "spaceUri", "kind", "usedCount", "createdBy", "createdAt"], 64 64 "properties": { 65 65 "tokenHash": { "type": "string" }, 66 - "spaceUri": { "type": "string", "format": "at-uri" }, 66 + "spaceUri": { "type": "string" }, 67 67 "kind": { "type": "string", "knownValues": ["join", "read", "read-join"] }, 68 68 "expiresAt": { "type": "integer" }, 69 69 "maxUses": { "type": "integer" },
+1 -1
packages/lexicons/lexicon-templates/spaces/deleteRecord.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri", "collection", "rkey"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "collection": { "type": "string", "format": "nsid" }, 16 16 "rkey": { "type": "string" } 17 17 }
+1 -1
packages/lexicons/lexicon-templates/spaces/getBlob.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri", "cid"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" }, 12 + "spaceUri": { "type": "string" }, 13 13 "cid": { "type": "string", "format": "cid" }, 14 14 "inviteToken": { 15 15 "type": "string",
+1 -1
packages/lexicons/lexicon-templates/spaces/getRecord.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri", "collection", "author", "rkey"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" }, 12 + "spaceUri": { "type": "string" }, 13 13 "collection": { "type": "string", "format": "nsid" }, 14 14 "author": { "type": "string", "format": "did" }, 15 15 "rkey": { "type": "string" },
+1 -1
packages/lexicons/lexicon-templates/spaces/getSpace.json
··· 9 9 "type": "params", 10 10 "required": ["uri"], 11 11 "properties": { 12 - "uri": { "type": "string", "format": "at-uri" }, 12 + "uri": { "type": "string" }, 13 13 "inviteToken": { "type": "string", "description": "Read-grant invite token. When supplied, replaces JWT auth for this read." } 14 14 } 15 15 },
+1 -1
packages/lexicons/lexicon-templates/spaces/leaveSpace.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" } 14 + "spaceUri": { "type": "string" } 15 15 } 16 16 } 17 17 },
+1 -1
packages/lexicons/lexicon-templates/spaces/listBlobs.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" }, 12 + "spaceUri": { "type": "string" }, 13 13 "byUser": { "type": "string", "format": "did", "description": "Only blobs uploaded by this DID." }, 14 14 "limit": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, 15 15 "cursor": { "type": "string" }
+1 -1
packages/lexicons/lexicon-templates/spaces/listMembers.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" } 12 + "spaceUri": { "type": "string" } 13 13 } 14 14 }, 15 15 "output": {
+1 -1
packages/lexicons/lexicon-templates/spaces/listRecords.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri", "collection"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" }, 12 + "spaceUri": { "type": "string" }, 13 13 "collection": { "type": "string", "format": "nsid" }, 14 14 "byUser": { "type": "string", "format": "did", "description": "Only return records authored by this DID." }, 15 15 "cursor": { "type": "string" },
+1 -1
packages/lexicons/lexicon-templates/spaces/putRecord.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri", "collection", "record"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "collection": { "type": "string", "format": "nsid" }, 16 16 "rkey": { "type": "string" }, 17 17 "record": { "type": "unknown" }
+1 -1
packages/lexicons/lexicon-templates/spaces/removeMember.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri", "did"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "did": { "type": "string", "format": "did" } 16 16 } 17 17 }
+1 -1
packages/lexicons/lexicon-templates/spaces/uploadBlob.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" } 12 + "spaceUri": { "type": "string" } 13 13 } 14 14 }, 15 15 "input": { "encoding": "*/*" },
+1 -1
apps/group-chat/src/lib/contrail/client.ts
··· 8 8 9 9 /** 10 10 * Extract a simple profile from a contrail profile entry. 11 - * Contrail returns { did, handle, record: { displayName, avatar, ... } } 11 + * Contrail returns { did, handle, value: { displayName, avatar, ... } } 12 12 * while components expect { handle, displayName?, avatar? }. 13 13 */ 14 14 export function extractProfile(entry: {
+1 -1
apps/group-chat/src/lib/rooms/profiles.svelte.ts
··· 35 35 profiles?: Array<{ 36 36 did: string; 37 37 handle?: string | null; 38 - record?: { displayName?: string; avatar?: string }; 38 + value?: { displayName?: string; avatar?: string }; 39 39 }>; 40 40 }; 41 41 const entry = data.profiles?.[0];
+3 -3
apps/group-chat/src/lib/rooms/uri.ts
··· 2 2 * 3 3 * The URIs are returned as `ResourceUri` (branded) so they flow into 4 4 * lexicon-typed XRPC params without per-call casts. We apply the brand 5 - * once here — structurally these are just `at://…` strings. */ 5 + * once here — structurally these are just `ats://…` strings. */ 6 6 7 7 import type { ResourceUri } from '@atcute/lexicons'; 8 8 9 9 const SPACE_TYPE = 'tools.atmo.chat.space'; 10 10 11 11 export function buildSpaceUri(communityDid: string, key: string): ResourceUri { 12 - return `at://${communityDid}/${SPACE_TYPE}/${key}` as ResourceUri; 12 + return `ats://${communityDid}/${SPACE_TYPE}/${key}` as ResourceUri; 13 13 } 14 14 15 15 export function buildMembersUri(communityDid: string): ResourceUri { ··· 20 20 return buildSpaceUri(communityDid, '$admin'); 21 21 } 22 22 23 - const SPACE_URI_RE = new RegExp(`^at://([^/]+)/${SPACE_TYPE.replace(/\./g, '\\.')}/([^/]+)$`); 23 + const SPACE_URI_RE = new RegExp(`^ats://([^/]+)/${SPACE_TYPE.replace(/\./g, '\\.')}/([^/]+)$`); 24 24 25 25 export function parseSpaceUri(uri: string): { communityDid: string; key: string } | null { 26 26 const m = SPACE_URI_RE.exec(uri);
+3 -3
apps/group-chat/src/lib/rooms/watch.svelte.ts
··· 59 59 : Record<string, unknown> 60 60 : Record<string, unknown>; 61 61 62 - /** WatchRecord with a typed `record` payload. Mirrors WatchRecord's explicit 62 + /** WatchRecord with a typed `value` payload. Mirrors WatchRecord's explicit 63 63 * fields but without the `[k: string]: unknown` catchall (which would 64 64 * poison property access under `Omit`). */ 65 65 export interface TypedWatchRecord<R> { ··· 67 67 did: string; 68 68 rkey: string; 69 69 collection: string; 70 - record: R; 70 + value: R; 71 71 time_us?: number; 72 72 indexed_at?: number; 73 73 cid?: string | null; 74 - _space?: string; 74 + space?: string; 75 75 /** Present on optimistic entries added via `addOptimistic`. */ 76 76 optimistic?: 'pending' | 'failed'; 77 77 /** Error attached via `markFailed`. */
+1 -1
apps/sveltekit-cloudflare-workers/src/lib/contrail/client.ts
··· 8 8 9 9 /** 10 10 * Extract a simple profile from a contrail profile entry. 11 - * Contrail returns { did, handle, record: { displayName, avatar, ... } } 11 + * Contrail returns { did, handle, value: { displayName, avatar, ... } } 12 12 * while components expect { handle, displayName?, avatar? }. 13 13 */ 14 14 export function extractProfile(entry: {
+1 -1
packages/contrail/src/core/db/records.ts
··· 793 793 record: row.record, 794 794 time_us: row.time_us, 795 795 indexed_at: row.indexed_at, 796 - ...(spaceUri ? { _space: spaceUri } : {}), 796 + ...(spaceUri ? { space: spaceUri } : {}), 797 797 }; 798 798 if (countCols.length > 0) { 799 799 const counts: Record<string, number> = {};
+8
packages/contrail/src/core/db/schema.ts
··· 12 12 import { getSearchableFields } from "../search"; 13 13 import { buildSpacesBaseSchema } from "../spaces/schema"; 14 14 import { buildCommunitySchema } from "../community/schema"; 15 + import { buildLabelsSchema } from "../labels/schema"; 15 16 16 17 function getResolved(config: ContrailConfig): ResolvedMaps { 17 18 return (config as ResolvedContrailConfig)._resolved ?? resolveConfig(config)._resolved; ··· 315 316 const target = spacesSharesMainDb ? db : spacesDb!; 316 317 const communityStmts = buildCommunitySchema(dialect); 317 318 await target.batch(communityStmts.map((s) => target.prepare(s))); 319 + } 320 + 321 + if (config.labels) { 322 + // Labels tables live on the main DB — they're keyed by at-URI / DID and 323 + // are read alongside public records during hydration. 324 + const labelsStmts = buildLabelsSchema(dialect); 325 + await db.batch(labelsStmts.map((s) => db.prepare(s))); 318 326 } 319 327 320 328 // FTS5 may not be available (e.g. node:sqlite) — skip gracefully
+64
packages/contrail/src/core/labels/apply.ts
··· 1 + import type { Database, Statement } from "../types"; 2 + 3 + /** Wire shape of a single `com.atproto.label.defs#label` entry. Field names 4 + * match the spec exactly. We accept the spec's ISO-8601 strings and 5 + * convert to unix seconds at the storage boundary. */ 6 + export interface IncomingLabel { 7 + src: string; 8 + uri: string; 9 + val: string; 10 + cid?: string; 11 + neg?: boolean; 12 + exp?: string; 13 + cts: string; 14 + sig?: Uint8Array; 15 + } 16 + 17 + /** Upsert a batch of labels. Idempotent on `(src, uri, val, cts)`. Bad rows 18 + * (missing required fields, unparseable timestamps) are dropped silently; 19 + * we don't want one malformed label to abort an entire labeler frame. */ 20 + export async function applyLabels( 21 + db: Database, 22 + labels: IncomingLabel[], 23 + ): Promise<number> { 24 + if (labels.length === 0) return 0; 25 + const stmts: Statement[] = []; 26 + let kept = 0; 27 + for (const l of labels) { 28 + if (!l.src || !l.uri || !l.val || !l.cts) continue; 29 + const cts = isoToUnixSec(l.cts); 30 + if (cts == null) continue; 31 + const exp = l.exp ? isoToUnixSec(l.exp) : null; 32 + stmts.push( 33 + db 34 + .prepare( 35 + `INSERT INTO labels (src, uri, val, cid, neg, exp, cts, sig) 36 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 37 + ON CONFLICT(src, uri, val, cts) DO UPDATE SET 38 + cid = excluded.cid, 39 + neg = excluded.neg, 40 + exp = excluded.exp, 41 + sig = excluded.sig`, 42 + ) 43 + .bind( 44 + l.src, 45 + l.uri, 46 + l.val, 47 + l.cid ?? null, 48 + l.neg ? 1 : 0, 49 + exp, 50 + cts, 51 + l.sig ?? null, 52 + ), 53 + ); 54 + kept++; 55 + } 56 + if (stmts.length > 0) await db.batch(stmts); 57 + return kept; 58 + } 59 + 60 + function isoToUnixSec(iso: string): number | null { 61 + const ms = Date.parse(iso); 62 + if (!Number.isFinite(ms)) return null; 63 + return Math.floor(ms / 1000); 64 + }
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
··· 1 + import { 2 + CompositeDidDocumentResolver, 3 + PlcDidDocumentResolver, 4 + WebDidDocumentResolver, 5 + } from "@atcute/identity-resolver"; 6 + import type { Did } from "@atcute/lexicons"; 7 + import type { Database } from "../types"; 8 + 9 + /** Reject endpoint URLs that point to private/internal addresses or non-HTTPS. 10 + * Mirrors the validator in core/client.ts — labeler endpoints should be 11 + * publicly reachable for the same reasons PDS endpoints should. */ 12 + function validateEndpointUrl(url: string): boolean { 13 + try { 14 + const parsed = new URL(url); 15 + if (parsed.protocol !== "https:") return false; 16 + const host = parsed.hostname; 17 + if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return false; 18 + if (host.startsWith("10.")) return false; 19 + if (host.startsWith("192.168.")) return false; 20 + if (host.startsWith("169.254.")) return false; 21 + if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false; 22 + return true; 23 + } catch { 24 + return false; 25 + } 26 + } 27 + 28 + const didResolver = new CompositeDidDocumentResolver({ 29 + methods: { 30 + plc: new PlcDidDocumentResolver(), 31 + web: new WebDidDocumentResolver(), 32 + }, 33 + }); 34 + 35 + /** Look up the labeler service endpoint from a DID. 36 + * Reads the DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. */ 37 + export async function resolveLabelerEndpoint(did: string): Promise<string | null> { 38 + if (!did.startsWith("did:plc:") && !did.startsWith("did:web:")) return null; 39 + try { 40 + const doc = await didResolver.resolve(did as Did<"plc"> | Did<"web">); 41 + const endpoint = doc.service 42 + ?.find((s) => s.id === "#atproto_labeler") 43 + ?.serviceEndpoint?.toString(); 44 + if (!endpoint) return null; 45 + if (!validateEndpointUrl(endpoint)) return null; 46 + return endpoint; 47 + } catch { 48 + return null; 49 + } 50 + } 51 + 52 + /** State row for a labeler — the per-DID equivalent of the singleton 53 + * jetstream `cursor` table, with cached endpoint to avoid repeated DID-doc 54 + * fetches. */ 55 + export interface LabelerState { 56 + did: string; 57 + cursor: number; 58 + endpoint: string | null; 59 + resolved_at: number | null; 60 + } 61 + 62 + const ENDPOINT_TTL_MS = 6 * 60 * 60 * 1000; // 6h, matches the recommended client cache for label-defs 63 + 64 + /** Get cached `(endpoint, cursor)` for a labeler. Resolves endpoint on 65 + * cache miss or staleness; persists endpoint + resolved_at back to the DB 66 + * so subsequent ingest cycles avoid the network round-trip. */ 67 + export async function getLabelerState( 68 + db: Database, 69 + did: string, 70 + endpointOverride: string | undefined, 71 + ): Promise<LabelerState | null> { 72 + const row = await db 73 + .prepare( 74 + "SELECT did, cursor, endpoint, resolved_at FROM labeler_cursors WHERE did = ?", 75 + ) 76 + .bind(did) 77 + .first<LabelerState>(); 78 + 79 + let endpoint = endpointOverride ?? row?.endpoint ?? null; 80 + const stale = 81 + !row?.resolved_at || Date.now() - row.resolved_at > ENDPOINT_TTL_MS; 82 + 83 + if (!endpoint || (!endpointOverride && stale)) { 84 + endpoint = await resolveLabelerEndpoint(did); 85 + if (!endpoint) return null; 86 + const now = Date.now(); 87 + await db 88 + .prepare( 89 + `INSERT INTO labeler_cursors (did, cursor, endpoint, resolved_at) 90 + VALUES (?, ?, ?, ?) 91 + ON CONFLICT(did) DO UPDATE SET endpoint = excluded.endpoint, resolved_at = excluded.resolved_at`, 92 + ) 93 + .bind(did, row?.cursor ?? 0, endpoint, now) 94 + .run(); 95 + return { 96 + did, 97 + cursor: row?.cursor ?? 0, 98 + endpoint, 99 + resolved_at: now, 100 + }; 101 + } 102 + 103 + return row ?? { did, cursor: 0, endpoint, resolved_at: null }; 104 + } 105 + 106 + /** Persist the highest seen seq number for a labeler. Idempotent; 107 + * the next ingest cycle resumes from `cursor + 1` via the `?cursor=` param. */ 108 + export async function saveLabelerCursor( 109 + db: Database, 110 + did: string, 111 + cursor: number, 112 + ): Promise<void> { 113 + await db 114 + .prepare( 115 + `INSERT INTO labeler_cursors (did, cursor) 116 + VALUES (?, ?) 117 + ON CONFLICT(did) DO UPDATE SET cursor = excluded.cursor`, 118 + ) 119 + .bind(did, cursor) 120 + .run(); 121 + } 122 + 123 + /** Reset cursor to 0 — used in response to `#info { name: "OutdatedCursor" }` 124 + * frames, which signal that the labeler's seq history was rewound. */ 125 + export async function resetLabelerCursor(db: Database, did: string): Promise<void> { 126 + await db 127 + .prepare( 128 + `INSERT INTO labeler_cursors (did, cursor) 129 + VALUES (?, 0) 130 + ON CONFLICT(did) DO UPDATE SET cursor = 0`, 131 + ) 132 + .bind(did) 133 + .run(); 134 + }
+30
packages/contrail/src/core/labels/schema.ts
··· 1 + import type { SqlDialect } from "../dialect"; 2 + 3 + /** DDL for the labels module. Single `labels` table covers record-level 4 + * (uri starts with `at://`) and account-level (uri is a bare DID) entries — 5 + * the spec collapses both into the same row shape. `labeler_cursors` 6 + * mirrors the role of the singleton `cursor` table for jetstream, but 7 + * per-labeler. */ 8 + export function buildLabelsSchema(dialect: SqlDialect): string[] { 9 + return [ 10 + `CREATE TABLE IF NOT EXISTS labels ( 11 + src TEXT NOT NULL, 12 + uri TEXT NOT NULL, 13 + val TEXT NOT NULL, 14 + cid TEXT, 15 + neg INTEGER NOT NULL DEFAULT 0, 16 + exp ${dialect.bigintType}, 17 + cts ${dialect.bigintType} NOT NULL, 18 + sig BLOB, 19 + PRIMARY KEY (src, uri, val, cts) 20 + )`, 21 + `CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri)`, 22 + `CREATE INDEX IF NOT EXISTS idx_labels_src_cts ON labels(src, cts DESC)`, 23 + `CREATE TABLE IF NOT EXISTS labeler_cursors ( 24 + did TEXT PRIMARY KEY, 25 + cursor ${dialect.bigintType} NOT NULL DEFAULT 0, 26 + endpoint TEXT, 27 + resolved_at ${dialect.bigintType} 28 + )`, 29 + ]; 30 + }
+75
packages/contrail/src/core/labels/select.ts
··· 1 + import type { LabelsConfig } from "./types"; 2 + import { DEFAULT_LABELS_MAX_PER_REQUEST } from "./types"; 3 + 4 + /** Pick which labelers to honor for this request. 5 + * 6 + * Order of precedence: 7 + * 1. `atproto-accept-labelers` header (atproto spec) 8 + * 2. `?labelers=` query param (fallback for SSE/WS where headers are awkward) 9 + * 3. `config.defaults` (operator policy) 10 + * 4. every entry in `config.sources` 11 + * 12 + * Each candidate DID is checked against `config.sources`. Unknown DIDs are 13 + * dropped unless `allowUserSupplied: true`, in which case they're returned 14 + * in `lazyAdd` for the caller to schedule a registration. The active 15 + * request gets results only from already-indexed sources. 16 + * 17 + * Header values can carry `;param` modifiers (e.g. `did:plc:...;redact`); 18 + * v1 strips and ignores those — only the bare DID is honored. */ 19 + export interface SelectedLabelers { 20 + /** DIDs to use for hydration this request. */ 21 + accepted: string[]; 22 + /** DIDs the caller asked for that aren't configured (only populated when 23 + * `allowUserSupplied: true`). The hydrator ignores these for the current 24 + * request — the caller should enqueue registration as a follow-up. */ 25 + lazyAdd: string[]; 26 + } 27 + 28 + export function selectAcceptedLabelers( 29 + headerValue: string | null | undefined, 30 + paramValue: string | null | undefined, 31 + cfg: LabelsConfig, 32 + ): SelectedLabelers { 33 + const cap = cfg.maxPerRequest ?? DEFAULT_LABELS_MAX_PER_REQUEST; 34 + const known = new Set(cfg.sources.map((s) => s.did)); 35 + 36 + const fromCaller = parseLabelerList(headerValue) ?? parseLabelerList(paramValue); 37 + 38 + let candidates: string[]; 39 + if (fromCaller && fromCaller.length > 0) { 40 + candidates = fromCaller; 41 + } else { 42 + candidates = (cfg.defaults ?? cfg.sources.map((s) => s.did)).slice(); 43 + } 44 + 45 + const accepted: string[] = []; 46 + const lazyAdd: string[] = []; 47 + const seen = new Set<string>(); 48 + for (const did of candidates) { 49 + if (seen.has(did)) continue; 50 + seen.add(did); 51 + if (known.has(did)) { 52 + accepted.push(did); 53 + } else if (cfg.allowUserSupplied) { 54 + lazyAdd.push(did); 55 + } 56 + if (accepted.length >= cap) break; 57 + } 58 + 59 + return { accepted, lazyAdd }; 60 + } 61 + 62 + /** Parse a comma-separated DID list. Returns null when the input is empty 63 + * or undefined so callers can distinguish "absent" from "empty list" (the 64 + * latter — `atproto-accept-labelers: ` — is technically valid and means 65 + * "no labelers"; we treat it the same as absent for ergonomics). */ 66 + function parseLabelerList(value: string | null | undefined): string[] | null { 67 + if (!value) return null; 68 + const out: string[] = []; 69 + for (const raw of value.split(",")) { 70 + // Drop `;param` modifiers from the spec (e.g. `;redact`). v1 ignores them. 71 + const head = raw.split(";")[0]!.trim(); 72 + if (head.startsWith("did:")) out.push(head); 73 + } 74 + return out.length > 0 ? out : null; 75 + }
+315
packages/contrail/src/core/labels/subscribe.ts
··· 1 + import { decodeFirst } from "@atcute/cbor"; 2 + import type { ContrailConfig, Database, Logger } from "../types"; 3 + import type { LabelerSource } from "./types"; 4 + import { applyLabels, type IncomingLabel } from "./apply"; 5 + import { 6 + getLabelerState, 7 + resetLabelerCursor, 8 + saveLabelerCursor, 9 + } from "./resolve"; 10 + 11 + const DEFAULT_CYCLE_TIMEOUT_MS = 25_000; 12 + const DEFAULT_BATCH_SIZE = 100; 13 + const DEFAULT_FLUSH_INTERVAL_MS = 5_000; 14 + 15 + function getLogger(config: ContrailConfig): Logger { 16 + return config.logger ?? console; 17 + } 18 + 19 + /** One catch-up cycle for every configured labeler. Designed to fit inside a 20 + * Cloudflare Workers cron tick — we drain frames until the labeler has no 21 + * more buffered events for us, or `timeoutMs` is reached, then save cursor 22 + * and disconnect. Mirrors the shape of `runIngestCycle` for jetstream. */ 23 + export async function runLabelIngestCycle( 24 + db: Database, 25 + config: ContrailConfig, 26 + timeoutMs = DEFAULT_CYCLE_TIMEOUT_MS, 27 + ): Promise<void> { 28 + if (!config.labels) return; 29 + const log = getLogger(config); 30 + const deadline = Date.now() + timeoutMs; 31 + 32 + for (const source of config.labels.sources) { 33 + if (Date.now() >= deadline) { 34 + log.log(`[labels] cycle deadline hit before processing ${source.did}`); 35 + break; 36 + } 37 + const remaining = Math.max(2_000, deadline - Date.now()); 38 + try { 39 + await pumpOneLabeler(db, source, log, remaining, /* persistent */ false); 40 + } catch (err) { 41 + log.warn(`[labels] cycle for ${source.did} failed: ${err}`); 42 + } 43 + } 44 + } 45 + 46 + export interface PersistentLabelsOptions { 47 + signal?: AbortSignal; 48 + batchSize?: number; 49 + flushIntervalMs?: number; 50 + logger?: Logger; 51 + } 52 + 53 + /** Long-lived equivalent — keeps one socket per labeler open forever, with 54 + * exponential backoff reconnect. Mirrors `runPersistent` for jetstream. */ 55 + export async function runPersistentLabels( 56 + db: Database, 57 + config: ContrailConfig, 58 + options: PersistentLabelsOptions = {}, 59 + ): Promise<void> { 60 + if (!config.labels) return; 61 + const log = options.logger ?? config.logger ?? console; 62 + const signal = options.signal; 63 + 64 + const tasks = config.labels.sources.map((source) => 65 + runOneLabelerForever(db, source, log, signal, options), 66 + ); 67 + await Promise.all(tasks); 68 + } 69 + 70 + async function runOneLabelerForever( 71 + db: Database, 72 + source: LabelerSource, 73 + log: Logger, 74 + signal: AbortSignal | undefined, 75 + options: PersistentLabelsOptions, 76 + ): Promise<void> { 77 + let attempts = 0; 78 + while (!signal?.aborted) { 79 + try { 80 + await pumpOneLabeler(db, source, log, /* timeoutMs */ Infinity, true, { 81 + signal, 82 + batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE, 83 + flushIntervalMs: options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS, 84 + }); 85 + attempts = 0; 86 + } catch (err) { 87 + if (signal?.aborted) break; 88 + log.error(`[labels] ${source.did} stream error: ${err}`); 89 + const delay = Math.min(1_000 * 2 ** attempts, 30_000); 90 + attempts++; 91 + log.log(`[labels] ${source.did} reconnecting in ${delay}ms (attempt ${attempts})`); 92 + await new Promise((r) => setTimeout(r, delay)); 93 + } 94 + } 95 + } 96 + 97 + interface PumpOptions { 98 + signal?: AbortSignal; 99 + batchSize?: number; 100 + flushIntervalMs?: number; 101 + } 102 + 103 + /** Open a `subscribeLabels` WebSocket, drain frames into a buffer, flush 104 + * the buffer to `labels` in batches, and persist the seq cursor. Returns 105 + * when: 106 + * - the labeler closes the socket cleanly (caught up + no more events) 107 + * - `timeoutMs` is reached (cron mode) 108 + * - `signal` is aborted (persistent mode) 109 + * - an error tears the socket down (caller may retry) */ 110 + async function pumpOneLabeler( 111 + db: Database, 112 + source: LabelerSource, 113 + log: Logger, 114 + timeoutMs: number, 115 + persistent: boolean, 116 + pumpOpts: PumpOptions = {}, 117 + ): Promise<void> { 118 + const state = await getLabelerState(db, source.did, source.endpoint); 119 + if (!state) { 120 + log.warn(`[labels] could not resolve labeler endpoint for ${source.did}; skipping`); 121 + return; 122 + } 123 + 124 + // First-time policy: cursor 0 = "from the beginning" if backfill is on 125 + // (default), null = "from now" otherwise. After the first cycle we always 126 + // resume from the saved cursor — `backfill` only flips the start point. 127 + const isFirstRun = state.cursor === 0 && state.resolved_at === null; 128 + const backfill = source.backfill !== false; 129 + const startCursor = isFirstRun && !backfill ? null : state.cursor; 130 + 131 + const url = buildWsUrl(state.endpoint!, startCursor); 132 + log.log(`[labels] connecting to ${source.did} (cursor=${startCursor ?? "now"})`); 133 + 134 + const ws = new WebSocket(url); 135 + ws.binaryType = "arraybuffer"; 136 + 137 + const buffer: IncomingLabel[] = []; 138 + let highestSeq = state.cursor; 139 + let flushing = false; 140 + let resolveDone!: () => void; 141 + let rejectDone!: (err: unknown) => void; 142 + const done = new Promise<void>((res, rej) => { 143 + resolveDone = res; 144 + rejectDone = rej; 145 + }); 146 + 147 + const flush = async () => { 148 + if (buffer.length === 0 || flushing) return; 149 + flushing = true; 150 + const batch = buffer.splice(0); 151 + try { 152 + const kept = await applyLabels(db, batch); 153 + if (highestSeq > state.cursor) { 154 + await saveLabelerCursor(db, source.did, highestSeq); 155 + state.cursor = highestSeq; 156 + } 157 + log.log( 158 + `[labels] ${source.did} flushed ${kept}/${batch.length} labels, cursor=${highestSeq}`, 159 + ); 160 + } catch (err) { 161 + log.error(`[labels] ${source.did} flush failed: ${err}`); 162 + } finally { 163 + flushing = false; 164 + } 165 + }; 166 + 167 + const batchSize = pumpOpts.batchSize ?? DEFAULT_BATCH_SIZE; 168 + const flushInterval = pumpOpts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; 169 + const flushTimer = setInterval(() => { 170 + flush().catch(() => {}); 171 + }, flushInterval); 172 + 173 + const cleanup = () => { 174 + clearInterval(flushTimer); 175 + try { 176 + ws.close(); 177 + } catch { 178 + /* already closed */ 179 + } 180 + }; 181 + 182 + // External abort (persistent mode) — close socket gracefully. 183 + const abortHandler = () => { 184 + cleanup(); 185 + flush().finally(() => resolveDone()); 186 + }; 187 + pumpOpts.signal?.addEventListener("abort", abortHandler, { once: true }); 188 + 189 + // Cron-mode time budget — close socket gracefully when reached. 190 + let deadlineTimer: ReturnType<typeof setTimeout> | undefined; 191 + if (Number.isFinite(timeoutMs)) { 192 + deadlineTimer = setTimeout(() => { 193 + log.log(`[labels] ${source.did} cycle deadline reached, closing`); 194 + cleanup(); 195 + flush().finally(() => resolveDone()); 196 + }, timeoutMs); 197 + } 198 + 199 + ws.addEventListener("error", (ev) => { 200 + cleanup(); 201 + if (deadlineTimer) clearTimeout(deadlineTimer); 202 + pumpOpts.signal?.removeEventListener("abort", abortHandler); 203 + rejectDone(new Error(`WebSocket error: ${(ev as ErrorEvent)?.message ?? "unknown"}`)); 204 + }); 205 + 206 + ws.addEventListener("close", () => { 207 + if (deadlineTimer) clearTimeout(deadlineTimer); 208 + pumpOpts.signal?.removeEventListener("abort", abortHandler); 209 + flush().finally(() => { 210 + clearInterval(flushTimer); 211 + resolveDone(); 212 + }); 213 + }); 214 + 215 + ws.addEventListener("message", async (ev) => { 216 + let bytes: Uint8Array; 217 + if (ev.data instanceof ArrayBuffer) { 218 + bytes = new Uint8Array(ev.data); 219 + } else if (ev.data instanceof Uint8Array) { 220 + bytes = ev.data; 221 + } else { 222 + // Binary-only protocol — text frames shouldn't arrive. 223 + return; 224 + } 225 + const frame = decodeFrame(bytes); 226 + if (!frame) return; 227 + 228 + if (frame.t === "#labels") { 229 + const seq = Number(frame.payload?.seq ?? 0); 230 + const rawLabels = Array.isArray(frame.payload?.labels) ? frame.payload.labels : []; 231 + for (const raw of rawLabels) { 232 + const lab = normalizeLabel(raw, source.did); 233 + if (lab) buffer.push(lab); 234 + } 235 + if (Number.isFinite(seq) && seq > highestSeq) highestSeq = seq; 236 + if (buffer.length >= batchSize) { 237 + flush().catch(() => {}); 238 + } 239 + } else if (frame.t === "#info") { 240 + const name = String(frame.payload?.name ?? ""); 241 + log.log(`[labels] ${source.did} info: ${name}`); 242 + if (name === "OutdatedCursor") { 243 + // Labeler rewound its log — discard our cursor and let the next 244 + // run start from the beginning. We don't reconnect here; the 245 + // caller (or the persistent loop) will pick up the reset on retry. 246 + await resetLabelerCursor(db, source.did); 247 + cleanup(); 248 + } 249 + } else if (frame.op === -1) { 250 + log.warn(`[labels] ${source.did} error frame: ${JSON.stringify(frame.payload)}`); 251 + cleanup(); 252 + } 253 + }); 254 + 255 + // Workers WebSocket doesn't always emit `open`; just await `done` directly. 256 + await done; 257 + } 258 + 259 + function buildWsUrl(httpEndpoint: string, cursor: number | null): string { 260 + const u = new URL("/xrpc/com.atproto.label.subscribeLabels", httpEndpoint); 261 + // wss:// for HTTPS endpoints — the protocol on the labeler service is 262 + // expected to be HTTPS already (validated at resolution time). 263 + u.protocol = u.protocol === "https:" ? "wss:" : "ws:"; 264 + if (cursor !== null) u.searchParams.set("cursor", String(cursor)); 265 + return u.toString(); 266 + } 267 + 268 + interface DecodedFrame { 269 + op: number; 270 + t: string | undefined; 271 + payload: Record<string, unknown>; 272 + } 273 + 274 + /** Decode an atproto subscription frame: two consecutive CBOR objects. 275 + * Header `{ op, t? }`, payload — shape depends on `t`. Returns null on 276 + * decode failure or non-object frames. */ 277 + function decodeFrame(bytes: Uint8Array): DecodedFrame | null { 278 + try { 279 + const [header, rest] = decodeFirst(bytes); 280 + if (!header || typeof header !== "object") return null; 281 + const op = typeof (header as { op?: number }).op === "number" ? (header as { op: number }).op : 1; 282 + const t = typeof (header as { t?: string }).t === "string" ? (header as { t: string }).t : undefined; 283 + const [payload] = decodeFirst(rest); 284 + if (!payload || typeof payload !== "object") return null; 285 + return { op, t, payload: payload as Record<string, unknown> }; 286 + } catch { 287 + return null; 288 + } 289 + } 290 + 291 + /** Coerce a wire `Label` object into our `IncomingLabel` shape. Returns 292 + * null when required fields are missing — we'd rather skip a row than 293 + * insert one with placeholder values. */ 294 + function normalizeLabel(raw: unknown, expectedSrc: string): IncomingLabel | null { 295 + if (!raw || typeof raw !== "object") return null; 296 + const r = raw as Record<string, unknown>; 297 + const src = typeof r.src === "string" ? r.src : null; 298 + const uri = typeof r.uri === "string" ? r.uri : null; 299 + const val = typeof r.val === "string" ? r.val : null; 300 + const cts = typeof r.cts === "string" ? r.cts : null; 301 + if (!src || !uri || !val || !cts) return null; 302 + // A labeler shouldn't emit labels under a different `src` than its own 303 + // DID — drop them rather than poison our table with cross-issuer rows. 304 + if (src !== expectedSrc) return null; 305 + return { 306 + src, 307 + uri, 308 + val, 309 + cts, 310 + cid: typeof r.cid === "string" ? r.cid : undefined, 311 + neg: r.neg === true, 312 + exp: typeof r.exp === "string" ? r.exp : undefined, 313 + sig: r.sig instanceof Uint8Array ? r.sig : undefined, 314 + }; 315 + }
+63
packages/contrail/src/core/labels/types.ts
··· 1 + import type { Database } from "../types"; 2 + 3 + /** A labeler the operator wants contrail to track. */ 4 + export interface LabelerSource { 5 + /** Labeler DID — `did:plc:...` or `did:web:...`. */ 6 + did: string; 7 + /** Override the service endpoint resolution. Otherwise resolved from the 8 + * DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. */ 9 + endpoint?: string; 10 + /** Backfill from `cursor=0` on first sight. Defaults to true. Set false 11 + * for "start from now" — useful for very chatty labelers. */ 12 + backfill?: boolean; 13 + } 14 + 15 + export interface LabelsConfig { 16 + /** Labelers to subscribe to and index. */ 17 + sources: LabelerSource[]; 18 + /** DIDs honored when the caller sends no `atproto-accept-labelers` / 19 + * `?labelers=`. Defaults to every entry in `sources`. Set `[]` for 20 + * opt-in-only — clients see no labels unless they ask. */ 21 + defaults?: string[]; 22 + /** Honor caller-supplied DIDs that aren't in `sources`. Default: false. 23 + * When true, unknown DIDs in the request are accepted (and the labeler 24 + * may be lazily registered for ingest in a later release). */ 25 + allowUserSupplied?: boolean; 26 + /** Per-request cap. Default: 20 (matches Bluesky). */ 27 + maxPerRequest?: number; 28 + } 29 + 30 + export const DEFAULT_LABELS_MAX_PER_REQUEST = 20; 31 + 32 + /** A single label as stored. Matches `com.atproto.label.defs#label`. */ 33 + export interface LabelRow { 34 + /** Issuing labeler DID. */ 35 + src: string; 36 + /** Subject — at-URI for record labels, plain DID for account labels. */ 37 + uri: string; 38 + /** Label value — kebab-case, ≤128 bytes per spec. */ 39 + val: string; 40 + /** Optional CID pin to a specific record version. */ 41 + cid: string | null; 42 + /** When true, retracts a previously-emitted label for the same (src, uri, val). */ 43 + neg: boolean; 44 + /** Expiry, unix seconds. Past this, hydration drops the row. */ 45 + exp: number | null; 46 + /** Creation timestamp, unix seconds — what we collapse on. */ 47 + cts: number; 48 + /** Raw signature bytes. Stored when present so we can re-emit later; 49 + * not verified in v1. */ 50 + sig: Uint8Array | null; 51 + } 52 + 53 + /** Per-labeler state row — endpoint cache and last-seen seq cursor. */ 54 + export interface LabelerCursorRow { 55 + did: string; 56 + cursor: number; 57 + endpoint: string | null; 58 + resolved_at: number | null; 59 + } 60 + 61 + export interface AdapterContext { 62 + db: Database; 63 + }
+4 -4
packages/contrail/src/core/realtime/query-filter.ts
··· 24 24 record: Record<string, unknown>; 25 25 time_us: number; 26 26 indexed_at: number; 27 - _space: string; 27 + space: string; 28 28 }; 29 29 }; 30 30 } ··· 41 41 collection: string; 42 42 cid: string | null | undefined; 43 43 record: Record<string, unknown>; 44 - _space: string; 44 + space: string; 45 45 }; 46 46 }; 47 47 } ··· 89 89 record: event.payload.record, 90 90 time_us: event.ts * 1000, 91 91 indexed_at: event.ts, 92 - _space: spec.spaceUri 92 + space: spec.spaceUri 93 93 } 94 94 } 95 95 } ··· 130 130 collection: event.payload.collection, 131 131 cid: event.payload.cid, 132 132 record: event.payload.record, 133 - _space: spec.spaceUri 133 + space: spec.spaceUri 134 134 } 135 135 } 136 136 }
+91 -20
packages/contrail/src/core/router/collection.ts
··· 16 16 import { resolveActor } from "../identity"; 17 17 import type { FormattedRecord } from "./helpers"; 18 18 import { formatRecord, parseIntParam, fieldToParam } from "./helpers"; 19 + import { selectAcceptedLabelers } from "../labels/select"; 20 + import { hydrateLabels } from "../labels/hydrate"; 19 21 import { verifyServiceAuthRequest, extractInviteToken, checkInviteReadGrant } from "../spaces/auth"; 20 22 import { checkAccess } from "../spaces/acl"; 21 23 import { hashInviteToken } from "../invite/token"; ··· 141 143 collection: event.payload.collection, 142 144 cid: event.payload.cid, 143 145 value: event.payload.record, 144 - _space: event.payload.space 146 + space: event.payload.space 145 147 } 146 148 }); 147 149 } else { ··· 188 190 value: event.payload.record, 189 191 time_us: nowUs, 190 192 indexed_at: event.ts, 191 - _space: event.payload.space 193 + space: event.payload.space 192 194 } 193 195 }); 194 196 } else { ··· 257 259 } 258 260 } 259 261 } 260 - // Normalize `space` → `_space` so snapshot records carry the same 261 - // field name as live `record.created` payloads. Clients then have a 262 - // single key to read regardless of origin. 263 - const normalized = record as Record<string, unknown>; 264 - if (normalized.space !== undefined && normalized._space === undefined) { 265 - normalized._space = normalized.space; 266 - delete normalized.space; 267 - } 268 - send("snapshot.record", { record: normalized }); 262 + send("snapshot.record", { record }); 269 263 } 270 264 send("snapshot.end", { cursor: result.cursor }); 271 265 snapshotDone = true; ··· 286 280 collection: string, 287 281 params: URLSearchParams, 288 282 source?: RecordSource, 289 - spaceUris?: string[] 290 - ): Promise<{ records: FormattedRecord[]; cursor?: string; profiles?: any[] }> { 283 + spaceUris?: string[], 284 + /** Optional headers from the originating request — used for label 285 + * hydration (`atproto-accept-labelers`). Other entry points pass nothing 286 + * and labels are gated by `?labelers=` / config defaults. */ 287 + headers?: Headers 288 + ): Promise<{ records: FormattedRecord[]; cursor?: string; profiles?: any[]; labelersApplied?: string[] }> { 291 289 const colConfig = config.collections[collection]; 292 290 if (!colConfig) throw new Error(`Unknown collection: ${collection}`); 293 291 ··· 441 439 ? await resolveProfiles(db, config, allDids) 442 440 : undefined; 443 441 442 + let labelersApplied: string[] | undefined; 443 + if (config.labels) { 444 + const sel = selectAcceptedLabelers( 445 + headers?.get("atproto-accept-labelers") ?? null, 446 + params.get("labelers"), 447 + config.labels, 448 + ); 449 + if (sel.accepted.length > 0) { 450 + const subjects: string[] = [ 451 + ...formattedRecords.map((r) => r.uri), 452 + ...allDids, 453 + ]; 454 + const cidByUri = new Map<string, string | null>(); 455 + for (const r of formattedRecords) cidByUri.set(r.uri, r.cid); 456 + const labelsByUri = await hydrateLabels(db, subjects, sel.accepted, cidByUri); 457 + for (const fr of formattedRecords) { 458 + const ls = labelsByUri[fr.uri]; 459 + if (ls && ls.length > 0) fr.labels = ls; 460 + } 461 + if (profileMap) { 462 + for (const entries of Object.values(profileMap)) { 463 + for (const entry of entries) { 464 + const ls = labelsByUri[entry.did]; 465 + if (ls && ls.length > 0) entry.labels = ls; 466 + } 467 + } 468 + } 469 + labelersApplied = sel.accepted; 470 + } 471 + } 472 + 444 473 return { 445 474 records: formattedRecords, 446 475 cursor: result.cursor, 447 476 ...(profileMap ? { profiles: Object.values(profileMap).flat() } : {}), 477 + ...(labelersApplied ? { labelersApplied } : {}), 448 478 }; 479 + } 480 + 481 + /** Serialize a runPipeline result as JSON, echoing 482 + * `atproto-content-labelers` when labels were applied. The result's 483 + * `labelersApplied` field never appears in the response body — it's a 484 + * side channel for the route to read and turn into a header. */ 485 + function jsonWithLabelers(c: Context, result: { labelersApplied?: string[] } & Record<string, unknown>) { 486 + const { labelersApplied, ...body } = result; 487 + if (labelersApplied && labelersApplied.length > 0) { 488 + c.header("atproto-content-labelers", labelersApplied.join(",")); 489 + } 490 + return c.json(body); 449 491 } 450 492 451 493 export function registerCollectionRoutes( ··· 545 587 // full filter / sort / hydrate / reference surface works on per-space 546 588 // queries too, not just on the cross-space union path. 547 589 try { 548 - const result = await runPipeline(db, config, collection, params, undefined, [spaceUri]); 549 - return c.json(result); 590 + const result = await runPipeline(db, config, collection, params, undefined, [spaceUri], c.req.raw.headers); 591 + return jsonWithLabelers(c, result); 550 592 } catch (e: any) { 551 593 if (e.message === "Could not resolve actor") { 552 594 return c.json({ error: e.message }, 400); ··· 579 621 } 580 622 581 623 try { 582 - const result = await runPipeline(db, config, collection, params, undefined, spaceUris); 583 - return c.json(result); 624 + const result = await runPipeline(db, config, collection, params, undefined, spaceUris, c.req.raw.headers); 625 + return jsonWithLabelers(c, result); 584 626 } catch (e: any) { 585 627 if (e.message === "Could not resolve actor") { 586 628 return c.json({ error: e.message }, 400); ··· 767 809 collection, 768 810 params, 769 811 undefined, 770 - snapshotSpaces 812 + snapshotSpaces, 813 + c.req.raw.headers 771 814 ); 772 815 let ticket: string | undefined; 773 816 if (ticketSigner && callerDid) { ··· 1048 1091 ? await resolveProfiles(db, config, allDids) 1049 1092 : undefined; 1050 1093 1094 + let labelersApplied: string[] | undefined; 1095 + if (config.labels) { 1096 + const sel = selectAcceptedLabelers( 1097 + c.req.raw.headers.get("atproto-accept-labelers"), 1098 + params.get("labelers"), 1099 + config.labels, 1100 + ); 1101 + if (sel.accepted.length > 0) { 1102 + const subjects: string[] = [row.uri, ...allDids]; 1103 + const cidByUri = new Map<string, string | null>([[row.uri, row.cid]]); 1104 + const labelsByUri = await hydrateLabels(db, subjects, sel.accepted, cidByUri); 1105 + const ls = labelsByUri[row.uri]; 1106 + if (ls && ls.length > 0) (formatted as Record<string, unknown>).labels = ls; 1107 + if (profileMap) { 1108 + for (const entries of Object.values(profileMap)) { 1109 + for (const entry of entries) { 1110 + const els = labelsByUri[entry.did]; 1111 + if (els && els.length > 0) entry.labels = els; 1112 + } 1113 + } 1114 + } 1115 + labelersApplied = sel.accepted; 1116 + } 1117 + } 1118 + if (labelersApplied) { 1119 + c.header("atproto-content-labelers", labelersApplied.join(",")); 1120 + } 1121 + 1051 1122 return c.json({ 1052 1123 ...formatted, 1053 1124 ...(profileMap ? { profiles: Object.values(profileMap).flat() } : {}), ··· 1070 1141 const params = new URL(c.req.url).searchParams; 1071 1142 try { 1072 1143 const source = await handler(db, params, config); 1073 - const result = await runPipeline(db, config, collection, params, source); 1074 - return c.json(result); 1144 + const result = await runPipeline(db, config, collection, params, source, undefined, c.req.raw.headers); 1145 + return jsonWithLabelers(c, result); 1075 1146 } catch (e: any) { 1076 1147 if (e.message === "Could not resolve actor") { 1077 1148 return c.json({ error: e.message }, 400);
+1 -1
packages/contrail/src/core/router/helpers.ts
··· 28 28 collection: row.collection, 29 29 rkey: row.rkey, 30 30 time_us: row.time_us, 31 - ...(row._space ? { space: row._space } : {}), 31 + ...(row.space ? { space: row.space } : {}), 32 32 }; 33 33 } 34 34
+4 -4
packages/contrail/src/core/router/hydrate.ts
··· 10 10 import { batchedInQuery, formatRecord } from "./helpers"; 11 11 12 12 /** Group rows by their origin: public (undefined key) or a specific spaceUri. */ 13 - function groupBySource<T extends { _space?: string }>(rows: T[]): Map<string | undefined, T[]> { 13 + function groupBySource<T extends { space?: string }>(rows: T[]): Map<string | undefined, T[]> { 14 14 const groups = new Map<string | undefined, T[]>(); 15 15 for (const r of rows) { 16 - const key = r._space; 16 + const key = r.space; 17 17 const g = groups.get(key); 18 18 if (g) g.push(r); 19 19 else groups.set(key, [r]); ··· 130 130 formatRecord({ 131 131 ...(row as any), 132 132 collection: childNsid, 133 - ...(sourceSpace ? { _space: sourceSpace } : {}), 133 + ...(sourceSpace ? { space: sourceSpace } : {}), 134 134 } as RecordRow) 135 135 ); 136 136 } ··· 212 212 result[parentUri][refName] = formatRecord({ 213 213 ...(row as any), 214 214 collection: refNsid, 215 - ...(sourceSpace ? { _space: sourceSpace } : {}), 215 + ...(sourceSpace ? { space: sourceSpace } : {}), 216 216 } as RecordRow); 217 217 } 218 218 }
+21
packages/contrail/src/core/router/index.ts
··· 24 24 import { resolveActor } from "../identity"; 25 25 import { resolveProfiles } from "./profiles"; 26 26 import { backfillUser } from "../backfill"; 27 + import { selectAcceptedLabelers } from "../labels/select"; 28 + import { hydrateLabels } from "../labels/hydrate"; 27 29 28 30 export interface SpacesContext { 29 31 adapter: StorageAdapter; ··· 88 90 const profileMap = await resolveProfiles(db, config, [did]); 89 91 const profiles = profileMap[did]; 90 92 if (!profiles || profiles.length === 0) return c.json({ error: "Profile not found" }, 404); 93 + 94 + if (config.labels) { 95 + const params = new URL(c.req.url).searchParams; 96 + const sel = selectAcceptedLabelers( 97 + c.req.raw.headers.get("atproto-accept-labelers"), 98 + params.get("labelers"), 99 + config.labels, 100 + ); 101 + if (sel.accepted.length > 0) { 102 + const labelsByUri = await hydrateLabels(db, [did], sel.accepted); 103 + const ls = labelsByUri[did]; 104 + if (ls && ls.length > 0) { 105 + for (const entry of profiles) { 106 + entry.labels = ls; 107 + } 108 + } 109 + c.header("atproto-content-labelers", sel.accepted.join(",")); 110 + } 111 + } 91 112 92 113 return c.json({ profiles }); 93 114 });
+3
packages/contrail/src/core/router/profiles.ts
··· 13 13 value?: unknown; 14 14 collection?: string; 15 15 rkey?: string; 16 + /** Hydrated by the labels module when the caller has accepted-labelers 17 + * active and there are matching labels on this DID. */ 18 + labels?: unknown; 16 19 } 17 20 18 21 export function collectDids(
+7 -7
packages/contrail/src/core/spaces/uri.ts
··· 1 1 /** Centralized space URI construction / parsing. 2 2 * 3 - * Permissioned spaces are addressed by (ownerDid, type, key). The rough spec 4 - * (https://dholms.leaflet.pub/3mhj6bcqats2o) floats `ats://` as a likely 5 - * distinct scheme for permissioned data, but that's unresolved — we keep 6 - * `at://` today and isolate the format here so swapping is a one-liner. 3 + * Permissioned spaces are addressed by (ownerDid, type, key) and use the 4 + * `ats://` scheme — distinct from atproto record URIs (`at://`) so the two 5 + * can't be confused at any layer (logs, params, dispatch). Tracks the rough 6 + * spec at https://dholms.leaflet.pub/3mhj6bcqats2o. 7 7 * 8 8 * Record URIs inside a space are minted by authorDid for index purposes 9 9 * (`at://<authorDid>/<collection>/<rkey>`); the spec is explicitly undecided ··· 18 18 19 19 /** Build a space URI from its three addressing components. */ 20 20 export function buildSpaceUri(parts: SpaceUriParts): string { 21 - return `at://${parts.ownerDid}/${parts.type}/${parts.key}`; 21 + return `ats://${parts.ownerDid}/${parts.type}/${parts.key}`; 22 22 } 23 23 24 24 /** Parse a space URI into its components, or null if malformed. */ 25 25 export function parseSpaceUri(uri: string): SpaceUriParts | null { 26 - if (!uri.startsWith("at://")) return null; 27 - const rest = uri.slice("at://".length); 26 + if (!uri.startsWith("ats://")) return null; 27 + const rest = uri.slice("ats://".length); 28 28 const [ownerDid, type, key, ...extra] = rest.split("/"); 29 29 if (!ownerDid || !type || !key || extra.length > 0) return null; 30 30 return { ownerDid, type, key };
+1 -1
packages/lexicons/lexicon-templates/community/space/delete.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" } 14 + "spaceUri": { "type": "string" } 15 15 } 16 16 } 17 17 },
+1 -1
packages/lexicons/lexicon-templates/community/space/deleteRecord.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri", "collection", "rkey"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "collection": { "type": "string", "format": "nsid" }, 16 16 "rkey": { "type": "string" } 17 17 }
+1 -1
packages/lexicons/lexicon-templates/community/space/grant.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri", "subject", "accessLevel"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "subject": { "type": "ref", "ref": "tools.atmo.community.defs#subject" }, 16 16 "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel" } 17 17 }
+1 -1
packages/lexicons/lexicon-templates/community/space/listMembers.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" }, 12 + "spaceUri": { "type": "string" }, 13 13 "flatten": { "type": "boolean", "description": "If true, return the flat DID list instead of raw rows." } 14 14 } 15 15 },
+1 -1
packages/lexicons/lexicon-templates/community/space/putRecord.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri", "collection", "record"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "collection": { "type": "string", "format": "nsid" }, 16 16 "rkey": { "type": "string" }, 17 17 "record": { "type": "unknown" }
+1 -1
packages/lexicons/lexicon-templates/community/space/resync.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" } 14 + "spaceUri": { "type": "string" } 15 15 } 16 16 } 17 17 },
+1 -1
packages/lexicons/lexicon-templates/community/space/revoke.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri", "subject"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "subject": { "type": "ref", "ref": "tools.atmo.community.defs#subject" } 16 16 } 17 17 }
+1 -1
packages/lexicons/lexicon-templates/community/space/setAccessLevel.json
··· 11 11 "type": "object", 12 12 "required": ["spaceUri", "subject", "accessLevel"], 13 13 "properties": { 14 - "spaceUri": { "type": "string", "format": "at-uri" }, 14 + "spaceUri": { "type": "string" }, 15 15 "subject": { "type": "ref", "ref": "tools.atmo.community.defs#subject" }, 16 16 "accessLevel": { "type": "ref", "ref": "tools.atmo.community.defs#accessLevel" } 17 17 }
+1 -1
packages/lexicons/lexicon-templates/spaces/ext/whoami.json
··· 9 9 "type": "params", 10 10 "required": ["spaceUri"], 11 11 "properties": { 12 - "spaceUri": { "type": "string", "format": "at-uri" } 12 + "spaceUri": { "type": "string" } 13 13 } 14 14 }, 15 15 "output": {
+1 -1
apps/group-chat/src/routes/c/[communityDid]/+layout.server.ts
··· 25 25 records: Array<{ 26 26 did: string; 27 27 rkey: string; 28 - record: { 28 + value: { 29 29 communityDid?: string; 30 30 name?: string; 31 31 description?: string;
+3 -3
apps/group-chat/src/routes/c/[communityDid]/+layout.svelte
··· 38 38 r: (typeof channelsQuery.records)[number] 39 39 ): ChannelMeta | null { 40 40 const rec = r.value; 41 - if (!r._space || rec.communityDid !== data.communityDid || !rec.name) return null; 41 + if (!r.space || rec.communityDid !== data.communityDid || !rec.name) return null; 42 42 if (!rec.createdAt) return null; 43 43 let visibility: 'public' | 'private'; 44 44 if (rec.visibility === 'public') visibility = 'public'; 45 45 else if (rec.visibility === 'private') visibility = 'private'; 46 46 else return null; 47 - const parsed = parseSpaceUri(r._space); 47 + const parsed = parseSpaceUri(r.space); 48 48 if (!parsed || parsed.key.startsWith('$') || parsed.key === 'members') return null; 49 49 return { 50 - spaceUri: r._space, 50 + spaceUri: r.space, 51 51 key: parsed.key, 52 52 name: rec.name, 53 53 topic: rec.topic,
-2
apps/group-chat/lexicons/generated/tools/atmo/chat/channel/getRecord.json
··· 22 22 }, 23 23 "spaceUri": { 24 24 "type": "string", 25 - "format": "at-uri", 26 25 "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." 27 26 }, 28 27 "inviteToken": { ··· 72 71 }, 73 72 "space": { 74 73 "type": "string", 75 - "format": "at-uri", 76 74 "description": "Present when the record was read from a permissioned space; its value is the space URI." 77 75 }, 78 76 "profiles": {
+1 -3
apps/group-chat/lexicons/generated/tools/atmo/chat/channel/listRecords.json
··· 28 28 }, 29 29 "spaceUri": { 30 30 "type": "string", 31 - "format": "at-uri", 32 31 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 32 }, 34 33 "byUser": { 35 34 "type": "string", 36 35 "format": "did", 37 - "description": "Only used with spaceUri — filter to records authored by this DID." 36 + "description": "Only used with spaceUri \u2014 filter to records authored by this DID." 38 37 }, 39 38 "inviteToken": { 40 39 "type": "string", ··· 135 134 }, 136 135 "space": { 137 136 "type": "string", 138 - "format": "at-uri", 139 137 "description": "Present when the record was read from a permissioned space; its value is the space URI." 140 138 } 141 139 }
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/channel/watchRecords.json
··· 28 28 }, 29 29 "spaceUri": { 30 30 "type": "string", 31 - "format": "at-uri", 32 31 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 32 }, 34 33 "byUser": { 35 34 "type": "string", 36 35 "format": "did", 37 - "description": "Only used with spaceUri — filter to records authored by this DID." 36 + "description": "Only used with spaceUri \u2014 filter to records authored by this DID." 38 37 }, 39 38 "inviteToken": { 40 39 "type": "string",
+3 -6
apps/group-chat/lexicons/generated/tools/atmo/chat/community/defs.json
··· 53 53 ], 54 54 "properties": { 55 55 "uri": { 56 - "type": "string", 57 - "format": "at-uri" 56 + "type": "string" 58 57 }, 59 58 "ownerDid": { 60 59 "type": "string", ··· 84 83 "format": "did" 85 84 }, 86 85 "spaceUri": { 87 - "type": "string", 88 - "format": "at-uri" 86 + "type": "string" 89 87 } 90 88 } 91 89 }, ··· 147 145 "description": "SHA-256 of the raw token. Stable id for list/revoke; never grants access on its own." 148 146 }, 149 147 "spaceUri": { 150 - "type": "string", 151 - "format": "at-uri" 148 + "type": "string" 152 149 }, 153 150 "accessLevel": { 154 151 "type": "ref",
+1 -1
apps/group-chat/lexicons/generated/tools/atmo/chat/community/mint.json
··· 36 36 }, 37 37 "recoveryKey": { 38 38 "type": "unknown", 39 - "description": "Creator's rotation key as a private JWK. Shown once — cannot be retrieved later." 39 + "description": "Creator's rotation key as a private JWK. Shown once \u2014 cannot be retrieved later." 40 40 } 41 41 } 42 42 }
+3 -4
apps/group-chat/lexicons/generated/tools/atmo/chat/invite/create.json
··· 14 14 ], 15 15 "properties": { 16 16 "spaceUri": { 17 - "type": "string", 18 - "format": "at-uri" 17 + "type": "string" 19 18 }, 20 19 "kind": { 21 20 "type": "string", ··· 34 33 "admin", 35 34 "owner" 36 35 ], 37 - "description": "For community-owned spaces. The access level granted on redemption — the creator's own level caps what they can grant." 36 + "description": "For community-owned spaces. The access level granted on redemption \u2014 the creator's own level caps what they can grant." 38 37 }, 39 38 "expiresAt": { 40 39 "type": "integer", ··· 62 61 "properties": { 63 62 "token": { 64 63 "type": "string", 65 - "description": "Raw token. Shown once — cannot be retrieved later." 64 + "description": "Raw token. Shown once \u2014 cannot be retrieved later." 66 65 }, 67 66 "invite": { 68 67 "type": "ref",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/invite/defs.json
··· 18 18 "description": "Stable identifier for list/revoke operations." 19 19 }, 20 20 "spaceUri": { 21 - "type": "string", 22 - "format": "at-uri" 21 + "type": "string" 23 22 }, 24 23 "kind": { 25 24 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/invite/list.json
··· 12 12 ], 13 13 "properties": { 14 14 "spaceUri": { 15 - "type": "string", 16 - "format": "at-uri" 15 + "type": "string" 17 16 }, 18 17 "includeRevoked": { 19 18 "type": "boolean",
+3 -4
apps/group-chat/lexicons/generated/tools/atmo/chat/invite/redeem.json
··· 28 28 ], 29 29 "properties": { 30 30 "spaceUri": { 31 - "type": "string", 32 - "format": "at-uri" 31 + "type": "string" 33 32 }, 34 33 "kind": { 35 34 "type": "string", 36 - "description": "Set for user-owned spaces — echoes the invite kind consumed." 35 + "description": "Set for user-owned spaces \u2014 echoes the invite kind consumed." 37 36 }, 38 37 "accessLevel": { 39 38 "type": "string", 40 - "description": "Set for community-owned spaces — the level granted." 39 + "description": "Set for community-owned spaces \u2014 the level granted." 41 40 }, 42 41 "communityDid": { 43 42 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/invite/revoke.json
··· 15 15 "properties": { 16 16 "spaceUri": { 17 17 "type": "string", 18 - "format": "at-uri", 19 - "description": "Optional — ownership is inferred from the invite row; required for user-owned spaces for a sanity check." 18 + "description": "Optional \u2014 ownership is inferred from the invite row; required for user-owned spaces for a sanity check." 20 19 }, 21 20 "tokenHash": { 22 21 "type": "string"
-2
apps/group-chat/lexicons/generated/tools/atmo/chat/message/getRecord.json
··· 22 22 }, 23 23 "spaceUri": { 24 24 "type": "string", 25 - "format": "at-uri", 26 25 "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." 27 26 }, 28 27 "inviteToken": { ··· 72 71 }, 73 72 "space": { 74 73 "type": "string", 75 - "format": "at-uri", 76 74 "description": "Present when the record was read from a permissioned space; its value is the space URI." 77 75 }, 78 76 "profiles": {
+1 -3
apps/group-chat/lexicons/generated/tools/atmo/chat/message/listRecords.json
··· 28 28 }, 29 29 "spaceUri": { 30 30 "type": "string", 31 - "format": "at-uri", 32 31 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 32 }, 34 33 "byUser": { 35 34 "type": "string", 36 35 "format": "did", 37 - "description": "Only used with spaceUri — filter to records authored by this DID." 36 + "description": "Only used with spaceUri \u2014 filter to records authored by this DID." 38 37 }, 39 38 "inviteToken": { 40 39 "type": "string", ··· 134 133 }, 135 134 "space": { 136 135 "type": "string", 137 - "format": "at-uri", 138 136 "description": "Present when the record was read from a permissioned space; its value is the space URI." 139 137 } 140 138 }
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/message/watchRecords.json
··· 28 28 }, 29 29 "spaceUri": { 30 30 "type": "string", 31 - "format": "at-uri", 32 31 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 32 }, 34 33 "byUser": { 35 34 "type": "string", 36 35 "format": "did", 37 - "description": "Only used with spaceUri — filter to records authored by this DID." 36 + "description": "Only used with spaceUri \u2014 filter to records authored by this DID." 38 37 }, 39 38 "inviteToken": { 40 39 "type": "string",
+1 -1
apps/group-chat/lexicons/generated/tools/atmo/chat/realtime/ticket.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "procedure", 7 - "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`.", 7 + "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`.", 8 8 "input": { 9 9 "encoding": "application/json", 10 10 "schema": {
-2
apps/group-chat/lexicons/generated/tools/atmo/chat/server/getRecord.json
··· 22 22 }, 23 23 "spaceUri": { 24 24 "type": "string", 25 - "format": "at-uri", 26 25 "description": "If set, fetch from this permissioned space (requires service-auth JWT or a read-grant invite token)." 27 26 }, 28 27 "inviteToken": { ··· 72 71 }, 73 72 "space": { 74 73 "type": "string", 75 - "format": "at-uri", 76 74 "description": "Present when the record was read from a permissioned space; its value is the space URI." 77 75 }, 78 76 "profiles": {
+1 -3
apps/group-chat/lexicons/generated/tools/atmo/chat/server/listRecords.json
··· 28 28 }, 29 29 "spaceUri": { 30 30 "type": "string", 31 - "format": "at-uri", 32 31 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 32 }, 34 33 "byUser": { 35 34 "type": "string", 36 35 "format": "did", 37 - "description": "Only used with spaceUri — filter to records authored by this DID." 36 + "description": "Only used with spaceUri \u2014 filter to records authored by this DID." 38 37 }, 39 38 "inviteToken": { 40 39 "type": "string", ··· 130 129 }, 131 130 "space": { 132 131 "type": "string", 133 - "format": "at-uri", 134 132 "description": "Present when the record was read from a permissioned space; its value is the space URI." 135 133 } 136 134 }
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/server/watchRecords.json
··· 28 28 }, 29 29 "spaceUri": { 30 30 "type": "string", 31 - "format": "at-uri", 32 31 "description": "If set, query records inside this permissioned space (requires service-auth JWT or a read-grant invite token)." 33 32 }, 34 33 "byUser": { 35 34 "type": "string", 36 35 "format": "did", 37 - "description": "Only used with spaceUri — filter to records authored by this DID." 36 + "description": "Only used with spaceUri \u2014 filter to records authored by this DID." 38 37 }, 39 38 "inviteToken": { 40 39 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/addMember.json
··· 15 15 ], 16 16 "properties": { 17 17 "spaceUri": { 18 - "type": "string", 19 - "format": "at-uri" 18 + "type": "string" 20 19 }, 21 20 "did": { 22 21 "type": "string",
+3 -6
apps/group-chat/lexicons/generated/tools/atmo/chat/space/defs.json
··· 15 15 ], 16 16 "properties": { 17 17 "uri": { 18 - "type": "string", 19 - "format": "at-uri" 18 + "type": "string" 20 19 }, 21 20 "ownerDid": { 22 21 "type": "string", ··· 78 77 ], 79 78 "properties": { 80 79 "spaceUri": { 81 - "type": "string", 82 - "format": "at-uri" 80 + "type": "string" 83 81 }, 84 82 "collection": { 85 83 "type": "string", ··· 171 169 "type": "string" 172 170 }, 173 171 "spaceUri": { 174 - "type": "string", 175 - "format": "at-uri" 172 + "type": "string" 176 173 }, 177 174 "kind": { 178 175 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/deleteRecord.json
··· 16 16 ], 17 17 "properties": { 18 18 "spaceUri": { 19 - "type": "string", 20 - "format": "at-uri" 19 + "type": "string" 21 20 }, 22 21 "collection": { 23 22 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/getBlob.json
··· 13 13 ], 14 14 "properties": { 15 15 "spaceUri": { 16 - "type": "string", 17 - "format": "at-uri" 16 + "type": "string" 18 17 }, 19 18 "cid": { 20 19 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/getRecord.json
··· 15 15 ], 16 16 "properties": { 17 17 "spaceUri": { 18 - "type": "string", 19 - "format": "at-uri" 18 + "type": "string" 20 19 }, 21 20 "collection": { 22 21 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/getSpace.json
··· 12 12 ], 13 13 "properties": { 14 14 "uri": { 15 - "type": "string", 16 - "format": "at-uri" 15 + "type": "string" 17 16 }, 18 17 "inviteToken": { 19 18 "type": "string",
+2 -3
apps/group-chat/lexicons/generated/tools/atmo/chat/space/leaveSpace.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "procedure", 7 - "description": "Remove the caller from a space's member list. The owner cannot leave — they must delete the space instead.", 7 + "description": "Remove the caller from a space's member list. The owner cannot leave \u2014 they must delete the space instead.", 8 8 "input": { 9 9 "encoding": "application/json", 10 10 "schema": { ··· 14 14 ], 15 15 "properties": { 16 16 "spaceUri": { 17 - "type": "string", 18 - "format": "at-uri" 17 + "type": "string" 19 18 } 20 19 } 21 20 }
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/listBlobs.json
··· 12 12 ], 13 13 "properties": { 14 14 "spaceUri": { 15 - "type": "string", 16 - "format": "at-uri" 15 + "type": "string" 17 16 }, 18 17 "byUser": { 19 18 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/listMembers.json
··· 12 12 ], 13 13 "properties": { 14 14 "spaceUri": { 15 - "type": "string", 16 - "format": "at-uri" 15 + "type": "string" 17 16 } 18 17 } 19 18 },
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/listRecords.json
··· 13 13 ], 14 14 "properties": { 15 15 "spaceUri": { 16 - "type": "string", 17 - "format": "at-uri" 16 + "type": "string" 18 17 }, 19 18 "collection": { 20 19 "type": "string",
+1 -1
apps/group-chat/lexicons/generated/tools/atmo/chat/space/listSpaces.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "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.", 7 + "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.", 8 8 "parameters": { 9 9 "type": "params", 10 10 "properties": {
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/putRecord.json
··· 16 16 ], 17 17 "properties": { 18 18 "spaceUri": { 19 - "type": "string", 20 - "format": "at-uri" 19 + "type": "string" 21 20 }, 22 21 "collection": { 23 22 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/removeMember.json
··· 15 15 ], 16 16 "properties": { 17 17 "spaceUri": { 18 - "type": "string", 19 - "format": "at-uri" 18 + "type": "string" 20 19 }, 21 20 "did": { 22 21 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/space/uploadBlob.json
··· 12 12 ], 13 13 "properties": { 14 14 "spaceUri": { 15 - "type": "string", 16 - "format": "at-uri" 15 + "type": "string" 17 16 } 18 17 } 19 18 },
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/spaceExt/whoami.json
··· 12 12 ], 13 13 "properties": { 14 14 "spaceUri": { 15 - "type": "string", 16 - "format": "at-uri" 15 + "type": "string" 17 16 } 18 17 } 19 18 },
+1 -1
apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/create.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "procedure", 7 - "description": "Create a community-owned space (group/role/channel). Caller needs `admin` or higher in the community's `$admin` space. Reserved keys (`$admin`, `$publishers`, …) are rejected here.", 7 + "description": "Create a community-owned space (group/role/channel). Caller needs `admin` or higher in the community's `$admin` space. Reserved keys (`$admin`, `$publishers`, \u2026) are rejected here.", 8 8 "input": { 9 9 "encoding": "application/json", 10 10 "schema": {
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/delete.json
··· 14 14 ], 15 15 "properties": { 16 16 "spaceUri": { 17 - "type": "string", 18 - "format": "at-uri" 17 + "type": "string" 19 18 } 20 19 } 21 20 }
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/deleteRecord.json
··· 16 16 ], 17 17 "properties": { 18 18 "spaceUri": { 19 - "type": "string", 20 - "format": "at-uri" 19 + "type": "string" 21 20 }, 22 21 "collection": { 23 22 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/grant.json
··· 16 16 ], 17 17 "properties": { 18 18 "spaceUri": { 19 - "type": "string", 20 - "format": "at-uri" 19 + "type": "string" 21 20 }, 22 21 "subject": { 23 22 "type": "ref",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/listMembers.json
··· 12 12 ], 13 13 "properties": { 14 14 "spaceUri": { 15 - "type": "string", 16 - "format": "at-uri" 15 + "type": "string" 17 16 }, 18 17 "flatten": { 19 18 "type": "boolean",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/putRecord.json
··· 16 16 ], 17 17 "properties": { 18 18 "spaceUri": { 19 - "type": "string", 20 - "format": "at-uri" 19 + "type": "string" 21 20 }, 22 21 "collection": { 23 22 "type": "string",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/resync.json
··· 14 14 ], 15 15 "properties": { 16 16 "spaceUri": { 17 - "type": "string", 18 - "format": "at-uri" 17 + "type": "string" 19 18 } 20 19 } 21 20 }
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/revoke.json
··· 15 15 ], 16 16 "properties": { 17 17 "spaceUri": { 18 - "type": "string", 19 - "format": "at-uri" 18 + "type": "string" 20 19 }, 21 20 "subject": { 22 21 "type": "ref",
+1 -2
apps/group-chat/lexicons/generated/tools/atmo/chat/community/space/setAccessLevel.json
··· 16 16 ], 17 17 "properties": { 18 18 "spaceUri": { 19 - "type": "string", 20 - "format": "at-uri" 19 + "type": "string" 21 20 }, 22 21 "subject": { 23 22 "type": "ref",