diff --git a/packages/contrail-base/package.json b/packages/contrail-base/package.json new file mode 100644 index 0000000..e72201a --- /dev/null +++ b/packages/contrail-base/package.json @@ -0,0 +1,67 @@ +{ + "name": "@atmo-dev/contrail-base", + "version": "0.6.0", + "description": "Shared infrastructure for the contrail family of packages — interfaces (SpaceAuthority, RecordHost, CommunityIntegration), credential primitives, binding resolvers, realtime infra, schema scaffolding. No routes, no tables of its own.", + "type": "module", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./sqlite": { + "types": "./dist/adapters/sqlite.d.ts", + "import": "./dist/adapters/sqlite.js" + }, + "./postgres": { + "types": "./dist/adapters/postgres.d.ts", + "import": "./dist/adapters/postgres.js" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/flo-bit/contrail.git", + "directory": "packages/contrail-base" + }, + "keywords": [ + "atproto", + "contrail" + ], + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@atcute/atproto": "^3.1.10", + "@atcute/cid": "^2.4.1", + "@atcute/client": "^4.2.1", + "@atcute/identity": "^1.1.4", + "@atcute/identity-resolver": "^1.2.2", + "@atcute/lexicons": "^1.2.9", + "@atcute/xrpc-server": "^0.1.12", + "hono": "^4.12.8" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "@types/pg": "^8.20.0", + "pg": "^8.20.0", + "tsup": "^8.5.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "pg": "^8.0.0" + }, + "peerDependenciesMeta": { + "pg": { + "optional": true + } + }, + "license": "MIT" +} diff --git a/packages/contrail-base/src/adapters/postgres.ts b/packages/contrail-base/src/adapters/postgres.ts new file mode 100644 index 0000000..e8c2b14 --- /dev/null +++ b/packages/contrail-base/src/adapters/postgres.ts @@ -0,0 +1,93 @@ +import pg from "pg"; +import type { Database, Statement } from "../types"; +import { postgresDialect } from "../dialect"; + +/** Internal interface for statements that can run on a specific client */ +interface PgStatement extends Statement { + /** Execute on a specific client (used by batch for transaction isolation) */ + _runOn(client: pg.PoolClient): Promise; +} + +/** Column names known to be BIGINT — PostgreSQL returns these as strings */ +const BIGINT_COLUMNS = new Set(["time_us", "indexed_at", "resolved_at"]); + +function normalizeRow(row: any): any { + if (!row) return row; + if (typeof row.record === "object" && row.record !== null) { + row.record = JSON.stringify(row.record); + } + for (const col of BIGINT_COLUMNS) { + if (typeof row[col] === "string") row[col] = Number(row[col]); + } + return row; +} + +export function createPostgresDatabase(pool: pg.Pool): Database { + function rewritePlaceholders(sql: string): string { + let idx = 0; + let inString = false; + let result = ""; + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + if (ch === "'" && sql[i - 1] !== "\\") { + inString = !inString; + result += ch; + } else if (ch === "?" && !inString) { + result += `$${++idx}`; + } else { + result += ch; + } + } + return result; + } + + function wrapStatement(sql: string, boundValues: any[] = []): PgStatement { + const pgSql = rewritePlaceholders(sql); + + return { + bind(...values: any[]): PgStatement { + return wrapStatement(sql, values); + }, + async run() { + const result = await pool.query(pgSql, boundValues); + return { changes: result.rowCount }; + }, + async _runOn(client: pg.PoolClient) { + const result = await client.query(pgSql, boundValues); + return { changes: result.rowCount }; + }, + async all() { + const result = await pool.query(pgSql, boundValues); + return { results: result.rows.map(normalizeRow) as T[] }; + }, + async first() { + const result = await pool.query(pgSql, boundValues); + return result.rows[0] ? (normalizeRow(result.rows[0]) as T) : null; + }, + }; + } + + return { + prepare(sql: string): Statement { + return wrapStatement(sql); + }, + async batch(stmts: Statement[]): Promise { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const results: any[] = []; + for (const stmt of stmts) { + results.push(await (stmt as PgStatement)._runOn(client)); + } + await client.query("COMMIT"); + return results; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } + }, + dialect: postgresDialect, + }; +} diff --git a/packages/contrail-base/src/adapters/sqlite.ts b/packages/contrail-base/src/adapters/sqlite.ts new file mode 100644 index 0000000..305dc39 --- /dev/null +++ b/packages/contrail-base/src/adapters/sqlite.ts @@ -0,0 +1,39 @@ +import { DatabaseSync } from "node:sqlite"; +import type { Database, Statement } from "../types"; +import { sqliteDialect } from "../dialect"; + +export function createSqliteDatabase(path: string): Database { + const raw = new DatabaseSync(path); + raw.exec("PRAGMA journal_mode = WAL"); + + function wrapStatement(sql: string, boundValues: any[] = []): Statement { + return { + bind(...values: any[]): Statement { + return wrapStatement(sql, values); + }, + async run() { + return raw.prepare(sql).run(...boundValues); + }, + async all() { + return { results: raw.prepare(sql).all(...boundValues) as T[] }; + }, + async first() { + return (raw.prepare(sql).get(...boundValues) as T) ?? null; + }, + }; + } + + return { + prepare(sql: string): Statement { + return wrapStatement(sql); + }, + async batch(stmts: Statement[]): Promise { + const results: any[] = []; + for (const stmt of stmts) { + results.push(await stmt.run()); + } + return results; + }, + dialect: sqliteDialect, + }; +} diff --git a/packages/contrail-base/src/client.ts b/packages/contrail-base/src/client.ts new file mode 100644 index 0000000..f37bea3 --- /dev/null +++ b/packages/contrail-base/src/client.ts @@ -0,0 +1,194 @@ +import { + CompositeDidDocumentResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, +} from "@atcute/identity-resolver"; +import { type Did } from "@atcute/lexicons"; +import { Client, simpleFetchHandler } from "@atcute/client"; +import type {} from "@atcute/atproto"; +import type { Database } from "./types"; + +// Slingshot-first PDS resolution with fallback to DID document resolution +const SLINGSHOT_URL = + "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; + +export interface ResolvedIdentity { + did: string; + handle: string | null; + pds: string | null; +} + +/** Reject PDS URLs that point to private/internal addresses or non-HTTPS */ +function validatePdsUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") return false; + const host = parsed.hostname; + // Block private/internal IP ranges + if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return false; + if (host.startsWith("10.")) return false; + if (host.startsWith("192.168.")) return false; + if (host.startsWith("169.254.")) return false; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false; + return true; + } catch { + return false; + } +} + +async function resolveViaSlingshot( + identifier: string +): Promise { + const url = new URL(SLINGSHOT_URL); + url.searchParams.set("identifier", identifier); + + try { + const response = await fetch(url.toString()); + if (!response.ok) return undefined; + const data = (await response.json()) as { + did?: string; + handle?: string; + pds?: string; + }; + if (!data.did && !data.pds) return undefined; + return { + did: data.did ?? identifier, + handle: data.handle ?? null, + pds: data.pds ?? null, + }; + } catch { + return undefined; + } +} + +const didResolver = new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, +}); + +async function getPDSViaDidDoc(did: Did): Promise { + const doc = await didResolver.resolve(did as Did<"plc"> | Did<"web">); + return doc.service + ?.find((s) => s.id === "#atproto_pds") + ?.serviceEndpoint.toString(); +} + +/** + * Resolve identity info (did, handle, pds) for a DID or handle. + * Uses slingshot first, falls back to DID doc for PDS. + */ +export async function resolvePDS( + identifier: string +): Promise { + const result = await resolveViaSlingshot(identifier); + if (result?.pds) { + if (!validatePdsUrl(result.pds)) return { ...result, pds: null }; + return result; + } + + // Fall back to DID doc resolution (only works for DIDs, not handles) + if (identifier.startsWith("did:")) { + try { + const pds = await getPDSViaDidDoc(identifier as Did); + if (pds && validatePdsUrl(pds)) { + return { + did: identifier, + handle: result?.handle ?? null, + pds, + }; + } + } catch { + // ignore + } + } + + return result; +} + +// In-memory PDS cache with TTL + size limit, plus in-flight deduplication +const PDS_CACHE_TTL = 60 * 60 * 1000; // 1 hour +const PDS_CACHE_MAX = 10_000; +const pdsCache = new Map(); +const pdsInflight = new Map>(); + +function pdsCacheGet(did: string): string | undefined { + const entry = pdsCache.get(did); + if (!entry) return undefined; + if (Date.now() - entry.at > PDS_CACHE_TTL) { + pdsCache.delete(did); + return undefined; + } + return entry.pds; +} + +function pdsCacheSet(did: string, pds: string): void { + // Evict oldest entries if over limit + if (pdsCache.size >= PDS_CACHE_MAX) { + const first = pdsCache.keys().next().value; + if (first) pdsCache.delete(first); + } + pdsCache.set(did, { pds, at: Date.now() }); +} + +export async function getPDS( + did: Did, + db?: Database +): Promise { + const mem = pdsCacheGet(did); + if (mem) return mem; + + // Deduplicate concurrent calls for the same DID + const inflight = pdsInflight.get(did); + if (inflight) return inflight; + + const promise = resolvePDSCached(did, db); + pdsInflight.set(did, promise); + try { + return await promise; + } finally { + pdsInflight.delete(did); + } +} + +async function resolvePDSCached( + did: Did, + db?: Database +): Promise { + if (db) { + const cached = await db + .prepare("SELECT pds FROM identities WHERE did = ? AND pds IS NOT NULL") + .bind(did) + .first<{ pds: string }>(); + if (cached?.pds) { + pdsCacheSet(did, cached.pds); + return cached.pds; + } + } + + const resolved = await resolvePDS(did); + if (!resolved?.pds) return undefined; + + pdsCacheSet(did, resolved.pds); + + // Persist to DB for future runs + if (db) { + await db + .prepare( + "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET pds = excluded.pds, handle = COALESCE(excluded.handle, identities.handle), resolved_at = excluded.resolved_at" + ) + .bind(did, resolved.handle, resolved.pds, Date.now()) + .run(); + } + + return resolved.pds; +} + +export async function getClient(did: Did, db?: Database): Promise { + const pds = await getPDS(did, db); + if (!pds) throw new Error(`PDS not found for ${did}`); + return new Client({ + handler: simpleFetchHandler({ service: pds }), + }); +} diff --git a/packages/contrail-base/src/community-integration.ts b/packages/contrail-base/src/community-integration.ts new file mode 100644 index 0000000..bc2f936 --- /dev/null +++ b/packages/contrail-base/src/community-integration.ts @@ -0,0 +1,67 @@ +/** Pluggable integration surface for the community module. + * + * Phase 6 extracted community to its own package (`@atmo-dev/contrail-community`). + * The contrail core package never imports from it — couplings only flow + * through these interfaces. The community package's + * `createCommunityIntegration({ ... })` returns a {@link CommunityIntegration}, + * which the consumer hands to `createApp` via `options.community`. + * + * Two layers: + * - {@link CommunityProbe}: minimal "is this a community DID" / "what + * spaces does this caller reach" surface used by realtime + collection + * routes for community-aware dispatch. + * - {@link CommunityIntegration}: the umbrella bundle — probe, whoami + * extension, invite handler, plus route + schema wiring that the + * umbrella router calls during setup. */ + +import type { Hono, MiddlewareHandler } from "hono"; +import type { Database } from "./types"; +import type { CommunityInviteHandler } from "./invite/community-handler"; + +/** Optional hook to extend `.spaceExt.whoami` with extra fields when a + * module above spaces (e.g. community) wants to override the default + * binary-membership response. If the hook returns a non-null object, that + * object is the entire response body. If null, falls through to the + * default behavior (just `isOwner`/`isMember`). + * + * Spaces stays community-agnostic: any consumer can plug in here. */ +export type WhoamiExtension = (input: { + spaceUri: string; + callerDid: string; + isOwner: boolean; + ownerDid: string; +}) => Promise | null>; + +/** Narrow interface for the deep callers (realtime/resolve, router/collection) + * that just need to ask "is this a community DID?" or "what spaces does this + * caller reach via community membership?" */ +export interface CommunityProbe { + /** Look up a community row by DID. Returns null for non-community DIDs. + * Callers usually only check truthiness — community-specific fields stay + * inside the community package. */ + getCommunity(did: string): Promise<{ did: string } | null>; + + /** Resolve the set of space URIs reachable by `callerDid` through community + * membership (direct grants + delegations). Used by realtime to expand + * community: topics into the caller's concrete space: topics. */ + resolveReachableSpaces(callerDid: string): Promise>; +} + +/** Umbrella integration the consumer constructs once and hands to createApp. + * contrail core treats this as an opaque bundle — it doesn't introspect + * community state, just calls these methods at the right wiring points. */ +export interface CommunityIntegration { + /** Probe used by realtime + collection cross-cutting concerns. */ + probe: CommunityProbe; + /** Whoami extension that returns `accessLevel` for community-owned spaces. */ + whoamiExtension: WhoamiExtension; + /** Handler for the community-grant path of the unified invite surface. */ + inviteHandler: CommunityInviteHandler; + /** Register `.community.*` routes onto the Hono app. */ + registerRoutes( + app: Hono, + options?: { authMiddleware?: MiddlewareHandler } + ): void; + /** Apply community schema (DDL) to the database. Called by initSchema. */ + applySchema(db: Database): Promise; +} diff --git a/packages/contrail-base/src/dialect.ts b/packages/contrail-base/src/dialect.ts new file mode 100644 index 0000000..e3bb790 --- /dev/null +++ b/packages/contrail-base/src/dialect.ts @@ -0,0 +1,127 @@ +/** Get the dialect from a Database, defaulting to SQLite (for D1 compatibility) */ +export function getDialect(db: { dialect?: SqlDialect }): SqlDialect { + return db.dialect ?? sqliteDialect; +} + +const SAFE_FIELD = /^[a-zA-Z0-9_.]+$/; + +function assertSafeField(field: string): void { + if (!SAFE_FIELD.test(field)) { + throw new Error(`Invalid field name: ${field}`); + } +} + +export interface SqlDialect { + /** json_extract(col, '$.field') or col->>'field' */ + jsonExtract(column: string, field: string): string; + + /** Convert INSERT INTO to ignore-duplicates form. + * SQLite: INSERT INTO → INSERT OR IGNORE INTO + * PG: appends ON CONFLICT DO NOTHING + * Accepts full SQL starting with "INSERT INTO" (works with both VALUES and SELECT). */ + insertOrIgnore(sql: string): string; + + /** Column type for the record column: TEXT (SQLite) or JSONB (PostgreSQL) */ + readonly recordColumnType: string; + + /** FTS strategy: 'virtual-table' (SQLite FTS5) or 'generated-column' (PG tsvector) */ + readonly ftsStrategy: "virtual-table" | "generated-column"; + + /** INTEGER type name — same on both, but PostgreSQL may want BIGINT for time_us */ + readonly integerType: string; + + /** BIGINT type name for timestamps */ + readonly bigintType: string; + + /** Wrap an expression for use in CREATE INDEX — PostgreSQL requires parens around expressions */ + indexExpression(expr: string): string; +} + +export const sqliteDialect: SqlDialect = { + jsonExtract(column: string, field: string): string { + assertSafeField(field); + return `json_extract(${column}, '$.${field}')`; + }, + + insertOrIgnore(sql: string): string { + return sql.replace(/^INSERT INTO/, "INSERT OR IGNORE INTO"); + }, + + recordColumnType: "TEXT", + ftsStrategy: "virtual-table", + integerType: "INTEGER", + bigintType: "INTEGER", + + indexExpression(expr: string): string { + return expr; + }, +}; + +export const postgresDialect: SqlDialect = { + jsonExtract(column: string, field: string): string { + assertSafeField(field); + const parts = field.split("."); + if (parts.length === 1) { + return `${column}->>'${parts[0]}'`; + } + // a.b.c → col->'a'->'b'->>'c' + const intermediate = parts.slice(0, -1).map((p) => `->'${p}'`).join(""); + return `${column}${intermediate}->>'${parts[parts.length - 1]}'`; + }, + + insertOrIgnore(sql: string): string { + return `${sql} ON CONFLICT DO NOTHING`; + }, + + recordColumnType: "JSONB", + ftsStrategy: "generated-column", + integerType: "INTEGER", + bigintType: "BIGINT", + + indexExpression(expr: string): string { + return `(${expr})`; + }, +}; + +/** Generate FTS schema statements based on dialect */ +export function buildFtsSchema( + dialect: SqlDialect, + recordsTable: string, + fields: string[] +): string[] { + if (dialect.ftsStrategy === "virtual-table") { + const ftsTable = recordsTable.replace("records_", "fts_"); + return [ + `CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(uri UNINDEXED, content)` + ]; + } else { + const concatExpr = fields + .map((f) => `COALESCE(${dialect.jsonExtract("record", f)}, '')`) + .join(" || ' ' || "); + return [ + `ALTER TABLE ${recordsTable} ADD COLUMN IF NOT EXISTS search_vector TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', ${concatExpr})) STORED`, + `CREATE INDEX IF NOT EXISTS idx_${recordsTable}_search ON ${recordsTable} USING GIN (search_vector)`, + ]; + } +} + +/** Generate FTS query clause based on dialect */ +export function ftsQueryClause( + dialect: SqlDialect, + recordsTable: string +): { join: string; condition: string; orderExpr: string } { + if (dialect.ftsStrategy === "virtual-table") { + const ftsTable = recordsTable.replace("records_", "fts_"); + return { + join: `JOIN ${ftsTable} fts ON fts.uri = r.uri`, + condition: "fts.content MATCH ?", + orderExpr: "fts.rank", + }; + } else { + return { + join: "", + condition: "r.search_vector @@ plainto_tsquery('english', ?)", + orderExpr: "ts_rank(r.search_vector, plainto_tsquery('english', ?))", + }; + } +} diff --git a/packages/contrail-base/src/identity.ts b/packages/contrail-base/src/identity.ts new file mode 100644 index 0000000..05040fd --- /dev/null +++ b/packages/contrail-base/src/identity.ts @@ -0,0 +1,159 @@ +import type { Did } from "@atcute/lexicons"; +import type { Database, Logger } from "./types"; +import { isDid, isHandle } from "@atcute/lexicons/syntax"; +import { resolvePDS } from "./client"; + +const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours + +export interface Identity { + did: string; + handle: string | null; + pds: string | null; + resolved_at: number; +} + +async function saveIdentity(db: Database, identity: Identity): Promise { + await db + .prepare( + "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, pds = excluded.pds, resolved_at = excluded.resolved_at" + ) + .bind(identity.did, identity.handle, identity.pds, identity.resolved_at) + .run(); +} + +function isStale(resolvedAt: number): boolean { + return Date.now() - resolvedAt >= STALE_MS; +} + +async function fetchAndSave( + db: Database, + identifier: string, + cached?: Identity | null +): Promise { + const resolved = await resolvePDS(identifier); + const identity: Identity = { + did: resolved?.did ?? identifier, + handle: resolved?.handle ?? cached?.handle ?? null, + pds: resolved?.pds ?? cached?.pds ?? null, + resolved_at: Date.now(), + }; + await saveIdentity(db, identity); + return identity; +} + +export async function resolveIdentity( + db: Database, + did: Did +): Promise { + const cached = await db + .prepare("SELECT did, handle, pds, resolved_at FROM identities WHERE did = ?") + .bind(did) + .first(); + + if (cached && !isStale(cached.resolved_at)) return cached; + + return fetchAndSave(db, did, cached); +} + +export async function resolveIdentities( + db: Database, + dids: string[] +): Promise> { + const map = new Map(); + if (dids.length === 0) return map; + + // Batch lookup from DB + const BATCH = 50; + for (let i = 0; i < dids.length; i += BATCH) { + const chunk = dids.slice(i, i + BATCH); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db + .prepare(`SELECT did, handle, pds, resolved_at FROM identities WHERE did IN (${placeholders})`) + .bind(...chunk) + .all(); + for (const row of rows.results ?? []) { + map.set(row.did, row); + } + } + + // Resolve missing via slingshot directly (no redundant DB lookup) + for (const did of dids) { + if (map.has(did) || !isDid(did)) continue; + try { + const identity = await fetchAndSave(db, did); + map.set(did, identity); + } catch { + // Silently skip unresolvable identities + } + } + + return map; +} + +export async function resolveActor( + db: Database, + actor: string +): Promise { + if (isDid(actor)) return actor; + if (!isHandle(actor)) return null; + + // Look up handle in identities table + const cached = await db + .prepare("SELECT did, resolved_at FROM identities WHERE handle = ?") + .bind(actor) + .first<{ did: string; resolved_at: number }>(); + + if (cached && !isStale(cached.resolved_at)) return cached.did; + + // Resolve via slingshot + const resolved = await resolvePDS(actor); + if (!resolved?.did || !isDid(resolved.did)) return null; + + await saveIdentity(db, { + did: resolved.did, + handle: resolved.handle ?? actor, + pds: resolved.pds ?? null, + resolved_at: Date.now(), + }); + + return resolved.did; +} + +export async function refreshStaleIdentities( + db: Database, + dids: string[] +): Promise { + if (dids.length === 0) return; + + const unique = [...new Set(dids)].filter(isDid); + if (unique.length === 0) return; + + const staleThreshold = Date.now() - STALE_MS; + const toRefresh: string[] = []; + + const BATCH = 50; + for (let i = 0; i < unique.length; i += BATCH) { + const chunk = unique.slice(i, i + BATCH); + const placeholders = chunk.map(() => "?").join(","); + const rows = await db + .prepare(`SELECT did, resolved_at FROM identities WHERE did IN (${placeholders})`) + .bind(...chunk) + .all<{ did: string; resolved_at: number }>(); + + const found = new Map((rows.results ?? []).map((r) => [r.did, r.resolved_at])); + for (const did of chunk) { + const resolvedAt = found.get(did); + if (resolvedAt === undefined || resolvedAt < staleThreshold) { + toRefresh.push(did); + } + } + } + + for (const did of toRefresh) { + try { + await fetchAndSave(db, did); + } catch { + // Silently skip unresolvable identities + } + } +} diff --git a/packages/contrail-base/src/index.ts b/packages/contrail-base/src/index.ts new file mode 100644 index 0000000..e11f46c --- /dev/null +++ b/packages/contrail-base/src/index.ts @@ -0,0 +1,67 @@ +/** @atmo-dev/contrail-base — shared infrastructure for the contrail family. + * + * No routes. No tables of its own. Pure types, interfaces, primitives, and + * shared utilities used across contrail / contrail-appview / contrail-authority / + * contrail-record-host / contrail-community. + * + * Re-exported wholesale from each source module — anything internal that + * needed to be hidden would have an explicit subpath export instead. */ + +// Core types + config + helpers (Database, ContrailConfig, dialect helpers, etc.) +export * from "./types"; + +// Dialect (SqlDialect, getDialect, sqliteDialect, postgresDialect, buildFtsSchema) +export * from "./dialect"; + +// Identity (resolveActor, resolveIdentities, refreshStaleIdentities) +export * from "./identity"; + +// PDS client helpers (getPDS, getClient) +export * from "./client"; + +// Spaces interfaces + shared types +export * from "./spaces/types"; + +// Spaces URI helpers +export * from "./spaces/uri"; + +// TID generator +export * from "./spaces/tid"; + +// In-process auth marker +export * from "./spaces/in-process"; + +// Service-auth verification +export * from "./spaces/auth"; + +// ACL pure functions +export * from "./spaces/acl"; + +// Credentials +export * from "./spaces/credentials"; + +// Binding + key resolution +export * from "./spaces/binding"; + +// Blob adapter interface + built-in impls +export * from "./spaces/blob-adapter"; + +// Invite token primitives + community-handler interface +export * from "./invite/token"; +export * from "./invite/community-handler"; + +// Community integration interface + WhoamiExtension +export * from "./community-integration"; + +// Labels types +export * from "./labels/types"; + +// Realtime infrastructure +export * from "./realtime/types"; +export * from "./realtime/in-memory"; +export * from "./realtime/ticket"; +export * from "./realtime/durable-object"; +export * from "./realtime/sse"; +export * from "./realtime/websocket"; +export * from "./realtime/merge"; +export * from "./realtime/query-filter"; diff --git a/packages/contrail-base/src/invite/community-handler.ts b/packages/contrail-base/src/invite/community-handler.ts new file mode 100644 index 0000000..1043525 --- /dev/null +++ b/packages/contrail-base/src/invite/community-handler.ts @@ -0,0 +1,67 @@ +/** Pluggable handler for community-grant invites within the unified invite + * surface. The invite router calls into this when the target space is + * community-owned, or "tries" it on the redeem / revoke-without-spaceUri + * paths. Community module provides the impl; invite/router doesn't import + * from community at all. + * + * Each method returns a `HandlerResponse`: a `{status, body}` envelope that + * the router relays as JSON, or `null` (only on the "try" methods) meaning + * "not applicable, fall through to the user-owned path." */ + +export type HandlerResponse = { + status: number; + body: Record; +}; + +export interface CommunityInviteHandler { + /** True iff this space is owned by a community (vs. a regular user DID). + * Used by the invite router to choose the dispatch path on + * create / list / revoke-with-spaceUri. */ + isCommunityOwned(spaceUri: string): Promise; + + /** Create a community-grant invite. Caller is validated upstream for + * having a JWT; this method handles the access-level checks. */ + create(input: { + spaceUri: string; + callerDid: string; + /** Raw caller-supplied access level — implementation validates. */ + accessLevel?: string; + /** Caller-supplied `kind` field — community spaces don't accept this; the + * handler returns an InvalidRequest if set. */ + kind?: string; + expiresAt: number | null; + maxUses: number | null; + note: string | null; + }): Promise; + + /** List invites for a community-owned space. */ + list(input: { + spaceUri: string; + callerDid: string; + includeRevoked: boolean; + }): Promise; + + /** Revoke a known community-owned invite (caller already passed spaceUri + * and the router classified it as community-owned). */ + revoke(input: { + spaceUri: string; + tokenHash: string; + callerDid: string; + }): Promise; + + /** Revoke without a spaceUri — try to find the invite in the community + * table; return null if not a community invite (router falls through). */ + tryRevokeByToken(input: { + tokenHash: string; + callerDid: string; + }): Promise; + + /** Try to redeem a token as a community invite. Returns null if the token + * is not a community invite, in which case the router falls through to + * the user-owned redeem path. */ + tryRedeem(input: { + tokenHash: string; + callerDid: string; + now: number; + }): Promise; +} diff --git a/packages/contrail-base/src/invite/token.ts b/packages/contrail-base/src/invite/token.ts new file mode 100644 index 0000000..76f4880 --- /dev/null +++ b/packages/contrail-base/src/invite/token.ts @@ -0,0 +1,43 @@ +const B64U_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +function bytesToB64Url(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = bytes[i]; + const b1 = bytes[i + 1] ?? 0; + const b2 = bytes[i + 2] ?? 0; + out += B64U_ALPHABET[b0 >> 2]; + out += B64U_ALPHABET[((b0 & 3) << 4) | (b1 >> 4)]; + if (i + 1 < bytes.length) out += B64U_ALPHABET[((b1 & 15) << 2) | (b2 >> 6)]; + if (i + 2 < bytes.length) out += B64U_ALPHABET[b2 & 63]; + } + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0"); + return out; +} + +/** Generate a fresh invite token (cryptographically random, 32 bytes base64url-encoded). */ +export function generateInviteToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return bytesToB64Url(bytes); +} + +/** SHA-256 hash of a token, hex-encoded. Used as the PK in storage so raw tokens are never persisted. */ +export async function hashInviteToken(token: string): Promise { + const encoded = new TextEncoder().encode(token); + const digest = await crypto.subtle.digest("SHA-256", encoded); + return bytesToHex(new Uint8Array(digest)); +} + +/** Convenience: generate a token and return both the raw form (returned to + * the creator once) and its hash (persisted as the stable ID). */ +export async function mintInviteToken(): Promise<{ token: string; tokenHash: string }> { + const token = generateInviteToken(); + const tokenHash = await hashInviteToken(token); + return { token, tokenHash }; +} diff --git a/packages/contrail-base/src/labels/types.ts b/packages/contrail-base/src/labels/types.ts new file mode 100644 index 0000000..f2e36c2 --- /dev/null +++ b/packages/contrail-base/src/labels/types.ts @@ -0,0 +1,56 @@ +import type { Database } from "../types"; + +/** A labeler the operator wants contrail to track. */ +export interface LabelerSource { + /** Labeler DID — `did:plc:...` or `did:web:...`. */ + did: string; + /** Override the service endpoint resolution. Otherwise resolved from the + * DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. */ + endpoint?: string; + /** Backfill from `cursor=0` on first sight. Defaults to true. Set false + * for "start from now" — useful for very chatty labelers. */ + backfill?: boolean; +} + +export interface LabelsConfig { + /** Labelers to subscribe to and index. */ + sources: LabelerSource[]; + /** DIDs honored when the caller sends no `atproto-accept-labelers` / + * `?labelers=`. Defaults to every entry in `sources`. Set `[]` for + * opt-in-only — clients see no labels unless they ask. */ + defaults?: string[]; + /** Per-request cap. Default: 20 (matches Bluesky). */ + maxPerRequest?: number; +} + +export const DEFAULT_LABELS_MAX_PER_REQUEST = 20; + +/** A single label as stored. Matches `com.atproto.label.defs#label`. */ +export interface LabelRow { + /** Issuing labeler DID. */ + src: string; + /** Subject — at-URI for record labels, plain DID for account labels. */ + uri: string; + /** Label value — kebab-case, ≤128 bytes per spec. */ + val: string; + /** Optional CID pin to a specific record version. */ + cid: string | null; + /** When true, retracts a previously-emitted label for the same (src, uri, val). */ + neg: boolean; + /** Expiry, unix seconds. Past this, hydration drops the row. */ + exp: number | null; + /** Creation timestamp, unix seconds — what we collapse on. */ + cts: number; + /** Raw signature bytes. Stored when present so we can re-emit later; + * not verified in v1. */ + sig: Uint8Array | null; +} + +/** Per-labeler state row — endpoint cache and last-seen seq cursor. */ +export interface LabelerCursorRow { + did: string; + cursor: number; + endpoint: string | null; + resolved_at: number | null; +} + diff --git a/packages/contrail-base/src/realtime/durable-object.ts b/packages/contrail-base/src/realtime/durable-object.ts new file mode 100644 index 0000000..966ab53 --- /dev/null +++ b/packages/contrail-base/src/realtime/durable-object.ts @@ -0,0 +1,567 @@ +/** Durable Object backend for realtime PubSub. + * + * Two pieces live here: + * 1. `RealtimePubSubDO` — the DO class. Ship it from your Worker via + * `export { RealtimePubSubDO } from "@atmo-dev/contrail";` and bind it in + * your `wrangler.toml`. One DO = one topic; addressed by name. + * 2. `DurableObjectPubSub` — client-side adapter implementing the PubSub + * interface against a DO namespace binding. + * + * Wire format between Worker and DO (internal, not a stable public contract): + * POST /publish — body = RealtimeEvent JSON + * GET /subscribe — server-sent events stream, optionally with + * `Upgrade: websocket` for WS connections. + * Auth/ACL is already checked at the Worker edge; + * the DO trusts anything that reaches it. */ + +import type { PubSub, RealtimeEvent } from "./types"; +import { translateForQuery, type TranslatedEnvelope } from "./query-filter"; +type TranslatedEvent = TranslatedEnvelope; + +/** Query spec attached to a WS subscriber, used to filter events before + * delivery. Shape matches what the Worker's `watchRecords` handler builds; + * forwarded to the DO via trusted internal headers on the WS upgrade. */ +export interface SubscriberQuerySpec { + /** NSID of the primary collection the client is watching. */ + collection: string; + /** Space URI this subscription is scoped to. Events outside are dropped. */ + spaceUri: string; + /** Hydrated relations. Keyed by relName — value is the child collection + * NSID and the field on the child record that references the parent. */ + hydrate?: Record; +} + +// ---- Minimal structural typings so we don't depend on @cloudflare/workers-types +// at the library level. Callers on Workers will have proper types. +// ---------------------------------------------------------------------------- + +export interface DurableObjectId { + toString(): string; +} + +export interface DurableObjectStub { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +export interface DurableObjectNamespace { + idFromName(name: string): DurableObjectId; + get(id: DurableObjectId): DurableObjectStub; +} + +export interface DurableObjectState { + acceptWebSocket(ws: any, tags?: string[]): void; + getWebSockets(tag?: string): any[]; +} + +// ---------------------------------------------------------------------------- +// Client adapter +// ---------------------------------------------------------------------------- + +export class DurableObjectPubSub implements PubSub { + constructor(private readonly namespace: DurableObjectNamespace) {} + + private stub(topic: string): DurableObjectStub { + return this.namespace.get(this.namespace.idFromName(topic)); + } + + async publish(event: RealtimeEvent): Promise { + const res = await this.stub(event.topic).fetch("https://do/publish", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(event), + }); + if (!res.ok) { + // Consume the body so the edge doesn't hold a dangling response. + await res.text().catch(() => ""); + throw new Error(`DO publish failed (${res.status})`); + } + } + + /** In-Worker server-side subscribe. Browsers should hit the SSE endpoint + * directly; this is the path for an in-process consumer that wants an + * AsyncIterable (tests, bots embedded in the Worker). */ + subscribe(topic: string, signal?: AbortSignal): AsyncIterable { + const stub = this.stub(topic); + return { + [Symbol.asyncIterator]() { + return pullIterator(stub, signal); + }, + }; + } + + /** Forward an incoming browser WS upgrade (or SSE GET) through to the DO + * that owns this topic, attaching a query-filter spec that the DO will use + * to decide what to deliver. The Worker must verify auth + spec validity + * before calling this — the DO trusts the headers. */ + async forwardSubscribe( + topic: string, + request: Request, + opts: { + did?: string; + querySpec?: SubscriberQuerySpec; + /** Unix ms. DO replays any buffered event with ts > sinceTs before + * going live — closes the snapshot→WS race window on the client. */ + sinceTs?: number; + } = {} + ): Promise { + const headers = new Headers(request.headers); + if (opts.querySpec) { + headers.set("X-Contrail-Query-Spec", JSON.stringify(opts.querySpec)); + } + const url = new URL("https://do/subscribe"); + if (opts.did) url.searchParams.set("did", opts.did); + if (opts.sinceTs && opts.sinceTs > 0) { + url.searchParams.set("sinceTs", String(opts.sinceTs)); + } + return this.stub(topic).fetch(url.toString(), { + method: "GET", + headers + }); + } +} + +function pullIterator( + stub: DurableObjectStub, + signal?: AbortSignal +): AsyncIterator { + let reader: ReadableStreamDefaultReader | null = null; + let buf = ""; + const decoder = new TextDecoder(); + const ac = new AbortController(); + if (signal) { + if (signal.aborted) ac.abort(); + else signal.addEventListener("abort", () => ac.abort(), { once: true }); + } + + const open = async () => { + const res = await stub.fetch("https://do/subscribe", { + method: "GET", + headers: { accept: "text/event-stream" }, + signal: ac.signal, + }); + if (!res.ok || !res.body) throw new Error(`DO subscribe failed (${res.status})`); + reader = res.body.getReader(); + }; + + return { + async next(): Promise> { + if (!reader) await open(); + while (true) { + // Drain buffered frames. + while (true) { + const sep = buf.indexOf("\n\n"); + if (sep < 0) break; + const frame = buf.slice(0, sep); + buf = buf.slice(sep + 2); + let data: string | null = null; + for (const line of frame.split("\n")) { + if (line.startsWith(":")) continue; + if (line.startsWith("data:")) data = line.slice(5).trim(); + } + if (data) { + try { + return { value: JSON.parse(data) as RealtimeEvent, done: false }; + } catch { + /* skip malformed */ + } + } + } + if (ac.signal.aborted) return { value: undefined, done: true }; + const r = await reader!.read(); + if (r.done) return { value: undefined, done: true }; + buf += decoder.decode(r.value, { stream: true }); + } + }, + async return(): Promise> { + ac.abort(); + try { + await reader?.cancel(); + } catch { + /* ignore */ + } + return { value: undefined, done: true }; + }, + }; +} + +// ---------------------------------------------------------------------------- +// Durable Object class +// ---------------------------------------------------------------------------- + +/** The Durable Object implementation. Each DO instance owns the fan-out for + * exactly one topic. WebSocket connections are stored via the Hibernation + * API (`state.acceptWebSocket`) so idle rooms cost near-zero. + * + * This class intentionally avoids the `DurableObject` base class so we don't + * have to depend on @cloudflare/workers-types at the library level — users + * wire it up directly in their Worker entry. */ +/** Rolling buffer of recent events, used to close the snapshot→WS race: + * when a new subscriber connects with `?sinceTs=X`, replay any buffered + * event with `event.ts > X` before going live. Bounded by count + age so + * memory stays small. */ +const RECENT_BUFFER_MS = 15_000; +const RECENT_BUFFER_MAX = 500; + +export class RealtimePubSubDO { + private readonly recentEvents: RealtimeEvent[] = []; + + constructor( + protected readonly state: DurableObjectState, + _env?: unknown + ) {} + + private pushRecent(event: RealtimeEvent): void { + this.recentEvents.push(event); + const cutoff = Date.now() - RECENT_BUFFER_MS; + while ( + this.recentEvents.length > RECENT_BUFFER_MAX || + (this.recentEvents.length > 0 && this.recentEvents[0]!.ts < cutoff) + ) { + this.recentEvents.shift(); + } + } + + /** Worker entry delegates `fetch` to this method. */ + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (request.method === "POST" && url.pathname === "/publish") { + let event: RealtimeEvent; + try { + event = (await request.json()) as RealtimeEvent; + } catch { + return new Response(JSON.stringify({ error: "InvalidRequest" }), { status: 400 }); + } + this.publishEvent(event); + return new Response("{}", { status: 200 }); + } + if (request.method === "GET" && url.pathname === "/subscribe") { + const did = url.searchParams.get("did") ?? undefined; + const sinceTsRaw = url.searchParams.get("sinceTs"); + const sinceTs = sinceTsRaw ? Number(sinceTsRaw) : 0; + // Optional query-filter spec, forwarded by the Worker after it has + // verified the caller's auth + access. Parsed once here; the parsed + // object is serialized into the WS attachment so the DO can filter + // events on publish without re-parsing. + let querySpec: SubscriberQuerySpec | undefined; + const rawSpec = request.headers.get("X-Contrail-Query-Spec"); + if (rawSpec) { + try { + querySpec = JSON.parse(rawSpec) as SubscriberQuerySpec; + } catch { + return new Response( + JSON.stringify({ error: "InvalidRequest", message: "bad X-Contrail-Query-Spec" }), + { status: 400 } + ); + } + } + + if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { + const Pair = (globalThis as unknown as { WebSocketPair?: any }).WebSocketPair; + if (!Pair) return new Response("websockets require Workers", { status: 426 }); + const pair = new Pair(); + this.acceptWebSocketSubscriber(pair[1], did, querySpec); + if (sinceTs > 0) this.replayRecentTo(pair[1], sinceTs); + return new Response(null, { + status: 101, + // Workers-specific init field + webSocket: pair[0], + } as ResponseInit & { webSocket: unknown }); + } + return this.openSseResponse(did, querySpec, sinceTs); + } + return new Response("not found", { status: 404 }); + } + + /** Fan-out an event to every connected subscriber (WS + SSE). + * Public so tests + advanced callers can skip the HTTP layer. + * + * If a subscriber has attached a `querySpec`, we translate the raw event + * into 0–1 watchRecords-shaped events (record.created, record.deleted, + * hydration.added, hydration.removed) and deliver only those. Otherwise + * the raw event is delivered as-is (topic-firehose behaviour for the + * `realtime.subscribe` endpoint). */ + publishEvent(event: RealtimeEvent): void { + // Buffer first so a subscriber connecting mid-publish (race-window + // replay) can pick up this event too once they provide their sinceTs. + this.pushRecent(event); + + const rawPayload = JSON.stringify(event); + const rawFrame = `event: ${event.kind}\ndata: ${rawPayload}\n\n`; + + for (const ws of this.state.getWebSockets()) { + const attachment = getAttachment(ws); + + if (attachment?.querySpec) { + const translated = translateForQuery(event, attachment); + if (translated) this.writeSubscriberState(ws, attachment, translated); + for (const msg of translated ?? []) { + try { + ws.send(JSON.stringify(msg)); + } catch { + /* ignore */ + } + } + } else { + try { + ws.send(rawPayload); + } catch { + /* ignore */ + } + } + + if ( + event.kind === "member.removed" && + attachment?.did && + event.payload.did === attachment.did + ) { + try { + ws.close(4003, "membership-revoked"); + } catch { + /* ignore */ + } + } + } + + for (const entry of this.sseControllers) { + if (entry.querySpec) { + const translated = translateForQuery(event, entry); + if (translated) this.writeSubscriberStateForSse(entry, translated); + for (const msg of translated ?? []) { + try { + entry.controller.enqueue( + this.encoder.encode(`event: ${msg.kind}\ndata: ${JSON.stringify(msg.data)}\n\n`) + ); + } catch { + /* drop */ + } + } + } else { + try { + entry.controller.enqueue(this.encoder.encode(rawFrame)); + } catch { + /* drop; cleanup happens on the subscribe-side */ + } + } + + if ( + event.kind === "member.removed" && + entry.did && + event.payload.did === entry.did + ) { + try { + entry.controller.close(); + } catch { + /* ignore */ + } + } + } + } + + /** Register a server-side WebSocket as a subscriber. Wires the DID + + * optional query spec into the hibernation attachment so this DO can + * filter and route events after going to sleep. */ + acceptWebSocketSubscriber( + serverWs: any, + did?: string, + querySpec?: SubscriberQuerySpec + ): void { + this.state.acceptWebSocket(serverWs, did ? [did] : undefined); + if (did || querySpec) { + setAttachment(serverWs, { + did, + querySpec, + parentUris: [], + childToParent: {} + }); + } + } + + /** Open an SSE subscriber; returns the streaming Response. */ + openSseResponse( + did?: string, + querySpec?: SubscriberQuerySpec, + sinceTs = 0 + ): Response { + let entry: SseEntry; + const stream = new ReadableStream({ + start: (controller) => { + entry = { + controller, + did, + querySpec, + parentUris: new Set(), + childToParent: new Map() + }; + this.sseControllers.add(entry); + controller.enqueue(this.encoder.encode(`: open\n\n`)); + if (sinceTs > 0) this.replayRecentToSse(entry, sinceTs); + }, + cancel: () => { + this.sseControllers.delete(entry); + }, + }); + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }, + }); + } + + /** Replay buffered events with ts > sinceTs through this subscriber's + * query-spec filter. Called once, synchronously, on WS connect. */ + private replayRecentTo(ws: any, sinceTs: number): void { + const attachment = getAttachment(ws); + for (const event of this.recentEvents) { + if (event.ts <= sinceTs) continue; + if (attachment?.querySpec) { + const translated = translateForQuery(event, attachment); + if (translated) this.writeSubscriberState(ws, attachment, translated); + for (const msg of translated ?? []) { + try { + ws.send(JSON.stringify(msg)); + } catch { + /* ignore */ + } + } + } else { + try { + ws.send(JSON.stringify(event)); + } catch { + /* ignore */ + } + } + } + } + + private replayRecentToSse(entry: SseEntry, sinceTs: number): void { + for (const event of this.recentEvents) { + if (event.ts <= sinceTs) continue; + if (entry.querySpec) { + const translated = translateForQuery(event, entry); + if (translated) this.writeSubscriberStateForSse(entry, translated); + for (const msg of translated ?? []) { + try { + entry.controller.enqueue( + this.encoder.encode(`event: ${msg.kind}\ndata: ${JSON.stringify(msg.data)}\n\n`) + ); + } catch { + /* drop */ + } + } + } else { + try { + entry.controller.enqueue( + this.encoder.encode(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`) + ); + } catch { + /* drop */ + } + } + } + } + + /** Update the persisted WS attachment state after we've decided which + * events to forward. Keeps the parent/child tracking tables warm across + * hibernation. */ + private writeSubscriberState( + ws: any, + attachment: WsAttachment, + translated: TranslatedEvent[] + ): void { + let dirty = false; + for (const msg of translated) { + if (msg.kind === "record.created" && msg.data.record?.uri) { + attachment.parentUris = Array.from( + new Set([...(attachment.parentUris ?? []), msg.data.record.uri]) + ); + dirty = true; + } else if (msg.kind === "record.deleted" && msg.data.uri) { + const before = attachment.parentUris ?? []; + attachment.parentUris = before.filter((u) => u !== msg.data.uri); + if (attachment.parentUris.length !== before.length) dirty = true; + } else if (msg.kind === "hydration.added" && msg.data.child?.rkey) { + attachment.childToParent = { + ...(attachment.childToParent ?? {}), + [msg.data.child.rkey]: { + parentUri: msg.data.parentUri, + relName: msg.data.relation + } + }; + dirty = true; + } else if (msg.kind === "hydration.removed" && msg.data.childRkey) { + const next = { ...(attachment.childToParent ?? {}) }; + if (next[msg.data.childRkey]) { + delete next[msg.data.childRkey]; + attachment.childToParent = next; + dirty = true; + } + } + } + if (dirty) setAttachment(ws, attachment); + } + + private writeSubscriberStateForSse( + entry: SseEntry, + translated: TranslatedEvent[] + ): void { + for (const msg of translated) { + if (msg.kind === "record.created" && msg.data.record?.uri) { + entry.parentUris?.add(msg.data.record.uri); + } else if (msg.kind === "record.deleted" && msg.data.uri) { + entry.parentUris?.delete(msg.data.uri); + } else if (msg.kind === "hydration.added" && msg.data.child?.rkey) { + entry.childToParent?.set(msg.data.child.rkey, { + parentUri: msg.data.parentUri, + relName: msg.data.relation + }); + } else if (msg.kind === "hydration.removed" && msg.data.childRkey) { + entry.childToParent?.delete(msg.data.childRkey); + } + } + } + + private readonly sseControllers = new Set(); + private readonly encoder = new TextEncoder(); +} + +interface SseEntry { + controller: ReadableStreamDefaultController; + did: string | undefined; + querySpec?: SubscriberQuerySpec; + parentUris?: Set; + childToParent?: Map; +} + +interface WsAttachment { + did?: string; + querySpec?: SubscriberQuerySpec; + /** URIs of primary records currently in this subscriber's result set. */ + parentUris?: string[]; + /** childRkey → parent info, for routing child delete events. */ + childToParent?: Record; +} + +function setAttachment(ws: any, attachment: WsAttachment): void { + try { + ws.serializeAttachment?.(attachment); + } catch { + /* non-hibernating socket — fall back to a direct property */ + ws.__attachment = attachment; + } +} + +function getAttachment(ws: any): WsAttachment | null { + try { + const a = ws.deserializeAttachment?.(); + if (a) return a as WsAttachment; + } catch { + /* ignore */ + } + return (ws.__attachment as WsAttachment | undefined) ?? null; +} + +// Query-spec filtering lives in ./query-filter so the Worker can reuse it for +// non-DO (InMemoryPubSub) watchRecords paths without bundling the whole DO. diff --git a/packages/contrail-base/src/realtime/in-memory.ts b/packages/contrail-base/src/realtime/in-memory.ts new file mode 100644 index 0000000..311c0a5 --- /dev/null +++ b/packages/contrail-base/src/realtime/in-memory.ts @@ -0,0 +1,116 @@ +import type { PubSub, RealtimeEvent } from "./types"; +import { DEFAULT_QUEUE_BOUND } from "./types"; + +/** Single-process PubSub backed by in-memory subscriber sets. + * + * Each subscriber owns a bounded queue; when full, oldest events are dropped. + * `publish` returns once every subscriber has been offered the event — it + * never awaits a subscriber's consumption, so a slow consumer can't block + * producers. The cost of that guarantee is the drop-oldest policy. */ +export class InMemoryPubSub implements PubSub { + private readonly subscribers = new Map>(); + private readonly queueBound: number; + + constructor(opts: { queueBound?: number } = {}) { + this.queueBound = opts.queueBound ?? DEFAULT_QUEUE_BOUND; + } + + async publish(event: RealtimeEvent): Promise { + const set = this.subscribers.get(event.topic); + if (!set) return; + for (const sub of set) sub.push(event); + } + + subscribe(topic: string, signal?: AbortSignal): AsyncIterable { + const sub = new Subscriber(this.queueBound); + let set = this.subscribers.get(topic); + if (!set) { + set = new Set(); + this.subscribers.set(topic, set); + } + set.add(sub); + + const cleanup = () => { + sub.close(); + const s = this.subscribers.get(topic); + if (s) { + s.delete(sub); + if (s.size === 0) this.subscribers.delete(topic); + } + }; + + if (signal) { + if (signal.aborted) cleanup(); + else signal.addEventListener("abort", cleanup, { once: true }); + } + + return sub.iterate(cleanup); + } + + /** Test-only: current subscriber count for a topic. */ + subscriberCount(topic: string): number { + return this.subscribers.get(topic)?.size ?? 0; + } +} + +class Subscriber { + private readonly queue: RealtimeEvent[] = []; + private pending: ((v: RealtimeEvent | null) => void) | null = null; + private closed = false; + /** Number of events dropped because the queue was full. The consumer can + * observe a gap by comparing monotonic event timestamps; exposing the + * count on a side channel is future work. */ + public droppedCount = 0; + + constructor(private readonly bound: number) {} + + push(event: RealtimeEvent): void { + if (this.closed) return; + if (this.pending) { + const p = this.pending; + this.pending = null; + p(event); + return; + } + if (this.queue.length >= this.bound) { + this.queue.shift(); + this.droppedCount += 1; + } + this.queue.push(event); + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.pending) { + const p = this.pending; + this.pending = null; + p(null); + } + } + + iterate(cleanup: () => void): AsyncIterable { + const self = this; + return { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + if (self.queue.length > 0) { + return { value: self.queue.shift()!, done: false }; + } + if (self.closed) return { value: undefined, done: true }; + const event = await new Promise((resolve) => { + self.pending = resolve; + }); + if (event === null) return { value: undefined, done: true }; + return { value: event, done: false }; + }, + async return(): Promise> { + cleanup(); + return { value: undefined, done: true }; + }, + }; + }, + }; + } +} diff --git a/packages/contrail-base/src/realtime/merge.ts b/packages/contrail-base/src/realtime/merge.ts new file mode 100644 index 0000000..e0fa7df --- /dev/null +++ b/packages/contrail-base/src/realtime/merge.ts @@ -0,0 +1,77 @@ +/** Merge N AsyncIterables into one, interleaving events as they arrive. + * Terminates when every source terminates, or when `signal` aborts. */ + +export function mergeAsyncIterables( + sources: AsyncIterable[], + signal?: AbortSignal +): AsyncIterable { + if (sources.length === 0) { + return { + async *[Symbol.asyncIterator]() { + /* nothing to yield */ + }, + }; + } + + return { + [Symbol.asyncIterator]() { + const iterators = sources.map((s) => s[Symbol.asyncIterator]()); + // One in-flight next() per source, racing each other. + type Slot = { + idx: number; + promise: Promise<{ idx: number; result: IteratorResult }>; + }; + const pending = new Map(); + let doneCount = 0; + + const schedule = (idx: number) => { + const slot: Slot = { + idx, + promise: iterators[idx]! + .next() + .then((result) => ({ idx, result })), + }; + pending.set(idx, slot); + }; + + for (let i = 0; i < iterators.length; i++) schedule(i); + + const cleanup = () => { + for (const it of iterators) { + try { + it.return?.(); + } catch { + /* ignore */ + } + } + }; + + if (signal) { + if (signal.aborted) cleanup(); + else signal.addEventListener("abort", cleanup, { once: true }); + } + + return { + async next(): Promise> { + while (pending.size > 0) { + const slots = [...pending.values()]; + const { idx, result } = await Promise.race(slots.map((s) => s.promise)); + pending.delete(idx); + if (result.done) { + doneCount += 1; + if (doneCount === iterators.length) return { value: undefined, done: true }; + continue; + } + schedule(idx); + return { value: result.value, done: false }; + } + return { value: undefined, done: true }; + }, + async return(): Promise> { + cleanup(); + return { value: undefined, done: true }; + }, + }; + }, + }; +} diff --git a/packages/contrail-base/src/realtime/query-filter.ts b/packages/contrail-base/src/realtime/query-filter.ts new file mode 100644 index 0000000..1b79eb6 --- /dev/null +++ b/packages/contrail-base/src/realtime/query-filter.ts @@ -0,0 +1,235 @@ +/** Shared query-spec → event-translation logic. + * + * Used by: + * - the Durable Object's WS publish path (per-subscriber filter after hibernation) + * - the Worker's SSE / Worker-terminated WS path (in-process filter) + * + * Given a raw RealtimeEvent and a SubscriberQuerySpec, returns the list of + * `{kind, data}` envelopes to send to the subscriber, or `null` if the + * subscriber has no spec (i.e. raw-firehose mode). */ + +import type { RealtimeEvent } from "./types"; +import type { SubscriberQuerySpec } from "./durable-object"; + +export type TranslatedEnvelope = + | { + kind: "record.created"; + data: { + record: { + uri: string; + did: string; + rkey: string; + collection: string; + cid: string | null | undefined; + record: Record; + time_us: number; + indexed_at: number; + space: string; + }; + }; + } + | { kind: "record.deleted"; data: { uri: string; did: string; rkey: string } } + | { + kind: "hydration.added"; + data: { + parentUri: string; + relation: string; + child: { + uri: string; + did: string; + rkey: string; + collection: string; + cid: string | null | undefined; + record: Record; + space: string; + }; + }; + } + | { + kind: "hydration.removed"; + data: { + parentUri: string; + relation: string; + childRkey: string; + childDid?: string; + }; + }; + +export interface SubscriberView { + querySpec?: SubscriberQuerySpec; + parentUris?: Set | string[]; + childToParent?: + | Map + | Record; +} + +export function translateForQuery( + event: RealtimeEvent, + sub: SubscriberView +): TranslatedEnvelope[] | null { + const spec = sub.querySpec; + if (!spec) return null; + if (event.kind !== "record.created" && event.kind !== "record.deleted") return []; + if (event.payload.space !== spec.spaceUri) return []; + + const primaryUri = `at://${event.payload.did}/${event.payload.collection}/${event.payload.rkey}`; + + if (event.payload.collection === spec.collection) { + if (event.kind === "record.created") { + return [ + { + kind: "record.created", + data: { + record: { + uri: primaryUri, + did: event.payload.did, + rkey: event.payload.rkey, + collection: event.payload.collection, + cid: event.payload.cid, + record: event.payload.record, + time_us: event.ts * 1000, + indexed_at: event.ts, + space: spec.spaceUri + } + } + } + ]; + } + return [ + { + kind: "record.deleted", + data: { + uri: primaryUri, + did: event.payload.did, + rkey: event.payload.rkey + } + } + ]; + } + + if (!spec.hydrate) return []; + for (const [relName, rel] of Object.entries(spec.hydrate)) { + if (rel.childCollection !== event.payload.collection) continue; + if (event.kind === "record.created") { + const parentUri = getNestedValue( + event.payload.record as Record, + rel.matchField + ); + if (typeof parentUri !== "string") continue; + if (!hasParent(sub.parentUris, parentUri)) continue; + return [ + { + kind: "hydration.added", + data: { + parentUri, + relation: relName, + child: { + uri: primaryUri, + did: event.payload.did, + rkey: event.payload.rkey, + collection: event.payload.collection, + cid: event.payload.cid, + record: event.payload.record, + space: spec.spaceUri + } + } + } + ]; + } + const info = getChildInfo(sub.childToParent, event.payload.rkey); + if (!info || info.relName !== relName) continue; + return [ + { + kind: "hydration.removed", + data: { + parentUri: info.parentUri, + relation: relName, + childRkey: event.payload.rkey, + childDid: event.payload.did + } + } + ]; + } + return []; +} + +export function applyEnvelopesToSubscriber( + subscriber: SubscriberView, + envs: TranslatedEnvelope[] +): void { + for (const msg of envs) { + if (msg.kind === "record.created") { + ensureParentSet(subscriber).add(msg.data.record.uri); + } else if (msg.kind === "record.deleted") { + const set = subscriber.parentUris; + if (set instanceof Set) set.delete(msg.data.uri); + else if (Array.isArray(set)) { + const idx = set.indexOf(msg.data.uri); + if (idx >= 0) set.splice(idx, 1); + } + } else if (msg.kind === "hydration.added") { + ensureChildMap(subscriber).set(msg.data.child.rkey, { + parentUri: msg.data.parentUri, + relName: msg.data.relation + }); + } else if (msg.kind === "hydration.removed") { + const map = subscriber.childToParent; + if (map instanceof Map) map.delete(msg.data.childRkey); + else if (map && typeof map === "object") { + delete (map as Record)[msg.data.childRkey]; + } + } + } +} + +function ensureParentSet(sub: SubscriberView): Set { + if (sub.parentUris instanceof Set) return sub.parentUris; + const set = new Set(sub.parentUris ?? []); + sub.parentUris = set; + return set; +} + +function ensureChildMap( + sub: SubscriberView +): Map { + if (sub.childToParent instanceof Map) return sub.childToParent; + const map = new Map(); + if (sub.childToParent && typeof sub.childToParent === "object") { + for (const [k, v] of Object.entries(sub.childToParent)) map.set(k, v); + } + sub.childToParent = map; + return map; +} + +function hasParent( + parents: Set | string[] | undefined, + uri: string +): boolean { + if (!parents) return false; + if (parents instanceof Set) return parents.has(uri); + return parents.includes(uri); +} + +function getChildInfo( + map: + | Map + | Record + | undefined, + rkey: string +): { parentUri: string; relName: string } | undefined { + if (!map) return undefined; + if (map instanceof Map) return map.get(rkey); + return map[rkey]; +} + +function getNestedValue( + obj: Record, + path: string +): unknown { + let cur: unknown = obj; + for (const key of path.split(".")) { + if (cur == null || typeof cur !== "object") return undefined; + cur = (cur as Record)[key]; + } + return cur; +} diff --git a/packages/contrail-base/src/realtime/sse.ts b/packages/contrail-base/src/realtime/sse.ts new file mode 100644 index 0000000..65d89c1 --- /dev/null +++ b/packages/contrail-base/src/realtime/sse.ts @@ -0,0 +1,98 @@ +/** Server-Sent Events transport. + * + * Wraps an AsyncIterable as a streaming Response. The caller + * (the router) has already done auth and has an AbortSignal it can use to + * tear the stream down (e.g. on `member.removed` for the subscriber's DID). */ + +import type { RealtimeEvent } from "./types"; +import { DEFAULT_KEEPALIVE_MS } from "./types"; + +export interface SseOptions { + keepaliveMs?: number; + /** Called before the stream closes. Useful for cleanup that the caller + * can't do via the signal (e.g. removing a subscriber from a set). */ + onClose?: () => void; +} + +export function sseResponse( + iter: AsyncIterable, + signal: AbortSignal, + opts: SseOptions = {} +): Response { + const keepaliveMs = opts.keepaliveMs ?? DEFAULT_KEEPALIVE_MS; + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + let keepalive: ReturnType | null = null; + + const close = () => { + if (closed) return; + closed = true; + if (keepalive) clearInterval(keepalive); + try { + controller.close(); + } catch { + /* already closed */ + } + opts.onClose?.(); + }; + + signal.addEventListener("abort", close, { once: true }); + + keepalive = setInterval(() => { + if (closed) return; + try { + controller.enqueue(encoder.encode(`: keepalive\n\n`)); + } catch { + close(); + } + }, keepaliveMs); + + (async () => { + // Opening comment — helps some clients / proxies initialize promptly. + controller.enqueue(encoder.encode(`: open\n\n`)); + try { + for await (const event of iter) { + if (closed) break; + controller.enqueue(encoder.encode(frameEvent(event))); + } + } catch (err) { + if (!closed) { + try { + controller.enqueue( + encoder.encode( + `event: error\ndata: ${JSON.stringify({ + message: err instanceof Error ? err.message : String(err), + })}\n\n` + ) + ); + } catch { + /* stream already torn down */ + } + } + } finally { + close(); + } + })(); + }, + cancel() { + opts.onClose?.(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }, + }); +} + +function frameEvent(event: RealtimeEvent): string { + return `event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`; +} diff --git a/packages/contrail-base/src/realtime/ticket.ts b/packages/contrail-base/src/realtime/ticket.ts new file mode 100644 index 0000000..1c6e3de --- /dev/null +++ b/packages/contrail-base/src/realtime/ticket.ts @@ -0,0 +1,179 @@ +/** Subscription tickets — HMAC-signed short-lived `{topics, did, exp}` blobs. + * + * Wire format: `.` where + * payload = base64url(JSON({ topics, did, exp, iat })) + * sig = base64url(HMAC-SHA256(key, payload)) + * + * Tickets are integrity-only (not encrypted). Browsers use them because + * EventSource / WebSocket can't send Authorization headers; server-side + * consumers skip the ticket dance and send their JWT directly. */ + +export interface TicketPayload { + /** Concrete delivery topics this ticket authorizes. `community:` is + * expanded to the caller's visible spaces before signing — a ticket never + * carries a community alias. */ + topics: string[]; + did: string; + /** Unix ms. */ + exp: number; + /** Unix ms — useful for debugging; ignored on verify. */ + iat: number; + /** Optional: query-scoped watchRecords spec this ticket authorizes. Present + * when the ticket was minted from a watchRecords handshake. The server + * trusts the signed spec on upgrade and forwards it to the DO. */ + querySpec?: TicketQuerySpec; +} + +export interface TicketQuerySpec { + collection: string; + /** Exactly one of `spaceUri` or `actor` is set. `spaceUri` = per-space + * watch; `actor` = cross-space watch for records authored by this DID + * (the ticket's `topics` list carries the expanded delivery topics). */ + spaceUri?: string; + actor?: string; + hydrate?: Record; +} + +function normalizeSecret(secret: Uint8Array | string): Uint8Array { + if (typeof secret !== "string") { + if (secret.length !== 32) { + throw new Error(`realtime ticketSecret must be 32 bytes, got ${secret.length}`); + } + return secret; + } + // 64 hex chars would also round-trip as base64 (to 48 bytes). Prefer hex + // when the input matches the hex alphabet exactly; fall back to base64. + const hex = tryHex(secret); + if (hex && hex.length === 32) return hex; + const b64 = tryBase64(secret); + if (b64 && b64.length === 32) return b64; + if (hex || b64) { + const got = (hex ?? b64)!.length; + throw new Error(`realtime ticketSecret must decode to 32 bytes, got ${got}`); + } + throw new Error("realtime ticketSecret must be a 32-byte Uint8Array or base64/hex string"); +} + +function tryBase64(s: string): Uint8Array | null { + try { + const normal = s.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); + if (!/^[A-Za-z0-9+/]*=*$/.test(padded)) return null; + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch { + return null; + } +} + +function tryHex(s: string): Uint8Array | null { + if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2 !== 0) return null; + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function b64urlFromBytes(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlToBytes(s: string): Uint8Array { + const normal = s.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function b64urlFromString(s: string): string { + return b64urlFromBytes(new TextEncoder().encode(s)); +} + +function stringFromB64url(s: string): string { + return new TextDecoder().decode(b64urlToBytes(s)); +} + +function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; + return diff === 0; +} + +export class TicketSigner { + private readonly keyPromise: Promise; + + constructor(secret: Uint8Array | string) { + const raw = normalizeSecret(secret); + this.keyPromise = crypto.subtle.importKey( + "raw", + raw as BufferSource, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); + } + + async sign(input: { + topics: string[]; + did: string; + ttlMs: number; + querySpec?: TicketQuerySpec; + }): Promise { + const now = Date.now(); + const payload: TicketPayload = { + topics: input.topics, + did: input.did, + exp: now + input.ttlMs, + iat: now, + ...(input.querySpec ? { querySpec: input.querySpec } : {}), + }; + const payloadPart = b64urlFromString(JSON.stringify(payload)); + const sig = await crypto.subtle.sign( + "HMAC", + await this.keyPromise, + new TextEncoder().encode(payloadPart) as BufferSource + ); + const sigPart = b64urlFromBytes(new Uint8Array(sig)); + return `${payloadPart}.${sigPart}`; + } + + /** Returns the decoded payload if the ticket is valid + unexpired, else null. */ + async verify(ticket: string): Promise { + const dot = ticket.indexOf("."); + if (dot < 0) return null; + const payloadPart = ticket.slice(0, dot); + const sigPart = ticket.slice(dot + 1); + let expectedSig: Uint8Array; + try { + expectedSig = b64urlToBytes(sigPart); + } catch { + return null; + } + const computedRaw = await crypto.subtle.sign( + "HMAC", + await this.keyPromise, + new TextEncoder().encode(payloadPart) as BufferSource + ); + const computed = new Uint8Array(computedRaw); + if (!constantTimeEq(expectedSig, computed)) return null; + let parsed: TicketPayload; + try { + parsed = JSON.parse(stringFromB64url(payloadPart)); + } catch { + return null; + } + if (!parsed || !Array.isArray(parsed.topics) || typeof parsed.did !== "string") { + return null; + } + if (typeof parsed.exp !== "number" || parsed.exp <= Date.now()) return null; + return parsed; + } +} diff --git a/packages/contrail-base/src/realtime/types.ts b/packages/contrail-base/src/realtime/types.ts new file mode 100644 index 0000000..05a246e --- /dev/null +++ b/packages/contrail-base/src/realtime/types.ts @@ -0,0 +1,127 @@ +/** Realtime module — canonical types + interfaces. See docs/realtime.md. */ + +/** Discriminated union of every event kind that flows through the PubSub. + * + * `record.created` carries the full record body so a subscriber can apply an + * insert or upsert without a follow-up `getRecord` call. Writing a new record + * to the same `(did, collection, rkey)` publishes another `record.created` — + * treat it as upsert. + * + * **Payload shape mirrors `listRecords` output** (`uri`, `did`, `space?`, + * `time_us`), so a subscriber can render a live row the same way it renders + * a fetched row. + * + * **Publisher/topic matrix (intentional trust split):** + * - `collection:` and `actor:` carry *public* record events only + * (from jetstream ingestion) — no `space`. + * - `space:` and `community:` carry *space* events — `space` is + * always set. Never cross-published to public topics (privacy). */ +export type RealtimeEvent = + | { + topic: string; + kind: "record.created"; + payload: { + uri: string; + did: string; + collection: string; + rkey: string; + cid: string | null; + record: Record; + time_us: number; + /** Present only for space records; absent for public records. */ + space?: string; + }; + ts: number; + } + | { + topic: string; + kind: "record.deleted"; + payload: { + uri: string; + did: string; + collection: string; + rkey: string; + /** Present only for space records; absent for public records. */ + space?: string; + }; + ts: number; + } + | { + topic: string; + kind: "member.added"; + payload: { space: string; did: string }; + ts: number; + } + | { + topic: string; + kind: "member.removed"; + payload: { space: string; did: string }; + ts: number; + }; + +export type RealtimeEventKind = RealtimeEvent["kind"]; + +/** Core pubsub abstraction. Implementations: InMemoryPubSub, DurableObjectPubSub. */ +export interface PubSub { + publish(event: RealtimeEvent): Promise; + /** Stream events on the topic until the signal aborts (or the iterator is + * returned/broken out of). Implementations use a bounded per-subscriber + * queue with drop-oldest semantics — a slow subscriber can't stall publishers. */ + subscribe(topic: string, signal?: AbortSignal): AsyncIterable; +} + +// ---- Canonical topic strings ----------------------------------------------- +// `community:` is an alias resolved at ticket-mint time to the concrete +// set of `space:` topics the caller can see; it is never a real delivery +// topic. The other three are real. + +export function spaceTopic(uri: string): string { + return `space:${uri}`; +} + +export function communityTopic(did: string): string { + return `community:${did}`; +} + +export function collectionTopic(nsid: string): string { + return `collection:${nsid}`; +} + +export function actorTopic(did: string): string { + return `actor:${did}`; +} + +export function isCommunityTopic(topic: string): boolean { + return topic.startsWith("community:"); +} + +export function parseCommunityTopic(topic: string): string | null { + return isCommunityTopic(topic) ? topic.slice("community:".length) : null; +} + +export function parseSpaceTopic(topic: string): string | null { + return topic.startsWith("space:") ? topic.slice("space:".length) : null; +} + +// ---- Config ----------------------------------------------------------------- + +export interface RealtimeConfig { + /** Backing pubsub. Default: new InMemoryPubSub() (single-process only). On + * Workers, pass `new DurableObjectPubSub(env.REALTIME)`. */ + pubsub?: PubSub; + /** HMAC secret used to sign subscription tickets. 32 bytes. Accepts raw + * Uint8Array or base64 / hex string. Envelope-encrypts nothing — tickets + * are integrity-only, not confidential. */ + ticketSecret: Uint8Array | string; + /** Ticket lifetime in ms. Default 120_000 (2 minutes). */ + ticketTtlMs?: number; + /** SSE/WS keepalive interval in ms. Default 15_000. */ + keepaliveMs?: number; + /** Per-subscriber queue bound. Default 1024. Events beyond this are dropped + * oldest-first and the subscriber receives a `lag` signal (out of band). */ + queueBound?: number; +} + +export const DEFAULT_TICKET_TTL_MS = 120_000; +export const DEFAULT_KEEPALIVE_MS = 15_000; +export const DEFAULT_QUEUE_BOUND = 1024; diff --git a/packages/contrail-base/src/realtime/websocket.ts b/packages/contrail-base/src/realtime/websocket.ts new file mode 100644 index 0000000..833483d --- /dev/null +++ b/packages/contrail-base/src/realtime/websocket.ts @@ -0,0 +1,84 @@ +/** WebSocket transport. + * + * Accepts a new WebSocket connection (either via `WebSocketPair` on Workers + * or a platform-provided server-side socket) and pumps events to it from an + * AsyncIterable. Messages are UTF-8 JSON, one event per frame. + * + * Close codes (subset, RFC 6455 + app-custom): + * - 4001: server error pumping + * - 4003: membership revoked + * - 4008: ticket/auth invalid (used by the router, not here) + */ + +import type { RealtimeEvent } from "./types"; +import { DEFAULT_KEEPALIVE_MS } from "./types"; + +export interface WebSocketLike { + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: "message" | "close" | "error", listener: (ev: any) => void): void; +} + +export interface WebSocketPumpOptions { + keepaliveMs?: number; + onClose?: () => void; +} + +/** Pump events from `iter` to `ws` until the signal aborts or the iter ends. + * Caller is responsible for having already accept()ed the socket. */ +export async function pumpWebSocket( + ws: WebSocketLike, + iter: AsyncIterable, + signal: AbortSignal, + opts: WebSocketPumpOptions = {} +): Promise { + const keepaliveMs = opts.keepaliveMs ?? DEFAULT_KEEPALIVE_MS; + let closed = false; + + const close = (code?: number, reason?: string) => { + if (closed) return; + closed = true; + try { + ws.close(code, reason); + } catch { + /* already closed */ + } + opts.onClose?.(); + }; + + ws.addEventListener("close", () => { + closed = true; + opts.onClose?.(); + }); + ws.addEventListener("error", () => { + closed = true; + opts.onClose?.(); + }); + signal.addEventListener("abort", () => close(1000, "aborted"), { once: true }); + + const keepalive = setInterval(() => { + if (closed) return; + try { + ws.send(JSON.stringify({ kind: "$keepalive" })); + } catch { + close(); + } + }, keepaliveMs); + + try { + for await (const event of iter) { + if (closed) break; + try { + ws.send(JSON.stringify(event)); + } catch { + close(4001, "send-failed"); + break; + } + } + } catch { + close(4001, "pump-error"); + } finally { + clearInterval(keepalive); + close(); + } +} diff --git a/packages/contrail-base/src/spaces/acl.ts b/packages/contrail-base/src/spaces/acl.ts new file mode 100644 index 0000000..6e0da0a --- /dev/null +++ b/packages/contrail-base/src/spaces/acl.ts @@ -0,0 +1,69 @@ +import type { AppPolicy, SpaceMemberRow, SpaceRow } from "./types"; + +export type AclOp = "read" | "write" | "delete"; + +export interface AclInput { + op: AclOp; + space: SpaceRow; + callerDid: string; + /** Membership row for the caller (or null). Owner does not require a row. */ + member: SpaceMemberRow | null; + /** OAuth client_id of the app calling on caller's behalf, for app policy checks. */ + clientId?: string; + /** For per-record ops (get/delete), the record's author DID. */ + targetAuthorDid?: string; +} + +export type AclResult = + | { allow: true } + | { allow: false; reason: AclDenyReason }; + +export type AclDenyReason = + | "not-member" + | "not-own-record" + | "app-not-allowed" + | "unknown-op"; + +/** Check whether the caller's app is permitted to act in this space. */ +export function checkAppPolicy( + appPolicy: AppPolicy | null, + clientId: string | undefined +): boolean { + if (!appPolicy) return true; // no policy = allow-all + const listed = clientId ? appPolicy.apps.includes(clientId) : false; + if (appPolicy.mode === "allow") return !listed; // apps[] is a denylist + return listed; // mode === "deny": apps[] is an allowlist +} + +const isOwner = (space: SpaceRow, did: string) => space.ownerDid === did; +const hasMember = (space: SpaceRow, member: SpaceMemberRow | null, did: string) => + isOwner(space, did) || member != null; + +/** Space-level access check. + * Membership = access. Any member (including owner) can read and write. + * Delete is scoped to the caller's own records — owners don't get a bypass. + * A random member can't nuke other people's records, and neither can the + * owner. To remove a non-author record, delete the whole space. */ +export function checkAccess(input: AclInput): AclResult { + if (!checkAppPolicy(input.space.appPolicy, input.clientId)) { + return { allow: false, reason: "app-not-allowed" }; + } + + if (input.op === "read" || input.op === "write") { + return hasMember(input.space, input.member, input.callerDid) + ? { allow: true } + : { allow: false, reason: "not-member" }; + } + + if (input.op === "delete") { + if (!hasMember(input.space, input.member, input.callerDid)) { + return { allow: false, reason: "not-member" }; + } + if (input.targetAuthorDid && input.targetAuthorDid !== input.callerDid) { + return { allow: false, reason: "not-own-record" }; + } + return { allow: true }; + } + + return { allow: false, reason: "unknown-op" }; +} diff --git a/packages/contrail-base/src/spaces/auth.ts b/packages/contrail-base/src/spaces/auth.ts new file mode 100644 index 0000000..4c1e817 --- /dev/null +++ b/packages/contrail-base/src/spaces/auth.ts @@ -0,0 +1,177 @@ +import type { Context, MiddlewareHandler } from "hono"; +import { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; +import { + CompositeDidDocumentResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + type DidDocumentResolver, +} from "@atcute/identity-resolver"; +import type { Did, Nsid } from "@atcute/lexicons"; +import type { AuthorityConfig } from "./types"; +import { readInProcess } from "./in-process"; + +export { ServiceJwtVerifier }; + +/** Build a ServiceJwtVerifier from an AuthorityConfig, using the configured + * resolver or a default PLC+Web composite. The verifier checks that incoming + * JWTs target this authority's serviceDid (aud claim). */ +export function buildVerifier(authority: AuthorityConfig): ServiceJwtVerifier { + const resolver = + authority.resolver ?? + new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, + }); + return new ServiceJwtVerifier({ + serviceDid: authority.serviceDid as Did, + resolver, + }); +} + +export interface ServiceAuth { + issuer: string; + audience: string; + lxm: string | undefined; + /** OAuth client_id of the caller, if the JWT carries one. */ + clientId?: string; +} + +export interface ServiceAuthOptions { + serviceDid: Did; + resolver: DidDocumentResolver; +} + +/** Hono middleware that authenticates XRPC requests. Order of precedence: + * 1. In-process marker (same-module calls; see `core/spaces/in-process.ts`) + * 2. Authorization: Bearer as an atproto service-auth token + * + * On success, attaches the claims to `c.var.serviceAuth`. Expected Nsid is + * taken from the route pattern (last segment after `/xrpc/`). */ +export function createServiceAuthMiddleware( + verifier: ServiceJwtVerifier +): MiddlewareHandler { + return async (c, next) => { + const lxm = extractLxmFromPath(c); + + const inProcess = readInProcess(c.req.raw); + if (inProcess) { + c.set("serviceAuth", { + issuer: inProcess.did, + audience: "", + lxm: lxm ?? undefined, + } satisfies ServiceAuth); + await next(); + return; + } + + const header = c.req.header("Authorization"); + if (!header || !header.startsWith("Bearer ")) { + return c.json({ error: "AuthRequired", message: "Missing bearer token" }, 401); + } + const token = header.slice(7).trim(); + + const result = await verifier.verify(token, { lxm }); + if (!result.ok) { + const err = result.error as { error?: string; description?: string } | undefined; + return c.json( + { + error: "AuthRequired", + message: err?.description ?? err?.error ?? String(result.error), + }, + 401, + ); + } + + c.set("serviceAuth", { + issuer: result.value.issuer, + audience: result.value.audience, + lxm: result.value.lxm, + } satisfies ServiceAuth); + + await next(); + }; +} + +function extractLxmFromPath(c: Context): Nsid | null { + const path = new URL(c.req.url).pathname; + const match = path.match(/\/xrpc\/([a-zA-Z0-9.-]+)/); + return (match?.[1] as Nsid) ?? null; +} + +/** Read the service auth claims set by the middleware. Throws if unset. */ +export function requireServiceAuth(c: Context): ServiceAuth { + const auth = c.get("serviceAuth") as ServiceAuth | undefined; + if (!auth) throw new Error("service auth missing; middleware not attached"); + return auth; +} + +/** Out-of-band auth check for handlers that don't always require auth. + * Returns claims on success, or null if no valid credentials are present. + * Order of precedence: in-process marker → service-auth JWT. */ +export async function verifyServiceAuthRequest( + verifier: ServiceJwtVerifier, + request: Request, + lxm?: Nsid | null +): Promise { + const inProcess = readInProcess(request); + if (inProcess) { + return { + issuer: inProcess.did, + audience: "", + lxm: lxm ?? undefined, + }; + } + + const header = request.headers.get("Authorization"); + if (!header || !header.startsWith("Bearer ")) return null; + const token = header.slice(7).trim(); + const result = await verifier.verify(token, { lxm: lxm ?? null }); + if (!result.ok) return null; + return { + issuer: result.value.issuer, + audience: result.value.audience, + lxm: result.value.lxm, + }; +} + +/** Pull a space credential off the request — `X-Space-Credential: ` + * header. Returns the raw token or null. */ +export function extractSpaceCredential(request: Request): string | null { + const header = request.headers.get("X-Space-Credential"); + return header ? header.trim() : null; +} + +/** Pull a read-grant invite token off the request — query string `?inviteToken=` + * or `Authorization: Bearer atmo-invite:`. Returns the raw token (not + * hashed) or null. Routes hash + look up via the adapter. */ +export function extractInviteToken(request: Request): string | null { + const url = new URL(request.url); + const q = url.searchParams.get("inviteToken"); + if (q) return q.trim(); + const header = request.headers.get("Authorization"); + if (header?.startsWith("Bearer atmo-invite:")) { + return header.slice("Bearer atmo-invite:".length).trim(); + } + return null; +} + +/** Validate a read-grant invite token against a target spaceUri. Returns true + * if the token exists, scopes to this space, has a kind that grants read + * (`read` or `read-join`), and is not expired/revoked. */ +export async function checkInviteReadGrant( + adapter: { getInvite(tokenHash: string): Promise<{ spaceUri: string; kind: string; revokedAt: number | null; expiresAt: number | null } | null> }, + rawToken: string, + spaceUri: string, + hashFn: (token: string) => Promise +): Promise { + const tokenHash = await hashFn(rawToken); + const invite = await adapter.getInvite(tokenHash); + if (!invite) return false; + if (invite.spaceUri !== spaceUri) return false; + if (invite.kind !== "read" && invite.kind !== "read-join") return false; + if (invite.revokedAt != null) return false; + if (invite.expiresAt != null && invite.expiresAt <= Date.now()) return false; + return true; +} diff --git a/packages/contrail-base/src/spaces/binding.ts b/packages/contrail-base/src/spaces/binding.ts new file mode 100644 index 0000000..a81b95f --- /dev/null +++ b/packages/contrail-base/src/spaces/binding.ts @@ -0,0 +1,274 @@ +/** Binding resolution: given a space URI, which DID is authorized to sign + * credentials for it, and where do we find that DID's verification key? + * + * Two layers of pluggable resolvers compose into a credential verifier: + * + * BindingResolver — `ats:////` → authority DID + * KeyResolver — (DID, kid) → JsonWebKey + * + * The BindingResolver is what makes user-owned-DID-with-PDS-record work: + * given a space URI, we resolve the owner's PDS, fetch the declaration + * record, and read its `authority` field. For provisioned (no-PDS) DIDs we + * fall back to the owner DID's `#atproto_space_authority` service entry. + * And finally for the trivial case (HappyView-style "owner self-issues"), + * we return the owner DID itself. + * + * See conversation history (phase 4 design) for the rationale on why these + * three sources, in this order. */ + +import type { DidDocumentResolver } from "@atcute/identity-resolver"; +import type { Did } from "@atcute/lexicons"; +import { parseSpaceUri } from "./uri"; +import type { RecordHost } from "./types"; + +export interface BindingResolver { + /** Resolve the DID authorized to sign credentials for this space. Returns + * null if no binding could be found via this resolver — the composite + * walks down its list looking for a non-null. */ + resolveAuthority(spaceUri: string): Promise; +} + +export interface KeyResolver { + /** Resolve `did`'s verification key for credential signing. `kid` is the + * full header `kid` value (e.g. "did:web:x.com#atproto_space_authority"), + * used to disambiguate when a DID doc lists multiple methods. */ + resolveKey(did: string, kid: string | undefined): Promise; +} + +// --------------------------------------------------------------------------- +// Binding resolvers +// --------------------------------------------------------------------------- + +/** Always returns the configured authority DID. Used in-process when the + * authority and record host run in one deployment — no need to walk DID + * docs or PDSes; we know what we are. */ +export function createLocalBindingResolver(args: { + authorityDid: string; +}): BindingResolver { + const { authorityDid } = args; + return { + async resolveAuthority() { + return authorityDid; + }, + }; +} + +/** Reads the record host's local enrollment table. This is the *canonical* + * binding source on a record host: the host owner explicitly consented to a + * given authority for a given space (via the `recordHost.enroll` endpoint + * or auto-enrollment from the authority's createSpace). PDS-record / + * DID-doc resolvers are out-of-band discovery aids; the enrollment is what + * actually gates whether records get stored here. */ +export function createEnrollmentBindingResolver(args: { + recordHost: RecordHost; +}): BindingResolver { + return { + async resolveAuthority(spaceUri) { + const e = await args.recordHost.getEnrollment(spaceUri); + return e?.authorityDid ?? null; + }, + }; +} + +/** Returns the space owner DID as the authority. This is the implicit + * fallback ("HappyView path") — when no PDS record and no DID-doc service + * entry declare an issuer, the owner is taken to be its own. Whether the + * resulting credential actually verifies depends on whether the owner's DID + * doc publishes a usable signing key. */ +export function createOwnerSelfBindingResolver(): BindingResolver { + return { + async resolveAuthority(spaceUri) { + const parts = parseSpaceUri(spaceUri); + return parts ? parts.ownerDid : null; + }, + }; +} + +/** Walks the resolver list in order, returns the first non-null. Use this + * to compose [pdsRecord, didDocService, ownerSelf] etc. */ +export function createCompositeBindingResolver( + resolvers: BindingResolver[] +): BindingResolver { + return { + async resolveAuthority(spaceUri) { + for (const r of resolvers) { + const did = await r.resolveAuthority(spaceUri); + if (did) return did; + } + return null; + }, + }; +} + +/** Reads a space-declaration record from the owner's PDS at + * `at:////` and returns its `authority` field if present. + * + * This is the user-owned-DID path: the user writes a record to their PDS + * authorizing some service as the space's authority, no DID-doc edits + * required. */ +export function createPdsBindingResolver(args: { + /** DID resolver, used to look up the owner's PDS endpoint. */ + resolver: DidDocumentResolver; + /** Fetch impl. Defaults to `globalThis.fetch`. */ + fetch?: typeof fetch; + /** Per-request timeout in ms. Defaults to 5000. */ + timeoutMs?: number; +}): BindingResolver { + const fetchImpl = args.fetch ?? globalThis.fetch; + const timeoutMs = args.timeoutMs ?? 5000; + + return { + async resolveAuthority(spaceUri) { + const parts = parseSpaceUri(spaceUri); + if (!parts) return null; + const pds = await pdsEndpointFor(args.resolver, parts.ownerDid); + if (!pds) return null; + + const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); + url.searchParams.set("repo", parts.ownerDid); + url.searchParams.set("collection", parts.type); + url.searchParams.set("rkey", parts.key); + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + let res: Response; + try { + res = await fetchImpl(url.toString(), { signal: ctrl.signal }); + } catch { + return null; + } finally { + clearTimeout(timer); + } + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as + | { value?: { authority?: unknown } } + | null; + const authority = body?.value?.authority; + return typeof authority === "string" && authority.startsWith("did:") ? authority : null; + }, + }; +} + +/** Reads `service[id="#atproto_space_authority"].serviceEndpoint` from the + * owner's DID doc. This is the no-PDS path — useful for provisioned space + * DIDs that exist as DID docs only. + * + * Note the service endpoint here is a *DID*, not a URL. The DID names the + * authority; the key resolver's job is to then fetch its verification key. + * For DID docs that declare a URL endpoint, we treat the URL as a + * did:web hint — caller can normalize. */ +export function createDidDocBindingResolver(args: { + resolver: DidDocumentResolver; + /** Service id to look up. Defaults to "#atproto_space_authority". */ + serviceId?: string; +}): BindingResolver { + const serviceId = args.serviceId ?? "#atproto_space_authority"; + return { + async resolveAuthority(spaceUri) { + const parts = parseSpaceUri(spaceUri); + if (!parts) return null; + let doc; + try { + doc = await args.resolver.resolve(parts.ownerDid as Did); + } catch { + return null; + } + const entry = doc.service?.find((s: { id?: string }) => s.id === serviceId); + if (!entry) return null; + const endpoint = (entry as { serviceEndpoint?: unknown }).serviceEndpoint; + if (typeof endpoint !== "string") return null; + // Endpoint may be a DID (preferred) or a URL hint. Only DIDs are + // verifiable downstream; URLs require the caller to map URL → DID. + return endpoint.startsWith("did:") ? endpoint : null; + }, + }; +} + +// --------------------------------------------------------------------------- +// Key resolvers +// --------------------------------------------------------------------------- + +/** Knows the local authority's public key directly. Returns null for any + * other DID — composite with a DID-doc resolver if you also accept + * external authorities. */ +export function createLocalKeyResolver(args: { + authorityDid: string; + publicKey: JsonWebKey; +}): KeyResolver { + return { + async resolveKey(did) { + return did === args.authorityDid ? args.publicKey : null; + }, + }; +} + +/** Resolves a DID, finds the verification method matching `kid`, returns + * its `publicKeyJwk`. */ +export function createDidDocKeyResolver(args: { + resolver: DidDocumentResolver; +}): KeyResolver { + return { + async resolveKey(did, kid) { + let doc; + try { + doc = await args.resolver.resolve(did as Did); + } catch { + return null; + } + const methods = (doc as { verificationMethod?: VerificationMethod[] }).verificationMethod; + if (!methods) return null; + // kid is "#" — we match against the method.id which DID + // docs spell as "#" too. + const method = kid + ? methods.find((m) => m.id === kid) + : methods[0]; + if (!method?.publicKeyJwk) return null; + return method.publicKeyJwk as JsonWebKey; + }, + }; +} + +/** Walks resolvers in order; returns the first non-null. */ +export function createCompositeKeyResolver( + resolvers: KeyResolver[] +): KeyResolver { + return { + async resolveKey(did, kid) { + for (const r of resolvers) { + const k = await r.resolveKey(did, kid); + if (k) return k; + } + return null; + }, + }; +} + +interface VerificationMethod { + id: string; + type?: string; + controller?: string; + publicKeyJwk?: unknown; + publicKeyMultibase?: string; +} + +// --------------------------------------------------------------------------- +// Internal: PDS endpoint lookup +// --------------------------------------------------------------------------- + +async function pdsEndpointFor( + resolver: DidDocumentResolver, + did: string +): Promise { + let doc; + try { + doc = await resolver.resolve(did as Did); + } catch { + return null; + } + const entry = doc.service?.find( + (s: { id?: string }) => s.id === "#atproto_pds" + ); + if (!entry) return null; + const endpoint = (entry as { serviceEndpoint?: unknown }).serviceEndpoint; + return typeof endpoint === "string" ? endpoint : null; +} diff --git a/packages/contrail-base/src/spaces/blob-adapter.ts b/packages/contrail-base/src/spaces/blob-adapter.ts new file mode 100644 index 0000000..632b566 --- /dev/null +++ b/packages/contrail-base/src/spaces/blob-adapter.ts @@ -0,0 +1,94 @@ +/** + * Bytes-only storage adapter for space blobs. Metadata (CID, mime, size, + * author, space) lives in the `spaces_blobs` table on the main StorageAdapter; + * this interface only moves bytes in and out of a backend (R2, S3, fs, …). + * + * Keys are opaque strings formed by the router as `blobKey(spaceUri, cid)`. + */ + +export interface BlobUploadMeta { + mimeType: string; + size: number; +} + +export interface BlobAdapter { + put(key: string, bytes: Uint8Array, meta: BlobUploadMeta): Promise; + get(key: string): Promise; + /** Bulk delete. Adapters that don't support batch can implement serially. */ + delete(keys: string[]): Promise; +} + +/** In-memory adapter. Useful for tests and local development. */ +export class MemoryBlobAdapter implements BlobAdapter { + private readonly store = new Map(); + + async put(key: string, bytes: Uint8Array): Promise { + this.store.set(key, bytes.slice()); + } + + async get(key: string): Promise { + const v = this.store.get(key); + return v ? v.slice() : null; + } + + async delete(keys: string[]): Promise { + for (const k of keys) this.store.delete(k); + } + + /** Test helper. */ + size(): number { + return this.store.size; + } +} + +/** Minimal Cloudflare R2 bucket shape — matches @cloudflare/workers-types' R2Bucket + * without forcing a types dependency here. */ +export interface R2BucketLike { + put( + key: string, + value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob, + options?: { httpMetadata?: { contentType?: string }; customMetadata?: Record } + ): Promise; + get(key: string): Promise<{ arrayBuffer(): Promise } | null>; + delete(keys: string | string[]): Promise; +} + +/** Cloudflare R2 adapter. Pass the `env.BLOBS` binding from your Worker. */ +export class R2BlobAdapter implements BlobAdapter { + constructor(private readonly bucket: R2BucketLike) {} + + async put(key: string, bytes: Uint8Array, meta: BlobUploadMeta): Promise { + await this.bucket.put(key, bytes, { + httpMetadata: { contentType: meta.mimeType }, + }); + } + + async get(key: string): Promise { + const obj = await this.bucket.get(key); + if (!obj) return null; + const buf = await obj.arrayBuffer(); + return new Uint8Array(buf); + } + + async delete(keys: string[]): Promise { + if (keys.length === 0) return; + await this.bucket.delete(keys); + } +} + +/** Hash a space URI to a short, filesystem/R2-safe key segment. + * Used as the first segment of a blob key so all blobs for one space + * share a common prefix (enables bulk delete on space deletion). */ +export async function spaceKeyPrefix(spaceUri: string): Promise { + const bytes = new TextEncoder().encode(spaceUri); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); + return hex.slice(0, 16); +} + +/** Compose an adapter key from a space URI and CID. + * Shape: `<16-hex-chars-of-sha256(spaceUri)>/`. */ +export async function blobKey(spaceUri: string, cid: string): Promise { + const prefix = await spaceKeyPrefix(spaceUri); + return `${prefix}/${cid}`; +} diff --git a/packages/contrail-base/src/spaces/credentials.ts b/packages/contrail-base/src/spaces/credentials.ts new file mode 100644 index 0000000..741b82d --- /dev/null +++ b/packages/contrail-base/src/spaces/credentials.ts @@ -0,0 +1,284 @@ +/** Space-credential primitives: ES256 (P-256) JWTs minted by the authority, + * verified by the record host (or any third party that can resolve the + * authority's DID document). + * + * Format is a compact JWS: + * header = { alg: "ES256", typ: "JWT", kid: "#" } + * payload = { iss, sub, space, scope, iat, exp } + * + * - `iss` is the authority DID (the signer; for phase 3 this is the local + * authority's serviceDid; phase 4 adds a binding-resolution layer that + * lets the issuer be a *different* DID from the space owner). + * - `sub` is the caller DID — the credential bearer. + * - `space` is the full `ats:////` URI. + * - `scope` is "rw" or "read". + * + * We don't use a JWT library — Web Crypto's subtle covers everything (P-256 + * generate, sign, verify, JWK import/export) and saves a runtime dep. */ + +const ALG = "ES256"; +const TYP = "JWT"; +const DEFAULT_KEY_ID = "atproto_space_authority"; + +export type CredentialScope = "rw" | "read"; + +export interface CredentialClaims { + iss: string; + sub: string; + space: string; + scope: CredentialScope; + iat: number; // seconds since epoch + exp: number; // seconds since epoch +} + +export interface CredentialKeyMaterial { + /** Private key in JWK form. P-256 / ES256. */ + privateKey: JsonWebKey; + /** Public key in JWK form. Must match privateKey. */ + publicKey: JsonWebKey; + /** DID-doc verification method id. The full JWT `kid` becomes + * `#`. Defaults to "atproto_space_authority". */ + keyId?: string; +} + +/** Generate a fresh P-256 keypair as JWKs. Useful for local dev / tests; in + * production the operator generates once and stores out-of-band. */ +export async function generateAuthoritySigningKey(): Promise { + const pair = (await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"] + )) as CryptoKeyPair; + const privateKey = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; + const publicKey = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as JsonWebKey; + return { privateKey, publicKey }; +} + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function base64urlEncode(bytes: Uint8Array): string { + let s = btoa(String.fromCharCode(...bytes)); + return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function base64urlDecode(s: string): Uint8Array { + const padded = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="); + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function jsonEncode(value: unknown): string { + return base64urlEncode(enc.encode(JSON.stringify(value))); +} + +function jsonDecode(seg: string): T { + return JSON.parse(dec.decode(base64urlDecode(seg))) as T; +} + +async function importPrivate(jwk: JsonWebKey): Promise { + return crypto.subtle.importKey( + "jwk", + jwk, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"] + ); +} + +async function importPublic(jwk: JsonWebKey): Promise { + return crypto.subtle.importKey( + "jwk", + jwk, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"] + ); +} + +/** Sign a credential payload with the authority's private key. + * `iat` and `exp` are filled in by the caller (so tests can mint expired + * tokens deterministically). */ +export async function signCredential( + payload: CredentialClaims, + key: CredentialKeyMaterial +): Promise { + const kid = `${payload.iss}#${key.keyId ?? DEFAULT_KEY_ID}`; + const header = { alg: ALG, typ: TYP, kid }; + const head = jsonEncode(header); + const body = jsonEncode(payload); + const signingInput = `${head}.${body}`; + const privateKey = await importPrivate(key.privateKey); + const sig = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + privateKey, + enc.encode(signingInput) + ); + return `${signingInput}.${base64urlEncode(new Uint8Array(sig))}`; +} + +/** Issue a credential using the current wall-clock for iat/exp. */ +export async function issueCredential( + args: Omit & { ttlMs: number }, + key: CredentialKeyMaterial +): Promise<{ credential: string; expiresAt: number }> { + const now = Math.floor(Date.now() / 1000); + const expSec = now + Math.floor(args.ttlMs / 1000); + const claims: CredentialClaims = { + iss: args.iss, + sub: args.sub, + space: args.space, + scope: args.scope, + iat: now, + exp: expSec, + }; + const credential = await signCredential(claims, key); + return { credential, expiresAt: expSec * 1000 }; +} + +export type VerifyOk = { ok: true; claims: CredentialClaims }; +export type VerifyErr = { + ok: false; + reason: + | "malformed" + | "bad-alg" + | "bad-signature" + | "expired" + | "not-yet-valid" + | "wrong-space" + | "wrong-scope" + | "unknown-issuer"; +}; + +export interface VerifyOptions { + /** Optional: when set, rejects credentials whose `space` claim differs. + * Omit when verifying in middleware where the target space isn't known + * yet — handlers can do the match themselves against the verified + * claims. */ + expectedSpace?: string; + /** Optional: required scope (e.g. "rw" rejects read-only credentials on writes). */ + requiredScope?: CredentialScope; + /** Resolve a verification key for `iss`. If null, verification fails with + * unknown-issuer. */ + resolveKey: (iss: string, kid: string | undefined) => Promise; + /** Time provider for tests. Returns ms since epoch. */ + now?: () => number; +} + +export async function verifyCredential( + jwt: string, + opts: VerifyOptions +): Promise { + const parts = jwt.split("."); + if (parts.length !== 3) return { ok: false, reason: "malformed" }; + const [headSeg, bodySeg, sigSeg] = parts as [string, string, string]; + + let header: { alg?: string; typ?: string; kid?: string }; + let claims: CredentialClaims; + try { + header = jsonDecode(headSeg); + claims = jsonDecode(bodySeg); + } catch { + return { ok: false, reason: "malformed" }; + } + if (header.alg !== ALG) return { ok: false, reason: "bad-alg" }; + if (opts.expectedSpace !== undefined && claims.space !== opts.expectedSpace) { + return { ok: false, reason: "wrong-space" }; + } + if (opts.requiredScope === "rw" && claims.scope !== "rw") { + return { ok: false, reason: "wrong-scope" }; + } + + const nowMs = (opts.now ?? Date.now)(); + const nowSec = Math.floor(nowMs / 1000); + if (claims.exp <= nowSec) return { ok: false, reason: "expired" }; + if (claims.iat > nowSec + 60) return { ok: false, reason: "not-yet-valid" }; + + const jwk = await opts.resolveKey(claims.iss, header.kid); + if (!jwk) return { ok: false, reason: "unknown-issuer" }; + + const publicKey = await importPublic(jwk); + const sigBytes = base64urlDecode(sigSeg); + const signingInput = `${headSeg}.${bodySeg}`; + const valid = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + sigBytes as BufferSource, + enc.encode(signingInput) + ); + if (!valid) return { ok: false, reason: "bad-signature" }; + return { ok: true, claims }; +} + +/** Header reader for handlers that want to peek at `iss` before resolving the + * key (e.g. to short-circuit DID-doc fetches for the local authority). */ +export function decodeUnverifiedClaims(jwt: string): CredentialClaims | null { + const parts = jwt.split("."); + if (parts.length !== 3) return null; + try { + return jsonDecode(parts[1]!); + } catch { + return null; + } +} + +/** Verifier interface consumed by the record host. The record host doesn't + * care HOW credentials get verified — it only cares whether a given JWT is + * valid. Phase 3 ships an in-process verifier that knows the local + * authority's public key; phase 4 adds a binding-resolving verifier that + * consults PDS records / DID docs. */ +export interface CredentialVerifier { + /** Verify a credential's signature, expiry, and `not-before` window. Does + * NOT enforce a space match — handlers do that against the request URI. */ + verify(jwt: string): Promise; +} + +/** In-process verifier for the simple deployment: the authority and record + * host run in one process and the record host has direct access to the + * authority's public key. Rejects any credential whose `iss` isn't the + * configured authority. Phase 4 has a more general + * {@link createBindingCredentialVerifier} that does proper binding lookup. */ +export function createInProcessVerifier(args: { + authorityDid: string; + publicKey: JsonWebKey; +}): CredentialVerifier { + return { + verify(jwt) { + return verifyCredential(jwt, { + resolveKey: async (iss) => (iss === args.authorityDid ? args.publicKey : null), + }); + }, + }; +} + +/** Verifier composed of a {@link BindingResolver} (which DID is authorized + * to issue for this space?) and a {@link KeyResolver} (what's that DID's + * public key?). This is the production-shape verifier — phase 4's main + * contribution. + * + * Verification flow: + * 1. Decode the JWT's claims (no signature check yet). + * 2. Ask the binding resolver: who's authorized for `claims.space`? + * 3. Confirm `claims.iss === authorizedDid`. + * 4. Ask the key resolver for that DID's verification key. + * 5. Verify signature + expiry + scope match. + */ +export function createBindingCredentialVerifier(args: { + bindings: import("./binding").BindingResolver; + keys: import("./binding").KeyResolver; +}): CredentialVerifier { + return { + async verify(jwt) { + const peek = decodeUnverifiedClaims(jwt); + if (!peek) return { ok: false, reason: "malformed" }; + const authorizedDid = await args.bindings.resolveAuthority(peek.space); + if (!authorizedDid) return { ok: false, reason: "unknown-issuer" }; + if (peek.iss !== authorizedDid) return { ok: false, reason: "unknown-issuer" }; + return verifyCredential(jwt, { + resolveKey: (iss, kid) => args.keys.resolveKey(iss, kid), + }); + }, + }; +} diff --git a/packages/contrail-base/src/spaces/in-process.ts b/packages/contrail-base/src/spaces/in-process.ts new file mode 100644 index 0000000..ec1d7a1 --- /dev/null +++ b/packages/contrail-base/src/spaces/in-process.ts @@ -0,0 +1,34 @@ +/** In-process auth marker. + * + * For same-module callers (e.g. a SvelteKit worker that imports contrail and + * dispatches requests directly to the handler), service-auth JWTs are pure + * overhead: no network boundary is crossed, so there's nothing for the JWT to + * protect against. Instead, the caller tags the `Request` with a principal via + * a module-private WeakMap, and the auth middleware reads it back. + * + * Security note: this is unforgeable from outside the module because + * - WeakMap keys are `Request` object identities, not serialized data; + * - no HTTP request crossing a network boundary can reach into this map; + * - exploiting it requires code execution inside the same isolate, at + * which point auth is already game over. + * + * This is the strongest auth adapter contrail offers — it has no secret to + * leak. */ + +export interface InProcessPrincipal { + did: string; +} + +const PRINCIPALS = new WeakMap(); + +/** Tag a Request with an in-process principal. The returned Request is the + * same reference; the return value is for ergonomics. */ +export function markInProcess(req: Request, did: string): Request { + PRINCIPALS.set(req, { did }); + return req; +} + +/** Read the in-process principal for a Request, or null if unmarked. */ +export function readInProcess(req: Request): InProcessPrincipal | null { + return PRINCIPALS.get(req) ?? null; +} diff --git a/packages/contrail-base/src/spaces/tid.ts b/packages/contrail-base/src/spaces/tid.ts new file mode 100644 index 0000000..c419090 --- /dev/null +++ b/packages/contrail-base/src/spaces/tid.ts @@ -0,0 +1,20 @@ +const B32_CHARSET = "234567abcdefghijklmnopqrstuvwxyz"; + +let lastTimestamp = 0; +let clockId = Math.floor(Math.random() * 1024); + +/** Generate an atproto TID: 13-char base32-sortable (timestamp-ordered). */ +export function nextTid(): string { + let now = Date.now() * 1000; + if (now <= lastTimestamp) now = lastTimestamp + 1; + lastTimestamp = now; + + const n = BigInt(now) * 1024n + BigInt(clockId); + let s = ""; + let v = n; + for (let i = 0; i < 13; i++) { + s = B32_CHARSET[Number(v & 31n)] + s; + v >>= 5n; + } + return s; +} diff --git a/packages/contrail-base/src/spaces/types.ts b/packages/contrail-base/src/spaces/types.ts new file mode 100644 index 0000000..6561773 --- /dev/null +++ b/packages/contrail-base/src/spaces/types.ts @@ -0,0 +1,273 @@ +import type { Database } from "../types"; +import type { DidDocumentResolver } from "@atcute/identity-resolver"; +import type { BlobAdapter } from "./blob-adapter"; +import type { CredentialKeyMaterial } from "./credentials"; + +export type AppPolicyMode = "allow" | "deny"; + +export interface AppPolicy { + mode: AppPolicyMode; + apps: string[]; +} + +export interface SpacesBlobsConfig { + /** Bytes backend (R2, S3, in-memory, …). */ + adapter: BlobAdapter; + /** Max blob size in bytes. Defaults to 2 MiB. */ + maxSize?: number; + /** MIME allowlist. If set, only these content types are accepted. */ + accept?: string[]; + /** Orphan blobs (those with no referencing record) are kept this long before + * GC can delete them, to allow upload-then-putRecord flows. + * Defaults to 24 hours. */ + gcOrphanAfterMs?: number; +} + +export const DEFAULT_BLOB_MAX_SIZE = 2 * 1024 * 1024; +export const DEFAULT_BLOB_GC_ORPHAN_AFTER_MS = 24 * 60 * 60 * 1000; + +/** Default credential lifetime. The rough spec calls for 2–4h; we pick the + * lower bound so revocation (kicked-from-space) is observable within 2h. */ +export const DEFAULT_CREDENTIAL_TTL_MS = 2 * 60 * 60 * 1000; + +/** Configuration for the **space authority** role: holds the member list, + * signs credentials, and gates space-management operations. In a fully-split + * deployment, the authority can run in a different process (or even a + * different operator) than the record host. */ +export interface AuthorityConfig { + /** NSID that identifies the kind of space this authority hosts, + * e.g. "tools.atmo.event.space". */ + type: string; + /** Service DID that service-auth tokens must target (aud claim) AND that + * signs credentials it issues (`iss` claim on emitted JWTs). */ + serviceDid: string; + /** Default app policy applied to new spaces. */ + defaultAppPolicy?: AppPolicy; + /** DID document resolver for service-auth JWT verification. + * Defaults to a composite PLC + did:web resolver if omitted. */ + resolver?: DidDocumentResolver; + /** Signing key material for issuing space credentials. When omitted, + * `.space.getCredential` returns 501 NotImplemented and the record + * host's credential-verifying middleware can't be wired up. */ + signing?: CredentialKeyMaterial; + /** Credential lifetime in ms. Defaults to {@link DEFAULT_CREDENTIAL_TTL_MS}. */ + credentialTtlMs?: number; +} + +/** Configuration for the **record host** role: stores per-space records and + * blobs and serves reads. Verifies space credentials (later phases) on + * incoming traffic. */ +export interface RecordHostConfig { + /** Blob-upload backend. When omitted, blob XRPCs are not exposed. */ + blobs?: SpacesBlobsConfig; +} + +/** Spaces config — host an authority, a record host, or both. + * Today both run in one process and most deployments will set both; the + * shape is split now so phase 5 can run them independently without churning + * every consumer's config. */ +export interface SpacesConfig { + /** Space-authority config — member list, credentials (later), space + * management. Required for any space to exist. */ + authority?: AuthorityConfig; + /** Record-host config — record + blob storage. Required for records to be + * written/read on this deployment. */ + recordHost?: RecordHostConfig; +} + +export interface SpaceRow { + uri: string; + ownerDid: string; + type: string; + key: string; + serviceDid: string; + appPolicyRef: string | null; + appPolicy: AppPolicy | null; + createdAt: number; + deletedAt: number | null; +} + +export interface SpaceMemberRow { + spaceUri: string; + did: string; + addedAt: number; + addedBy: string | null; +} + +export interface StoredRecord { + spaceUri: string; + collection: string; + authorDid: string; + rkey: string; + cid: string | null; + record: Record; + createdAt: number; +} + +export interface ListOptions { + byUser?: string; + cursor?: string; + limit?: number; +} + +export interface ListResult { + records: StoredRecord[]; + cursor?: string; +} + +export interface ListSpacesOptions { + type?: string; + ownerDid?: string; + memberDid?: string; + limit?: number; + cursor?: string; +} + +export interface CollectionCount { + collection: string; + count: number; +} + +/** What a token holder can do with this invite. + * - `'join'`: must be redeemed while signed in; redeemer becomes a member. + * - `'read'`: bearer-only — token itself grants read access to the space; cannot be redeemed. + * - `'read-join'`: both — anonymous holders read; signed-in holders may also redeem to join. */ +export type InviteKind = "join" | "read" | "read-join"; + +export interface InviteRow { + tokenHash: string; + spaceUri: string; + kind: InviteKind; + expiresAt: number | null; + maxUses: number | null; + usedCount: number; + createdBy: string; + createdAt: number; + revokedAt: number | null; + note: string | null; +} + +export interface CreateInviteInput { + spaceUri: string; + tokenHash: string; + kind: InviteKind; + expiresAt: number | null; + maxUses: number | null; + createdBy: string; + note: string | null; +} + +export interface RedeemInviteResult { + spaceUri: string; +} + +export interface BlobMetaRow { + spaceUri: string; + cid: string; + mimeType: string; + size: number; + authorDid: string; + createdAt: number; +} + +/** Local cache on the record host: this space is accepted here, and `authority_did` + * is the DID authorized to sign credentials for it. Populated via the + * `recordHost.enroll` endpoint or auto-populated by the authority's + * createSpace when both roles run in one process. */ +export interface EnrollmentRow { + spaceUri: string; + authorityDid: string; + enrolledAt: number; + enrolledBy: string; +} + +export interface ListBlobsOptions { + byUser?: string; + cursor?: string; + limit?: number; +} + +export interface ListBlobsResult { + blobs: BlobMetaRow[]; + cursor?: string; +} + +/** **Space authority** interface — owner of the space's ACL state and + * (eventually) credential issuer. Holds the member list, manages invites, + * governs space lifecycle and app policy. Does NOT touch records or blobs. + * + * In a fully-split deployment this is a separate service; today the + * HostedAdapter implements both this and {@link RecordHost} against one DB. */ +export interface SpaceAuthority { + // Space lifecycle + createSpace(space: Omit): Promise; + getSpace(spaceUri: string): Promise; + listSpaces(options: ListSpacesOptions): Promise<{ spaces: SpaceRow[]; cursor?: string }>; + deleteSpace(spaceUri: string): Promise; + updateSpaceAppPolicy(spaceUri: string, appPolicy: AppPolicy): Promise; + + // Members + addMember(spaceUri: string, did: string, addedBy: string | null): Promise; + removeMember(spaceUri: string, did: string): Promise; + getMember(spaceUri: string, did: string): Promise; + listMembers(spaceUri: string): Promise; + /** Bulk-apply a membership diff. Used only by the community module's reconciler; + * not exposed as an XRPC endpoint. */ + applyMembershipDiff( + spaceUri: string, + adds: string[], + removes: string[], + addedBy: string | null + ): Promise; + + // Invites (token primitive — issued by the authority, scoped to a space) + createInvite(input: CreateInviteInput): Promise; + listInvites(spaceUri: string, options?: { includeRevoked?: boolean }): Promise; + revokeInvite(tokenHash: string): Promise; + /** Look up an invite without consuming it. Used to validate read-token bearer access. */ + getInvite(tokenHash: string): Promise; + /** Atomically mark a join-capable invite as used. Returns the row if usable + * (kind allows join, not expired/revoked/exhausted), null otherwise. */ + redeemInvite(tokenHash: string, now: number): Promise; +} + +/** **Record host** interface — stores records and blobs for a space, plus + * the local enrollment table that decides which spaces this host accepts. + * + * Trust model: the host trusts whatever credential the authority signs, so + * long as the authority is the one named in the local enrollment for this + * space. Enrollment is the consent step — the host owner agrees to spend + * storage on a given space, scoped to a specific authority. */ +export interface RecordHost { + // Enrollment + enroll(input: EnrollmentRow): Promise; + getEnrollment(spaceUri: string): Promise; + listEnrollments(options?: { authorityDid?: string; limit?: number }): Promise; + removeEnrollment(spaceUri: string): Promise; + + // Records + putRecord(record: StoredRecord): Promise; + getRecord(spaceUri: string, collection: string, authorDid: string, rkey: string): Promise; + listRecords(spaceUri: string, collection: string, options?: ListOptions): Promise; + deleteRecord(spaceUri: string, collection: string, authorDid: string, rkey: string): Promise; + listCollections(spaceUri: string, options?: { byUser?: string }): Promise; + + // Blobs (metadata only; bytes live on BlobAdapter) + putBlobMeta(row: BlobMetaRow): Promise; + getBlobMeta(spaceUri: string, cid: string): Promise; + listBlobMeta(spaceUri: string, options?: ListBlobsOptions): Promise; + deleteBlobMeta(spaceUri: string, cid: string): Promise; + /** Find blob rows older than `cutoff` whose CIDs are not referenced in any + * record JSON in this space. Capped at `limit` to bound a single GC pass. */ + findOrphanBlobs(spaceUri: string, cutoff: number, limit: number): Promise; +} + +/** Combined adapter. Used internally where a single object satisfies both + * roles (today's HostedAdapter, the community reconciler, the realtime + * publishing wrapper). Phases 5+ replace consumers of this with two + * injected interfaces. */ +export type StorageAdapter = SpaceAuthority & RecordHost; + +export interface AdapterContext { + db: Database; +} diff --git a/packages/contrail-base/src/spaces/uri.ts b/packages/contrail-base/src/spaces/uri.ts new file mode 100644 index 0000000..e73ec21 --- /dev/null +++ b/packages/contrail-base/src/spaces/uri.ts @@ -0,0 +1,37 @@ +/** Centralized space URI construction / parsing. + * + * Permissioned spaces are addressed by (ownerDid, type, key) and use the + * `ats://` scheme — distinct from atproto record URIs (`at://`) so the two + * can't be confused at any layer (logs, params, dispatch). Tracks the rough + * spec at https://dholms.leaflet.pub/3mhj6bcqats2o. + * + * Record URIs inside a space are minted by authorDid for index purposes + * (`at:////`); the spec is explicitly undecided + * about authority (user vs space owner), so we don't expose those as a + * canonical record address — they're storage-internal. */ + +export interface SpaceUriParts { + ownerDid: string; + type: string; + key: string; +} + +/** Build a space URI from its three addressing components. */ +export function buildSpaceUri(parts: SpaceUriParts): string { + return `ats://${parts.ownerDid}/${parts.type}/${parts.key}`; +} + +/** Parse a space URI into its components, or null if malformed. */ +export function parseSpaceUri(uri: string): SpaceUriParts | null { + if (!uri.startsWith("ats://")) return null; + const rest = uri.slice("ats://".length); + const [ownerDid, type, key, ...extra] = rest.split("/"); + if (!ownerDid || !type || !key || extra.length > 0) return null; + return { ownerDid, type, key }; +} + +/** Build a record URI under a given author. Used only as a secondary index key + * inside storage — not a canonical address for permissioned records. */ +export function buildRecordUri(authorDid: string, collection: string, rkey: string): string { + return `at://${authorDid}/${collection}/${rkey}`; +} diff --git a/packages/contrail-base/src/types.ts b/packages/contrail-base/src/types.ts new file mode 100644 index 0000000..3b9dc48 --- /dev/null +++ b/packages/contrail-base/src/types.ts @@ -0,0 +1,513 @@ +import type { SqlDialect } from "./dialect"; + +// Database interface — D1 implements this natively +export interface Database { + prepare(sql: string): Statement; + batch(stmts: Statement[]): Promise; + dialect?: SqlDialect; +} + +export interface Statement { + bind(...values: any[]): Statement; + run(): Promise; + all(): Promise<{ results: T[] }>; + first(): Promise; +} + +// Config types + +export interface QueryableField { + type?: "range"; +} + +export interface RelationConfig { + /** Short name of the child collection (a key in `collections`). */ + collection: string; + field?: string; + match?: "uri" | "did"; + groupBy?: string; + /** Enable materialized count columns on the parent. Defaults to true. */ + count?: boolean; + /** Count distinct values of a field (e.g. "did" for unique users) instead of total records. */ + countDistinct?: string; + /** Pre-resolved group mappings: shortName → full token (e.g. { going: "community.lexicon.calendar.rsvp#going" }). Auto-computed from groupBy if omitted. */ + groups?: Record; +} + +/** A forward reference: this collection's records point at another collection. */ +export interface ReferenceConfig { + /** Short name of the target collection. */ + collection: string; + /** Field on this collection's records containing the target URI. */ + field: string; +} + +export type CustomQueryHandler = ( + db: Database, + params: URLSearchParams, + config: ContrailConfig +) => Promise; + +export interface RecordSource { + joins?: string; + conditions?: string[]; + params?: (string | number)[]; +} + +export type PipelineQueryHandler = ( + db: Database, + params: URLSearchParams, + config: ContrailConfig +) => Promise; + +export interface FeedConfig { + /** Short name of the follow collection. */ + follow: string; + /** Short names of target collections to fan out to. */ + targets: string[]; + /** Max feed items per user (default: 200). Oldest items are pruned after backfill. */ + maxItems?: number; +} + +export const DEFAULT_FEED_MAX_ITEMS = 200; + +export type CollectionMethod = "listRecords" | "getRecord"; +export const DEFAULT_COLLECTION_METHODS: CollectionMethod[] = [ + "listRecords", + "getRecord", +]; + +export interface CollectionConfig { + /** Full NSID of the record type this collection indexes. */ + collection: string; + /** Include this collection in Jetstream ingest / discovery (default true). + * Set false for dependent collections (auto-fetched on demand). */ + discover?: boolean; + queryable?: Record; + relations?: Record; + /** Forward references: fields on this collection's records that point at another collection. */ + references?: Record; + queries?: Record; + pipelineQueries?: Record; + /** FTS5 search fields. Provide an array of field names to enable full-text search. Omit or set to false to disable. */ + searchable?: string[] | false; + /** XRPC methods to emit. Defaults to ['listRecords', 'getRecord']. */ + methods?: CollectionMethod[]; + /** When spaces are enabled globally, emit a parallel spaces_records_ table + * so this collection can also live inside spaces. Defaults to true. */ + allowInSpaces?: boolean; +} + +export interface ProfileConfig { + /** Full NSID of the profile record type. */ + collection: string; + /** Short name used for table/endpoint naming. Defaults to the NSID's last segment. */ + shortName?: string; + rkey?: string; // defaults to "self" +} + +export const DEFAULT_PROFILES: ProfileConfig[] = [ + { collection: "app.bsky.actor.profile", shortName: "profile" }, +]; + +/** Normalize a profiles config entry (string or object) into ProfileConfig. */ +export function normalizeProfileConfig( + p: string | ProfileConfig +): ProfileConfig { + if (typeof p === "string") { + return { collection: p, shortName: deriveShortName(p) }; + } + return { ...p, shortName: p.shortName ?? deriveShortName(p.collection) }; +} + +/** Last NSID segment, used as fallback short name. */ +export function deriveShortName(nsid: string): string { + const parts = nsid.split("."); + return parts[parts.length - 1] ?? nsid; +} + +export const DEFAULT_JETSTREAMS = [ + "wss://jetstream1.us-east.bsky.network", +]; + +export const DEFAULT_RELAYS = [ + "https://relay1.us-east.bsky.network" +]; + +export interface Logger { + log(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; +} + +export interface ContrailConfig { + namespace: string; + /** Collections to index, keyed by short name. Short names become endpoint URL segments + * (`..listRecords`) and table suffixes (`records_`). */ + collections: Record; + profiles?: (string | ProfileConfig)[]; + relays?: string[]; + jetstreams?: string[]; + feeds?: Record; + logger?: Logger; + /** Expose the notifyOfUpdate HTTP endpoint. Off by default. + * Set to `true` for open access, or a string to require `Authorization: Bearer `. */ + notify?: boolean | string; + /** Permissioned spaces configuration. When set, the service exposes space XRPCs. */ + spaces?: import("./spaces/types").SpacesConfig; + /** Community module configuration. Typed by the community package via + * declaration merging — contrail core only knows it's "something the + * community package consumes." Set when wiring community via + * `createCommunityIntegration({ ... })`. Requires `spaces.authority`. */ + community?: unknown; + /** Realtime module configuration. When set, the service exposes ticket + SSE/WS + * subscribe XRPCs, and wraps the spaces adapter to publish events after writes. */ + realtime?: import("./realtime/types").RealtimeConfig; + /** Labels module configuration. When set, contrail subscribes to the + * configured labelers, indexes their labels into a single `labels` table, + * and hydrates `record.labels` onto `listRecords` / `getRecord` / profile + * responses gated by the caller's `atproto-accept-labelers` header. */ + labels?: import("./labels/types").LabelsConfig; + /** Customize the auto-generated `.authFull` lexicon. */ + permissionSet?: PermissionSetConfig; +} + +/** Single entry in an atproto permission-set's `permissions` array. + * See https://atproto.com/guides/permission-sets for the full schema. */ +export type PermissionEntry = + | { type: "permission"; resource: "rpc"; lxm?: string[]; aud?: string; inheritAud?: boolean } + | { type: "permission"; resource: "repo"; collection?: string[] } + | { type: "permission"; resource: "blob"; accept?: string[]; maxSize?: number } + | { type: "permission"; resource: "account"; attr?: string[] } + | { type: "permission"; resource: string; [key: string]: unknown }; + +export interface PermissionSetConfig { + /** Shown on the OAuth consent screen. Defaults to the namespace. */ + title?: string; + /** Shown on the OAuth consent screen. Defaults to a generated description. */ + description?: string; + /** Extra permission entries appended after the auto-generated rpc entry — + * e.g. repo writes for collections your app needs the user to create, or + * blob permissions for uploads. */ + additional?: PermissionEntry[]; +} + +export interface ResolvedRelation { + /** Short name of the child collection. */ + collection: string; + groupBy: string; + groups: Record; // shortName → full token value +} + +export interface ResolvedMaps { + queryable: Record>; + relations: Record>; + /** Reverse map: full record NSID → short name. */ + nsidToShort: Record; +} + +/** Config after resolveConfig() — has computed queryable/relation maps attached. */ +export interface ResolvedContrailConfig extends ContrailConfig { + _resolved: ResolvedMaps; +} + +/** + * Resolve config: apply defaults, auto-add profile collections, compute queryable maps. + */ +export function resolveConfig(config: ContrailConfig): ResolvedContrailConfig { + const profiles = (config.profiles ?? DEFAULT_PROFILES).map( + normalizeProfileConfig + ); + const collections = { ...config.collections }; + for (const p of profiles) { + const short = p.shortName!; + if (!collections[short]) { + collections[short] = { collection: p.collection, discover: false }; + } + } + + // Auto-add follow collections from feed configs as dependent collections if they're + // not already listed. Feed config already uses short names so nothing to resolve — + // but if the user forgot to declare the follow collection, we can't auto-add it without + // knowing its NSID. In that case we warn later via validateConfig. + + const base = { + ...config, + collections, + profiles, + jetstreams: config.jetstreams ?? DEFAULT_JETSTREAMS, + relays: config.relays ?? DEFAULT_RELAYS, + logger: config.logger ?? console, + }; + + return { + ...base, + _resolved: _resolveQueryableMaps(base), + }; +} + +function _resolveQueryableMaps(config: ContrailConfig): ResolvedMaps { + const queryable: Record> = {}; + const relations: Record> = {}; + const nsidToShort: Record = {}; + + for (const [short, colConfig] of Object.entries(config.collections)) { + nsidToShort[colConfig.collection] = short; + + if (colConfig.queryable) { + queryable[short] = colConfig.queryable; + } + + if (colConfig.relations) { + for (const [relName, rel] of Object.entries(colConfig.relations)) { + if (!rel.groupBy) continue; + const groups: Record = rel.groups ? { ...rel.groups } : {}; + if (Object.keys(groups).length > 0) { + if (!relations[short]) relations[short] = {}; + relations[short][relName] = { + collection: rel.collection, + groupBy: rel.groupBy, + groups, + }; + } + } + } + } + + return { queryable, relations, nsidToShort }; +} + +export function getFeedFollowShortNames(config: ContrailConfig): string[] { + if (!config.feeds) return []; + return [...new Set(Object.values(config.feeds).map((f) => f.follow))]; +} + +/** Alias for getFeedFollowShortNames. */ +export const getFeedFollowCollections = getFeedFollowShortNames; + +// Record types + +export interface RecordRow { + uri: string; + did: string; + collection: string; // full NSID + rkey: string; + cid: string | null; + record: string | null; + time_us: number; + indexed_at: number; + /** Set when the row originates from a per-space table. Used by the + * pipeline/hydration/response layers to route child queries to the same + * space and tag the output. */ + space?: string; +} + +export interface IngestEvent { + uri: string; + did: string; + collection: string; // full NSID + rkey: string; + operation: "create" | "update" | "delete"; + cid: string | null; + record: string | null; + time_us: number; + indexed_at: number; +} + +// Validation + +const SAFE_FIELD_NAME = /^[a-zA-Z0-9_.]+$/; +const SAFE_SHORT_NAME = /^[a-zA-Z][a-zA-Z0-9]*$/; + +export function validateFieldName(field: string): string { + if (!SAFE_FIELD_NAME.test(field)) { + throw new Error(`Invalid field name: ${field}`); + } + return field; +} + +function validateShortName(short: string): void { + if (!SAFE_SHORT_NAME.test(short)) { + throw new Error( + `Invalid collection short name: "${short}". Must be alphanumeric, starting with a letter.` + ); + } +} + +export function validateConfig(config: ContrailConfig): void { + const shortNames = new Set(); + for (const [short, colConfig] of Object.entries(config.collections)) { + validateShortName(short); + if (shortNames.has(short)) { + throw new Error(`Duplicate collection short name: ${short}`); + } + shortNames.add(short); + + if (!colConfig.collection) { + throw new Error(`Collection "${short}" is missing required 'collection' field (NSID)`); + } + + for (const field of Object.keys(colConfig.queryable ?? {})) { + validateFieldName(field); + } + for (const [, rel] of Object.entries(colConfig.relations ?? {})) { + if (rel.field) validateFieldName(rel.field); + if (rel.groupBy) validateFieldName(rel.groupBy); + if (rel.countDistinct) validateFieldName(rel.countDistinct); + if (!config.collections[rel.collection]) { + throw new Error( + `Relation in "${short}" references unknown collection short name "${rel.collection}"` + ); + } + } + for (const [, ref] of Object.entries(colConfig.references ?? {})) { + validateFieldName(ref.field); + if (!config.collections[ref.collection]) { + throw new Error( + `Reference in "${short}" references unknown collection short name "${ref.collection}"` + ); + } + } + if (Array.isArray(colConfig.searchable)) { + for (const field of colConfig.searchable) { + validateFieldName(field); + } + } + } + + if (config.feeds) { + for (const [feedName, feed] of Object.entries(config.feeds)) { + if (!config.collections[feed.follow]) { + throw new Error( + `Feed "${feedName}" references unknown follow collection "${feed.follow}"` + ); + } + for (const target of feed.targets) { + if (!config.collections[target]) { + throw new Error( + `Feed "${feedName}" references unknown target collection "${target}"` + ); + } + } + } + } + + if (config.community && !config.spaces?.authority) { + throw new Error( + "Invalid config: `community` requires `spaces.authority`. Community-owned spaces reuse the spaces storage adapter." + ); + } +} + +// Helpers + +export function getNestedValue(obj: any, path: string): any { + let current = obj; + for (const key of path.split(".")) { + if (current == null) return undefined; + current = current[key]; + } + return current; +} + +const DEFAULT_RELATION_FIELD = "subject.uri"; + +export function getRelationField(rel: RelationConfig): string { + return rel.field ?? DEFAULT_RELATION_FIELD; +} + +/** Sanitize a short name for use in SQL identifiers (already-validated; kept for paranoia). */ +function sanitizeIdentifier(name: string): string { + return name.replace(/[^a-zA-Z0-9]/g, "_"); +} + +/** Total-count column name for a relation targeting the given short name. */ +export function countColumnName(childShortName: string): string { + return "count_" + sanitizeIdentifier(childShortName); +} + +/** Grouped-count column name: `count__`. */ +export function groupedCountColumnName( + childShortName: string, + groupKey: string +): string { + return `count_${sanitizeIdentifier(childShortName)}_${sanitizeIdentifier(groupKey)}`; +} + +/** Table name for a collection's records. */ +export function recordsTableName(shortName: string): string { + return "records_" + sanitizeIdentifier(shortName); +} + +/** Table name for a collection's records inside spaces. */ +export function spacesRecordsTableName(shortName: string): string { + return "spaces_records_" + sanitizeIdentifier(shortName); +} + +/** All collection short names. */ +export function getCollectionShortNames(config: ContrailConfig): string[] { + return Object.keys(config.collections); +} + +/** Alias: collection short names (same as getCollectionShortNames). */ +export const getCollectionNames = getCollectionShortNames; + +/** All indexed record NSIDs (what Jetstream filters on). */ +export function getCollectionNsids(config: ContrailConfig): string[] { + return Object.values(config.collections).map((c) => c.collection); +} + +export function getDependentShortNames(config: ContrailConfig): string[] { + return Object.entries(config.collections) + .filter(([, c]) => c.discover === false) + .map(([name]) => name); +} + +export function getDiscoverableShortNames(config: ContrailConfig): string[] { + return Object.entries(config.collections) + .filter(([, c]) => c.discover !== false) + .map(([name]) => name); +} + +/** Aliases for readability elsewhere. These return short names (new semantic). */ +export const getDependentCollections = getDependentShortNames; +export const getDiscoverableCollections = getDiscoverableShortNames; + +/** Short names of collections the user declared with `discover !== false`, mapped to NSIDs. */ +export function getDiscoverableNsids(config: ContrailConfig): string[] { + return Object.values(config.collections) + .filter((c) => c.discover !== false) + .map((c) => c.collection); +} + +export function getDependentNsids(config: ContrailConfig): string[] { + return Object.values(config.collections) + .filter((c) => c.discover === false) + .map((c) => c.collection); +} + +/** Short name for a record NSID, if known. */ +export function shortNameForNsid( + config: ContrailConfig, + nsid: string +): string | undefined { + const resolved = (config as ResolvedContrailConfig)._resolved; + if (resolved?.nsidToShort) return resolved.nsidToShort[nsid]; + for (const [short, c] of Object.entries(config.collections)) { + if (c.collection === nsid) return short; + } + return undefined; +} + +/** Full NSID for a collection short name. */ +export function nsidForShortName( + config: ContrailConfig, + short: string +): string | undefined { + return config.collections[short]?.collection; +} + +/** The methods a collection should expose via XRPC. */ +export function getCollectionMethods(cfg: CollectionConfig): CollectionMethod[] { + return cfg.methods ?? DEFAULT_COLLECTION_METHODS; +} diff --git a/packages/contrail-base/tsconfig.build.json b/packages/contrail-base/tsconfig.build.json new file mode 100644 index 0000000..6092abf --- /dev/null +++ b/packages/contrail-base/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/packages/contrail-base/tsconfig.json b/packages/contrail-base/tsconfig.json new file mode 100644 index 0000000..6092abf --- /dev/null +++ b/packages/contrail-base/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/packages/contrail-base/tsup.config.ts b/packages/contrail-base/tsup.config.ts new file mode 100644 index 0000000..3ad3082 --- /dev/null +++ b/packages/contrail-base/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts", "src/adapters/sqlite.ts", "src/adapters/postgres.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + tsconfig: "tsconfig.build.json", + external: ["pg", "node:sqlite"], +}); diff --git a/packages/contrail-community/package.json b/packages/contrail-community/package.json index 43fe2e1..c6abcb2 100644 --- a/packages/contrail-community/package.json +++ b/packages/contrail-community/package.json @@ -41,6 +41,7 @@ "@atcute/lexicons": "^1.2.9", "@atcute/xrpc-server": "^0.1.12", "@atmo-dev/contrail": "workspace:*", + "@atmo-dev/contrail-base": "workspace:*", "hono": "^4.12.8" }, "devDependencies": { diff --git a/packages/contrail-community/vitest.config.ts b/packages/contrail-community/vitest.config.ts index 56720e0..a440f50 100644 --- a/packages/contrail-community/vitest.config.ts +++ b/packages/contrail-community/vitest.config.ts @@ -2,13 +2,16 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; const contrailSrc = path.resolve(__dirname, "../contrail/src"); +const baseSrc = path.resolve(__dirname, "../contrail-base/src"); -// Alias `@atmo-dev/contrail` and its subpaths to the source so tests don't -// run through the built dist. Mirrors the in-tree-source-resolution pattern -// the contrail package's own tests use (they import via ../src/...). +// Resolve workspace-internal imports to source so tests don't run through +// dists (where tsup mangles `node:sqlite` → `sqlite`). export default defineConfig({ resolve: { alias: { + "@atmo-dev/contrail-base/sqlite": path.join(baseSrc, "adapters/sqlite.ts"), + "@atmo-dev/contrail-base/postgres": path.join(baseSrc, "adapters/postgres.ts"), + "@atmo-dev/contrail-base": path.join(baseSrc, "index.ts"), "@atmo-dev/contrail/sqlite": path.join(contrailSrc, "adapters/sqlite.ts"), "@atmo-dev/contrail/postgres": path.join(contrailSrc, "adapters/postgres.ts"), "@atmo-dev/contrail": path.join(contrailSrc, "index.ts"), diff --git a/packages/contrail/package.json b/packages/contrail/package.json index a64484d..8409809 100644 --- a/packages/contrail/package.json +++ b/packages/contrail/package.json @@ -73,6 +73,7 @@ "@atcute/jetstream": "^1.0.2", "@atcute/lexicons": "^1.2.9", "@atcute/xrpc-server": "^0.1.12", + "@atmo-dev/contrail-base": "workspace:*", "cac": "^7.0.0", "hono": "^4.12.8", "jiti": "^2.4.0" diff --git a/packages/contrail/src/adapters/postgres.ts b/packages/contrail/src/adapters/postgres.ts index ae75c9f..6ab2902 100644 --- a/packages/contrail/src/adapters/postgres.ts +++ b/packages/contrail/src/adapters/postgres.ts @@ -1,93 +1 @@ -import pg from "pg"; -import type { Database, Statement } from "../core/types"; -import { postgresDialect } from "../core/dialect"; - -/** Internal interface for statements that can run on a specific client */ -interface PgStatement extends Statement { - /** Execute on a specific client (used by batch for transaction isolation) */ - _runOn(client: pg.PoolClient): Promise; -} - -/** Column names known to be BIGINT — PostgreSQL returns these as strings */ -const BIGINT_COLUMNS = new Set(["time_us", "indexed_at", "resolved_at"]); - -function normalizeRow(row: any): any { - if (!row) return row; - if (typeof row.record === "object" && row.record !== null) { - row.record = JSON.stringify(row.record); - } - for (const col of BIGINT_COLUMNS) { - if (typeof row[col] === "string") row[col] = Number(row[col]); - } - return row; -} - -export function createPostgresDatabase(pool: pg.Pool): Database { - function rewritePlaceholders(sql: string): string { - let idx = 0; - let inString = false; - let result = ""; - for (let i = 0; i < sql.length; i++) { - const ch = sql[i]; - if (ch === "'" && sql[i - 1] !== "\\") { - inString = !inString; - result += ch; - } else if (ch === "?" && !inString) { - result += `$${++idx}`; - } else { - result += ch; - } - } - return result; - } - - function wrapStatement(sql: string, boundValues: any[] = []): PgStatement { - const pgSql = rewritePlaceholders(sql); - - return { - bind(...values: any[]): PgStatement { - return wrapStatement(sql, values); - }, - async run() { - const result = await pool.query(pgSql, boundValues); - return { changes: result.rowCount }; - }, - async _runOn(client: pg.PoolClient) { - const result = await client.query(pgSql, boundValues); - return { changes: result.rowCount }; - }, - async all() { - const result = await pool.query(pgSql, boundValues); - return { results: result.rows.map(normalizeRow) as T[] }; - }, - async first() { - const result = await pool.query(pgSql, boundValues); - return result.rows[0] ? (normalizeRow(result.rows[0]) as T) : null; - }, - }; - } - - return { - prepare(sql: string): Statement { - return wrapStatement(sql); - }, - async batch(stmts: Statement[]): Promise { - const client = await pool.connect(); - try { - await client.query("BEGIN"); - const results: any[] = []; - for (const stmt of stmts) { - results.push(await (stmt as PgStatement)._runOn(client)); - } - await client.query("COMMIT"); - return results; - } catch (e) { - await client.query("ROLLBACK"); - throw e; - } finally { - client.release(); - } - }, - dialect: postgresDialect, - }; -} +export * from "@atmo-dev/contrail-base/postgres"; diff --git a/packages/contrail/src/adapters/sqlite.ts b/packages/contrail/src/adapters/sqlite.ts index 2f46dce..42032b3 100644 --- a/packages/contrail/src/adapters/sqlite.ts +++ b/packages/contrail/src/adapters/sqlite.ts @@ -1,39 +1 @@ -import { DatabaseSync } from "node:sqlite"; -import type { Database, Statement } from "../core/types"; -import { sqliteDialect } from "../core/dialect"; - -export function createSqliteDatabase(path: string): Database { - const raw = new DatabaseSync(path); - raw.exec("PRAGMA journal_mode = WAL"); - - function wrapStatement(sql: string, boundValues: any[] = []): Statement { - return { - bind(...values: any[]): Statement { - return wrapStatement(sql, values); - }, - async run() { - return raw.prepare(sql).run(...boundValues); - }, - async all() { - return { results: raw.prepare(sql).all(...boundValues) as T[] }; - }, - async first() { - return (raw.prepare(sql).get(...boundValues) as T) ?? null; - }, - }; - } - - return { - prepare(sql: string): Statement { - return wrapStatement(sql); - }, - async batch(stmts: Statement[]): Promise { - const results: any[] = []; - for (const stmt of stmts) { - results.push(await stmt.run()); - } - return results; - }, - dialect: sqliteDialect, - }; -} +export * from "@atmo-dev/contrail-base/sqlite"; diff --git a/packages/contrail/src/core/backfill.ts b/packages/contrail/src/core/backfill.ts index 3d23dc1..713e23e 100644 --- a/packages/contrail/src/core/backfill.ts +++ b/packages/contrail/src/core/backfill.ts @@ -1,3 +1,4 @@ +import type {} from "@atcute/atproto"; import { type Did } from "@atcute/lexicons"; import { isDid, isNsid } from "@atcute/lexicons/syntax"; diff --git a/packages/contrail/src/core/client.ts b/packages/contrail/src/core/client.ts index f37bea3..1129419 100644 --- a/packages/contrail/src/core/client.ts +++ b/packages/contrail/src/core/client.ts @@ -1,194 +1 @@ -import { - CompositeDidDocumentResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, -} from "@atcute/identity-resolver"; -import { type Did } from "@atcute/lexicons"; -import { Client, simpleFetchHandler } from "@atcute/client"; -import type {} from "@atcute/atproto"; -import type { Database } from "./types"; - -// Slingshot-first PDS resolution with fallback to DID document resolution -const SLINGSHOT_URL = - "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; - -export interface ResolvedIdentity { - did: string; - handle: string | null; - pds: string | null; -} - -/** Reject PDS URLs that point to private/internal addresses or non-HTTPS */ -function validatePdsUrl(url: string): boolean { - try { - const parsed = new URL(url); - if (parsed.protocol !== "https:") return false; - const host = parsed.hostname; - // Block private/internal IP ranges - if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return false; - if (host.startsWith("10.")) return false; - if (host.startsWith("192.168.")) return false; - if (host.startsWith("169.254.")) return false; - if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false; - return true; - } catch { - return false; - } -} - -async function resolveViaSlingshot( - identifier: string -): Promise { - const url = new URL(SLINGSHOT_URL); - url.searchParams.set("identifier", identifier); - - try { - const response = await fetch(url.toString()); - if (!response.ok) return undefined; - const data = (await response.json()) as { - did?: string; - handle?: string; - pds?: string; - }; - if (!data.did && !data.pds) return undefined; - return { - did: data.did ?? identifier, - handle: data.handle ?? null, - pds: data.pds ?? null, - }; - } catch { - return undefined; - } -} - -const didResolver = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, -}); - -async function getPDSViaDidDoc(did: Did): Promise { - const doc = await didResolver.resolve(did as Did<"plc"> | Did<"web">); - return doc.service - ?.find((s) => s.id === "#atproto_pds") - ?.serviceEndpoint.toString(); -} - -/** - * Resolve identity info (did, handle, pds) for a DID or handle. - * Uses slingshot first, falls back to DID doc for PDS. - */ -export async function resolvePDS( - identifier: string -): Promise { - const result = await resolveViaSlingshot(identifier); - if (result?.pds) { - if (!validatePdsUrl(result.pds)) return { ...result, pds: null }; - return result; - } - - // Fall back to DID doc resolution (only works for DIDs, not handles) - if (identifier.startsWith("did:")) { - try { - const pds = await getPDSViaDidDoc(identifier as Did); - if (pds && validatePdsUrl(pds)) { - return { - did: identifier, - handle: result?.handle ?? null, - pds, - }; - } - } catch { - // ignore - } - } - - return result; -} - -// In-memory PDS cache with TTL + size limit, plus in-flight deduplication -const PDS_CACHE_TTL = 60 * 60 * 1000; // 1 hour -const PDS_CACHE_MAX = 10_000; -const pdsCache = new Map(); -const pdsInflight = new Map>(); - -function pdsCacheGet(did: string): string | undefined { - const entry = pdsCache.get(did); - if (!entry) return undefined; - if (Date.now() - entry.at > PDS_CACHE_TTL) { - pdsCache.delete(did); - return undefined; - } - return entry.pds; -} - -function pdsCacheSet(did: string, pds: string): void { - // Evict oldest entries if over limit - if (pdsCache.size >= PDS_CACHE_MAX) { - const first = pdsCache.keys().next().value; - if (first) pdsCache.delete(first); - } - pdsCache.set(did, { pds, at: Date.now() }); -} - -export async function getPDS( - did: Did, - db?: Database -): Promise { - const mem = pdsCacheGet(did); - if (mem) return mem; - - // Deduplicate concurrent calls for the same DID - const inflight = pdsInflight.get(did); - if (inflight) return inflight; - - const promise = resolvePDSCached(did, db); - pdsInflight.set(did, promise); - try { - return await promise; - } finally { - pdsInflight.delete(did); - } -} - -async function resolvePDSCached( - did: Did, - db?: Database -): Promise { - if (db) { - const cached = await db - .prepare("SELECT pds FROM identities WHERE did = ? AND pds IS NOT NULL") - .bind(did) - .first<{ pds: string }>(); - if (cached?.pds) { - pdsCacheSet(did, cached.pds); - return cached.pds; - } - } - - const resolved = await resolvePDS(did); - if (!resolved?.pds) return undefined; - - pdsCacheSet(did, resolved.pds); - - // Persist to DB for future runs - if (db) { - await db - .prepare( - "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET pds = excluded.pds, handle = COALESCE(excluded.handle, identities.handle), resolved_at = excluded.resolved_at" - ) - .bind(did, resolved.handle, resolved.pds, Date.now()) - .run(); - } - - return resolved.pds; -} - -export async function getClient(did: Did, db?: Database): Promise { - const pds = await getPDS(did, db); - if (!pds) throw new Error(`PDS not found for ${did}`); - return new Client({ - handler: simpleFetchHandler({ service: pds }), - }); -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/community-integration.ts b/packages/contrail/src/core/community-integration.ts index d91b3a4..1129419 100644 --- a/packages/contrail/src/core/community-integration.ts +++ b/packages/contrail/src/core/community-integration.ts @@ -1,54 +1 @@ -/** Pluggable integration surface for the community module. - * - * Phase 6 extracted community to its own package (`@atmo-dev/contrail-community`). - * The contrail core package never imports from it — couplings only flow - * through these interfaces. The community package's - * `createCommunityIntegration({ ... })` returns a {@link CommunityIntegration}, - * which the consumer hands to `createApp` via `options.community`. - * - * Two layers: - * - {@link CommunityProbe}: minimal "is this a community DID" / "what - * spaces does this caller reach" surface used by realtime + collection - * routes for community-aware dispatch. - * - {@link CommunityIntegration}: the umbrella bundle — probe, whoami - * extension, invite handler, plus route + schema wiring that the - * umbrella router calls during setup. */ - -import type { Hono, MiddlewareHandler } from "hono"; -import type { Database } from "./types"; -import type { CommunityInviteHandler } from "./invite/community-handler"; -import type { WhoamiExtension } from "./spaces/router"; - -/** Narrow interface for the deep callers (realtime/resolve, router/collection) - * that just need to ask "is this a community DID?" or "what spaces does this - * caller reach via community membership?" */ -export interface CommunityProbe { - /** Look up a community row by DID. Returns null for non-community DIDs. - * Callers usually only check truthiness — community-specific fields stay - * inside the community package. */ - getCommunity(did: string): Promise<{ did: string } | null>; - - /** Resolve the set of space URIs reachable by `callerDid` through community - * membership (direct grants + delegations). Used by realtime to expand - * community: topics into the caller's concrete space: topics. */ - resolveReachableSpaces(callerDid: string): Promise>; -} - -/** Umbrella integration the consumer constructs once and hands to createApp. - * contrail core treats this as an opaque bundle — it doesn't introspect - * community state, just calls these methods at the right wiring points. */ -export interface CommunityIntegration { - /** Probe used by realtime + collection cross-cutting concerns. */ - probe: CommunityProbe; - /** Whoami extension that returns `accessLevel` for community-owned spaces. */ - whoamiExtension: WhoamiExtension; - /** Handler for the community-grant path of the unified invite surface. */ - inviteHandler: CommunityInviteHandler; - /** Register `.community.*` routes onto the Hono app. */ - registerRoutes( - app: Hono, - options?: { authMiddleware?: MiddlewareHandler } - ): void; - /** Apply community schema (DDL) to the database. Called by initSchema. */ - applySchema(db: Database): Promise; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/dialect.ts b/packages/contrail/src/core/dialect.ts index e3bb790..1129419 100644 --- a/packages/contrail/src/core/dialect.ts +++ b/packages/contrail/src/core/dialect.ts @@ -1,127 +1 @@ -/** Get the dialect from a Database, defaulting to SQLite (for D1 compatibility) */ -export function getDialect(db: { dialect?: SqlDialect }): SqlDialect { - return db.dialect ?? sqliteDialect; -} - -const SAFE_FIELD = /^[a-zA-Z0-9_.]+$/; - -function assertSafeField(field: string): void { - if (!SAFE_FIELD.test(field)) { - throw new Error(`Invalid field name: ${field}`); - } -} - -export interface SqlDialect { - /** json_extract(col, '$.field') or col->>'field' */ - jsonExtract(column: string, field: string): string; - - /** Convert INSERT INTO to ignore-duplicates form. - * SQLite: INSERT INTO → INSERT OR IGNORE INTO - * PG: appends ON CONFLICT DO NOTHING - * Accepts full SQL starting with "INSERT INTO" (works with both VALUES and SELECT). */ - insertOrIgnore(sql: string): string; - - /** Column type for the record column: TEXT (SQLite) or JSONB (PostgreSQL) */ - readonly recordColumnType: string; - - /** FTS strategy: 'virtual-table' (SQLite FTS5) or 'generated-column' (PG tsvector) */ - readonly ftsStrategy: "virtual-table" | "generated-column"; - - /** INTEGER type name — same on both, but PostgreSQL may want BIGINT for time_us */ - readonly integerType: string; - - /** BIGINT type name for timestamps */ - readonly bigintType: string; - - /** Wrap an expression for use in CREATE INDEX — PostgreSQL requires parens around expressions */ - indexExpression(expr: string): string; -} - -export const sqliteDialect: SqlDialect = { - jsonExtract(column: string, field: string): string { - assertSafeField(field); - return `json_extract(${column}, '$.${field}')`; - }, - - insertOrIgnore(sql: string): string { - return sql.replace(/^INSERT INTO/, "INSERT OR IGNORE INTO"); - }, - - recordColumnType: "TEXT", - ftsStrategy: "virtual-table", - integerType: "INTEGER", - bigintType: "INTEGER", - - indexExpression(expr: string): string { - return expr; - }, -}; - -export const postgresDialect: SqlDialect = { - jsonExtract(column: string, field: string): string { - assertSafeField(field); - const parts = field.split("."); - if (parts.length === 1) { - return `${column}->>'${parts[0]}'`; - } - // a.b.c → col->'a'->'b'->>'c' - const intermediate = parts.slice(0, -1).map((p) => `->'${p}'`).join(""); - return `${column}${intermediate}->>'${parts[parts.length - 1]}'`; - }, - - insertOrIgnore(sql: string): string { - return `${sql} ON CONFLICT DO NOTHING`; - }, - - recordColumnType: "JSONB", - ftsStrategy: "generated-column", - integerType: "INTEGER", - bigintType: "BIGINT", - - indexExpression(expr: string): string { - return `(${expr})`; - }, -}; - -/** Generate FTS schema statements based on dialect */ -export function buildFtsSchema( - dialect: SqlDialect, - recordsTable: string, - fields: string[] -): string[] { - if (dialect.ftsStrategy === "virtual-table") { - const ftsTable = recordsTable.replace("records_", "fts_"); - return [ - `CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(uri UNINDEXED, content)` - ]; - } else { - const concatExpr = fields - .map((f) => `COALESCE(${dialect.jsonExtract("record", f)}, '')`) - .join(" || ' ' || "); - return [ - `ALTER TABLE ${recordsTable} ADD COLUMN IF NOT EXISTS search_vector TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', ${concatExpr})) STORED`, - `CREATE INDEX IF NOT EXISTS idx_${recordsTable}_search ON ${recordsTable} USING GIN (search_vector)`, - ]; - } -} - -/** Generate FTS query clause based on dialect */ -export function ftsQueryClause( - dialect: SqlDialect, - recordsTable: string -): { join: string; condition: string; orderExpr: string } { - if (dialect.ftsStrategy === "virtual-table") { - const ftsTable = recordsTable.replace("records_", "fts_"); - return { - join: `JOIN ${ftsTable} fts ON fts.uri = r.uri`, - condition: "fts.content MATCH ?", - orderExpr: "fts.rank", - }; - } else { - return { - join: "", - condition: "r.search_vector @@ plainto_tsquery('english', ?)", - orderExpr: "ts_rank(r.search_vector, plainto_tsquery('english', ?))", - }; - } -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/identity.ts b/packages/contrail/src/core/identity.ts index 05040fd..1129419 100644 --- a/packages/contrail/src/core/identity.ts +++ b/packages/contrail/src/core/identity.ts @@ -1,159 +1 @@ -import type { Did } from "@atcute/lexicons"; -import type { Database, Logger } from "./types"; -import { isDid, isHandle } from "@atcute/lexicons/syntax"; -import { resolvePDS } from "./client"; - -const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours - -export interface Identity { - did: string; - handle: string | null; - pds: string | null; - resolved_at: number; -} - -async function saveIdentity(db: Database, identity: Identity): Promise { - await db - .prepare( - "INSERT INTO identities (did, handle, pds, resolved_at) VALUES (?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, pds = excluded.pds, resolved_at = excluded.resolved_at" - ) - .bind(identity.did, identity.handle, identity.pds, identity.resolved_at) - .run(); -} - -function isStale(resolvedAt: number): boolean { - return Date.now() - resolvedAt >= STALE_MS; -} - -async function fetchAndSave( - db: Database, - identifier: string, - cached?: Identity | null -): Promise { - const resolved = await resolvePDS(identifier); - const identity: Identity = { - did: resolved?.did ?? identifier, - handle: resolved?.handle ?? cached?.handle ?? null, - pds: resolved?.pds ?? cached?.pds ?? null, - resolved_at: Date.now(), - }; - await saveIdentity(db, identity); - return identity; -} - -export async function resolveIdentity( - db: Database, - did: Did -): Promise { - const cached = await db - .prepare("SELECT did, handle, pds, resolved_at FROM identities WHERE did = ?") - .bind(did) - .first(); - - if (cached && !isStale(cached.resolved_at)) return cached; - - return fetchAndSave(db, did, cached); -} - -export async function resolveIdentities( - db: Database, - dids: string[] -): Promise> { - const map = new Map(); - if (dids.length === 0) return map; - - // Batch lookup from DB - const BATCH = 50; - for (let i = 0; i < dids.length; i += BATCH) { - const chunk = dids.slice(i, i + BATCH); - const placeholders = chunk.map(() => "?").join(","); - const rows = await db - .prepare(`SELECT did, handle, pds, resolved_at FROM identities WHERE did IN (${placeholders})`) - .bind(...chunk) - .all(); - for (const row of rows.results ?? []) { - map.set(row.did, row); - } - } - - // Resolve missing via slingshot directly (no redundant DB lookup) - for (const did of dids) { - if (map.has(did) || !isDid(did)) continue; - try { - const identity = await fetchAndSave(db, did); - map.set(did, identity); - } catch { - // Silently skip unresolvable identities - } - } - - return map; -} - -export async function resolveActor( - db: Database, - actor: string -): Promise { - if (isDid(actor)) return actor; - if (!isHandle(actor)) return null; - - // Look up handle in identities table - const cached = await db - .prepare("SELECT did, resolved_at FROM identities WHERE handle = ?") - .bind(actor) - .first<{ did: string; resolved_at: number }>(); - - if (cached && !isStale(cached.resolved_at)) return cached.did; - - // Resolve via slingshot - const resolved = await resolvePDS(actor); - if (!resolved?.did || !isDid(resolved.did)) return null; - - await saveIdentity(db, { - did: resolved.did, - handle: resolved.handle ?? actor, - pds: resolved.pds ?? null, - resolved_at: Date.now(), - }); - - return resolved.did; -} - -export async function refreshStaleIdentities( - db: Database, - dids: string[] -): Promise { - if (dids.length === 0) return; - - const unique = [...new Set(dids)].filter(isDid); - if (unique.length === 0) return; - - const staleThreshold = Date.now() - STALE_MS; - const toRefresh: string[] = []; - - const BATCH = 50; - for (let i = 0; i < unique.length; i += BATCH) { - const chunk = unique.slice(i, i + BATCH); - const placeholders = chunk.map(() => "?").join(","); - const rows = await db - .prepare(`SELECT did, resolved_at FROM identities WHERE did IN (${placeholders})`) - .bind(...chunk) - .all<{ did: string; resolved_at: number }>(); - - const found = new Map((rows.results ?? []).map((r) => [r.did, r.resolved_at])); - for (const did of chunk) { - const resolvedAt = found.get(did); - if (resolvedAt === undefined || resolvedAt < staleThreshold) { - toRefresh.push(did); - } - } - } - - for (const did of toRefresh) { - try { - await fetchAndSave(db, did); - } catch { - // Silently skip unresolvable identities - } - } -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/invite/community-handler.ts b/packages/contrail/src/core/invite/community-handler.ts index 1043525..1129419 100644 --- a/packages/contrail/src/core/invite/community-handler.ts +++ b/packages/contrail/src/core/invite/community-handler.ts @@ -1,67 +1 @@ -/** Pluggable handler for community-grant invites within the unified invite - * surface. The invite router calls into this when the target space is - * community-owned, or "tries" it on the redeem / revoke-without-spaceUri - * paths. Community module provides the impl; invite/router doesn't import - * from community at all. - * - * Each method returns a `HandlerResponse`: a `{status, body}` envelope that - * the router relays as JSON, or `null` (only on the "try" methods) meaning - * "not applicable, fall through to the user-owned path." */ - -export type HandlerResponse = { - status: number; - body: Record; -}; - -export interface CommunityInviteHandler { - /** True iff this space is owned by a community (vs. a regular user DID). - * Used by the invite router to choose the dispatch path on - * create / list / revoke-with-spaceUri. */ - isCommunityOwned(spaceUri: string): Promise; - - /** Create a community-grant invite. Caller is validated upstream for - * having a JWT; this method handles the access-level checks. */ - create(input: { - spaceUri: string; - callerDid: string; - /** Raw caller-supplied access level — implementation validates. */ - accessLevel?: string; - /** Caller-supplied `kind` field — community spaces don't accept this; the - * handler returns an InvalidRequest if set. */ - kind?: string; - expiresAt: number | null; - maxUses: number | null; - note: string | null; - }): Promise; - - /** List invites for a community-owned space. */ - list(input: { - spaceUri: string; - callerDid: string; - includeRevoked: boolean; - }): Promise; - - /** Revoke a known community-owned invite (caller already passed spaceUri - * and the router classified it as community-owned). */ - revoke(input: { - spaceUri: string; - tokenHash: string; - callerDid: string; - }): Promise; - - /** Revoke without a spaceUri — try to find the invite in the community - * table; return null if not a community invite (router falls through). */ - tryRevokeByToken(input: { - tokenHash: string; - callerDid: string; - }): Promise; - - /** Try to redeem a token as a community invite. Returns null if the token - * is not a community invite, in which case the router falls through to - * the user-owned redeem path. */ - tryRedeem(input: { - tokenHash: string; - callerDid: string; - now: number; - }): Promise; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/invite/token.ts b/packages/contrail/src/core/invite/token.ts index 76f4880..1129419 100644 --- a/packages/contrail/src/core/invite/token.ts +++ b/packages/contrail/src/core/invite/token.ts @@ -1,43 +1 @@ -const B64U_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - -function bytesToB64Url(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i += 3) { - const b0 = bytes[i]; - const b1 = bytes[i + 1] ?? 0; - const b2 = bytes[i + 2] ?? 0; - out += B64U_ALPHABET[b0 >> 2]; - out += B64U_ALPHABET[((b0 & 3) << 4) | (b1 >> 4)]; - if (i + 1 < bytes.length) out += B64U_ALPHABET[((b1 & 15) << 2) | (b2 >> 6)]; - if (i + 2 < bytes.length) out += B64U_ALPHABET[b2 & 63]; - } - return out; -} - -function bytesToHex(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0"); - return out; -} - -/** Generate a fresh invite token (cryptographically random, 32 bytes base64url-encoded). */ -export function generateInviteToken(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return bytesToB64Url(bytes); -} - -/** SHA-256 hash of a token, hex-encoded. Used as the PK in storage so raw tokens are never persisted. */ -export async function hashInviteToken(token: string): Promise { - const encoded = new TextEncoder().encode(token); - const digest = await crypto.subtle.digest("SHA-256", encoded); - return bytesToHex(new Uint8Array(digest)); -} - -/** Convenience: generate a token and return both the raw form (returned to - * the creator once) and its hash (persisted as the stable ID). */ -export async function mintInviteToken(): Promise<{ token: string; tokenHash: string }> { - const token = generateInviteToken(); - const tokenHash = await hashInviteToken(token); - return { token, tokenHash }; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/labels/types.ts b/packages/contrail/src/core/labels/types.ts index f8bc2e6..1129419 100644 --- a/packages/contrail/src/core/labels/types.ts +++ b/packages/contrail/src/core/labels/types.ts @@ -1,59 +1 @@ -import type { Database } from "../types"; - -/** A labeler the operator wants contrail to track. */ -export interface LabelerSource { - /** Labeler DID — `did:plc:...` or `did:web:...`. */ - did: string; - /** Override the service endpoint resolution. Otherwise resolved from the - * DID doc's `service[id="#atproto_labeler"].serviceEndpoint`. */ - endpoint?: string; - /** Backfill from `cursor=0` on first sight. Defaults to true. Set false - * for "start from now" — useful for very chatty labelers. */ - backfill?: boolean; -} - -export interface LabelsConfig { - /** Labelers to subscribe to and index. */ - sources: LabelerSource[]; - /** DIDs honored when the caller sends no `atproto-accept-labelers` / - * `?labelers=`. Defaults to every entry in `sources`. Set `[]` for - * opt-in-only — clients see no labels unless they ask. */ - defaults?: string[]; - /** Per-request cap. Default: 20 (matches Bluesky). */ - maxPerRequest?: number; -} - -export const DEFAULT_LABELS_MAX_PER_REQUEST = 20; - -/** A single label as stored. Matches `com.atproto.label.defs#label`. */ -export interface LabelRow { - /** Issuing labeler DID. */ - src: string; - /** Subject — at-URI for record labels, plain DID for account labels. */ - uri: string; - /** Label value — kebab-case, ≤128 bytes per spec. */ - val: string; - /** Optional CID pin to a specific record version. */ - cid: string | null; - /** When true, retracts a previously-emitted label for the same (src, uri, val). */ - neg: boolean; - /** Expiry, unix seconds. Past this, hydration drops the row. */ - exp: number | null; - /** Creation timestamp, unix seconds — what we collapse on. */ - cts: number; - /** Raw signature bytes. Stored when present so we can re-emit later; - * not verified in v1. */ - sig: Uint8Array | null; -} - -/** Per-labeler state row — endpoint cache and last-seen seq cursor. */ -export interface LabelerCursorRow { - did: string; - cursor: number; - endpoint: string | null; - resolved_at: number | null; -} - -export interface AdapterContext { - db: Database; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/realtime/durable-object.ts b/packages/contrail/src/core/realtime/durable-object.ts index 966ab53..1129419 100644 --- a/packages/contrail/src/core/realtime/durable-object.ts +++ b/packages/contrail/src/core/realtime/durable-object.ts @@ -1,567 +1 @@ -/** Durable Object backend for realtime PubSub. - * - * Two pieces live here: - * 1. `RealtimePubSubDO` — the DO class. Ship it from your Worker via - * `export { RealtimePubSubDO } from "@atmo-dev/contrail";` and bind it in - * your `wrangler.toml`. One DO = one topic; addressed by name. - * 2. `DurableObjectPubSub` — client-side adapter implementing the PubSub - * interface against a DO namespace binding. - * - * Wire format between Worker and DO (internal, not a stable public contract): - * POST /publish — body = RealtimeEvent JSON - * GET /subscribe — server-sent events stream, optionally with - * `Upgrade: websocket` for WS connections. - * Auth/ACL is already checked at the Worker edge; - * the DO trusts anything that reaches it. */ - -import type { PubSub, RealtimeEvent } from "./types"; -import { translateForQuery, type TranslatedEnvelope } from "./query-filter"; -type TranslatedEvent = TranslatedEnvelope; - -/** Query spec attached to a WS subscriber, used to filter events before - * delivery. Shape matches what the Worker's `watchRecords` handler builds; - * forwarded to the DO via trusted internal headers on the WS upgrade. */ -export interface SubscriberQuerySpec { - /** NSID of the primary collection the client is watching. */ - collection: string; - /** Space URI this subscription is scoped to. Events outside are dropped. */ - spaceUri: string; - /** Hydrated relations. Keyed by relName — value is the child collection - * NSID and the field on the child record that references the parent. */ - hydrate?: Record; -} - -// ---- Minimal structural typings so we don't depend on @cloudflare/workers-types -// at the library level. Callers on Workers will have proper types. -// ---------------------------------------------------------------------------- - -export interface DurableObjectId { - toString(): string; -} - -export interface DurableObjectStub { - fetch(input: RequestInfo | URL, init?: RequestInit): Promise; -} - -export interface DurableObjectNamespace { - idFromName(name: string): DurableObjectId; - get(id: DurableObjectId): DurableObjectStub; -} - -export interface DurableObjectState { - acceptWebSocket(ws: any, tags?: string[]): void; - getWebSockets(tag?: string): any[]; -} - -// ---------------------------------------------------------------------------- -// Client adapter -// ---------------------------------------------------------------------------- - -export class DurableObjectPubSub implements PubSub { - constructor(private readonly namespace: DurableObjectNamespace) {} - - private stub(topic: string): DurableObjectStub { - return this.namespace.get(this.namespace.idFromName(topic)); - } - - async publish(event: RealtimeEvent): Promise { - const res = await this.stub(event.topic).fetch("https://do/publish", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(event), - }); - if (!res.ok) { - // Consume the body so the edge doesn't hold a dangling response. - await res.text().catch(() => ""); - throw new Error(`DO publish failed (${res.status})`); - } - } - - /** In-Worker server-side subscribe. Browsers should hit the SSE endpoint - * directly; this is the path for an in-process consumer that wants an - * AsyncIterable (tests, bots embedded in the Worker). */ - subscribe(topic: string, signal?: AbortSignal): AsyncIterable { - const stub = this.stub(topic); - return { - [Symbol.asyncIterator]() { - return pullIterator(stub, signal); - }, - }; - } - - /** Forward an incoming browser WS upgrade (or SSE GET) through to the DO - * that owns this topic, attaching a query-filter spec that the DO will use - * to decide what to deliver. The Worker must verify auth + spec validity - * before calling this — the DO trusts the headers. */ - async forwardSubscribe( - topic: string, - request: Request, - opts: { - did?: string; - querySpec?: SubscriberQuerySpec; - /** Unix ms. DO replays any buffered event with ts > sinceTs before - * going live — closes the snapshot→WS race window on the client. */ - sinceTs?: number; - } = {} - ): Promise { - const headers = new Headers(request.headers); - if (opts.querySpec) { - headers.set("X-Contrail-Query-Spec", JSON.stringify(opts.querySpec)); - } - const url = new URL("https://do/subscribe"); - if (opts.did) url.searchParams.set("did", opts.did); - if (opts.sinceTs && opts.sinceTs > 0) { - url.searchParams.set("sinceTs", String(opts.sinceTs)); - } - return this.stub(topic).fetch(url.toString(), { - method: "GET", - headers - }); - } -} - -function pullIterator( - stub: DurableObjectStub, - signal?: AbortSignal -): AsyncIterator { - let reader: ReadableStreamDefaultReader | null = null; - let buf = ""; - const decoder = new TextDecoder(); - const ac = new AbortController(); - if (signal) { - if (signal.aborted) ac.abort(); - else signal.addEventListener("abort", () => ac.abort(), { once: true }); - } - - const open = async () => { - const res = await stub.fetch("https://do/subscribe", { - method: "GET", - headers: { accept: "text/event-stream" }, - signal: ac.signal, - }); - if (!res.ok || !res.body) throw new Error(`DO subscribe failed (${res.status})`); - reader = res.body.getReader(); - }; - - return { - async next(): Promise> { - if (!reader) await open(); - while (true) { - // Drain buffered frames. - while (true) { - const sep = buf.indexOf("\n\n"); - if (sep < 0) break; - const frame = buf.slice(0, sep); - buf = buf.slice(sep + 2); - let data: string | null = null; - for (const line of frame.split("\n")) { - if (line.startsWith(":")) continue; - if (line.startsWith("data:")) data = line.slice(5).trim(); - } - if (data) { - try { - return { value: JSON.parse(data) as RealtimeEvent, done: false }; - } catch { - /* skip malformed */ - } - } - } - if (ac.signal.aborted) return { value: undefined, done: true }; - const r = await reader!.read(); - if (r.done) return { value: undefined, done: true }; - buf += decoder.decode(r.value, { stream: true }); - } - }, - async return(): Promise> { - ac.abort(); - try { - await reader?.cancel(); - } catch { - /* ignore */ - } - return { value: undefined, done: true }; - }, - }; -} - -// ---------------------------------------------------------------------------- -// Durable Object class -// ---------------------------------------------------------------------------- - -/** The Durable Object implementation. Each DO instance owns the fan-out for - * exactly one topic. WebSocket connections are stored via the Hibernation - * API (`state.acceptWebSocket`) so idle rooms cost near-zero. - * - * This class intentionally avoids the `DurableObject` base class so we don't - * have to depend on @cloudflare/workers-types at the library level — users - * wire it up directly in their Worker entry. */ -/** Rolling buffer of recent events, used to close the snapshot→WS race: - * when a new subscriber connects with `?sinceTs=X`, replay any buffered - * event with `event.ts > X` before going live. Bounded by count + age so - * memory stays small. */ -const RECENT_BUFFER_MS = 15_000; -const RECENT_BUFFER_MAX = 500; - -export class RealtimePubSubDO { - private readonly recentEvents: RealtimeEvent[] = []; - - constructor( - protected readonly state: DurableObjectState, - _env?: unknown - ) {} - - private pushRecent(event: RealtimeEvent): void { - this.recentEvents.push(event); - const cutoff = Date.now() - RECENT_BUFFER_MS; - while ( - this.recentEvents.length > RECENT_BUFFER_MAX || - (this.recentEvents.length > 0 && this.recentEvents[0]!.ts < cutoff) - ) { - this.recentEvents.shift(); - } - } - - /** Worker entry delegates `fetch` to this method. */ - async fetch(request: Request): Promise { - const url = new URL(request.url); - if (request.method === "POST" && url.pathname === "/publish") { - let event: RealtimeEvent; - try { - event = (await request.json()) as RealtimeEvent; - } catch { - return new Response(JSON.stringify({ error: "InvalidRequest" }), { status: 400 }); - } - this.publishEvent(event); - return new Response("{}", { status: 200 }); - } - if (request.method === "GET" && url.pathname === "/subscribe") { - const did = url.searchParams.get("did") ?? undefined; - const sinceTsRaw = url.searchParams.get("sinceTs"); - const sinceTs = sinceTsRaw ? Number(sinceTsRaw) : 0; - // Optional query-filter spec, forwarded by the Worker after it has - // verified the caller's auth + access. Parsed once here; the parsed - // object is serialized into the WS attachment so the DO can filter - // events on publish without re-parsing. - let querySpec: SubscriberQuerySpec | undefined; - const rawSpec = request.headers.get("X-Contrail-Query-Spec"); - if (rawSpec) { - try { - querySpec = JSON.parse(rawSpec) as SubscriberQuerySpec; - } catch { - return new Response( - JSON.stringify({ error: "InvalidRequest", message: "bad X-Contrail-Query-Spec" }), - { status: 400 } - ); - } - } - - if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { - const Pair = (globalThis as unknown as { WebSocketPair?: any }).WebSocketPair; - if (!Pair) return new Response("websockets require Workers", { status: 426 }); - const pair = new Pair(); - this.acceptWebSocketSubscriber(pair[1], did, querySpec); - if (sinceTs > 0) this.replayRecentTo(pair[1], sinceTs); - return new Response(null, { - status: 101, - // Workers-specific init field - webSocket: pair[0], - } as ResponseInit & { webSocket: unknown }); - } - return this.openSseResponse(did, querySpec, sinceTs); - } - return new Response("not found", { status: 404 }); - } - - /** Fan-out an event to every connected subscriber (WS + SSE). - * Public so tests + advanced callers can skip the HTTP layer. - * - * If a subscriber has attached a `querySpec`, we translate the raw event - * into 0–1 watchRecords-shaped events (record.created, record.deleted, - * hydration.added, hydration.removed) and deliver only those. Otherwise - * the raw event is delivered as-is (topic-firehose behaviour for the - * `realtime.subscribe` endpoint). */ - publishEvent(event: RealtimeEvent): void { - // Buffer first so a subscriber connecting mid-publish (race-window - // replay) can pick up this event too once they provide their sinceTs. - this.pushRecent(event); - - const rawPayload = JSON.stringify(event); - const rawFrame = `event: ${event.kind}\ndata: ${rawPayload}\n\n`; - - for (const ws of this.state.getWebSockets()) { - const attachment = getAttachment(ws); - - if (attachment?.querySpec) { - const translated = translateForQuery(event, attachment); - if (translated) this.writeSubscriberState(ws, attachment, translated); - for (const msg of translated ?? []) { - try { - ws.send(JSON.stringify(msg)); - } catch { - /* ignore */ - } - } - } else { - try { - ws.send(rawPayload); - } catch { - /* ignore */ - } - } - - if ( - event.kind === "member.removed" && - attachment?.did && - event.payload.did === attachment.did - ) { - try { - ws.close(4003, "membership-revoked"); - } catch { - /* ignore */ - } - } - } - - for (const entry of this.sseControllers) { - if (entry.querySpec) { - const translated = translateForQuery(event, entry); - if (translated) this.writeSubscriberStateForSse(entry, translated); - for (const msg of translated ?? []) { - try { - entry.controller.enqueue( - this.encoder.encode(`event: ${msg.kind}\ndata: ${JSON.stringify(msg.data)}\n\n`) - ); - } catch { - /* drop */ - } - } - } else { - try { - entry.controller.enqueue(this.encoder.encode(rawFrame)); - } catch { - /* drop; cleanup happens on the subscribe-side */ - } - } - - if ( - event.kind === "member.removed" && - entry.did && - event.payload.did === entry.did - ) { - try { - entry.controller.close(); - } catch { - /* ignore */ - } - } - } - } - - /** Register a server-side WebSocket as a subscriber. Wires the DID + - * optional query spec into the hibernation attachment so this DO can - * filter and route events after going to sleep. */ - acceptWebSocketSubscriber( - serverWs: any, - did?: string, - querySpec?: SubscriberQuerySpec - ): void { - this.state.acceptWebSocket(serverWs, did ? [did] : undefined); - if (did || querySpec) { - setAttachment(serverWs, { - did, - querySpec, - parentUris: [], - childToParent: {} - }); - } - } - - /** Open an SSE subscriber; returns the streaming Response. */ - openSseResponse( - did?: string, - querySpec?: SubscriberQuerySpec, - sinceTs = 0 - ): Response { - let entry: SseEntry; - const stream = new ReadableStream({ - start: (controller) => { - entry = { - controller, - did, - querySpec, - parentUris: new Set(), - childToParent: new Map() - }; - this.sseControllers.add(entry); - controller.enqueue(this.encoder.encode(`: open\n\n`)); - if (sinceTs > 0) this.replayRecentToSse(entry, sinceTs); - }, - cancel: () => { - this.sseControllers.delete(entry); - }, - }); - return new Response(stream, { - status: 200, - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache, no-transform", - connection: "keep-alive", - }, - }); - } - - /** Replay buffered events with ts > sinceTs through this subscriber's - * query-spec filter. Called once, synchronously, on WS connect. */ - private replayRecentTo(ws: any, sinceTs: number): void { - const attachment = getAttachment(ws); - for (const event of this.recentEvents) { - if (event.ts <= sinceTs) continue; - if (attachment?.querySpec) { - const translated = translateForQuery(event, attachment); - if (translated) this.writeSubscriberState(ws, attachment, translated); - for (const msg of translated ?? []) { - try { - ws.send(JSON.stringify(msg)); - } catch { - /* ignore */ - } - } - } else { - try { - ws.send(JSON.stringify(event)); - } catch { - /* ignore */ - } - } - } - } - - private replayRecentToSse(entry: SseEntry, sinceTs: number): void { - for (const event of this.recentEvents) { - if (event.ts <= sinceTs) continue; - if (entry.querySpec) { - const translated = translateForQuery(event, entry); - if (translated) this.writeSubscriberStateForSse(entry, translated); - for (const msg of translated ?? []) { - try { - entry.controller.enqueue( - this.encoder.encode(`event: ${msg.kind}\ndata: ${JSON.stringify(msg.data)}\n\n`) - ); - } catch { - /* drop */ - } - } - } else { - try { - entry.controller.enqueue( - this.encoder.encode(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`) - ); - } catch { - /* drop */ - } - } - } - } - - /** Update the persisted WS attachment state after we've decided which - * events to forward. Keeps the parent/child tracking tables warm across - * hibernation. */ - private writeSubscriberState( - ws: any, - attachment: WsAttachment, - translated: TranslatedEvent[] - ): void { - let dirty = false; - for (const msg of translated) { - if (msg.kind === "record.created" && msg.data.record?.uri) { - attachment.parentUris = Array.from( - new Set([...(attachment.parentUris ?? []), msg.data.record.uri]) - ); - dirty = true; - } else if (msg.kind === "record.deleted" && msg.data.uri) { - const before = attachment.parentUris ?? []; - attachment.parentUris = before.filter((u) => u !== msg.data.uri); - if (attachment.parentUris.length !== before.length) dirty = true; - } else if (msg.kind === "hydration.added" && msg.data.child?.rkey) { - attachment.childToParent = { - ...(attachment.childToParent ?? {}), - [msg.data.child.rkey]: { - parentUri: msg.data.parentUri, - relName: msg.data.relation - } - }; - dirty = true; - } else if (msg.kind === "hydration.removed" && msg.data.childRkey) { - const next = { ...(attachment.childToParent ?? {}) }; - if (next[msg.data.childRkey]) { - delete next[msg.data.childRkey]; - attachment.childToParent = next; - dirty = true; - } - } - } - if (dirty) setAttachment(ws, attachment); - } - - private writeSubscriberStateForSse( - entry: SseEntry, - translated: TranslatedEvent[] - ): void { - for (const msg of translated) { - if (msg.kind === "record.created" && msg.data.record?.uri) { - entry.parentUris?.add(msg.data.record.uri); - } else if (msg.kind === "record.deleted" && msg.data.uri) { - entry.parentUris?.delete(msg.data.uri); - } else if (msg.kind === "hydration.added" && msg.data.child?.rkey) { - entry.childToParent?.set(msg.data.child.rkey, { - parentUri: msg.data.parentUri, - relName: msg.data.relation - }); - } else if (msg.kind === "hydration.removed" && msg.data.childRkey) { - entry.childToParent?.delete(msg.data.childRkey); - } - } - } - - private readonly sseControllers = new Set(); - private readonly encoder = new TextEncoder(); -} - -interface SseEntry { - controller: ReadableStreamDefaultController; - did: string | undefined; - querySpec?: SubscriberQuerySpec; - parentUris?: Set; - childToParent?: Map; -} - -interface WsAttachment { - did?: string; - querySpec?: SubscriberQuerySpec; - /** URIs of primary records currently in this subscriber's result set. */ - parentUris?: string[]; - /** childRkey → parent info, for routing child delete events. */ - childToParent?: Record; -} - -function setAttachment(ws: any, attachment: WsAttachment): void { - try { - ws.serializeAttachment?.(attachment); - } catch { - /* non-hibernating socket — fall back to a direct property */ - ws.__attachment = attachment; - } -} - -function getAttachment(ws: any): WsAttachment | null { - try { - const a = ws.deserializeAttachment?.(); - if (a) return a as WsAttachment; - } catch { - /* ignore */ - } - return (ws.__attachment as WsAttachment | undefined) ?? null; -} - -// Query-spec filtering lives in ./query-filter so the Worker can reuse it for -// non-DO (InMemoryPubSub) watchRecords paths without bundling the whole DO. +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/realtime/in-memory.ts b/packages/contrail/src/core/realtime/in-memory.ts index 311c0a5..1129419 100644 --- a/packages/contrail/src/core/realtime/in-memory.ts +++ b/packages/contrail/src/core/realtime/in-memory.ts @@ -1,116 +1 @@ -import type { PubSub, RealtimeEvent } from "./types"; -import { DEFAULT_QUEUE_BOUND } from "./types"; - -/** Single-process PubSub backed by in-memory subscriber sets. - * - * Each subscriber owns a bounded queue; when full, oldest events are dropped. - * `publish` returns once every subscriber has been offered the event — it - * never awaits a subscriber's consumption, so a slow consumer can't block - * producers. The cost of that guarantee is the drop-oldest policy. */ -export class InMemoryPubSub implements PubSub { - private readonly subscribers = new Map>(); - private readonly queueBound: number; - - constructor(opts: { queueBound?: number } = {}) { - this.queueBound = opts.queueBound ?? DEFAULT_QUEUE_BOUND; - } - - async publish(event: RealtimeEvent): Promise { - const set = this.subscribers.get(event.topic); - if (!set) return; - for (const sub of set) sub.push(event); - } - - subscribe(topic: string, signal?: AbortSignal): AsyncIterable { - const sub = new Subscriber(this.queueBound); - let set = this.subscribers.get(topic); - if (!set) { - set = new Set(); - this.subscribers.set(topic, set); - } - set.add(sub); - - const cleanup = () => { - sub.close(); - const s = this.subscribers.get(topic); - if (s) { - s.delete(sub); - if (s.size === 0) this.subscribers.delete(topic); - } - }; - - if (signal) { - if (signal.aborted) cleanup(); - else signal.addEventListener("abort", cleanup, { once: true }); - } - - return sub.iterate(cleanup); - } - - /** Test-only: current subscriber count for a topic. */ - subscriberCount(topic: string): number { - return this.subscribers.get(topic)?.size ?? 0; - } -} - -class Subscriber { - private readonly queue: RealtimeEvent[] = []; - private pending: ((v: RealtimeEvent | null) => void) | null = null; - private closed = false; - /** Number of events dropped because the queue was full. The consumer can - * observe a gap by comparing monotonic event timestamps; exposing the - * count on a side channel is future work. */ - public droppedCount = 0; - - constructor(private readonly bound: number) {} - - push(event: RealtimeEvent): void { - if (this.closed) return; - if (this.pending) { - const p = this.pending; - this.pending = null; - p(event); - return; - } - if (this.queue.length >= this.bound) { - this.queue.shift(); - this.droppedCount += 1; - } - this.queue.push(event); - } - - close(): void { - if (this.closed) return; - this.closed = true; - if (this.pending) { - const p = this.pending; - this.pending = null; - p(null); - } - } - - iterate(cleanup: () => void): AsyncIterable { - const self = this; - return { - [Symbol.asyncIterator]() { - return { - async next(): Promise> { - if (self.queue.length > 0) { - return { value: self.queue.shift()!, done: false }; - } - if (self.closed) return { value: undefined, done: true }; - const event = await new Promise((resolve) => { - self.pending = resolve; - }); - if (event === null) return { value: undefined, done: true }; - return { value: event, done: false }; - }, - async return(): Promise> { - cleanup(); - return { value: undefined, done: true }; - }, - }; - }, - }; - } -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/realtime/merge.ts b/packages/contrail/src/core/realtime/merge.ts index e0fa7df..1129419 100644 --- a/packages/contrail/src/core/realtime/merge.ts +++ b/packages/contrail/src/core/realtime/merge.ts @@ -1,77 +1 @@ -/** Merge N AsyncIterables into one, interleaving events as they arrive. - * Terminates when every source terminates, or when `signal` aborts. */ - -export function mergeAsyncIterables( - sources: AsyncIterable[], - signal?: AbortSignal -): AsyncIterable { - if (sources.length === 0) { - return { - async *[Symbol.asyncIterator]() { - /* nothing to yield */ - }, - }; - } - - return { - [Symbol.asyncIterator]() { - const iterators = sources.map((s) => s[Symbol.asyncIterator]()); - // One in-flight next() per source, racing each other. - type Slot = { - idx: number; - promise: Promise<{ idx: number; result: IteratorResult }>; - }; - const pending = new Map(); - let doneCount = 0; - - const schedule = (idx: number) => { - const slot: Slot = { - idx, - promise: iterators[idx]! - .next() - .then((result) => ({ idx, result })), - }; - pending.set(idx, slot); - }; - - for (let i = 0; i < iterators.length; i++) schedule(i); - - const cleanup = () => { - for (const it of iterators) { - try { - it.return?.(); - } catch { - /* ignore */ - } - } - }; - - if (signal) { - if (signal.aborted) cleanup(); - else signal.addEventListener("abort", cleanup, { once: true }); - } - - return { - async next(): Promise> { - while (pending.size > 0) { - const slots = [...pending.values()]; - const { idx, result } = await Promise.race(slots.map((s) => s.promise)); - pending.delete(idx); - if (result.done) { - doneCount += 1; - if (doneCount === iterators.length) return { value: undefined, done: true }; - continue; - } - schedule(idx); - return { value: result.value, done: false }; - } - return { value: undefined, done: true }; - }, - async return(): Promise> { - cleanup(); - return { value: undefined, done: true }; - }, - }; - }, - }; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/realtime/query-filter.ts b/packages/contrail/src/core/realtime/query-filter.ts index 1b79eb6..1129419 100644 --- a/packages/contrail/src/core/realtime/query-filter.ts +++ b/packages/contrail/src/core/realtime/query-filter.ts @@ -1,235 +1 @@ -/** Shared query-spec → event-translation logic. - * - * Used by: - * - the Durable Object's WS publish path (per-subscriber filter after hibernation) - * - the Worker's SSE / Worker-terminated WS path (in-process filter) - * - * Given a raw RealtimeEvent and a SubscriberQuerySpec, returns the list of - * `{kind, data}` envelopes to send to the subscriber, or `null` if the - * subscriber has no spec (i.e. raw-firehose mode). */ - -import type { RealtimeEvent } from "./types"; -import type { SubscriberQuerySpec } from "./durable-object"; - -export type TranslatedEnvelope = - | { - kind: "record.created"; - data: { - record: { - uri: string; - did: string; - rkey: string; - collection: string; - cid: string | null | undefined; - record: Record; - time_us: number; - indexed_at: number; - space: string; - }; - }; - } - | { kind: "record.deleted"; data: { uri: string; did: string; rkey: string } } - | { - kind: "hydration.added"; - data: { - parentUri: string; - relation: string; - child: { - uri: string; - did: string; - rkey: string; - collection: string; - cid: string | null | undefined; - record: Record; - space: string; - }; - }; - } - | { - kind: "hydration.removed"; - data: { - parentUri: string; - relation: string; - childRkey: string; - childDid?: string; - }; - }; - -export interface SubscriberView { - querySpec?: SubscriberQuerySpec; - parentUris?: Set | string[]; - childToParent?: - | Map - | Record; -} - -export function translateForQuery( - event: RealtimeEvent, - sub: SubscriberView -): TranslatedEnvelope[] | null { - const spec = sub.querySpec; - if (!spec) return null; - if (event.kind !== "record.created" && event.kind !== "record.deleted") return []; - if (event.payload.space !== spec.spaceUri) return []; - - const primaryUri = `at://${event.payload.did}/${event.payload.collection}/${event.payload.rkey}`; - - if (event.payload.collection === spec.collection) { - if (event.kind === "record.created") { - return [ - { - kind: "record.created", - data: { - record: { - uri: primaryUri, - did: event.payload.did, - rkey: event.payload.rkey, - collection: event.payload.collection, - cid: event.payload.cid, - record: event.payload.record, - time_us: event.ts * 1000, - indexed_at: event.ts, - space: spec.spaceUri - } - } - } - ]; - } - return [ - { - kind: "record.deleted", - data: { - uri: primaryUri, - did: event.payload.did, - rkey: event.payload.rkey - } - } - ]; - } - - if (!spec.hydrate) return []; - for (const [relName, rel] of Object.entries(spec.hydrate)) { - if (rel.childCollection !== event.payload.collection) continue; - if (event.kind === "record.created") { - const parentUri = getNestedValue( - event.payload.record as Record, - rel.matchField - ); - if (typeof parentUri !== "string") continue; - if (!hasParent(sub.parentUris, parentUri)) continue; - return [ - { - kind: "hydration.added", - data: { - parentUri, - relation: relName, - child: { - uri: primaryUri, - did: event.payload.did, - rkey: event.payload.rkey, - collection: event.payload.collection, - cid: event.payload.cid, - record: event.payload.record, - space: spec.spaceUri - } - } - } - ]; - } - const info = getChildInfo(sub.childToParent, event.payload.rkey); - if (!info || info.relName !== relName) continue; - return [ - { - kind: "hydration.removed", - data: { - parentUri: info.parentUri, - relation: relName, - childRkey: event.payload.rkey, - childDid: event.payload.did - } - } - ]; - } - return []; -} - -export function applyEnvelopesToSubscriber( - subscriber: SubscriberView, - envs: TranslatedEnvelope[] -): void { - for (const msg of envs) { - if (msg.kind === "record.created") { - ensureParentSet(subscriber).add(msg.data.record.uri); - } else if (msg.kind === "record.deleted") { - const set = subscriber.parentUris; - if (set instanceof Set) set.delete(msg.data.uri); - else if (Array.isArray(set)) { - const idx = set.indexOf(msg.data.uri); - if (idx >= 0) set.splice(idx, 1); - } - } else if (msg.kind === "hydration.added") { - ensureChildMap(subscriber).set(msg.data.child.rkey, { - parentUri: msg.data.parentUri, - relName: msg.data.relation - }); - } else if (msg.kind === "hydration.removed") { - const map = subscriber.childToParent; - if (map instanceof Map) map.delete(msg.data.childRkey); - else if (map && typeof map === "object") { - delete (map as Record)[msg.data.childRkey]; - } - } - } -} - -function ensureParentSet(sub: SubscriberView): Set { - if (sub.parentUris instanceof Set) return sub.parentUris; - const set = new Set(sub.parentUris ?? []); - sub.parentUris = set; - return set; -} - -function ensureChildMap( - sub: SubscriberView -): Map { - if (sub.childToParent instanceof Map) return sub.childToParent; - const map = new Map(); - if (sub.childToParent && typeof sub.childToParent === "object") { - for (const [k, v] of Object.entries(sub.childToParent)) map.set(k, v); - } - sub.childToParent = map; - return map; -} - -function hasParent( - parents: Set | string[] | undefined, - uri: string -): boolean { - if (!parents) return false; - if (parents instanceof Set) return parents.has(uri); - return parents.includes(uri); -} - -function getChildInfo( - map: - | Map - | Record - | undefined, - rkey: string -): { parentUri: string; relName: string } | undefined { - if (!map) return undefined; - if (map instanceof Map) return map.get(rkey); - return map[rkey]; -} - -function getNestedValue( - obj: Record, - path: string -): unknown { - let cur: unknown = obj; - for (const key of path.split(".")) { - if (cur == null || typeof cur !== "object") return undefined; - cur = (cur as Record)[key]; - } - return cur; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/realtime/sse.ts b/packages/contrail/src/core/realtime/sse.ts index 65d89c1..1129419 100644 --- a/packages/contrail/src/core/realtime/sse.ts +++ b/packages/contrail/src/core/realtime/sse.ts @@ -1,98 +1 @@ -/** Server-Sent Events transport. - * - * Wraps an AsyncIterable as a streaming Response. The caller - * (the router) has already done auth and has an AbortSignal it can use to - * tear the stream down (e.g. on `member.removed` for the subscriber's DID). */ - -import type { RealtimeEvent } from "./types"; -import { DEFAULT_KEEPALIVE_MS } from "./types"; - -export interface SseOptions { - keepaliveMs?: number; - /** Called before the stream closes. Useful for cleanup that the caller - * can't do via the signal (e.g. removing a subscriber from a set). */ - onClose?: () => void; -} - -export function sseResponse( - iter: AsyncIterable, - signal: AbortSignal, - opts: SseOptions = {} -): Response { - const keepaliveMs = opts.keepaliveMs ?? DEFAULT_KEEPALIVE_MS; - const encoder = new TextEncoder(); - - const stream = new ReadableStream({ - start(controller) { - let closed = false; - let keepalive: ReturnType | null = null; - - const close = () => { - if (closed) return; - closed = true; - if (keepalive) clearInterval(keepalive); - try { - controller.close(); - } catch { - /* already closed */ - } - opts.onClose?.(); - }; - - signal.addEventListener("abort", close, { once: true }); - - keepalive = setInterval(() => { - if (closed) return; - try { - controller.enqueue(encoder.encode(`: keepalive\n\n`)); - } catch { - close(); - } - }, keepaliveMs); - - (async () => { - // Opening comment — helps some clients / proxies initialize promptly. - controller.enqueue(encoder.encode(`: open\n\n`)); - try { - for await (const event of iter) { - if (closed) break; - controller.enqueue(encoder.encode(frameEvent(event))); - } - } catch (err) { - if (!closed) { - try { - controller.enqueue( - encoder.encode( - `event: error\ndata: ${JSON.stringify({ - message: err instanceof Error ? err.message : String(err), - })}\n\n` - ) - ); - } catch { - /* stream already torn down */ - } - } - } finally { - close(); - } - })(); - }, - cancel() { - opts.onClose?.(); - }, - }); - - return new Response(stream, { - status: 200, - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache, no-transform", - connection: "keep-alive", - "x-accel-buffering": "no", - }, - }); -} - -function frameEvent(event: RealtimeEvent): string { - return `event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/realtime/ticket.ts b/packages/contrail/src/core/realtime/ticket.ts index 1c6e3de..1129419 100644 --- a/packages/contrail/src/core/realtime/ticket.ts +++ b/packages/contrail/src/core/realtime/ticket.ts @@ -1,179 +1 @@ -/** Subscription tickets — HMAC-signed short-lived `{topics, did, exp}` blobs. - * - * Wire format: `.` where - * payload = base64url(JSON({ topics, did, exp, iat })) - * sig = base64url(HMAC-SHA256(key, payload)) - * - * Tickets are integrity-only (not encrypted). Browsers use them because - * EventSource / WebSocket can't send Authorization headers; server-side - * consumers skip the ticket dance and send their JWT directly. */ - -export interface TicketPayload { - /** Concrete delivery topics this ticket authorizes. `community:` is - * expanded to the caller's visible spaces before signing — a ticket never - * carries a community alias. */ - topics: string[]; - did: string; - /** Unix ms. */ - exp: number; - /** Unix ms — useful for debugging; ignored on verify. */ - iat: number; - /** Optional: query-scoped watchRecords spec this ticket authorizes. Present - * when the ticket was minted from a watchRecords handshake. The server - * trusts the signed spec on upgrade and forwards it to the DO. */ - querySpec?: TicketQuerySpec; -} - -export interface TicketQuerySpec { - collection: string; - /** Exactly one of `spaceUri` or `actor` is set. `spaceUri` = per-space - * watch; `actor` = cross-space watch for records authored by this DID - * (the ticket's `topics` list carries the expanded delivery topics). */ - spaceUri?: string; - actor?: string; - hydrate?: Record; -} - -function normalizeSecret(secret: Uint8Array | string): Uint8Array { - if (typeof secret !== "string") { - if (secret.length !== 32) { - throw new Error(`realtime ticketSecret must be 32 bytes, got ${secret.length}`); - } - return secret; - } - // 64 hex chars would also round-trip as base64 (to 48 bytes). Prefer hex - // when the input matches the hex alphabet exactly; fall back to base64. - const hex = tryHex(secret); - if (hex && hex.length === 32) return hex; - const b64 = tryBase64(secret); - if (b64 && b64.length === 32) return b64; - if (hex || b64) { - const got = (hex ?? b64)!.length; - throw new Error(`realtime ticketSecret must decode to 32 bytes, got ${got}`); - } - throw new Error("realtime ticketSecret must be a 32-byte Uint8Array or base64/hex string"); -} - -function tryBase64(s: string): Uint8Array | null { - try { - const normal = s.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); - if (!/^[A-Za-z0-9+/]*=*$/.test(padded)) return null; - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; - } catch { - return null; - } -} - -function tryHex(s: string): Uint8Array | null { - if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2 !== 0) return null; - const out = new Uint8Array(s.length / 2); - for (let i = 0; i < out.length; i++) { - out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); - } - return out; -} - -function b64urlFromBytes(bytes: Uint8Array): string { - let bin = ""; - for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function b64urlToBytes(s: string): Uint8Array { - const normal = s.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function b64urlFromString(s: string): string { - return b64urlFromBytes(new TextEncoder().encode(s)); -} - -function stringFromB64url(s: string): string { - return new TextDecoder().decode(b64urlToBytes(s)); -} - -function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; - return diff === 0; -} - -export class TicketSigner { - private readonly keyPromise: Promise; - - constructor(secret: Uint8Array | string) { - const raw = normalizeSecret(secret); - this.keyPromise = crypto.subtle.importKey( - "raw", - raw as BufferSource, - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign", "verify"] - ); - } - - async sign(input: { - topics: string[]; - did: string; - ttlMs: number; - querySpec?: TicketQuerySpec; - }): Promise { - const now = Date.now(); - const payload: TicketPayload = { - topics: input.topics, - did: input.did, - exp: now + input.ttlMs, - iat: now, - ...(input.querySpec ? { querySpec: input.querySpec } : {}), - }; - const payloadPart = b64urlFromString(JSON.stringify(payload)); - const sig = await crypto.subtle.sign( - "HMAC", - await this.keyPromise, - new TextEncoder().encode(payloadPart) as BufferSource - ); - const sigPart = b64urlFromBytes(new Uint8Array(sig)); - return `${payloadPart}.${sigPart}`; - } - - /** Returns the decoded payload if the ticket is valid + unexpired, else null. */ - async verify(ticket: string): Promise { - const dot = ticket.indexOf("."); - if (dot < 0) return null; - const payloadPart = ticket.slice(0, dot); - const sigPart = ticket.slice(dot + 1); - let expectedSig: Uint8Array; - try { - expectedSig = b64urlToBytes(sigPart); - } catch { - return null; - } - const computedRaw = await crypto.subtle.sign( - "HMAC", - await this.keyPromise, - new TextEncoder().encode(payloadPart) as BufferSource - ); - const computed = new Uint8Array(computedRaw); - if (!constantTimeEq(expectedSig, computed)) return null; - let parsed: TicketPayload; - try { - parsed = JSON.parse(stringFromB64url(payloadPart)); - } catch { - return null; - } - if (!parsed || !Array.isArray(parsed.topics) || typeof parsed.did !== "string") { - return null; - } - if (typeof parsed.exp !== "number" || parsed.exp <= Date.now()) return null; - return parsed; - } -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/realtime/types.ts b/packages/contrail/src/core/realtime/types.ts index 05a246e..1129419 100644 --- a/packages/contrail/src/core/realtime/types.ts +++ b/packages/contrail/src/core/realtime/types.ts @@ -1,127 +1 @@ -/** Realtime module — canonical types + interfaces. See docs/realtime.md. */ - -/** Discriminated union of every event kind that flows through the PubSub. - * - * `record.created` carries the full record body so a subscriber can apply an - * insert or upsert without a follow-up `getRecord` call. Writing a new record - * to the same `(did, collection, rkey)` publishes another `record.created` — - * treat it as upsert. - * - * **Payload shape mirrors `listRecords` output** (`uri`, `did`, `space?`, - * `time_us`), so a subscriber can render a live row the same way it renders - * a fetched row. - * - * **Publisher/topic matrix (intentional trust split):** - * - `collection:` and `actor:` carry *public* record events only - * (from jetstream ingestion) — no `space`. - * - `space:` and `community:` carry *space* events — `space` is - * always set. Never cross-published to public topics (privacy). */ -export type RealtimeEvent = - | { - topic: string; - kind: "record.created"; - payload: { - uri: string; - did: string; - collection: string; - rkey: string; - cid: string | null; - record: Record; - time_us: number; - /** Present only for space records; absent for public records. */ - space?: string; - }; - ts: number; - } - | { - topic: string; - kind: "record.deleted"; - payload: { - uri: string; - did: string; - collection: string; - rkey: string; - /** Present only for space records; absent for public records. */ - space?: string; - }; - ts: number; - } - | { - topic: string; - kind: "member.added"; - payload: { space: string; did: string }; - ts: number; - } - | { - topic: string; - kind: "member.removed"; - payload: { space: string; did: string }; - ts: number; - }; - -export type RealtimeEventKind = RealtimeEvent["kind"]; - -/** Core pubsub abstraction. Implementations: InMemoryPubSub, DurableObjectPubSub. */ -export interface PubSub { - publish(event: RealtimeEvent): Promise; - /** Stream events on the topic until the signal aborts (or the iterator is - * returned/broken out of). Implementations use a bounded per-subscriber - * queue with drop-oldest semantics — a slow subscriber can't stall publishers. */ - subscribe(topic: string, signal?: AbortSignal): AsyncIterable; -} - -// ---- Canonical topic strings ----------------------------------------------- -// `community:` is an alias resolved at ticket-mint time to the concrete -// set of `space:` topics the caller can see; it is never a real delivery -// topic. The other three are real. - -export function spaceTopic(uri: string): string { - return `space:${uri}`; -} - -export function communityTopic(did: string): string { - return `community:${did}`; -} - -export function collectionTopic(nsid: string): string { - return `collection:${nsid}`; -} - -export function actorTopic(did: string): string { - return `actor:${did}`; -} - -export function isCommunityTopic(topic: string): boolean { - return topic.startsWith("community:"); -} - -export function parseCommunityTopic(topic: string): string | null { - return isCommunityTopic(topic) ? topic.slice("community:".length) : null; -} - -export function parseSpaceTopic(topic: string): string | null { - return topic.startsWith("space:") ? topic.slice("space:".length) : null; -} - -// ---- Config ----------------------------------------------------------------- - -export interface RealtimeConfig { - /** Backing pubsub. Default: new InMemoryPubSub() (single-process only). On - * Workers, pass `new DurableObjectPubSub(env.REALTIME)`. */ - pubsub?: PubSub; - /** HMAC secret used to sign subscription tickets. 32 bytes. Accepts raw - * Uint8Array or base64 / hex string. Envelope-encrypts nothing — tickets - * are integrity-only, not confidential. */ - ticketSecret: Uint8Array | string; - /** Ticket lifetime in ms. Default 120_000 (2 minutes). */ - ticketTtlMs?: number; - /** SSE/WS keepalive interval in ms. Default 15_000. */ - keepaliveMs?: number; - /** Per-subscriber queue bound. Default 1024. Events beyond this are dropped - * oldest-first and the subscriber receives a `lag` signal (out of band). */ - queueBound?: number; -} - -export const DEFAULT_TICKET_TTL_MS = 120_000; -export const DEFAULT_KEEPALIVE_MS = 15_000; -export const DEFAULT_QUEUE_BOUND = 1024; +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/realtime/websocket.ts b/packages/contrail/src/core/realtime/websocket.ts index 833483d..1129419 100644 --- a/packages/contrail/src/core/realtime/websocket.ts +++ b/packages/contrail/src/core/realtime/websocket.ts @@ -1,84 +1 @@ -/** WebSocket transport. - * - * Accepts a new WebSocket connection (either via `WebSocketPair` on Workers - * or a platform-provided server-side socket) and pumps events to it from an - * AsyncIterable. Messages are UTF-8 JSON, one event per frame. - * - * Close codes (subset, RFC 6455 + app-custom): - * - 4001: server error pumping - * - 4003: membership revoked - * - 4008: ticket/auth invalid (used by the router, not here) - */ - -import type { RealtimeEvent } from "./types"; -import { DEFAULT_KEEPALIVE_MS } from "./types"; - -export interface WebSocketLike { - send(data: string): void; - close(code?: number, reason?: string): void; - addEventListener(type: "message" | "close" | "error", listener: (ev: any) => void): void; -} - -export interface WebSocketPumpOptions { - keepaliveMs?: number; - onClose?: () => void; -} - -/** Pump events from `iter` to `ws` until the signal aborts or the iter ends. - * Caller is responsible for having already accept()ed the socket. */ -export async function pumpWebSocket( - ws: WebSocketLike, - iter: AsyncIterable, - signal: AbortSignal, - opts: WebSocketPumpOptions = {} -): Promise { - const keepaliveMs = opts.keepaliveMs ?? DEFAULT_KEEPALIVE_MS; - let closed = false; - - const close = (code?: number, reason?: string) => { - if (closed) return; - closed = true; - try { - ws.close(code, reason); - } catch { - /* already closed */ - } - opts.onClose?.(); - }; - - ws.addEventListener("close", () => { - closed = true; - opts.onClose?.(); - }); - ws.addEventListener("error", () => { - closed = true; - opts.onClose?.(); - }); - signal.addEventListener("abort", () => close(1000, "aborted"), { once: true }); - - const keepalive = setInterval(() => { - if (closed) return; - try { - ws.send(JSON.stringify({ kind: "$keepalive" })); - } catch { - close(); - } - }, keepaliveMs); - - try { - for await (const event of iter) { - if (closed) break; - try { - ws.send(JSON.stringify(event)); - } catch { - close(4001, "send-failed"); - break; - } - } - } catch { - close(4001, "pump-error"); - } finally { - clearInterval(keepalive); - close(); - } -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/refresh.ts b/packages/contrail/src/core/refresh.ts index aacedaa..7b2fd21 100644 --- a/packages/contrail/src/core/refresh.ts +++ b/packages/contrail/src/core/refresh.ts @@ -1,3 +1,4 @@ +import type {} from "@atcute/atproto"; /** * Fresh refresh: re-walk every known DID's PDS for every configured collection * and reconcile against what's in our DB. Unlike `backfillPending`, this diff --git a/packages/contrail/src/core/spaces/acl.ts b/packages/contrail/src/core/spaces/acl.ts index 6e0da0a..1129419 100644 --- a/packages/contrail/src/core/spaces/acl.ts +++ b/packages/contrail/src/core/spaces/acl.ts @@ -1,69 +1 @@ -import type { AppPolicy, SpaceMemberRow, SpaceRow } from "./types"; - -export type AclOp = "read" | "write" | "delete"; - -export interface AclInput { - op: AclOp; - space: SpaceRow; - callerDid: string; - /** Membership row for the caller (or null). Owner does not require a row. */ - member: SpaceMemberRow | null; - /** OAuth client_id of the app calling on caller's behalf, for app policy checks. */ - clientId?: string; - /** For per-record ops (get/delete), the record's author DID. */ - targetAuthorDid?: string; -} - -export type AclResult = - | { allow: true } - | { allow: false; reason: AclDenyReason }; - -export type AclDenyReason = - | "not-member" - | "not-own-record" - | "app-not-allowed" - | "unknown-op"; - -/** Check whether the caller's app is permitted to act in this space. */ -export function checkAppPolicy( - appPolicy: AppPolicy | null, - clientId: string | undefined -): boolean { - if (!appPolicy) return true; // no policy = allow-all - const listed = clientId ? appPolicy.apps.includes(clientId) : false; - if (appPolicy.mode === "allow") return !listed; // apps[] is a denylist - return listed; // mode === "deny": apps[] is an allowlist -} - -const isOwner = (space: SpaceRow, did: string) => space.ownerDid === did; -const hasMember = (space: SpaceRow, member: SpaceMemberRow | null, did: string) => - isOwner(space, did) || member != null; - -/** Space-level access check. - * Membership = access. Any member (including owner) can read and write. - * Delete is scoped to the caller's own records — owners don't get a bypass. - * A random member can't nuke other people's records, and neither can the - * owner. To remove a non-author record, delete the whole space. */ -export function checkAccess(input: AclInput): AclResult { - if (!checkAppPolicy(input.space.appPolicy, input.clientId)) { - return { allow: false, reason: "app-not-allowed" }; - } - - if (input.op === "read" || input.op === "write") { - return hasMember(input.space, input.member, input.callerDid) - ? { allow: true } - : { allow: false, reason: "not-member" }; - } - - if (input.op === "delete") { - if (!hasMember(input.space, input.member, input.callerDid)) { - return { allow: false, reason: "not-member" }; - } - if (input.targetAuthorDid && input.targetAuthorDid !== input.callerDid) { - return { allow: false, reason: "not-own-record" }; - } - return { allow: true }; - } - - return { allow: false, reason: "unknown-op" }; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/spaces/auth.ts b/packages/contrail/src/core/spaces/auth.ts index 4c1e817..1129419 100644 --- a/packages/contrail/src/core/spaces/auth.ts +++ b/packages/contrail/src/core/spaces/auth.ts @@ -1,177 +1 @@ -import type { Context, MiddlewareHandler } from "hono"; -import { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; -import { - CompositeDidDocumentResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - type DidDocumentResolver, -} from "@atcute/identity-resolver"; -import type { Did, Nsid } from "@atcute/lexicons"; -import type { AuthorityConfig } from "./types"; -import { readInProcess } from "./in-process"; - -export { ServiceJwtVerifier }; - -/** Build a ServiceJwtVerifier from an AuthorityConfig, using the configured - * resolver or a default PLC+Web composite. The verifier checks that incoming - * JWTs target this authority's serviceDid (aud claim). */ -export function buildVerifier(authority: AuthorityConfig): ServiceJwtVerifier { - const resolver = - authority.resolver ?? - new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, - }); - return new ServiceJwtVerifier({ - serviceDid: authority.serviceDid as Did, - resolver, - }); -} - -export interface ServiceAuth { - issuer: string; - audience: string; - lxm: string | undefined; - /** OAuth client_id of the caller, if the JWT carries one. */ - clientId?: string; -} - -export interface ServiceAuthOptions { - serviceDid: Did; - resolver: DidDocumentResolver; -} - -/** Hono middleware that authenticates XRPC requests. Order of precedence: - * 1. In-process marker (same-module calls; see `core/spaces/in-process.ts`) - * 2. Authorization: Bearer as an atproto service-auth token - * - * On success, attaches the claims to `c.var.serviceAuth`. Expected Nsid is - * taken from the route pattern (last segment after `/xrpc/`). */ -export function createServiceAuthMiddleware( - verifier: ServiceJwtVerifier -): MiddlewareHandler { - return async (c, next) => { - const lxm = extractLxmFromPath(c); - - const inProcess = readInProcess(c.req.raw); - if (inProcess) { - c.set("serviceAuth", { - issuer: inProcess.did, - audience: "", - lxm: lxm ?? undefined, - } satisfies ServiceAuth); - await next(); - return; - } - - const header = c.req.header("Authorization"); - if (!header || !header.startsWith("Bearer ")) { - return c.json({ error: "AuthRequired", message: "Missing bearer token" }, 401); - } - const token = header.slice(7).trim(); - - const result = await verifier.verify(token, { lxm }); - if (!result.ok) { - const err = result.error as { error?: string; description?: string } | undefined; - return c.json( - { - error: "AuthRequired", - message: err?.description ?? err?.error ?? String(result.error), - }, - 401, - ); - } - - c.set("serviceAuth", { - issuer: result.value.issuer, - audience: result.value.audience, - lxm: result.value.lxm, - } satisfies ServiceAuth); - - await next(); - }; -} - -function extractLxmFromPath(c: Context): Nsid | null { - const path = new URL(c.req.url).pathname; - const match = path.match(/\/xrpc\/([a-zA-Z0-9.-]+)/); - return (match?.[1] as Nsid) ?? null; -} - -/** Read the service auth claims set by the middleware. Throws if unset. */ -export function requireServiceAuth(c: Context): ServiceAuth { - const auth = c.get("serviceAuth") as ServiceAuth | undefined; - if (!auth) throw new Error("service auth missing; middleware not attached"); - return auth; -} - -/** Out-of-band auth check for handlers that don't always require auth. - * Returns claims on success, or null if no valid credentials are present. - * Order of precedence: in-process marker → service-auth JWT. */ -export async function verifyServiceAuthRequest( - verifier: ServiceJwtVerifier, - request: Request, - lxm?: Nsid | null -): Promise { - const inProcess = readInProcess(request); - if (inProcess) { - return { - issuer: inProcess.did, - audience: "", - lxm: lxm ?? undefined, - }; - } - - const header = request.headers.get("Authorization"); - if (!header || !header.startsWith("Bearer ")) return null; - const token = header.slice(7).trim(); - const result = await verifier.verify(token, { lxm: lxm ?? null }); - if (!result.ok) return null; - return { - issuer: result.value.issuer, - audience: result.value.audience, - lxm: result.value.lxm, - }; -} - -/** Pull a space credential off the request — `X-Space-Credential: ` - * header. Returns the raw token or null. */ -export function extractSpaceCredential(request: Request): string | null { - const header = request.headers.get("X-Space-Credential"); - return header ? header.trim() : null; -} - -/** Pull a read-grant invite token off the request — query string `?inviteToken=` - * or `Authorization: Bearer atmo-invite:`. Returns the raw token (not - * hashed) or null. Routes hash + look up via the adapter. */ -export function extractInviteToken(request: Request): string | null { - const url = new URL(request.url); - const q = url.searchParams.get("inviteToken"); - if (q) return q.trim(); - const header = request.headers.get("Authorization"); - if (header?.startsWith("Bearer atmo-invite:")) { - return header.slice("Bearer atmo-invite:".length).trim(); - } - return null; -} - -/** Validate a read-grant invite token against a target spaceUri. Returns true - * if the token exists, scopes to this space, has a kind that grants read - * (`read` or `read-join`), and is not expired/revoked. */ -export async function checkInviteReadGrant( - adapter: { getInvite(tokenHash: string): Promise<{ spaceUri: string; kind: string; revokedAt: number | null; expiresAt: number | null } | null> }, - rawToken: string, - spaceUri: string, - hashFn: (token: string) => Promise -): Promise { - const tokenHash = await hashFn(rawToken); - const invite = await adapter.getInvite(tokenHash); - if (!invite) return false; - if (invite.spaceUri !== spaceUri) return false; - if (invite.kind !== "read" && invite.kind !== "read-join") return false; - if (invite.revokedAt != null) return false; - if (invite.expiresAt != null && invite.expiresAt <= Date.now()) return false; - return true; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/spaces/binding.ts b/packages/contrail/src/core/spaces/binding.ts index a81b95f..1129419 100644 --- a/packages/contrail/src/core/spaces/binding.ts +++ b/packages/contrail/src/core/spaces/binding.ts @@ -1,274 +1 @@ -/** Binding resolution: given a space URI, which DID is authorized to sign - * credentials for it, and where do we find that DID's verification key? - * - * Two layers of pluggable resolvers compose into a credential verifier: - * - * BindingResolver — `ats:////` → authority DID - * KeyResolver — (DID, kid) → JsonWebKey - * - * The BindingResolver is what makes user-owned-DID-with-PDS-record work: - * given a space URI, we resolve the owner's PDS, fetch the declaration - * record, and read its `authority` field. For provisioned (no-PDS) DIDs we - * fall back to the owner DID's `#atproto_space_authority` service entry. - * And finally for the trivial case (HappyView-style "owner self-issues"), - * we return the owner DID itself. - * - * See conversation history (phase 4 design) for the rationale on why these - * three sources, in this order. */ - -import type { DidDocumentResolver } from "@atcute/identity-resolver"; -import type { Did } from "@atcute/lexicons"; -import { parseSpaceUri } from "./uri"; -import type { RecordHost } from "./types"; - -export interface BindingResolver { - /** Resolve the DID authorized to sign credentials for this space. Returns - * null if no binding could be found via this resolver — the composite - * walks down its list looking for a non-null. */ - resolveAuthority(spaceUri: string): Promise; -} - -export interface KeyResolver { - /** Resolve `did`'s verification key for credential signing. `kid` is the - * full header `kid` value (e.g. "did:web:x.com#atproto_space_authority"), - * used to disambiguate when a DID doc lists multiple methods. */ - resolveKey(did: string, kid: string | undefined): Promise; -} - -// --------------------------------------------------------------------------- -// Binding resolvers -// --------------------------------------------------------------------------- - -/** Always returns the configured authority DID. Used in-process when the - * authority and record host run in one deployment — no need to walk DID - * docs or PDSes; we know what we are. */ -export function createLocalBindingResolver(args: { - authorityDid: string; -}): BindingResolver { - const { authorityDid } = args; - return { - async resolveAuthority() { - return authorityDid; - }, - }; -} - -/** Reads the record host's local enrollment table. This is the *canonical* - * binding source on a record host: the host owner explicitly consented to a - * given authority for a given space (via the `recordHost.enroll` endpoint - * or auto-enrollment from the authority's createSpace). PDS-record / - * DID-doc resolvers are out-of-band discovery aids; the enrollment is what - * actually gates whether records get stored here. */ -export function createEnrollmentBindingResolver(args: { - recordHost: RecordHost; -}): BindingResolver { - return { - async resolveAuthority(spaceUri) { - const e = await args.recordHost.getEnrollment(spaceUri); - return e?.authorityDid ?? null; - }, - }; -} - -/** Returns the space owner DID as the authority. This is the implicit - * fallback ("HappyView path") — when no PDS record and no DID-doc service - * entry declare an issuer, the owner is taken to be its own. Whether the - * resulting credential actually verifies depends on whether the owner's DID - * doc publishes a usable signing key. */ -export function createOwnerSelfBindingResolver(): BindingResolver { - return { - async resolveAuthority(spaceUri) { - const parts = parseSpaceUri(spaceUri); - return parts ? parts.ownerDid : null; - }, - }; -} - -/** Walks the resolver list in order, returns the first non-null. Use this - * to compose [pdsRecord, didDocService, ownerSelf] etc. */ -export function createCompositeBindingResolver( - resolvers: BindingResolver[] -): BindingResolver { - return { - async resolveAuthority(spaceUri) { - for (const r of resolvers) { - const did = await r.resolveAuthority(spaceUri); - if (did) return did; - } - return null; - }, - }; -} - -/** Reads a space-declaration record from the owner's PDS at - * `at:////` and returns its `authority` field if present. - * - * This is the user-owned-DID path: the user writes a record to their PDS - * authorizing some service as the space's authority, no DID-doc edits - * required. */ -export function createPdsBindingResolver(args: { - /** DID resolver, used to look up the owner's PDS endpoint. */ - resolver: DidDocumentResolver; - /** Fetch impl. Defaults to `globalThis.fetch`. */ - fetch?: typeof fetch; - /** Per-request timeout in ms. Defaults to 5000. */ - timeoutMs?: number; -}): BindingResolver { - const fetchImpl = args.fetch ?? globalThis.fetch; - const timeoutMs = args.timeoutMs ?? 5000; - - return { - async resolveAuthority(spaceUri) { - const parts = parseSpaceUri(spaceUri); - if (!parts) return null; - const pds = await pdsEndpointFor(args.resolver, parts.ownerDid); - if (!pds) return null; - - const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); - url.searchParams.set("repo", parts.ownerDid); - url.searchParams.set("collection", parts.type); - url.searchParams.set("rkey", parts.key); - - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), timeoutMs); - let res: Response; - try { - res = await fetchImpl(url.toString(), { signal: ctrl.signal }); - } catch { - return null; - } finally { - clearTimeout(timer); - } - if (!res.ok) return null; - const body = (await res.json().catch(() => null)) as - | { value?: { authority?: unknown } } - | null; - const authority = body?.value?.authority; - return typeof authority === "string" && authority.startsWith("did:") ? authority : null; - }, - }; -} - -/** Reads `service[id="#atproto_space_authority"].serviceEndpoint` from the - * owner's DID doc. This is the no-PDS path — useful for provisioned space - * DIDs that exist as DID docs only. - * - * Note the service endpoint here is a *DID*, not a URL. The DID names the - * authority; the key resolver's job is to then fetch its verification key. - * For DID docs that declare a URL endpoint, we treat the URL as a - * did:web hint — caller can normalize. */ -export function createDidDocBindingResolver(args: { - resolver: DidDocumentResolver; - /** Service id to look up. Defaults to "#atproto_space_authority". */ - serviceId?: string; -}): BindingResolver { - const serviceId = args.serviceId ?? "#atproto_space_authority"; - return { - async resolveAuthority(spaceUri) { - const parts = parseSpaceUri(spaceUri); - if (!parts) return null; - let doc; - try { - doc = await args.resolver.resolve(parts.ownerDid as Did); - } catch { - return null; - } - const entry = doc.service?.find((s: { id?: string }) => s.id === serviceId); - if (!entry) return null; - const endpoint = (entry as { serviceEndpoint?: unknown }).serviceEndpoint; - if (typeof endpoint !== "string") return null; - // Endpoint may be a DID (preferred) or a URL hint. Only DIDs are - // verifiable downstream; URLs require the caller to map URL → DID. - return endpoint.startsWith("did:") ? endpoint : null; - }, - }; -} - -// --------------------------------------------------------------------------- -// Key resolvers -// --------------------------------------------------------------------------- - -/** Knows the local authority's public key directly. Returns null for any - * other DID — composite with a DID-doc resolver if you also accept - * external authorities. */ -export function createLocalKeyResolver(args: { - authorityDid: string; - publicKey: JsonWebKey; -}): KeyResolver { - return { - async resolveKey(did) { - return did === args.authorityDid ? args.publicKey : null; - }, - }; -} - -/** Resolves a DID, finds the verification method matching `kid`, returns - * its `publicKeyJwk`. */ -export function createDidDocKeyResolver(args: { - resolver: DidDocumentResolver; -}): KeyResolver { - return { - async resolveKey(did, kid) { - let doc; - try { - doc = await args.resolver.resolve(did as Did); - } catch { - return null; - } - const methods = (doc as { verificationMethod?: VerificationMethod[] }).verificationMethod; - if (!methods) return null; - // kid is "#" — we match against the method.id which DID - // docs spell as "#" too. - const method = kid - ? methods.find((m) => m.id === kid) - : methods[0]; - if (!method?.publicKeyJwk) return null; - return method.publicKeyJwk as JsonWebKey; - }, - }; -} - -/** Walks resolvers in order; returns the first non-null. */ -export function createCompositeKeyResolver( - resolvers: KeyResolver[] -): KeyResolver { - return { - async resolveKey(did, kid) { - for (const r of resolvers) { - const k = await r.resolveKey(did, kid); - if (k) return k; - } - return null; - }, - }; -} - -interface VerificationMethod { - id: string; - type?: string; - controller?: string; - publicKeyJwk?: unknown; - publicKeyMultibase?: string; -} - -// --------------------------------------------------------------------------- -// Internal: PDS endpoint lookup -// --------------------------------------------------------------------------- - -async function pdsEndpointFor( - resolver: DidDocumentResolver, - did: string -): Promise { - let doc; - try { - doc = await resolver.resolve(did as Did); - } catch { - return null; - } - const entry = doc.service?.find( - (s: { id?: string }) => s.id === "#atproto_pds" - ); - if (!entry) return null; - const endpoint = (entry as { serviceEndpoint?: unknown }).serviceEndpoint; - return typeof endpoint === "string" ? endpoint : null; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/spaces/blob-adapter.ts b/packages/contrail/src/core/spaces/blob-adapter.ts index 632b566..1129419 100644 --- a/packages/contrail/src/core/spaces/blob-adapter.ts +++ b/packages/contrail/src/core/spaces/blob-adapter.ts @@ -1,94 +1 @@ -/** - * Bytes-only storage adapter for space blobs. Metadata (CID, mime, size, - * author, space) lives in the `spaces_blobs` table on the main StorageAdapter; - * this interface only moves bytes in and out of a backend (R2, S3, fs, …). - * - * Keys are opaque strings formed by the router as `blobKey(spaceUri, cid)`. - */ - -export interface BlobUploadMeta { - mimeType: string; - size: number; -} - -export interface BlobAdapter { - put(key: string, bytes: Uint8Array, meta: BlobUploadMeta): Promise; - get(key: string): Promise; - /** Bulk delete. Adapters that don't support batch can implement serially. */ - delete(keys: string[]): Promise; -} - -/** In-memory adapter. Useful for tests and local development. */ -export class MemoryBlobAdapter implements BlobAdapter { - private readonly store = new Map(); - - async put(key: string, bytes: Uint8Array): Promise { - this.store.set(key, bytes.slice()); - } - - async get(key: string): Promise { - const v = this.store.get(key); - return v ? v.slice() : null; - } - - async delete(keys: string[]): Promise { - for (const k of keys) this.store.delete(k); - } - - /** Test helper. */ - size(): number { - return this.store.size; - } -} - -/** Minimal Cloudflare R2 bucket shape — matches @cloudflare/workers-types' R2Bucket - * without forcing a types dependency here. */ -export interface R2BucketLike { - put( - key: string, - value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob, - options?: { httpMetadata?: { contentType?: string }; customMetadata?: Record } - ): Promise; - get(key: string): Promise<{ arrayBuffer(): Promise } | null>; - delete(keys: string | string[]): Promise; -} - -/** Cloudflare R2 adapter. Pass the `env.BLOBS` binding from your Worker. */ -export class R2BlobAdapter implements BlobAdapter { - constructor(private readonly bucket: R2BucketLike) {} - - async put(key: string, bytes: Uint8Array, meta: BlobUploadMeta): Promise { - await this.bucket.put(key, bytes, { - httpMetadata: { contentType: meta.mimeType }, - }); - } - - async get(key: string): Promise { - const obj = await this.bucket.get(key); - if (!obj) return null; - const buf = await obj.arrayBuffer(); - return new Uint8Array(buf); - } - - async delete(keys: string[]): Promise { - if (keys.length === 0) return; - await this.bucket.delete(keys); - } -} - -/** Hash a space URI to a short, filesystem/R2-safe key segment. - * Used as the first segment of a blob key so all blobs for one space - * share a common prefix (enables bulk delete on space deletion). */ -export async function spaceKeyPrefix(spaceUri: string): Promise { - const bytes = new TextEncoder().encode(spaceUri); - const digest = await crypto.subtle.digest("SHA-256", bytes); - const hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); - return hex.slice(0, 16); -} - -/** Compose an adapter key from a space URI and CID. - * Shape: `<16-hex-chars-of-sha256(spaceUri)>/`. */ -export async function blobKey(spaceUri: string, cid: string): Promise { - const prefix = await spaceKeyPrefix(spaceUri); - return `${prefix}/${cid}`; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/spaces/credentials.ts b/packages/contrail/src/core/spaces/credentials.ts index d5c3029..1129419 100644 --- a/packages/contrail/src/core/spaces/credentials.ts +++ b/packages/contrail/src/core/spaces/credentials.ts @@ -1,284 +1 @@ -/** Space-credential primitives: ES256 (P-256) JWTs minted by the authority, - * verified by the record host (or any third party that can resolve the - * authority's DID document). - * - * Format is a compact JWS: - * header = { alg: "ES256", typ: "JWT", kid: "#" } - * payload = { iss, sub, space, scope, iat, exp } - * - * - `iss` is the authority DID (the signer; for phase 3 this is the local - * authority's serviceDid; phase 4 adds a binding-resolution layer that - * lets the issuer be a *different* DID from the space owner). - * - `sub` is the caller DID — the credential bearer. - * - `space` is the full `ats:////` URI. - * - `scope` is "rw" or "read". - * - * We don't use a JWT library — Web Crypto's subtle covers everything (P-256 - * generate, sign, verify, JWK import/export) and saves a runtime dep. */ - -const ALG = "ES256"; -const TYP = "JWT"; -const DEFAULT_KEY_ID = "atproto_space_authority"; - -export type CredentialScope = "rw" | "read"; - -export interface CredentialClaims { - iss: string; - sub: string; - space: string; - scope: CredentialScope; - iat: number; // seconds since epoch - exp: number; // seconds since epoch -} - -export interface CredentialKeyMaterial { - /** Private key in JWK form. P-256 / ES256. */ - privateKey: JsonWebKey; - /** Public key in JWK form. Must match privateKey. */ - publicKey: JsonWebKey; - /** DID-doc verification method id. The full JWT `kid` becomes - * `#`. Defaults to "atproto_space_authority". */ - keyId?: string; -} - -/** Generate a fresh P-256 keypair as JWKs. Useful for local dev / tests; in - * production the operator generates once and stores out-of-band. */ -export async function generateAuthoritySigningKey(): Promise { - const pair = (await crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["sign", "verify"] - )) as CryptoKeyPair; - const privateKey = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; - const publicKey = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as JsonWebKey; - return { privateKey, publicKey }; -} - -const enc = new TextEncoder(); -const dec = new TextDecoder(); - -function base64urlEncode(bytes: Uint8Array): string { - let s = btoa(String.fromCharCode(...bytes)); - return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function base64urlDecode(s: string): Uint8Array { - const padded = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="); - const bin = atob(padded); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function jsonEncode(value: unknown): string { - return base64urlEncode(enc.encode(JSON.stringify(value))); -} - -function jsonDecode(seg: string): T { - return JSON.parse(dec.decode(base64urlDecode(seg))) as T; -} - -async function importPrivate(jwk: JsonWebKey): Promise { - return crypto.subtle.importKey( - "jwk", - jwk, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"] - ); -} - -async function importPublic(jwk: JsonWebKey): Promise { - return crypto.subtle.importKey( - "jwk", - jwk, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["verify"] - ); -} - -/** Sign a credential payload with the authority's private key. - * `iat` and `exp` are filled in by the caller (so tests can mint expired - * tokens deterministically). */ -export async function signCredential( - payload: CredentialClaims, - key: CredentialKeyMaterial -): Promise { - const kid = `${payload.iss}#${key.keyId ?? DEFAULT_KEY_ID}`; - const header = { alg: ALG, typ: TYP, kid }; - const head = jsonEncode(header); - const body = jsonEncode(payload); - const signingInput = `${head}.${body}`; - const privateKey = await importPrivate(key.privateKey); - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - privateKey, - enc.encode(signingInput) - ); - return `${signingInput}.${base64urlEncode(new Uint8Array(sig))}`; -} - -/** Issue a credential using the current wall-clock for iat/exp. */ -export async function issueCredential( - args: Omit & { ttlMs: number }, - key: CredentialKeyMaterial -): Promise<{ credential: string; expiresAt: number }> { - const now = Math.floor(Date.now() / 1000); - const expSec = now + Math.floor(args.ttlMs / 1000); - const claims: CredentialClaims = { - iss: args.iss, - sub: args.sub, - space: args.space, - scope: args.scope, - iat: now, - exp: expSec, - }; - const credential = await signCredential(claims, key); - return { credential, expiresAt: expSec * 1000 }; -} - -export type VerifyOk = { ok: true; claims: CredentialClaims }; -export type VerifyErr = { - ok: false; - reason: - | "malformed" - | "bad-alg" - | "bad-signature" - | "expired" - | "not-yet-valid" - | "wrong-space" - | "wrong-scope" - | "unknown-issuer"; -}; - -export interface VerifyOptions { - /** Optional: when set, rejects credentials whose `space` claim differs. - * Omit when verifying in middleware where the target space isn't known - * yet — handlers can do the match themselves against the verified - * claims. */ - expectedSpace?: string; - /** Optional: required scope (e.g. "rw" rejects read-only credentials on writes). */ - requiredScope?: CredentialScope; - /** Resolve a verification key for `iss`. If null, verification fails with - * unknown-issuer. */ - resolveKey: (iss: string, kid: string | undefined) => Promise; - /** Time provider for tests. Returns ms since epoch. */ - now?: () => number; -} - -export async function verifyCredential( - jwt: string, - opts: VerifyOptions -): Promise { - const parts = jwt.split("."); - if (parts.length !== 3) return { ok: false, reason: "malformed" }; - const [headSeg, bodySeg, sigSeg] = parts as [string, string, string]; - - let header: { alg?: string; typ?: string; kid?: string }; - let claims: CredentialClaims; - try { - header = jsonDecode(headSeg); - claims = jsonDecode(bodySeg); - } catch { - return { ok: false, reason: "malformed" }; - } - if (header.alg !== ALG) return { ok: false, reason: "bad-alg" }; - if (opts.expectedSpace !== undefined && claims.space !== opts.expectedSpace) { - return { ok: false, reason: "wrong-space" }; - } - if (opts.requiredScope === "rw" && claims.scope !== "rw") { - return { ok: false, reason: "wrong-scope" }; - } - - const nowMs = (opts.now ?? Date.now)(); - const nowSec = Math.floor(nowMs / 1000); - if (claims.exp <= nowSec) return { ok: false, reason: "expired" }; - if (claims.iat > nowSec + 60) return { ok: false, reason: "not-yet-valid" }; - - const jwk = await opts.resolveKey(claims.iss, header.kid); - if (!jwk) return { ok: false, reason: "unknown-issuer" }; - - const publicKey = await importPublic(jwk); - const sigBytes = base64urlDecode(sigSeg); - const signingInput = `${headSeg}.${bodySeg}`; - const valid = await crypto.subtle.verify( - { name: "ECDSA", hash: "SHA-256" }, - publicKey, - sigBytes, - enc.encode(signingInput) - ); - if (!valid) return { ok: false, reason: "bad-signature" }; - return { ok: true, claims }; -} - -/** Header reader for handlers that want to peek at `iss` before resolving the - * key (e.g. to short-circuit DID-doc fetches for the local authority). */ -export function decodeUnverifiedClaims(jwt: string): CredentialClaims | null { - const parts = jwt.split("."); - if (parts.length !== 3) return null; - try { - return jsonDecode(parts[1]!); - } catch { - return null; - } -} - -/** Verifier interface consumed by the record host. The record host doesn't - * care HOW credentials get verified — it only cares whether a given JWT is - * valid. Phase 3 ships an in-process verifier that knows the local - * authority's public key; phase 4 adds a binding-resolving verifier that - * consults PDS records / DID docs. */ -export interface CredentialVerifier { - /** Verify a credential's signature, expiry, and `not-before` window. Does - * NOT enforce a space match — handlers do that against the request URI. */ - verify(jwt: string): Promise; -} - -/** In-process verifier for the simple deployment: the authority and record - * host run in one process and the record host has direct access to the - * authority's public key. Rejects any credential whose `iss` isn't the - * configured authority. Phase 4 has a more general - * {@link createBindingCredentialVerifier} that does proper binding lookup. */ -export function createInProcessVerifier(args: { - authorityDid: string; - publicKey: JsonWebKey; -}): CredentialVerifier { - return { - verify(jwt) { - return verifyCredential(jwt, { - resolveKey: async (iss) => (iss === args.authorityDid ? args.publicKey : null), - }); - }, - }; -} - -/** Verifier composed of a {@link BindingResolver} (which DID is authorized - * to issue for this space?) and a {@link KeyResolver} (what's that DID's - * public key?). This is the production-shape verifier — phase 4's main - * contribution. - * - * Verification flow: - * 1. Decode the JWT's claims (no signature check yet). - * 2. Ask the binding resolver: who's authorized for `claims.space`? - * 3. Confirm `claims.iss === authorizedDid`. - * 4. Ask the key resolver for that DID's verification key. - * 5. Verify signature + expiry + scope match. - */ -export function createBindingCredentialVerifier(args: { - bindings: import("./binding").BindingResolver; - keys: import("./binding").KeyResolver; -}): CredentialVerifier { - return { - async verify(jwt) { - const peek = decodeUnverifiedClaims(jwt); - if (!peek) return { ok: false, reason: "malformed" }; - const authorizedDid = await args.bindings.resolveAuthority(peek.space); - if (!authorizedDid) return { ok: false, reason: "unknown-issuer" }; - if (peek.iss !== authorizedDid) return { ok: false, reason: "unknown-issuer" }; - return verifyCredential(jwt, { - resolveKey: (iss, kid) => args.keys.resolveKey(iss, kid), - }); - }, - }; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/spaces/in-process.ts b/packages/contrail/src/core/spaces/in-process.ts index ec1d7a1..1129419 100644 --- a/packages/contrail/src/core/spaces/in-process.ts +++ b/packages/contrail/src/core/spaces/in-process.ts @@ -1,34 +1 @@ -/** In-process auth marker. - * - * For same-module callers (e.g. a SvelteKit worker that imports contrail and - * dispatches requests directly to the handler), service-auth JWTs are pure - * overhead: no network boundary is crossed, so there's nothing for the JWT to - * protect against. Instead, the caller tags the `Request` with a principal via - * a module-private WeakMap, and the auth middleware reads it back. - * - * Security note: this is unforgeable from outside the module because - * - WeakMap keys are `Request` object identities, not serialized data; - * - no HTTP request crossing a network boundary can reach into this map; - * - exploiting it requires code execution inside the same isolate, at - * which point auth is already game over. - * - * This is the strongest auth adapter contrail offers — it has no secret to - * leak. */ - -export interface InProcessPrincipal { - did: string; -} - -const PRINCIPALS = new WeakMap(); - -/** Tag a Request with an in-process principal. The returned Request is the - * same reference; the return value is for ergonomics. */ -export function markInProcess(req: Request, did: string): Request { - PRINCIPALS.set(req, { did }); - return req; -} - -/** Read the in-process principal for a Request, or null if unmarked. */ -export function readInProcess(req: Request): InProcessPrincipal | null { - return PRINCIPALS.get(req) ?? null; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/spaces/tid.ts b/packages/contrail/src/core/spaces/tid.ts index c419090..1129419 100644 --- a/packages/contrail/src/core/spaces/tid.ts +++ b/packages/contrail/src/core/spaces/tid.ts @@ -1,20 +1 @@ -const B32_CHARSET = "234567abcdefghijklmnopqrstuvwxyz"; - -let lastTimestamp = 0; -let clockId = Math.floor(Math.random() * 1024); - -/** Generate an atproto TID: 13-char base32-sortable (timestamp-ordered). */ -export function nextTid(): string { - let now = Date.now() * 1000; - if (now <= lastTimestamp) now = lastTimestamp + 1; - lastTimestamp = now; - - const n = BigInt(now) * 1024n + BigInt(clockId); - let s = ""; - let v = n; - for (let i = 0; i < 13; i++) { - s = B32_CHARSET[Number(v & 31n)] + s; - v >>= 5n; - } - return s; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/spaces/types.ts b/packages/contrail/src/core/spaces/types.ts index 6561773..1129419 100644 --- a/packages/contrail/src/core/spaces/types.ts +++ b/packages/contrail/src/core/spaces/types.ts @@ -1,273 +1 @@ -import type { Database } from "../types"; -import type { DidDocumentResolver } from "@atcute/identity-resolver"; -import type { BlobAdapter } from "./blob-adapter"; -import type { CredentialKeyMaterial } from "./credentials"; - -export type AppPolicyMode = "allow" | "deny"; - -export interface AppPolicy { - mode: AppPolicyMode; - apps: string[]; -} - -export interface SpacesBlobsConfig { - /** Bytes backend (R2, S3, in-memory, …). */ - adapter: BlobAdapter; - /** Max blob size in bytes. Defaults to 2 MiB. */ - maxSize?: number; - /** MIME allowlist. If set, only these content types are accepted. */ - accept?: string[]; - /** Orphan blobs (those with no referencing record) are kept this long before - * GC can delete them, to allow upload-then-putRecord flows. - * Defaults to 24 hours. */ - gcOrphanAfterMs?: number; -} - -export const DEFAULT_BLOB_MAX_SIZE = 2 * 1024 * 1024; -export const DEFAULT_BLOB_GC_ORPHAN_AFTER_MS = 24 * 60 * 60 * 1000; - -/** Default credential lifetime. The rough spec calls for 2–4h; we pick the - * lower bound so revocation (kicked-from-space) is observable within 2h. */ -export const DEFAULT_CREDENTIAL_TTL_MS = 2 * 60 * 60 * 1000; - -/** Configuration for the **space authority** role: holds the member list, - * signs credentials, and gates space-management operations. In a fully-split - * deployment, the authority can run in a different process (or even a - * different operator) than the record host. */ -export interface AuthorityConfig { - /** NSID that identifies the kind of space this authority hosts, - * e.g. "tools.atmo.event.space". */ - type: string; - /** Service DID that service-auth tokens must target (aud claim) AND that - * signs credentials it issues (`iss` claim on emitted JWTs). */ - serviceDid: string; - /** Default app policy applied to new spaces. */ - defaultAppPolicy?: AppPolicy; - /** DID document resolver for service-auth JWT verification. - * Defaults to a composite PLC + did:web resolver if omitted. */ - resolver?: DidDocumentResolver; - /** Signing key material for issuing space credentials. When omitted, - * `.space.getCredential` returns 501 NotImplemented and the record - * host's credential-verifying middleware can't be wired up. */ - signing?: CredentialKeyMaterial; - /** Credential lifetime in ms. Defaults to {@link DEFAULT_CREDENTIAL_TTL_MS}. */ - credentialTtlMs?: number; -} - -/** Configuration for the **record host** role: stores per-space records and - * blobs and serves reads. Verifies space credentials (later phases) on - * incoming traffic. */ -export interface RecordHostConfig { - /** Blob-upload backend. When omitted, blob XRPCs are not exposed. */ - blobs?: SpacesBlobsConfig; -} - -/** Spaces config — host an authority, a record host, or both. - * Today both run in one process and most deployments will set both; the - * shape is split now so phase 5 can run them independently without churning - * every consumer's config. */ -export interface SpacesConfig { - /** Space-authority config — member list, credentials (later), space - * management. Required for any space to exist. */ - authority?: AuthorityConfig; - /** Record-host config — record + blob storage. Required for records to be - * written/read on this deployment. */ - recordHost?: RecordHostConfig; -} - -export interface SpaceRow { - uri: string; - ownerDid: string; - type: string; - key: string; - serviceDid: string; - appPolicyRef: string | null; - appPolicy: AppPolicy | null; - createdAt: number; - deletedAt: number | null; -} - -export interface SpaceMemberRow { - spaceUri: string; - did: string; - addedAt: number; - addedBy: string | null; -} - -export interface StoredRecord { - spaceUri: string; - collection: string; - authorDid: string; - rkey: string; - cid: string | null; - record: Record; - createdAt: number; -} - -export interface ListOptions { - byUser?: string; - cursor?: string; - limit?: number; -} - -export interface ListResult { - records: StoredRecord[]; - cursor?: string; -} - -export interface ListSpacesOptions { - type?: string; - ownerDid?: string; - memberDid?: string; - limit?: number; - cursor?: string; -} - -export interface CollectionCount { - collection: string; - count: number; -} - -/** What a token holder can do with this invite. - * - `'join'`: must be redeemed while signed in; redeemer becomes a member. - * - `'read'`: bearer-only — token itself grants read access to the space; cannot be redeemed. - * - `'read-join'`: both — anonymous holders read; signed-in holders may also redeem to join. */ -export type InviteKind = "join" | "read" | "read-join"; - -export interface InviteRow { - tokenHash: string; - spaceUri: string; - kind: InviteKind; - expiresAt: number | null; - maxUses: number | null; - usedCount: number; - createdBy: string; - createdAt: number; - revokedAt: number | null; - note: string | null; -} - -export interface CreateInviteInput { - spaceUri: string; - tokenHash: string; - kind: InviteKind; - expiresAt: number | null; - maxUses: number | null; - createdBy: string; - note: string | null; -} - -export interface RedeemInviteResult { - spaceUri: string; -} - -export interface BlobMetaRow { - spaceUri: string; - cid: string; - mimeType: string; - size: number; - authorDid: string; - createdAt: number; -} - -/** Local cache on the record host: this space is accepted here, and `authority_did` - * is the DID authorized to sign credentials for it. Populated via the - * `recordHost.enroll` endpoint or auto-populated by the authority's - * createSpace when both roles run in one process. */ -export interface EnrollmentRow { - spaceUri: string; - authorityDid: string; - enrolledAt: number; - enrolledBy: string; -} - -export interface ListBlobsOptions { - byUser?: string; - cursor?: string; - limit?: number; -} - -export interface ListBlobsResult { - blobs: BlobMetaRow[]; - cursor?: string; -} - -/** **Space authority** interface — owner of the space's ACL state and - * (eventually) credential issuer. Holds the member list, manages invites, - * governs space lifecycle and app policy. Does NOT touch records or blobs. - * - * In a fully-split deployment this is a separate service; today the - * HostedAdapter implements both this and {@link RecordHost} against one DB. */ -export interface SpaceAuthority { - // Space lifecycle - createSpace(space: Omit): Promise; - getSpace(spaceUri: string): Promise; - listSpaces(options: ListSpacesOptions): Promise<{ spaces: SpaceRow[]; cursor?: string }>; - deleteSpace(spaceUri: string): Promise; - updateSpaceAppPolicy(spaceUri: string, appPolicy: AppPolicy): Promise; - - // Members - addMember(spaceUri: string, did: string, addedBy: string | null): Promise; - removeMember(spaceUri: string, did: string): Promise; - getMember(spaceUri: string, did: string): Promise; - listMembers(spaceUri: string): Promise; - /** Bulk-apply a membership diff. Used only by the community module's reconciler; - * not exposed as an XRPC endpoint. */ - applyMembershipDiff( - spaceUri: string, - adds: string[], - removes: string[], - addedBy: string | null - ): Promise; - - // Invites (token primitive — issued by the authority, scoped to a space) - createInvite(input: CreateInviteInput): Promise; - listInvites(spaceUri: string, options?: { includeRevoked?: boolean }): Promise; - revokeInvite(tokenHash: string): Promise; - /** Look up an invite without consuming it. Used to validate read-token bearer access. */ - getInvite(tokenHash: string): Promise; - /** Atomically mark a join-capable invite as used. Returns the row if usable - * (kind allows join, not expired/revoked/exhausted), null otherwise. */ - redeemInvite(tokenHash: string, now: number): Promise; -} - -/** **Record host** interface — stores records and blobs for a space, plus - * the local enrollment table that decides which spaces this host accepts. - * - * Trust model: the host trusts whatever credential the authority signs, so - * long as the authority is the one named in the local enrollment for this - * space. Enrollment is the consent step — the host owner agrees to spend - * storage on a given space, scoped to a specific authority. */ -export interface RecordHost { - // Enrollment - enroll(input: EnrollmentRow): Promise; - getEnrollment(spaceUri: string): Promise; - listEnrollments(options?: { authorityDid?: string; limit?: number }): Promise; - removeEnrollment(spaceUri: string): Promise; - - // Records - putRecord(record: StoredRecord): Promise; - getRecord(spaceUri: string, collection: string, authorDid: string, rkey: string): Promise; - listRecords(spaceUri: string, collection: string, options?: ListOptions): Promise; - deleteRecord(spaceUri: string, collection: string, authorDid: string, rkey: string): Promise; - listCollections(spaceUri: string, options?: { byUser?: string }): Promise; - - // Blobs (metadata only; bytes live on BlobAdapter) - putBlobMeta(row: BlobMetaRow): Promise; - getBlobMeta(spaceUri: string, cid: string): Promise; - listBlobMeta(spaceUri: string, options?: ListBlobsOptions): Promise; - deleteBlobMeta(spaceUri: string, cid: string): Promise; - /** Find blob rows older than `cutoff` whose CIDs are not referenced in any - * record JSON in this space. Capped at `limit` to bound a single GC pass. */ - findOrphanBlobs(spaceUri: string, cutoff: number, limit: number): Promise; -} - -/** Combined adapter. Used internally where a single object satisfies both - * roles (today's HostedAdapter, the community reconciler, the realtime - * publishing wrapper). Phases 5+ replace consumers of this with two - * injected interfaces. */ -export type StorageAdapter = SpaceAuthority & RecordHost; - -export interface AdapterContext { - db: Database; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/spaces/uri.ts b/packages/contrail/src/core/spaces/uri.ts index e73ec21..1129419 100644 --- a/packages/contrail/src/core/spaces/uri.ts +++ b/packages/contrail/src/core/spaces/uri.ts @@ -1,37 +1 @@ -/** Centralized space URI construction / parsing. - * - * Permissioned spaces are addressed by (ownerDid, type, key) and use the - * `ats://` scheme — distinct from atproto record URIs (`at://`) so the two - * can't be confused at any layer (logs, params, dispatch). Tracks the rough - * spec at https://dholms.leaflet.pub/3mhj6bcqats2o. - * - * Record URIs inside a space are minted by authorDid for index purposes - * (`at:////`); the spec is explicitly undecided - * about authority (user vs space owner), so we don't expose those as a - * canonical record address — they're storage-internal. */ - -export interface SpaceUriParts { - ownerDid: string; - type: string; - key: string; -} - -/** Build a space URI from its three addressing components. */ -export function buildSpaceUri(parts: SpaceUriParts): string { - return `ats://${parts.ownerDid}/${parts.type}/${parts.key}`; -} - -/** Parse a space URI into its components, or null if malformed. */ -export function parseSpaceUri(uri: string): SpaceUriParts | null { - if (!uri.startsWith("ats://")) return null; - const rest = uri.slice("ats://".length); - const [ownerDid, type, key, ...extra] = rest.split("/"); - if (!ownerDid || !type || !key || extra.length > 0) return null; - return { ownerDid, type, key }; -} - -/** Build a record URI under a given author. Used only as a secondary index key - * inside storage — not a canonical address for permissioned records. */ -export function buildRecordUri(authorDid: string, collection: string, rkey: string): string { - return `at://${authorDid}/${collection}/${rkey}`; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/src/core/types.ts b/packages/contrail/src/core/types.ts index 3b9dc48..1129419 100644 --- a/packages/contrail/src/core/types.ts +++ b/packages/contrail/src/core/types.ts @@ -1,513 +1 @@ -import type { SqlDialect } from "./dialect"; - -// Database interface — D1 implements this natively -export interface Database { - prepare(sql: string): Statement; - batch(stmts: Statement[]): Promise; - dialect?: SqlDialect; -} - -export interface Statement { - bind(...values: any[]): Statement; - run(): Promise; - all(): Promise<{ results: T[] }>; - first(): Promise; -} - -// Config types - -export interface QueryableField { - type?: "range"; -} - -export interface RelationConfig { - /** Short name of the child collection (a key in `collections`). */ - collection: string; - field?: string; - match?: "uri" | "did"; - groupBy?: string; - /** Enable materialized count columns on the parent. Defaults to true. */ - count?: boolean; - /** Count distinct values of a field (e.g. "did" for unique users) instead of total records. */ - countDistinct?: string; - /** Pre-resolved group mappings: shortName → full token (e.g. { going: "community.lexicon.calendar.rsvp#going" }). Auto-computed from groupBy if omitted. */ - groups?: Record; -} - -/** A forward reference: this collection's records point at another collection. */ -export interface ReferenceConfig { - /** Short name of the target collection. */ - collection: string; - /** Field on this collection's records containing the target URI. */ - field: string; -} - -export type CustomQueryHandler = ( - db: Database, - params: URLSearchParams, - config: ContrailConfig -) => Promise; - -export interface RecordSource { - joins?: string; - conditions?: string[]; - params?: (string | number)[]; -} - -export type PipelineQueryHandler = ( - db: Database, - params: URLSearchParams, - config: ContrailConfig -) => Promise; - -export interface FeedConfig { - /** Short name of the follow collection. */ - follow: string; - /** Short names of target collections to fan out to. */ - targets: string[]; - /** Max feed items per user (default: 200). Oldest items are pruned after backfill. */ - maxItems?: number; -} - -export const DEFAULT_FEED_MAX_ITEMS = 200; - -export type CollectionMethod = "listRecords" | "getRecord"; -export const DEFAULT_COLLECTION_METHODS: CollectionMethod[] = [ - "listRecords", - "getRecord", -]; - -export interface CollectionConfig { - /** Full NSID of the record type this collection indexes. */ - collection: string; - /** Include this collection in Jetstream ingest / discovery (default true). - * Set false for dependent collections (auto-fetched on demand). */ - discover?: boolean; - queryable?: Record; - relations?: Record; - /** Forward references: fields on this collection's records that point at another collection. */ - references?: Record; - queries?: Record; - pipelineQueries?: Record; - /** FTS5 search fields. Provide an array of field names to enable full-text search. Omit or set to false to disable. */ - searchable?: string[] | false; - /** XRPC methods to emit. Defaults to ['listRecords', 'getRecord']. */ - methods?: CollectionMethod[]; - /** When spaces are enabled globally, emit a parallel spaces_records_ table - * so this collection can also live inside spaces. Defaults to true. */ - allowInSpaces?: boolean; -} - -export interface ProfileConfig { - /** Full NSID of the profile record type. */ - collection: string; - /** Short name used for table/endpoint naming. Defaults to the NSID's last segment. */ - shortName?: string; - rkey?: string; // defaults to "self" -} - -export const DEFAULT_PROFILES: ProfileConfig[] = [ - { collection: "app.bsky.actor.profile", shortName: "profile" }, -]; - -/** Normalize a profiles config entry (string or object) into ProfileConfig. */ -export function normalizeProfileConfig( - p: string | ProfileConfig -): ProfileConfig { - if (typeof p === "string") { - return { collection: p, shortName: deriveShortName(p) }; - } - return { ...p, shortName: p.shortName ?? deriveShortName(p.collection) }; -} - -/** Last NSID segment, used as fallback short name. */ -export function deriveShortName(nsid: string): string { - const parts = nsid.split("."); - return parts[parts.length - 1] ?? nsid; -} - -export const DEFAULT_JETSTREAMS = [ - "wss://jetstream1.us-east.bsky.network", -]; - -export const DEFAULT_RELAYS = [ - "https://relay1.us-east.bsky.network" -]; - -export interface Logger { - log(...args: any[]): void; - warn(...args: any[]): void; - error(...args: any[]): void; -} - -export interface ContrailConfig { - namespace: string; - /** Collections to index, keyed by short name. Short names become endpoint URL segments - * (`..listRecords`) and table suffixes (`records_`). */ - collections: Record; - profiles?: (string | ProfileConfig)[]; - relays?: string[]; - jetstreams?: string[]; - feeds?: Record; - logger?: Logger; - /** Expose the notifyOfUpdate HTTP endpoint. Off by default. - * Set to `true` for open access, or a string to require `Authorization: Bearer `. */ - notify?: boolean | string; - /** Permissioned spaces configuration. When set, the service exposes space XRPCs. */ - spaces?: import("./spaces/types").SpacesConfig; - /** Community module configuration. Typed by the community package via - * declaration merging — contrail core only knows it's "something the - * community package consumes." Set when wiring community via - * `createCommunityIntegration({ ... })`. Requires `spaces.authority`. */ - community?: unknown; - /** Realtime module configuration. When set, the service exposes ticket + SSE/WS - * subscribe XRPCs, and wraps the spaces adapter to publish events after writes. */ - realtime?: import("./realtime/types").RealtimeConfig; - /** Labels module configuration. When set, contrail subscribes to the - * configured labelers, indexes their labels into a single `labels` table, - * and hydrates `record.labels` onto `listRecords` / `getRecord` / profile - * responses gated by the caller's `atproto-accept-labelers` header. */ - labels?: import("./labels/types").LabelsConfig; - /** Customize the auto-generated `.authFull` lexicon. */ - permissionSet?: PermissionSetConfig; -} - -/** Single entry in an atproto permission-set's `permissions` array. - * See https://atproto.com/guides/permission-sets for the full schema. */ -export type PermissionEntry = - | { type: "permission"; resource: "rpc"; lxm?: string[]; aud?: string; inheritAud?: boolean } - | { type: "permission"; resource: "repo"; collection?: string[] } - | { type: "permission"; resource: "blob"; accept?: string[]; maxSize?: number } - | { type: "permission"; resource: "account"; attr?: string[] } - | { type: "permission"; resource: string; [key: string]: unknown }; - -export interface PermissionSetConfig { - /** Shown on the OAuth consent screen. Defaults to the namespace. */ - title?: string; - /** Shown on the OAuth consent screen. Defaults to a generated description. */ - description?: string; - /** Extra permission entries appended after the auto-generated rpc entry — - * e.g. repo writes for collections your app needs the user to create, or - * blob permissions for uploads. */ - additional?: PermissionEntry[]; -} - -export interface ResolvedRelation { - /** Short name of the child collection. */ - collection: string; - groupBy: string; - groups: Record; // shortName → full token value -} - -export interface ResolvedMaps { - queryable: Record>; - relations: Record>; - /** Reverse map: full record NSID → short name. */ - nsidToShort: Record; -} - -/** Config after resolveConfig() — has computed queryable/relation maps attached. */ -export interface ResolvedContrailConfig extends ContrailConfig { - _resolved: ResolvedMaps; -} - -/** - * Resolve config: apply defaults, auto-add profile collections, compute queryable maps. - */ -export function resolveConfig(config: ContrailConfig): ResolvedContrailConfig { - const profiles = (config.profiles ?? DEFAULT_PROFILES).map( - normalizeProfileConfig - ); - const collections = { ...config.collections }; - for (const p of profiles) { - const short = p.shortName!; - if (!collections[short]) { - collections[short] = { collection: p.collection, discover: false }; - } - } - - // Auto-add follow collections from feed configs as dependent collections if they're - // not already listed. Feed config already uses short names so nothing to resolve — - // but if the user forgot to declare the follow collection, we can't auto-add it without - // knowing its NSID. In that case we warn later via validateConfig. - - const base = { - ...config, - collections, - profiles, - jetstreams: config.jetstreams ?? DEFAULT_JETSTREAMS, - relays: config.relays ?? DEFAULT_RELAYS, - logger: config.logger ?? console, - }; - - return { - ...base, - _resolved: _resolveQueryableMaps(base), - }; -} - -function _resolveQueryableMaps(config: ContrailConfig): ResolvedMaps { - const queryable: Record> = {}; - const relations: Record> = {}; - const nsidToShort: Record = {}; - - for (const [short, colConfig] of Object.entries(config.collections)) { - nsidToShort[colConfig.collection] = short; - - if (colConfig.queryable) { - queryable[short] = colConfig.queryable; - } - - if (colConfig.relations) { - for (const [relName, rel] of Object.entries(colConfig.relations)) { - if (!rel.groupBy) continue; - const groups: Record = rel.groups ? { ...rel.groups } : {}; - if (Object.keys(groups).length > 0) { - if (!relations[short]) relations[short] = {}; - relations[short][relName] = { - collection: rel.collection, - groupBy: rel.groupBy, - groups, - }; - } - } - } - } - - return { queryable, relations, nsidToShort }; -} - -export function getFeedFollowShortNames(config: ContrailConfig): string[] { - if (!config.feeds) return []; - return [...new Set(Object.values(config.feeds).map((f) => f.follow))]; -} - -/** Alias for getFeedFollowShortNames. */ -export const getFeedFollowCollections = getFeedFollowShortNames; - -// Record types - -export interface RecordRow { - uri: string; - did: string; - collection: string; // full NSID - rkey: string; - cid: string | null; - record: string | null; - time_us: number; - indexed_at: number; - /** Set when the row originates from a per-space table. Used by the - * pipeline/hydration/response layers to route child queries to the same - * space and tag the output. */ - space?: string; -} - -export interface IngestEvent { - uri: string; - did: string; - collection: string; // full NSID - rkey: string; - operation: "create" | "update" | "delete"; - cid: string | null; - record: string | null; - time_us: number; - indexed_at: number; -} - -// Validation - -const SAFE_FIELD_NAME = /^[a-zA-Z0-9_.]+$/; -const SAFE_SHORT_NAME = /^[a-zA-Z][a-zA-Z0-9]*$/; - -export function validateFieldName(field: string): string { - if (!SAFE_FIELD_NAME.test(field)) { - throw new Error(`Invalid field name: ${field}`); - } - return field; -} - -function validateShortName(short: string): void { - if (!SAFE_SHORT_NAME.test(short)) { - throw new Error( - `Invalid collection short name: "${short}". Must be alphanumeric, starting with a letter.` - ); - } -} - -export function validateConfig(config: ContrailConfig): void { - const shortNames = new Set(); - for (const [short, colConfig] of Object.entries(config.collections)) { - validateShortName(short); - if (shortNames.has(short)) { - throw new Error(`Duplicate collection short name: ${short}`); - } - shortNames.add(short); - - if (!colConfig.collection) { - throw new Error(`Collection "${short}" is missing required 'collection' field (NSID)`); - } - - for (const field of Object.keys(colConfig.queryable ?? {})) { - validateFieldName(field); - } - for (const [, rel] of Object.entries(colConfig.relations ?? {})) { - if (rel.field) validateFieldName(rel.field); - if (rel.groupBy) validateFieldName(rel.groupBy); - if (rel.countDistinct) validateFieldName(rel.countDistinct); - if (!config.collections[rel.collection]) { - throw new Error( - `Relation in "${short}" references unknown collection short name "${rel.collection}"` - ); - } - } - for (const [, ref] of Object.entries(colConfig.references ?? {})) { - validateFieldName(ref.field); - if (!config.collections[ref.collection]) { - throw new Error( - `Reference in "${short}" references unknown collection short name "${ref.collection}"` - ); - } - } - if (Array.isArray(colConfig.searchable)) { - for (const field of colConfig.searchable) { - validateFieldName(field); - } - } - } - - if (config.feeds) { - for (const [feedName, feed] of Object.entries(config.feeds)) { - if (!config.collections[feed.follow]) { - throw new Error( - `Feed "${feedName}" references unknown follow collection "${feed.follow}"` - ); - } - for (const target of feed.targets) { - if (!config.collections[target]) { - throw new Error( - `Feed "${feedName}" references unknown target collection "${target}"` - ); - } - } - } - } - - if (config.community && !config.spaces?.authority) { - throw new Error( - "Invalid config: `community` requires `spaces.authority`. Community-owned spaces reuse the spaces storage adapter." - ); - } -} - -// Helpers - -export function getNestedValue(obj: any, path: string): any { - let current = obj; - for (const key of path.split(".")) { - if (current == null) return undefined; - current = current[key]; - } - return current; -} - -const DEFAULT_RELATION_FIELD = "subject.uri"; - -export function getRelationField(rel: RelationConfig): string { - return rel.field ?? DEFAULT_RELATION_FIELD; -} - -/** Sanitize a short name for use in SQL identifiers (already-validated; kept for paranoia). */ -function sanitizeIdentifier(name: string): string { - return name.replace(/[^a-zA-Z0-9]/g, "_"); -} - -/** Total-count column name for a relation targeting the given short name. */ -export function countColumnName(childShortName: string): string { - return "count_" + sanitizeIdentifier(childShortName); -} - -/** Grouped-count column name: `count__`. */ -export function groupedCountColumnName( - childShortName: string, - groupKey: string -): string { - return `count_${sanitizeIdentifier(childShortName)}_${sanitizeIdentifier(groupKey)}`; -} - -/** Table name for a collection's records. */ -export function recordsTableName(shortName: string): string { - return "records_" + sanitizeIdentifier(shortName); -} - -/** Table name for a collection's records inside spaces. */ -export function spacesRecordsTableName(shortName: string): string { - return "spaces_records_" + sanitizeIdentifier(shortName); -} - -/** All collection short names. */ -export function getCollectionShortNames(config: ContrailConfig): string[] { - return Object.keys(config.collections); -} - -/** Alias: collection short names (same as getCollectionShortNames). */ -export const getCollectionNames = getCollectionShortNames; - -/** All indexed record NSIDs (what Jetstream filters on). */ -export function getCollectionNsids(config: ContrailConfig): string[] { - return Object.values(config.collections).map((c) => c.collection); -} - -export function getDependentShortNames(config: ContrailConfig): string[] { - return Object.entries(config.collections) - .filter(([, c]) => c.discover === false) - .map(([name]) => name); -} - -export function getDiscoverableShortNames(config: ContrailConfig): string[] { - return Object.entries(config.collections) - .filter(([, c]) => c.discover !== false) - .map(([name]) => name); -} - -/** Aliases for readability elsewhere. These return short names (new semantic). */ -export const getDependentCollections = getDependentShortNames; -export const getDiscoverableCollections = getDiscoverableShortNames; - -/** Short names of collections the user declared with `discover !== false`, mapped to NSIDs. */ -export function getDiscoverableNsids(config: ContrailConfig): string[] { - return Object.values(config.collections) - .filter((c) => c.discover !== false) - .map((c) => c.collection); -} - -export function getDependentNsids(config: ContrailConfig): string[] { - return Object.values(config.collections) - .filter((c) => c.discover === false) - .map((c) => c.collection); -} - -/** Short name for a record NSID, if known. */ -export function shortNameForNsid( - config: ContrailConfig, - nsid: string -): string | undefined { - const resolved = (config as ResolvedContrailConfig)._resolved; - if (resolved?.nsidToShort) return resolved.nsidToShort[nsid]; - for (const [short, c] of Object.entries(config.collections)) { - if (c.collection === nsid) return short; - } - return undefined; -} - -/** Full NSID for a collection short name. */ -export function nsidForShortName( - config: ContrailConfig, - short: string -): string | undefined { - return config.collections[short]?.collection; -} - -/** The methods a collection should expose via XRPC. */ -export function getCollectionMethods(cfg: CollectionConfig): CollectionMethod[] { - return cfg.methods ?? DEFAULT_COLLECTION_METHODS; -} +export * from "@atmo-dev/contrail-base"; diff --git a/packages/contrail/vitest.config.ts b/packages/contrail/vitest.config.ts index 04f1a15..0738fd3 100644 --- a/packages/contrail/vitest.config.ts +++ b/packages/contrail/vitest.config.ts @@ -1,4 +1,7 @@ import { defineConfig } from "vitest/config"; +import path from "node:path"; + +const baseSrc = path.resolve(__dirname, "../contrail-base/src"); export default defineConfig({ test: { @@ -6,4 +9,13 @@ export default defineConfig({ // PostgreSQL tests share a single database and cannot run in parallel fileParallelism: false, }, + resolve: { + alias: { + // Resolve workspace-internal contrail-base subpaths to source so tests + // don't run through the dist (where tsup drops `node:` prefixes). + "@atmo-dev/contrail-base/sqlite": path.join(baseSrc, "adapters/sqlite.ts"), + "@atmo-dev/contrail-base/postgres": path.join(baseSrc, "adapters/postgres.ts"), + "@atmo-dev/contrail-base": path.join(baseSrc, "index.ts"), + }, + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ffc2e..01c655a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,6 +376,9 @@ importers: '@atcute/xrpc-server': specifier: ^0.1.12 version: 0.1.12 + '@atmo-dev/contrail-base': + specifier: workspace:* + version: link:../contrail-base cac: specifier: ^7.0.0 version: 7.0.0 @@ -411,6 +414,49 @@ importers: specifier: ^4.63.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) + packages/contrail-base: + dependencies: + '@atcute/atproto': + specifier: ^3.1.10 + version: 3.1.11 + '@atcute/cid': + specifier: ^2.4.1 + version: 2.4.1 + '@atcute/client': + specifier: ^4.2.1 + version: 4.2.1 + '@atcute/identity': + specifier: ^1.1.4 + version: 1.1.4 + '@atcute/identity-resolver': + specifier: ^1.2.2 + version: 1.2.2(@atcute/identity@1.1.4) + '@atcute/lexicons': + specifier: ^1.2.9 + version: 1.3.0 + '@atcute/xrpc-server': + specifier: ^0.1.12 + version: 0.1.12 + hono: + specifier: ^4.12.8 + version: 4.12.15 + devDependencies: + '@types/node': + specifier: ^25.5.0 + version: 25.6.0 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + pg: + specifier: ^8.20.0 + version: 8.20.0 + tsup: + specifier: ^8.5.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/contrail-community: dependencies: '@atcute/atproto': @@ -434,6 +480,9 @@ importers: '@atmo-dev/contrail': specifier: workspace:* version: link:../contrail + '@atmo-dev/contrail-base': + specifier: workspace:* + version: link:../contrail-base hono: specifier: ^4.12.8 version: 4.12.15