From edf66ed0cf948b77d63648f67074a324b084956d Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sun, 3 May 2026 01:35:24 +0200 Subject: [PATCH] phase 7a ^^ --- packages/contrail-authority/package.json | 44 +++ packages/contrail-authority/src/adapter.ts | 317 +++++++++++++++++ packages/contrail-authority/src/index.ts | 19 ++ packages/contrail-authority/src/schema.ts | 52 +++ .../contrail-authority/tsconfig.build.json | 7 + packages/contrail-authority/tsconfig.json | 7 + packages/contrail-authority/tsup.config.ts | 11 + packages/contrail/package.json | 1 + packages/contrail/src/core/spaces/adapter.ts | 318 +----------------- packages/contrail/vitest.config.ts | 6 +- pnpm-lock.yaml | 25 ++ 11 files changed, 505 insertions(+), 302 deletions(-) create mode 100644 packages/contrail-authority/package.json create mode 100644 packages/contrail-authority/src/adapter.ts create mode 100644 packages/contrail-authority/src/index.ts create mode 100644 packages/contrail-authority/src/schema.ts create mode 100644 packages/contrail-authority/tsconfig.build.json create mode 100644 packages/contrail-authority/tsconfig.json create mode 100644 packages/contrail-authority/tsup.config.ts diff --git a/packages/contrail-authority/package.json b/packages/contrail-authority/package.json new file mode 100644 index 0000000..07c0477 --- /dev/null +++ b/packages/contrail-authority/package.json @@ -0,0 +1,44 @@ +{ + "name": "@atmo-dev/contrail-authority", + "version": "0.6.0", + "description": "Default space-authority implementation for contrail — member list, invites, app policy, credential issuance. Contrail's binary-membership ACL flavor; for ladder-style access levels see @atmo-dev/contrail-community.", + "type": "module", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/flo-bit/contrail.git", + "directory": "packages/contrail-authority" + }, + "keywords": [ + "atproto", + "contrail" + ], + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@atcute/cid": "^2.4.1", + "@atcute/lexicons": "^1.2.9", + "@atmo-dev/contrail-base": "workspace:*", + "hono": "^4.12.8" + }, + "devDependencies": { + "tsup": "^8.5.0", + "typescript": "^5.7.3" + }, + "license": "MIT" +} diff --git a/packages/contrail-authority/src/adapter.ts b/packages/contrail-authority/src/adapter.ts new file mode 100644 index 0000000..66ffdfb --- /dev/null +++ b/packages/contrail-authority/src/adapter.ts @@ -0,0 +1,317 @@ +/** Default {@link SpaceAuthority} implementation backed by a Database. + * + * Owns three tables (`spaces`, `spaces_members`, `spaces_invites`) and + * exposes the authority surface: space lifecycle, member list, invite + * storage, app-policy management. + * + * Designed for inheritance — fields are `protected` so a record-host + * adapter (or, transitionally, contrail's all-in-one HostedAdapter) can + * extend this class to add its own methods without re-implementing the + * authority side. */ + +import type { + ContrailConfig, + Database, + SpaceAuthority, + AppPolicy, + CreateInviteInput, + InviteKind, + InviteRow, + ListSpacesOptions, + SpaceMemberRow, + SpaceRow, +} from "@atmo-dev/contrail-base"; + +export function parseJson(value: unknown): T | null { + if (value == null) return null; + if (typeof value === "string") { + try { + return JSON.parse(value) as T; + } catch { + return null; + } + } + return value as T; +} + +export function toNum(v: unknown): number { + return typeof v === "string" ? Number(v) : (v as number); +} + +export function mapSpaceRow(row: any): SpaceRow { + return { + uri: row.uri, + ownerDid: row.owner_did, + type: row.type, + key: row.key, + serviceDid: row.service_did, + appPolicyRef: row.app_policy_ref ?? null, + appPolicy: parseJson(row.app_policy), + createdAt: toNum(row.created_at), + deletedAt: row.deleted_at == null ? null : toNum(row.deleted_at), + }; +} + +export function mapMemberRow(row: any): SpaceMemberRow { + return { + spaceUri: row.space_uri, + did: row.did, + addedAt: toNum(row.added_at), + addedBy: row.added_by ?? null, + }; +} + +export function mapInviteRow(row: any): InviteRow { + return { + tokenHash: row.token_hash, + spaceUri: row.space_uri, + kind: (row.kind ?? "join") as InviteKind, + expiresAt: row.expires_at == null ? null : toNum(row.expires_at), + maxUses: row.max_uses == null ? null : Number(row.max_uses), + usedCount: Number(row.used_count), + createdBy: row.created_by, + createdAt: toNum(row.created_at), + revokedAt: row.revoked_at == null ? null : toNum(row.revoked_at), + note: row.note ?? null, + }; +} + +export class HostedAuthorityAdapter implements SpaceAuthority { + constructor( + protected readonly db: Database, + protected readonly config?: ContrailConfig + ) {} + + async createSpace(space: Omit): Promise { + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO spaces (uri, owner_did, type, key, service_did, app_policy_ref, app_policy, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + space.uri, + space.ownerDid, + space.type, + space.key, + space.serviceDid, + space.appPolicyRef, + space.appPolicy ? JSON.stringify(space.appPolicy) : null, + now + ) + .run(); + return { ...space, createdAt: now, deletedAt: null }; + } + + async getSpace(spaceUri: string): Promise { + const row = await this.db + .prepare(`SELECT * FROM spaces WHERE uri = ? AND deleted_at IS NULL`) + .bind(spaceUri) + .first(); + return row ? mapSpaceRow(row) : null; + } + + async listSpaces(options: ListSpacesOptions): Promise<{ spaces: SpaceRow[]; cursor?: string }> { + const limit = Math.min(options.limit ?? 50, 200); + const clauses: string[] = ["s.deleted_at IS NULL"]; + const params: any[] = []; + let join = ""; + + if (options.type) { + clauses.push("s.type = ?"); + params.push(options.type); + } + if (options.ownerDid) { + clauses.push("s.owner_did = ?"); + params.push(options.ownerDid); + } + if (options.memberDid) { + join = "JOIN spaces_members m ON m.space_uri = s.uri"; + clauses.push("m.did = ?"); + params.push(options.memberDid); + } + if (options.cursor) { + clauses.push("s.created_at < ?"); + params.push(Number(options.cursor)); + } + + const sql = `SELECT s.* FROM spaces s ${join} + WHERE ${clauses.join(" AND ")} + ORDER BY s.created_at DESC + LIMIT ?`; + params.push(limit + 1); + + const { results } = await this.db.prepare(sql).bind(...params).all(); + const spaces = results.map(mapSpaceRow); + let cursor: string | undefined; + if (spaces.length > limit) { + const next = spaces.pop()!; + cursor = String(next.createdAt); + } + return { spaces, cursor }; + } + + async deleteSpace(spaceUri: string): Promise { + await this.db + .prepare(`UPDATE spaces SET deleted_at = ? WHERE uri = ?`) + .bind(Date.now(), spaceUri) + .run(); + } + + async updateSpaceAppPolicy(spaceUri: string, appPolicy: AppPolicy): Promise { + await this.db + .prepare(`UPDATE spaces SET app_policy = ? WHERE uri = ?`) + .bind(JSON.stringify(appPolicy), spaceUri) + .run(); + } + + async addMember(spaceUri: string, did: string, addedBy: string | null): Promise { + await this.db + .prepare( + `INSERT INTO spaces_members (space_uri, did, added_at, added_by) + VALUES (?, ?, ?, ?) + ON CONFLICT (space_uri, did) DO NOTHING` + ) + .bind(spaceUri, did, Date.now(), addedBy) + .run(); + } + + async removeMember(spaceUri: string, did: string): Promise { + await this.db + .prepare(`DELETE FROM spaces_members WHERE space_uri = ? AND did = ?`) + .bind(spaceUri, did) + .run(); + } + + async getMember(spaceUri: string, did: string): Promise { + const row = await this.db + .prepare(`SELECT * FROM spaces_members WHERE space_uri = ? AND did = ?`) + .bind(spaceUri, did) + .first(); + return row ? mapMemberRow(row) : null; + } + + async listMembers(spaceUri: string): Promise { + const { results } = await this.db + .prepare(`SELECT * FROM spaces_members WHERE space_uri = ? ORDER BY added_at ASC`) + .bind(spaceUri) + .all(); + return results.map(mapMemberRow); + } + + async applyMembershipDiff( + spaceUri: string, + adds: string[], + removes: string[], + addedBy: string | null + ): Promise { + const now = Date.now(); + const stmts: any[] = []; + for (const did of adds) { + stmts.push( + this.db + .prepare( + `INSERT INTO spaces_members (space_uri, did, added_at, added_by) + VALUES (?, ?, ?, ?) + ON CONFLICT (space_uri, did) DO NOTHING` + ) + .bind(spaceUri, did, now, addedBy) + ); + } + for (const did of removes) { + stmts.push( + this.db + .prepare(`DELETE FROM spaces_members WHERE space_uri = ? AND did = ?`) + .bind(spaceUri, did) + ); + } + if (stmts.length > 0) { + await this.db.batch(stmts); + } + } + + async createInvite(input: CreateInviteInput): Promise { + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO spaces_invites (token_hash, space_uri, kind, expires_at, max_uses, used_count, created_by, created_at, note) + VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)` + ) + .bind( + input.tokenHash, + input.spaceUri, + input.kind, + input.expiresAt, + input.maxUses, + input.createdBy, + now, + input.note + ) + .run(); + return { + tokenHash: input.tokenHash, + spaceUri: input.spaceUri, + kind: input.kind, + expiresAt: input.expiresAt, + maxUses: input.maxUses, + usedCount: 0, + createdBy: input.createdBy, + createdAt: now, + revokedAt: null, + note: input.note, + }; + } + + async getInvite(tokenHash: string): Promise { + const row = await this.db + .prepare(`SELECT * FROM spaces_invites WHERE token_hash = ?`) + .bind(tokenHash) + .first(); + return row ? mapInviteRow(row) : null; + } + + async listInvites( + spaceUri: string, + options: { includeRevoked?: boolean } = {} + ): Promise { + const sql = options.includeRevoked + ? `SELECT * FROM spaces_invites WHERE space_uri = ? ORDER BY created_at DESC` + : `SELECT * FROM spaces_invites WHERE space_uri = ? AND revoked_at IS NULL ORDER BY created_at DESC`; + const { results } = await this.db.prepare(sql).bind(spaceUri).all(); + return results.map(mapInviteRow); + } + + async revokeInvite(tokenHash: string): Promise { + const res = await this.db + .prepare(`UPDATE spaces_invites SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`) + .bind(Date.now(), tokenHash) + .run(); + const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; + return Number(changes) > 0; + } + + async redeemInvite(tokenHash: string, now: number): Promise { + // Atomic: increment used_count only if the invite is usable right now AND + // its kind allows redemption (read-only tokens cannot be consumed for membership). + const res = await this.db + .prepare( + `UPDATE spaces_invites + SET used_count = used_count + 1 + WHERE token_hash = ? + AND kind IN ('join', 'read-join') + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) + AND (max_uses IS NULL OR used_count < max_uses)` + ) + .bind(tokenHash, now) + .run(); + const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; + if (Number(changes) === 0) return null; + + const row = await this.db + .prepare(`SELECT * FROM spaces_invites WHERE token_hash = ?`) + .bind(tokenHash) + .first(); + return row ? mapInviteRow(row) : null; + } +} diff --git a/packages/contrail-authority/src/index.ts b/packages/contrail-authority/src/index.ts new file mode 100644 index 0000000..638b762 --- /dev/null +++ b/packages/contrail-authority/src/index.ts @@ -0,0 +1,19 @@ +/** @atmo-dev/contrail-authority — default space-authority implementation. + * + * Owns the authority-side adapter (member list, invites, app policy, space + * lifecycle) and DDL. Route registration currently lives in + * @atmo-dev/contrail and will move here in a subsequent extraction pass. */ + +export { + HostedAuthorityAdapter, + parseJson, + toNum, + mapSpaceRow, + mapMemberRow, + mapInviteRow, +} from "./adapter"; + +export { + buildAuthoritySchema, + applyAuthoritySchema, +} from "./schema"; diff --git a/packages/contrail-authority/src/schema.ts b/packages/contrail-authority/src/schema.ts new file mode 100644 index 0000000..e9f19e9 --- /dev/null +++ b/packages/contrail-authority/src/schema.ts @@ -0,0 +1,52 @@ +/** Authority-side DDL: `spaces`, `spaces_members`, `spaces_invites`. */ + +import type { Database, SqlDialect } from "@atmo-dev/contrail-base"; +import { getDialect } from "@atmo-dev/contrail-base"; + +export function buildAuthoritySchema(dialect: SqlDialect): string[] { + return [ + `CREATE TABLE IF NOT EXISTS spaces ( + uri TEXT PRIMARY KEY, + owner_did TEXT NOT NULL, + type TEXT NOT NULL, + key TEXT NOT NULL, + service_did TEXT NOT NULL, + app_policy_ref TEXT, + app_policy ${dialect.recordColumnType}, + created_at ${dialect.bigintType} NOT NULL, + deleted_at ${dialect.bigintType} + )`, + `CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_did)`, + `CREATE INDEX IF NOT EXISTS idx_spaces_type ON spaces(type)`, + + `CREATE TABLE IF NOT EXISTS spaces_members ( + space_uri TEXT NOT NULL, + did TEXT NOT NULL, + added_at ${dialect.bigintType} NOT NULL, + added_by TEXT, + PRIMARY KEY (space_uri, did) + )`, + `CREATE INDEX IF NOT EXISTS idx_spaces_members_did ON spaces_members(did)`, + + `CREATE TABLE IF NOT EXISTS spaces_invites ( + token_hash TEXT PRIMARY KEY, + space_uri TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'join', + expires_at ${dialect.bigintType}, + max_uses INTEGER, + used_count INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL, + created_at ${dialect.bigintType} NOT NULL, + revoked_at ${dialect.bigintType}, + note TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_spaces_invites_space ON spaces_invites(space_uri, created_at DESC)`, + ]; +} + +/** SchemaModule-shaped function suitable for `initSchema({ extraSchemas: [...] })`. */ +export async function applyAuthoritySchema(db: Database): Promise { + const dialect = getDialect(db); + const stmts = buildAuthoritySchema(dialect); + await db.batch(stmts.map((s) => db.prepare(s))); +} diff --git a/packages/contrail-authority/tsconfig.build.json b/packages/contrail-authority/tsconfig.build.json new file mode 100644 index 0000000..6092abf --- /dev/null +++ b/packages/contrail-authority/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/packages/contrail-authority/tsconfig.json b/packages/contrail-authority/tsconfig.json new file mode 100644 index 0000000..6092abf --- /dev/null +++ b/packages/contrail-authority/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/packages/contrail-authority/tsup.config.ts b/packages/contrail-authority/tsup.config.ts new file mode 100644 index 0000000..7d6b7eb --- /dev/null +++ b/packages/contrail-authority/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + tsconfig: "tsconfig.build.json", + external: ["@atmo-dev/contrail-base"], +}); diff --git a/packages/contrail/package.json b/packages/contrail/package.json index 8409809..02a833f 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-authority": "workspace:*", "@atmo-dev/contrail-base": "workspace:*", "cac": "^7.0.0", "hono": "^4.12.8", diff --git a/packages/contrail/src/core/spaces/adapter.ts b/packages/contrail/src/core/spaces/adapter.ts index f80ba5e..50fb33d 100644 --- a/packages/contrail/src/core/spaces/adapter.ts +++ b/packages/contrail/src/core/spaces/adapter.ts @@ -1,4 +1,13 @@ -import type { ContrailConfig, Database, RelationConfig, ResolvedContrailConfig } from "../types"; +/** Contrail's all-in-one default adapter — extends the authority package's + * {@link HostedAuthorityAdapter} (which owns space lifecycle, member list, + * invites) and adds the record-host methods (records, blobs, enrollment). + * + * Phase 7a step 3 will lift the record-host methods into a separate + * HostedRecordHostAdapter, at which point this class becomes a thin + * composition / re-export. For now we keep both roles in one class so + * consumers can wire a single object that satisfies the full StorageAdapter. */ + +import type { ContrailConfig, RelationConfig, ResolvedContrailConfig } from "../types"; import { shortNameForNsid, spacesRecordsTableName, @@ -9,63 +18,19 @@ import { } from "../types"; import { getDialect } from "../dialect"; import type { - AppPolicy, BlobMetaRow, CollectionCount, - CreateInviteInput, EnrollmentRow, - InviteKind, - InviteRow, ListBlobsOptions, ListBlobsResult, ListOptions, ListResult, - ListSpacesOptions, - SpaceMemberRow, - SpaceRow, StorageAdapter, StoredRecord, } from "./types"; +import type { Database } from "../types"; import { buildRecordUri } from "./uri"; - -function parseJson(value: unknown): T | null { - if (value == null) return null; - if (typeof value === "string") { - try { - return JSON.parse(value) as T; - } catch { - return null; - } - } - return value as T; -} - -function toNum(v: unknown): number { - return typeof v === "string" ? Number(v) : (v as number); -} - -function mapSpaceRow(row: any): SpaceRow { - return { - uri: row.uri, - ownerDid: row.owner_did, - type: row.type, - key: row.key, - serviceDid: row.service_did, - appPolicyRef: row.app_policy_ref ?? null, - appPolicy: parseJson(row.app_policy), - createdAt: toNum(row.created_at), - deletedAt: row.deleted_at == null ? null : toNum(row.deleted_at), - }; -} - -function mapMemberRow(row: any): SpaceMemberRow { - return { - spaceUri: row.space_uri, - did: row.did, - addedAt: toNum(row.added_at), - addedBy: row.added_by ?? null, - }; -} +import { HostedAuthorityAdapter, parseJson, toNum } from "@atmo-dev/contrail-authority"; function mapBlobMetaRow(row: any): BlobMetaRow { return { @@ -87,21 +52,6 @@ function mapEnrollmentRow(row: any): EnrollmentRow { }; } -function mapInviteRow(row: any): InviteRow { - return { - tokenHash: row.token_hash, - spaceUri: row.space_uri, - kind: (row.kind ?? "join") as InviteKind, - expiresAt: row.expires_at == null ? null : toNum(row.expires_at), - maxUses: row.max_uses == null ? null : Number(row.max_uses), - usedCount: Number(row.used_count), - createdBy: row.created_by, - createdAt: toNum(row.created_at), - revokedAt: row.revoked_at == null ? null : toNum(row.revoked_at), - note: row.note ?? null, - }; -} - /** Row mapper for per-collection spaces_records_ tables. * `collection` is injected by the caller (known from the table name). */ function mapRecordRow(row: any, collection: string): StoredRecord { @@ -116,12 +66,7 @@ function mapRecordRow(row: any, collection: string): StoredRecord { }; } -export class HostedAdapter implements StorageAdapter { - constructor( - private readonly db: Database, - private readonly config?: ContrailConfig - ) {} - +export class HostedAdapter extends HostedAuthorityAdapter implements StorageAdapter { /** Resolve the per-collection spaces table name, or throw if the collection * isn't configured (and therefore has no table). */ private tableFor(collection: string): string { @@ -139,238 +84,7 @@ export class HostedAdapter implements StorageAdapter { return spacesRecordsTableName(short); } - async createSpace(space: Omit): Promise { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO spaces (uri, owner_did, type, key, service_did, app_policy_ref, app_policy, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - space.uri, - space.ownerDid, - space.type, - space.key, - space.serviceDid, - space.appPolicyRef, - space.appPolicy ? JSON.stringify(space.appPolicy) : null, - now - ) - .run(); - return { ...space, createdAt: now, deletedAt: null }; - } - - async getSpace(spaceUri: string): Promise { - const row = await this.db - .prepare(`SELECT * FROM spaces WHERE uri = ? AND deleted_at IS NULL`) - .bind(spaceUri) - .first(); - return row ? mapSpaceRow(row) : null; - } - - async listSpaces(options: ListSpacesOptions): Promise<{ spaces: SpaceRow[]; cursor?: string }> { - const limit = Math.min(options.limit ?? 50, 200); - const clauses: string[] = ["s.deleted_at IS NULL"]; - const params: any[] = []; - let join = ""; - - if (options.type) { - clauses.push("s.type = ?"); - params.push(options.type); - } - if (options.ownerDid) { - clauses.push("s.owner_did = ?"); - params.push(options.ownerDid); - } - if (options.memberDid) { - join = "JOIN spaces_members m ON m.space_uri = s.uri"; - clauses.push("m.did = ?"); - params.push(options.memberDid); - } - if (options.cursor) { - clauses.push("s.created_at < ?"); - params.push(Number(options.cursor)); - } - - const sql = `SELECT s.* FROM spaces s ${join} - WHERE ${clauses.join(" AND ")} - ORDER BY s.created_at DESC - LIMIT ?`; - params.push(limit + 1); - - const { results } = await this.db.prepare(sql).bind(...params).all(); - const spaces = results.map(mapSpaceRow); - let cursor: string | undefined; - if (spaces.length > limit) { - const next = spaces.pop()!; - cursor = String(next.createdAt); - } - return { spaces, cursor }; - } - - async deleteSpace(spaceUri: string): Promise { - await this.db - .prepare(`UPDATE spaces SET deleted_at = ? WHERE uri = ?`) - .bind(Date.now(), spaceUri) - .run(); - } - - async updateSpaceAppPolicy(spaceUri: string, appPolicy: AppPolicy): Promise { - await this.db - .prepare(`UPDATE spaces SET app_policy = ? WHERE uri = ?`) - .bind(JSON.stringify(appPolicy), spaceUri) - .run(); - } - - async addMember(spaceUri: string, did: string, addedBy: string | null): Promise { - await this.db - .prepare( - `INSERT INTO spaces_members (space_uri, did, added_at, added_by) - VALUES (?, ?, ?, ?) - ON CONFLICT (space_uri, did) DO NOTHING` - ) - .bind(spaceUri, did, Date.now(), addedBy) - .run(); - } - - async removeMember(spaceUri: string, did: string): Promise { - await this.db - .prepare(`DELETE FROM spaces_members WHERE space_uri = ? AND did = ?`) - .bind(spaceUri, did) - .run(); - } - - async getMember(spaceUri: string, did: string): Promise { - const row = await this.db - .prepare(`SELECT * FROM spaces_members WHERE space_uri = ? AND did = ?`) - .bind(spaceUri, did) - .first(); - return row ? mapMemberRow(row) : null; - } - - async listMembers(spaceUri: string): Promise { - const { results } = await this.db - .prepare(`SELECT * FROM spaces_members WHERE space_uri = ? ORDER BY added_at ASC`) - .bind(spaceUri) - .all(); - return results.map(mapMemberRow); - } - - async applyMembershipDiff( - spaceUri: string, - adds: string[], - removes: string[], - addedBy: string | null - ): Promise { - const now = Date.now(); - const stmts: any[] = []; - for (const did of adds) { - stmts.push( - this.db - .prepare( - `INSERT INTO spaces_members (space_uri, did, added_at, added_by) - VALUES (?, ?, ?, ?) - ON CONFLICT (space_uri, did) DO NOTHING` - ) - .bind(spaceUri, did, now, addedBy) - ); - } - for (const did of removes) { - stmts.push( - this.db - .prepare(`DELETE FROM spaces_members WHERE space_uri = ? AND did = ?`) - .bind(spaceUri, did) - ); - } - if (stmts.length > 0) { - await this.db.batch(stmts); - } - } - - async createInvite(input: CreateInviteInput): Promise { - const now = Date.now(); - await this.db - .prepare( - `INSERT INTO spaces_invites (token_hash, space_uri, kind, expires_at, max_uses, used_count, created_by, created_at, note) - VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)` - ) - .bind( - input.tokenHash, - input.spaceUri, - input.kind, - input.expiresAt, - input.maxUses, - input.createdBy, - now, - input.note - ) - .run(); - return { - tokenHash: input.tokenHash, - spaceUri: input.spaceUri, - kind: input.kind, - expiresAt: input.expiresAt, - maxUses: input.maxUses, - usedCount: 0, - createdBy: input.createdBy, - createdAt: now, - revokedAt: null, - note: input.note, - }; - } - - async getInvite(tokenHash: string): Promise { - const row = await this.db - .prepare(`SELECT * FROM spaces_invites WHERE token_hash = ?`) - .bind(tokenHash) - .first(); - return row ? mapInviteRow(row) : null; - } - - async listInvites( - spaceUri: string, - options: { includeRevoked?: boolean } = {} - ): Promise { - const sql = options.includeRevoked - ? `SELECT * FROM spaces_invites WHERE space_uri = ? ORDER BY created_at DESC` - : `SELECT * FROM spaces_invites WHERE space_uri = ? AND revoked_at IS NULL ORDER BY created_at DESC`; - const { results } = await this.db.prepare(sql).bind(spaceUri).all(); - return results.map(mapInviteRow); - } - - async revokeInvite(tokenHash: string): Promise { - const res = await this.db - .prepare(`UPDATE spaces_invites SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`) - .bind(Date.now(), tokenHash) - .run(); - const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; - return Number(changes) > 0; - } - - async redeemInvite(tokenHash: string, now: number): Promise { - // Atomic: increment used_count only if the invite is usable right now AND - // its kind allows redemption (read-only tokens cannot be consumed for membership). - const res = await this.db - .prepare( - `UPDATE spaces_invites - SET used_count = used_count + 1 - WHERE token_hash = ? - AND kind IN ('join', 'read-join') - AND revoked_at IS NULL - AND (expires_at IS NULL OR expires_at > ?) - AND (max_uses IS NULL OR used_count < max_uses)` - ) - .bind(tokenHash, now) - .run(); - const changes = (res as any)?.changes ?? (res as any)?.meta?.changes ?? 0; - if (Number(changes) === 0) return null; - - const row = await this.db - .prepare(`SELECT * FROM spaces_invites WHERE token_hash = ?`) - .bind(tokenHash) - .first(); - return row ? mapInviteRow(row) : null; - } + // ---- Enrollment ---- async enroll(input: EnrollmentRow): Promise { await this.db @@ -421,6 +135,8 @@ export class HostedAdapter implements StorageAdapter { .run(); } + // ---- Records ---- + async putRecord(record: StoredRecord): Promise { const table = this.tableFor(record.collection); const uri = buildRecordUri(record.authorDid, record.collection, record.rkey); @@ -675,6 +391,8 @@ export class HostedAdapter implements StorageAdapter { return results; } + // ---- Blobs ---- + async putBlobMeta(row: BlobMetaRow): Promise { const sql = `INSERT INTO spaces_blobs (space_uri, cid, mime_type, size, author_did, created_at) VALUES (?, ?, ?, ?, ?, ?) diff --git a/packages/contrail/vitest.config.ts b/packages/contrail/vitest.config.ts index 0738fd3..9c32af0 100644 --- a/packages/contrail/vitest.config.ts +++ b/packages/contrail/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; const baseSrc = path.resolve(__dirname, "../contrail-base/src"); +const authoritySrc = path.resolve(__dirname, "../contrail-authority/src"); export default defineConfig({ test: { @@ -11,11 +12,12 @@ export default defineConfig({ }, resolve: { alias: { - // Resolve workspace-internal contrail-base subpaths to source so tests - // don't run through the dist (where tsup drops `node:` prefixes). + // Resolve workspace-internal contrail-* subpaths to source so tests + // don't run through the dists (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"), + "@atmo-dev/contrail-authority": path.join(authoritySrc, "index.ts"), }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01c655a..6e26e5a 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-authority': + specifier: workspace:* + version: link:../contrail-authority '@atmo-dev/contrail-base': specifier: workspace:* version: link:../contrail-base @@ -414,6 +417,28 @@ importers: specifier: ^4.63.0 version: 4.84.1(@cloudflare/workers-types@4.20260424.1) + packages/contrail-authority: + dependencies: + '@atcute/cid': + specifier: ^2.4.1 + version: 2.4.1 + '@atcute/lexicons': + specifier: ^1.2.9 + version: 1.3.0 + '@atmo-dev/contrail-base': + specifier: workspace:* + version: link:../contrail-base + hono: + specifier: ^4.12.8 + version: 4.12.15 + devDependencies: + 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-base: dependencies: '@atcute/atproto': -- 2.51.2