diff --git a/packages/cli/src/commands/coordinator.ts b/packages/cli/src/commands/coordinator.ts index cfe84dcf..1e8aa8f9 100644 --- a/packages/cli/src/commands/coordinator.ts +++ b/packages/cli/src/commands/coordinator.ts @@ -561,6 +561,7 @@ export function buildCoordinatorCommand(): Command { .argument("", "group id") .argument("", "Sharing domain scope_id") .argument("", "device id") + .requiredOption("--effect-id ", "deterministic mutation effect id") .option("--role ", "membership role") .option("--membership-epoch ", "membership epoch") .option("--manifest-hash ", "membership manifest hash") @@ -574,6 +575,7 @@ export function buildCoordinatorCommand(): Command { scopeId: string, deviceId: string, opts: { + effectId: string; role?: string; membershipEpoch?: string; manifestHash?: string; @@ -586,6 +588,7 @@ export function buildCoordinatorCommand(): Command { ) => { try { const membership = await coordinatorGrantScopeMembershipAction({ + effectId: opts.effectId, groupId, scopeId, deviceId, @@ -626,6 +629,7 @@ export function buildCoordinatorCommand(): Command { .argument("", "group id") .argument("", "Sharing domain scope_id") .argument("", "device id") + .requiredOption("--effect-id ", "deterministic mutation effect id") .option("--membership-epoch ", "membership epoch") .option("--manifest-hash ", "membership manifest hash") .option("--remote-url ", "remote coordinator URL override") @@ -638,6 +642,7 @@ export function buildCoordinatorCommand(): Command { scopeId: string, deviceId: string, opts: { + effectId: string; membershipEpoch?: string; manifestHash?: string; remoteUrl?: string; @@ -649,6 +654,7 @@ export function buildCoordinatorCommand(): Command { ) => { try { const ok = await coordinatorRevokeScopeMembershipAction({ + effectId: opts.effectId, groupId, scopeId, deviceId, diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index a5725995..15eed008 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -16,6 +16,7 @@ import { maintenanceWorkerPidFilePath, pickViewerPidCandidate, prepareViewerDatabase, + runServeCoordinatorMaintenance, sqliteVecFailureDiagnostics, terminateTrustedMaintenanceWorker, terminateTrustedViewerPid, @@ -186,6 +187,39 @@ describe("serve command option resolution", () => { ]); }); + it("runs recipient-policy maintenance only after existing share maintenance", async () => { + const calls: string[] = []; + const store = {} as MemoryStore; + const result = await runServeCoordinatorMaintenance(store, { + advancePendingProjectShares: vi.fn(async (_store, options) => { + calls.push(`shares:${options.limit}`); + return { processed: 1, failed: 0 }; + }), + reconcileRecipientPolicyProjects: vi.fn(async (_store, options) => { + calls.push(`policies:${options.limit}`); + return { processed: 2, failed: 1 }; + }), + }); + + expect(calls).toEqual(["shares:3", "policies:3"]); + expect(result).toEqual({ + projectShares: { processed: 1, failed: 0 }, + recipientPolicies: { processed: 2, failed: 1 }, + }); + }); + + it("does not start policy reconciliation when old share maintenance fails", async () => { + const reconcileRecipientPolicyProjects = vi.fn(async () => ({ processed: 0, failed: 0 })); + + await expect( + runServeCoordinatorMaintenance({} as MemoryStore, { + advancePendingProjectShares: vi.fn(async () => ({ processed: 2, failed: 1 })), + reconcileRecipientPolicyProjects, + }), + ).rejects.toThrow("share operation maintenance failed for 1 of 2 operations"); + expect(reconcileRecipientPolicyProjects).not.toHaveBeenCalled(); + }); + it("detects sqlite-vec load errors for viewer startup fallback", () => { expect(isSqliteVecLoadFailure(new Error("sqlite-vec loaded but version check failed"))).toBe( true, diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 6acbf374..9f194026 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -515,6 +515,36 @@ export function sqliteVecFailureDiagnostics(error: unknown, dbPath: string): str ]; } +export interface ServeCoordinatorMaintenanceResult { + projectShares: { processed: number; failed: number }; + recipientPolicies: { processed: number; failed: number }; +} + +export async function runServeCoordinatorMaintenance( + store: MemoryStore, + dependencies: { + advancePendingProjectShares: ( + store: MemoryStore, + options: { limit: number }, + ) => Promise<{ processed: number; failed: number }>; + reconcileRecipientPolicyProjects: ( + store: MemoryStore, + options: { limit: number }, + ) => Promise<{ processed: number; failed: number }>; + }, +): Promise { + const projectShares = await dependencies.advancePendingProjectShares(store, { limit: 3 }); + if (projectShares.failed > 0) { + throw new Error( + `share operation maintenance failed for ${projectShares.failed} of ${projectShares.processed} operations`, + ); + } + const recipientPolicies = await dependencies.reconcileRecipientPolicyProjects(store, { + limit: 3, + }); + return { projectShares, recipientPolicies }; +} + async function startBackgroundViewer(invocation: ResolvedServeInvocation): Promise { warnIfViewerExposed(invocation.host, invocation.port); if (await isPortOpen(invocation.host, invocation.port)) { @@ -552,8 +582,14 @@ async function startBackgroundViewer(invocation: ResolvedServeInvocation): Promi } async function startForegroundViewer(invocation: ResolvedServeInvocation): Promise { - const { advancePendingProjectShares, createApp, createSyncApp, closeStore, getStore } = - await import("@codemem/server"); + const { + advancePendingProjectShares, + createApp, + createSyncApp, + closeStore, + getStore, + reconcileRecipientPolicyProjects, + } = await import("@codemem/server"); const { serve } = await import("@hono/node-server"); if (invocation.dbPath) process.env.CODEMEM_DB = invocation.dbPath; @@ -677,12 +713,10 @@ async function startForegroundViewer(invocation: ResolvedServeInvocation): Promi // silently falling back to the built-in default ruleset. scanner: store.scanner, onAfterCoordinatorRefresh: async () => { - const result = await advancePendingProjectShares(store, { limit: 3 }); - if (result.failed > 0) { - throw new Error( - `share operation maintenance failed for ${result.failed} of ${result.processed} operations`, - ); - } + await runServeCoordinatorMaintenance(store, { + advancePendingProjectShares, + reconcileRecipientPolicyProjects, + }); }, onPhaseChange: (phase) => { if (phase === "running") { diff --git a/packages/cli/src/commands/sync.test.ts b/packages/cli/src/commands/sync.test.ts index 574789ba..ed19d7e0 100644 --- a/packages/cli/src/commands/sync.test.ts +++ b/packages/cli/src/commands/sync.test.ts @@ -265,6 +265,8 @@ describe("formatSyncAttempt", () => { "team-a", "scope-acme", "device-a", + "--effect-id", + "cli:scope-acme:device-a:grant", "--role", "admin", "--db-path", @@ -315,6 +317,8 @@ describe("formatSyncAttempt", () => { "team-a", "missing-scope", "device-a", + "--effect-id", + "cli:missing-scope:device-a:grant", "--db-path", dbPath, "--json", diff --git a/packages/cloudflare-coordinator-worker/migrations/0010_add_scope_membership_effect_receipts.sql b/packages/cloudflare-coordinator-worker/migrations/0010_add_scope_membership_effect_receipts.sql new file mode 100644 index 00000000..f2e7c257 --- /dev/null +++ b/packages/cloudflare-coordinator-worker/migrations/0010_add_scope_membership_effect_receipts.sql @@ -0,0 +1,23 @@ +ALTER TABLE coordinator_scope_membership_audit_log ADD COLUMN effect_id TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_coordinator_scope_membership_audit_effect +ON coordinator_scope_membership_audit_log(effect_id) WHERE effect_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS coordinator_scope_membership_effect_receipts ( + effect_id TEXT PRIMARY KEY, + action TEXT NOT NULL CHECK (action IN ('grant', 'revoke')), + request_json TEXT NOT NULL, + outcome_applied INTEGER NOT NULL CHECK (outcome_applied IN (0, 1)), + scope_id TEXT NOT NULL, + device_id TEXT NOT NULL, + role TEXT, + status TEXT, + membership_epoch INTEGER, + coordinator_id TEXT, + group_id TEXT, + manifest_issuer_device_id TEXT, + manifest_hash TEXT, + signed_manifest_json TEXT, + updated_at TEXT, + created_at TEXT NOT NULL +); diff --git a/packages/cloudflare-coordinator-worker/schema.sql b/packages/cloudflare-coordinator-worker/schema.sql index c129c359..0798e8f1 100644 --- a/packages/cloudflare-coordinator-worker/schema.sql +++ b/packages/cloudflare-coordinator-worker/schema.sql @@ -157,6 +157,7 @@ ON coordinator_scope_memberships(coordinator_id, group_id); CREATE TABLE IF NOT EXISTS coordinator_scope_membership_audit_log ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, + effect_id TEXT, action TEXT NOT NULL, scope_id TEXT NOT NULL, device_id TEXT NOT NULL, @@ -180,6 +181,28 @@ ON coordinator_scope_membership_audit_log(scope_id, created_at, event_id); CREATE INDEX IF NOT EXISTS idx_coordinator_scope_membership_audit_device_created ON coordinator_scope_membership_audit_log(device_id, created_at, event_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_coordinator_scope_membership_audit_effect +ON coordinator_scope_membership_audit_log(effect_id) WHERE effect_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS coordinator_scope_membership_effect_receipts ( + effect_id TEXT PRIMARY KEY, + action TEXT NOT NULL CHECK (action IN ('grant', 'revoke')), + request_json TEXT NOT NULL, + outcome_applied INTEGER NOT NULL CHECK (outcome_applied IN (0, 1)), + scope_id TEXT NOT NULL, + device_id TEXT NOT NULL, + role TEXT, + status TEXT, + membership_epoch INTEGER, + coordinator_id TEXT, + group_id TEXT, + manifest_issuer_device_id TEXT, + manifest_hash TEXT, + signed_manifest_json TEXT, + updated_at TEXT, + created_at TEXT NOT NULL +); + CREATE UNIQUE INDEX IF NOT EXISTS idx_coordinator_reciprocal_pending_pair ON coordinator_reciprocal_approvals(group_id, pending_pair_low_device_id, pending_pair_high_device_id) WHERE status = 'pending'; diff --git a/packages/cloudflare-coordinator-worker/src/index.test.ts b/packages/cloudflare-coordinator-worker/src/index.test.ts index 33f96cc1..27992f94 100644 --- a/packages/cloudflare-coordinator-worker/src/index.test.ts +++ b/packages/cloudflare-coordinator-worker/src/index.test.ts @@ -89,6 +89,8 @@ describe("createCloudflareCoordinatorWorker", () => { tmpDir = mkdtempSync(join(tmpdir(), "cloudflare-coord-worker-test-")); db = connectCoordinator(join(tmpDir, "coordinator.sqlite")); db.exec(` + DROP TABLE IF EXISTS coordinator_scope_membership_effect_receipts; + DROP TABLE IF EXISTS coordinator_scope_membership_audit_log; DROP TABLE IF EXISTS coordinator_scope_memberships; DROP TABLE IF EXISTS coordinator_scopes; DROP TABLE IF EXISTS coordinator_reciprocal_approvals; @@ -104,6 +106,54 @@ describe("createCloudflareCoordinatorWorker", () => { d1db = new SqliteD1Database(db); }); + it("migration 0010 preserves audit history and adds immutable effect receipts", () => { + db.exec("DROP TABLE coordinator_scope_membership_effect_receipts"); + db.exec("DROP INDEX idx_coordinator_scope_membership_audit_effect"); + db.exec("ALTER TABLE coordinator_scope_membership_audit_log RENAME TO audit_with_effect"); + db.exec(` + CREATE TABLE coordinator_scope_membership_audit_log ( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + scope_id TEXT NOT NULL, + device_id TEXT NOT NULL, + role TEXT, + status TEXT NOT NULL, + membership_epoch INTEGER NOT NULL, + previous_role TEXT, + previous_status TEXT, + previous_membership_epoch INTEGER, + coordinator_id TEXT, + group_id TEXT, + actor_type TEXT, + actor_id TEXT, + manifest_hash TEXT, + created_at TEXT NOT NULL + ); + INSERT INTO coordinator_scope_membership_audit_log( + action, scope_id, device_id, status, membership_epoch, created_at + ) VALUES ('grant', 'scope-a', 'device-a', 'active', 1, '2026-07-21T00:00:00Z'); + DROP TABLE audit_with_effect; + `); + const migration = readFileSync( + join(import.meta.dirname, "../migrations/0010_add_scope_membership_effect_receipts.sql"), + "utf8", + ); + + db.exec(migration); + + expect( + db.prepare("SELECT action, effect_id FROM coordinator_scope_membership_audit_log").get(), + ).toEqual({ action: "grant", effect_id: null }); + expect( + db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'coordinator_scope_membership_effect_receipts'", + ) + .pluck() + .get(), + ).toBe("coordinator_scope_membership_effect_receipts"); + }); + afterEach(() => { db.close(); rmSync(tmpDir, { recursive: true, force: true }); diff --git a/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts b/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts index 31a33683..d8d8fb49 100644 --- a/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts +++ b/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts @@ -72,6 +72,7 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: describe("workers vitest + local D1 validation", () => { afterEach(async () => { await env.COORDINATOR_DB.prepare("DELETE FROM coordinator_bootstrap_grants").run(); + await env.COORDINATOR_DB.prepare("DELETE FROM coordinator_scope_membership_effect_receipts").run(); await env.COORDINATOR_DB.prepare("DELETE FROM coordinator_scope_membership_audit_log").run(); await env.COORDINATOR_DB.prepare("DELETE FROM coordinator_scope_memberships").run(); await env.COORDINATOR_DB.prepare("DELETE FROM coordinator_scopes").run(); @@ -530,6 +531,7 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: method: "POST", headers: adminHeaders, body: JSON.stringify({ + effect_id: "worker:scope-a:grant:device", device_id: device.deviceId, role: "reader", membership_epoch: 3, @@ -564,7 +566,11 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: { method: "POST", headers: adminHeaders, - body: JSON.stringify({ membership_epoch: 4, memory_payload: { id: 1 } }), + body: JSON.stringify({ + effect_id: "worker:scope-a:revoke:device", + membership_epoch: 4, + memory_payload: { id: 1 }, + }), }, ); expect(revokeRes.status).toBe(200); @@ -635,7 +641,7 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: ]); const auditRows = await env.COORDINATOR_DB.prepare( - `SELECT action, scope_id, device_id, status, membership_epoch, + `SELECT effect_id, action, scope_id, device_id, status, membership_epoch, previous_status, previous_membership_epoch, actor_type, actor_id FROM coordinator_scope_membership_audit_log WHERE scope_id = ? @@ -645,6 +651,7 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: .all>(); expect(auditRows.results).toEqual([ expect.objectContaining({ + effect_id: "worker:scope-a:grant:device", action: "grant", scope_id: "scope-a", device_id: device.deviceId, @@ -656,6 +663,7 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: actor_id: "admin-worker", }), expect.objectContaining({ + effect_id: "worker:scope-a:revoke:device", action: "revoke", scope_id: "scope-a", device_id: device.deviceId, @@ -738,6 +746,7 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: "X-Codemem-Coordinator-Admin": "test-secret", }, body: JSON.stringify({ + effect_id: `worker:managed-project:grant:${device.deviceId}:1`, device_id: device.deviceId, role: "member", membership_epoch: 1, @@ -747,6 +756,38 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: expect((await grant()).status).toBe(201); expect((await grant()).status).toBe(201); } + const conflictingEffect = await exports.default.fetch( + "https://example.com/v1/admin/groups/g1/scopes/managed-project%3Adeterministic/members", + { + method: "POST", + headers: { + "content-type": "application/json", + "X-Codemem-Coordinator-Admin": "test-secret", + }, + body: JSON.stringify({ + effect_id: `worker:managed-project:grant:${reviewed[0]?.deviceId}:1`, + device_id: reviewed[0]?.deviceId, + role: "admin", + membership_epoch: 1, + }), + }, + ); + expect(conflictingEffect.status).toBe(409); + expect(await conflictingEffect.json()).toEqual({ error: "scope_membership_effect_conflict" }); + expect( + await env.COORDINATOR_DB.prepare( + "SELECT COUNT(*) AS count FROM coordinator_scope_membership_effect_receipts WHERE scope_id = ?", + ) + .bind("managed-project:deterministic") + .first<{ count: number }>(), + ).toEqual({ count: reviewed.length }); + expect( + await env.COORDINATOR_DB.prepare( + "SELECT COUNT(*) AS count FROM coordinator_scope_membership_audit_log WHERE scope_id = ?", + ) + .bind("managed-project:deterministic") + .first<{ count: number }>(), + ).toEqual({ count: reviewed.length }); const listed = await exports.default.fetch( "https://example.com/v1/admin/groups/g1/scopes/managed-project%3Adeterministic/members", { headers: { "X-Codemem-Coordinator-Admin": "test-secret" } }, diff --git a/packages/core/src/better-sqlite-coordinator-store.ts b/packages/core/src/better-sqlite-coordinator-store.ts index 53d62a53..a087b78e 100644 --- a/packages/core/src/better-sqlite-coordinator-store.ts +++ b/packages/core/src/better-sqlite-coordinator-store.ts @@ -16,6 +16,15 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { Database as DatabaseType } from "better-sqlite3"; import Database from "better-sqlite3"; +import { + assertMatchingMembershipEffectReceipt, + type CoordinatorMembershipEffectReceipt, + CoordinatorMembershipError, + grantMembershipEffectRequestJson, + membershipFromEffectReceipt, + normalizeMembershipEffectId, + revokeMembershipEffectRequestJson, +} from "./coordinator-membership-effects.js"; import type { CoordinatorBootstrapGrant, CoordinatorConsumeProjectInviteInput, @@ -328,7 +337,7 @@ function normalizeGrantInput( throw new Error("membership coordinatorId must match the scope coordinatorId."); } if (groupId && groupId !== scope?.group_id) { - throw new Error("membership groupId must match the scope groupId."); + throw new CoordinatorMembershipError("scope_group_mismatch"); } const requestedEpoch = opts.membershipEpoch == null ? null : normalizeEpoch(opts.membershipEpoch); const inheritedEpoch = scope?.membership_epoch ?? 0; @@ -375,6 +384,7 @@ function normalizeAuditLimit(limit: number | null | undefined): number { function insertMembershipAuditSync( db: DatabaseType, input: { + effectId: string; action: "grant" | "revoke"; current: CoordinatorScopeMembership; previous: CoordinatorScopeMembership | null; @@ -384,10 +394,11 @@ function insertMembershipAuditSync( }, ): void { db.prepare(`INSERT INTO coordinator_scope_membership_audit_log( - action, scope_id, device_id, role, status, membership_epoch, + effect_id, action, scope_id, device_id, role, status, membership_epoch, previous_role, previous_status, previous_membership_epoch, coordinator_id, group_id, actor_type, actor_id, manifest_hash, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + input.effectId, input.action, input.current.scope_id, input.current.device_id, @@ -406,6 +417,56 @@ function insertMembershipAuditSync( ); } +function getMembershipEffectReceiptSync( + db: DatabaseType, + effectId: string, +): CoordinatorMembershipEffectReceipt | null { + const row = db + .prepare(`SELECT effect_id, action, request_json, outcome_applied, scope_id, device_id, + role, status, membership_epoch, coordinator_id, group_id, manifest_issuer_device_id, + manifest_hash, signed_manifest_json, updated_at, created_at + FROM coordinator_scope_membership_effect_receipts WHERE effect_id = ?`) + .get(effectId); + return row ? rowToRecord(row) : null; +} + +function insertMembershipEffectReceiptSync( + db: DatabaseType, + input: { + effectId: string; + action: "grant" | "revoke"; + requestJson: string; + applied: boolean; + scopeId: string; + deviceId: string; + membership: CoordinatorScopeMembership | null; + createdAt: string; + }, +): void { + db.prepare(`INSERT INTO coordinator_scope_membership_effect_receipts( + effect_id, action, request_json, outcome_applied, scope_id, device_id, + role, status, membership_epoch, coordinator_id, group_id, manifest_issuer_device_id, + manifest_hash, signed_manifest_json, updated_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + input.effectId, + input.action, + input.requestJson, + input.applied ? 1 : 0, + input.scopeId, + input.deviceId, + input.membership?.role ?? null, + input.membership?.status ?? null, + input.membership?.membership_epoch ?? null, + input.membership?.coordinator_id ?? null, + input.membership?.group_id ?? null, + input.membership?.manifest_issuer_device_id ?? null, + input.membership?.manifest_hash ?? null, + input.membership?.signed_manifest_json ?? null, + input.membership?.updated_at ?? null, + input.createdAt, + ); +} + function assertScopeMembershipDeviceEnrolled( db: DatabaseType, groupId: string | null, @@ -416,7 +477,7 @@ function assertScopeMembershipDeviceEnrolled( .prepare("SELECT 1 FROM enrolled_devices WHERE group_id = ? AND device_id = ? AND enabled = 1") .get(groupId, deviceId); if (!row) { - throw new Error("device must be enrolled and enabled in the scope group."); + throw new CoordinatorMembershipError("device_not_enrolled"); } } @@ -597,6 +658,7 @@ function initializeSchema(db: DatabaseType): void { CREATE TABLE IF NOT EXISTS coordinator_scope_membership_audit_log ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, + effect_id TEXT, action TEXT NOT NULL, scope_id TEXT NOT NULL, device_id TEXT NOT NULL, @@ -618,7 +680,35 @@ function initializeSchema(db: DatabaseType): void { ON coordinator_scope_membership_audit_log(scope_id, created_at, event_id); CREATE INDEX IF NOT EXISTS idx_coordinator_scope_membership_audit_device_created ON coordinator_scope_membership_audit_log(device_id, created_at, event_id); + + CREATE TABLE IF NOT EXISTS coordinator_scope_membership_effect_receipts ( + effect_id TEXT PRIMARY KEY, + action TEXT NOT NULL CHECK (action IN ('grant', 'revoke')), + request_json TEXT NOT NULL, + outcome_applied INTEGER NOT NULL CHECK (outcome_applied IN (0, 1)), + scope_id TEXT NOT NULL, + device_id TEXT NOT NULL, + role TEXT, + status TEXT, + membership_epoch INTEGER, + coordinator_id TEXT, + group_id TEXT, + manifest_issuer_device_id TEXT, + manifest_hash TEXT, + signed_manifest_json TEXT, + updated_at TEXT, + created_at TEXT NOT NULL + ); `); + try { + db.prepare( + "ALTER TABLE coordinator_scope_membership_audit_log ADD COLUMN effect_id TEXT", + ).run(); + } catch { + // already exists + } + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_coordinator_scope_membership_audit_effect + ON coordinator_scope_membership_audit_log(effect_id) WHERE effect_id IS NOT NULL`); try { db.prepare("ALTER TABLE groups ADD COLUMN archived_at TEXT").run(); } catch { @@ -1540,17 +1630,27 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { async grantScopeMembership( opts: CoordinatorGrantScopeMembershipInput, ): Promise { - const scopeId = clean(opts.scopeId); - const deviceId = clean(opts.deviceId); - const scope = scopeId ? this.getScopeSync(scopeId) : null; - if (!scope) throw new Error("scopeId must reference an existing scope."); - const existing = scopeId && deviceId ? this.getScopeMembershipSync(scopeId, deviceId) : null; - const normalized = normalizeGrantInput(opts, scope, existing); - assertScopeMembershipDeviceEnrolled(this.db, normalized.groupId, normalized.deviceId); - const now = nowISO(); - return this.db.transaction(() => { - const result = this.db - .prepare(`INSERT INTO coordinator_scope_memberships( + const effectId = normalizeMembershipEffectId(opts.effectId); + const requestJson = grantMembershipEffectRequestJson(opts); + return this.db + .transaction(() => { + const receipt = getMembershipEffectReceiptSync(this.db, effectId); + if (receipt) { + assertMatchingMembershipEffectReceipt(receipt, "grant", requestJson); + return membershipFromEffectReceipt(receipt); + } + const scopeId = clean(opts.scopeId); + const deviceId = clean(opts.deviceId); + const scope = scopeId ? this.getScopeSync(scopeId) : null; + if (!scope) throw new CoordinatorMembershipError("scope_not_found"); + if (scope.status !== "active") throw new CoordinatorMembershipError("scope_inactive"); + const existing = + scopeId && deviceId ? this.getScopeMembershipSync(scopeId, deviceId) : null; + const normalized = normalizeGrantInput(opts, scope, existing); + assertScopeMembershipDeviceEnrolled(this.db, normalized.groupId, normalized.deviceId); + const now = nowISO(); + const result = this.db + .prepare(`INSERT INTO coordinator_scope_memberships( scope_id, device_id, role, status, membership_epoch, coordinator_id, group_id, manifest_issuer_device_id, manifest_hash, signed_manifest_json, updated_at ) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?) @@ -1569,76 +1669,114 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { excluded.membership_epoch = coordinator_scope_memberships.membership_epoch AND coordinator_scope_memberships.status != 'revoked' )`) - .run( - normalized.scopeId, - normalized.deviceId, - normalized.role, - normalized.membershipEpoch, - normalized.coordinatorId, - normalized.groupId, - normalized.manifestIssuerDeviceId, - normalized.manifestHash, - normalized.signedManifestJson, - now, - ); - if (result.changes <= 0) { - throw new Error("scope membership grant was not applied."); - } - const row = this.db - .prepare(`SELECT scope_id, device_id, role, status, membership_epoch, coordinator_id, group_id, + .run( + normalized.scopeId, + normalized.deviceId, + normalized.role, + normalized.membershipEpoch, + normalized.coordinatorId, + normalized.groupId, + normalized.manifestIssuerDeviceId, + normalized.manifestHash, + normalized.signedManifestJson, + now, + ); + if (result.changes <= 0) { + throw new Error("scope membership grant was not applied."); + } + const row = this.db + .prepare(`SELECT scope_id, device_id, role, status, membership_epoch, coordinator_id, group_id, manifest_issuer_device_id, manifest_hash, signed_manifest_json, updated_at FROM coordinator_scope_memberships WHERE scope_id = ? AND device_id = ?`) - .get(normalized.scopeId, normalized.deviceId); - const membership = rowToRecord(row); - insertMembershipAuditSync(this.db, { - action: "grant", - current: membership, - previous: existing, - actorType: normalized.actorType, - actorId: normalized.actorId, - createdAt: now, - }); - return membership; - })(); + .get(normalized.scopeId, normalized.deviceId); + const membership = rowToRecord(row); + insertMembershipAuditSync(this.db, { + effectId, + action: "grant", + current: membership, + previous: existing, + actorType: normalized.actorType, + actorId: normalized.actorId, + createdAt: now, + }); + insertMembershipEffectReceiptSync(this.db, { + effectId, + action: "grant", + requestJson, + applied: true, + scopeId: normalized.scopeId, + deviceId: normalized.deviceId, + membership, + createdAt: now, + }); + return membership; + }) + .immediate(); } async revokeScopeMembership(opts: CoordinatorRevokeScopeMembershipInput): Promise { const scopeId = clean(opts.scopeId); const deviceId = clean(opts.deviceId); if (!scopeId || !deviceId) throw new Error("scopeId and deviceId are required."); - const membershipEpoch = - opts.membershipEpoch == null ? null : normalizeEpoch(opts.membershipEpoch); - const existing = this.getScopeMembershipSync(scopeId, deviceId); - if (!existing) return false; - if (membershipEpoch != null && existing && membershipEpoch <= existing.membership_epoch) { - throw new Error("membershipEpoch must increase on revoke."); - } - const now = nowISO(); - return this.db.transaction(() => { - const result = this.db - .prepare(`UPDATE coordinator_scope_memberships + const effectId = normalizeMembershipEffectId(opts.effectId); + const requestJson = revokeMembershipEffectRequestJson(opts); + return this.db + .transaction(() => { + const receipt = getMembershipEffectReceiptSync(this.db, effectId); + if (receipt) { + assertMatchingMembershipEffectReceipt(receipt, "revoke", requestJson); + return receipt.outcome_applied === 1; + } + const membershipEpoch = + opts.membershipEpoch == null ? null : normalizeEpoch(opts.membershipEpoch); + const requestedGroupId = clean(opts.groupId); + const scope = requestedGroupId ? this.getScopeSync(scopeId) : null; + if (requestedGroupId && requestedGroupId !== scope?.group_id) { + throw new CoordinatorMembershipError("scope_group_mismatch"); + } + const existing = this.getScopeMembershipSync(scopeId, deviceId); + const now = nowISO(); + if (!existing) { + insertMembershipEffectReceiptSync(this.db, { + effectId, + action: "revoke", + requestJson, + applied: false, + scopeId, + deviceId, + membership: null, + createdAt: now, + }); + return false; + } + if (membershipEpoch != null && membershipEpoch <= existing.membership_epoch) { + throw new Error("membershipEpoch must increase on revoke."); + } + const result = this.db + .prepare(`UPDATE coordinator_scope_memberships SET status = 'revoked', membership_epoch = CASE WHEN ? IS NULL THEN membership_epoch + 1 ELSE ? END, manifest_hash = COALESCE(?, manifest_hash), signed_manifest_json = COALESCE(?, signed_manifest_json), updated_at = ? WHERE scope_id = ? AND device_id = ? AND membership_epoch = ? AND status = ?`) - .run( - membershipEpoch, - membershipEpoch, - clean(opts.manifestHash), - clean(opts.signedManifestJson), - now, - scopeId, - deviceId, - existing.membership_epoch, - existing.status, - ); - if (result.changes <= 0) return false; - const membership = this.getScopeMembershipSync(scopeId, deviceId); - if (membership) { + .run( + membershipEpoch, + membershipEpoch, + clean(opts.manifestHash), + clean(opts.signedManifestJson), + now, + scopeId, + deviceId, + existing.membership_epoch, + existing.status, + ); + if (result.changes <= 0) return false; + const membership = this.getScopeMembershipSync(scopeId, deviceId); + if (!membership) throw new Error("scope membership revoke was not applied."); insertMembershipAuditSync(this.db, { + effectId, action: "revoke", current: membership, previous: existing, @@ -1646,9 +1784,19 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { actorId: clean(opts.actorId), createdAt: now, }); - } - return true; - })(); + insertMembershipEffectReceiptSync(this.db, { + effectId, + action: "revoke", + requestJson, + applied: true, + scopeId, + deviceId, + membership, + createdAt: now, + }); + return true; + }) + .immediate(); } async listScopeMemberships( @@ -1678,7 +1826,7 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { const where = deviceId ? "scope_id = ? AND device_id = ?" : "scope_id = ?"; const params = deviceId ? [scopeId, deviceId, limit] : [scopeId, limit]; return this.db - .prepare(`SELECT event_id, action, scope_id, device_id, role, status, membership_epoch, + .prepare(`SELECT event_id, effect_id, action, scope_id, device_id, role, status, membership_epoch, previous_role, previous_status, previous_membership_epoch, coordinator_id, group_id, actor_type, actor_id, manifest_hash, created_at FROM coordinator_scope_membership_audit_log diff --git a/packages/core/src/coordinator-actions.test.ts b/packages/core/src/coordinator-actions.test.ts index aaa2589d..83fd506f 100644 --- a/packages/core/src/coordinator-actions.test.ts +++ b/packages/core/src/coordinator-actions.test.ts @@ -227,6 +227,7 @@ describe("coordinator local admin actions", () => { ); const grant = await coordinatorGrantScopeMembershipAction({ + effectId: "actions:team-a:scope-acme:device-1:grant:3", groupId: "team-a", scopeId: "scope-acme", deviceId: "device-1", @@ -252,6 +253,7 @@ describe("coordinator local admin actions", () => { ).toEqual([expect.objectContaining({ device_id: "device-1", status: "active" })]); expect( await coordinatorRevokeScopeMembershipAction({ + effectId: "actions:team-a:scope-acme:device-1:revoke:4", groupId: "team-a", scopeId: "scope-acme", deviceId: "device-1", @@ -325,6 +327,7 @@ describe("coordinator local admin actions", () => { ).rejects.toThrow("Scope not found: missing-scope"); await expect( coordinatorGrantScopeMembershipAction({ + effectId: "actions:missing-scope:grant", groupId: "team-a", scopeId: "missing-scope", deviceId: "device-1", @@ -400,6 +403,7 @@ describe("coordinator local admin actions", () => { ).toEqual(scope); expect( await coordinatorGrantScopeMembershipAction({ + effectId: "actions:remote:grant", groupId: "team-a", scopeId: "scope-acme", deviceId: "device-1", @@ -408,6 +412,7 @@ describe("coordinator local admin actions", () => { ).toEqual(membership); expect( await coordinatorRevokeScopeMembershipAction({ + effectId: "actions:remote:revoke", groupId: "team-a", scopeId: "scope-acme", deviceId: "device-1", @@ -432,6 +437,7 @@ describe("coordinator local admin actions", () => { expect( await coordinatorRevokeScopeMembershipAction({ + effectId: "actions:remote:missing-revoke", groupId: "team-a", scopeId: "scope-acme", deviceId: "device-1", diff --git a/packages/core/src/coordinator-actions.ts b/packages/core/src/coordinator-actions.ts index 1fd311dd..59746a36 100644 --- a/packages/core/src/coordinator-actions.ts +++ b/packages/core/src/coordinator-actions.ts @@ -10,6 +10,10 @@ import { type InvitePayload, inviteLink, } from "./coordinator-invites.js"; +import { + CoordinatorMembershipError, + normalizeMembershipEffectId, +} from "./coordinator-membership-effects.js"; import type { CoordinatorBootstrapGrant, CoordinatorEnrollment, @@ -601,6 +605,7 @@ export async function coordinatorGrantScopeMembershipAction( const groupId = String(opts.groupId ?? "").trim(); const scopeId = String(opts.scopeId ?? "").trim(); const deviceId = String(opts.deviceId ?? "").trim(); + const effectId = normalizeMembershipEffectId(opts.effectId); if (!groupId || !scopeId || !deviceId) { throw new Error("group_id, scope_id, and device_id are required."); } @@ -614,6 +619,7 @@ export async function coordinatorGrantScopeMembershipAction( `${stripTrailingSlashes(remote)}/v1/admin/groups/${encodeURIComponent(groupId)}/scopes/${encodeURIComponent(scopeId)}/members`, adminSecret, { + effect_id: effectId, device_id: deviceId, role: opts.role ?? null, membership_epoch: opts.membershipEpoch ?? null, @@ -632,22 +638,30 @@ export async function coordinatorGrantScopeMembershipAction( } const store = new BetterSqliteCoordinatorStore(opts.dbPath ?? DEFAULT_COORDINATOR_DB_PATH); try { - const scope = await localScopeForGroup(store, groupId, scopeId); - if (!scope) throw new Error(`Scope not found: ${scopeId}`); - if (scope.status !== "active") throw new Error(`Scope is not active: ${scopeId}`); - return await store.grantScopeMembership({ - scopeId, - deviceId, - role: opts.role ?? null, - membershipEpoch: opts.membershipEpoch ?? null, - coordinatorId: opts.coordinatorId ?? null, - groupId, - manifestIssuerDeviceId: opts.manifestIssuerDeviceId ?? null, - manifestHash: opts.manifestHash ?? null, - signedManifestJson: opts.signedManifestJson ?? null, - actorType: opts.actorType ?? "admin", - actorId: opts.actorId ?? null, - }); + try { + return await store.grantScopeMembership({ + effectId, + scopeId, + deviceId, + role: opts.role ?? null, + membershipEpoch: opts.membershipEpoch ?? null, + coordinatorId: opts.coordinatorId ?? null, + groupId, + manifestIssuerDeviceId: opts.manifestIssuerDeviceId ?? null, + manifestHash: opts.manifestHash ?? null, + signedManifestJson: opts.signedManifestJson ?? null, + actorType: opts.actorType ?? "admin", + actorId: opts.actorId ?? null, + }); + } catch (error) { + if (error instanceof CoordinatorMembershipError && error.code === "scope_not_found") { + throw new Error(`Scope not found: ${scopeId}`); + } + if (error instanceof CoordinatorMembershipError && error.code === "scope_inactive") { + throw new Error(`Scope is not active: ${scopeId}`); + } + throw error; + } } finally { await store.close(); } @@ -664,6 +678,7 @@ export async function coordinatorRevokeScopeMembershipAction( const groupId = String(opts.groupId ?? "").trim(); const scopeId = String(opts.scopeId ?? "").trim(); const deviceId = String(opts.deviceId ?? "").trim(); + const effectId = normalizeMembershipEffectId(opts.effectId); if (!groupId || !scopeId || !deviceId) { throw new Error("group_id, scope_id, and device_id are required."); } @@ -678,6 +693,7 @@ export async function coordinatorRevokeScopeMembershipAction( `${stripTrailingSlashes(remote)}/v1/admin/groups/${encodeURIComponent(groupId)}/scopes/${encodeURIComponent(scopeId)}/members/${encodeURIComponent(deviceId)}/revoke`, adminSecret, { + effect_id: effectId, membership_epoch: opts.membershipEpoch ?? null, manifest_hash: opts.manifestHash ?? null, signed_manifest_json: opts.signedManifestJson ?? null, @@ -692,10 +708,11 @@ export async function coordinatorRevokeScopeMembershipAction( } const store = new BetterSqliteCoordinatorStore(opts.dbPath ?? DEFAULT_COORDINATOR_DB_PATH); try { - if (!(await localScopeForGroup(store, groupId, scopeId))) return false; return await store.revokeScopeMembership({ + effectId, scopeId, deviceId, + groupId, membershipEpoch: opts.membershipEpoch ?? null, manifestHash: opts.manifestHash ?? null, signedManifestJson: opts.signedManifestJson ?? null, diff --git a/packages/core/src/coordinator-api.test.ts b/packages/core/src/coordinator-api.test.ts index 026733e7..cd68a41e 100644 --- a/packages/core/src/coordinator-api.test.ts +++ b/packages/core/src/coordinator-api.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { CoordinatorMembershipError } from "./coordinator-membership-effects.js"; import { BetterSqliteCoordinatorStore, type CoordinatorCreateInviteInput, @@ -1291,12 +1292,16 @@ describe("createCoordinatorApp dependency injection", () => { { path: "/v1/admin/groups/g1/scopes/scope-acme/members", method: "POST", - body: { device_id: "device-a", membership_epoch: " " }, + body: { + effect_id: "test:invalid-epoch:grant", + device_id: "device-a", + membership_epoch: " ", + }, }, { path: "/v1/admin/groups/g1/scopes/scope-acme/members/device-a/revoke", method: "POST", - body: { membership_epoch: true }, + body: { effect_id: "test:invalid-epoch:revoke", membership_epoch: true }, }, ]; @@ -1676,6 +1681,7 @@ describe("createCoordinatorApp dependency injection", () => { "X-Codemem-Coordinator-Admin": "test-secret", }, body: JSON.stringify({ + effect_id: "api:grant:scope-acme:device-a:4", device_id: "device-a", role: "admin", membership_epoch: 4, @@ -1687,8 +1693,8 @@ describe("createCoordinatorApp dependency injection", () => { expect(res.status).toBe(201); expect(await res.json()).toEqual({ ok: true, membership }); - expect(store.getEnrollment).toHaveBeenCalledWith("g1", "device-a"); expect(store.grantScopeMembership).toHaveBeenCalledWith({ + effectId: "api:grant:scope-acme:device-a:4", scopeId: "scope-acme", deviceId: "device-a", role: "admin", @@ -1703,6 +1709,41 @@ describe("createCoordinatorApp dependency injection", () => { }); }); + it("rejects grants for archived groups before mutating membership", async () => { + const store = createMockStore({ + getGroup: vi.fn(async () => ({ + group_id: "g1", + display_name: "Archived", + archived_at: "2026-03-28T00:00:00Z", + created_at: "2026-03-27T00:00:00Z", + })), + grantScopeMembership: vi.fn(async () => { + throw new Error("grant must not run"); + }), + }); + const app = createCoordinatorApp({ + storeFactory: () => store, + runtime: { + adminSecret: () => "test-secret", + now: () => "2026-03-28T00:00:00Z", + }, + requestVerifier: allowRequest, + }); + + const res = await app.request("/v1/admin/groups/g1/scopes/scope-acme/members", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Codemem-Coordinator-Admin": "test-secret", + }, + body: JSON.stringify({ effect_id: "api:archived:grant", device_id: "device-a" }), + }); + + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "group_archived" }); + expect(store.grantScopeMembership).not.toHaveBeenCalled(); + }); + it("writes audit rows through admin Sharing domain grant and revoke APIs", async () => { const tmpDir = mkdtempSync(join(tmpdir(), "coord-api-audit-test-")); const dbPath = join(tmpDir, "coordinator.sqlite"); @@ -1739,6 +1780,7 @@ describe("createCoordinatorApp dependency injection", () => { "X-Codemem-Coordinator-Admin-Actor": "admin-alice", }, body: JSON.stringify({ + effect_id: "api:audit:grant", device_id: "device-a", membership_epoch: 2, manifest_hash: "hash-grant", @@ -1746,6 +1788,39 @@ describe("createCoordinatorApp dependency injection", () => { }), }); expect(grantRes.status).toBe(201); + const grantReplay = await app.request("/v1/admin/groups/g1/scopes/scope-acme/members", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Codemem-Coordinator-Admin": "test-secret", + "X-Codemem-Coordinator-Admin-Actor": "admin-alice", + }, + body: JSON.stringify({ + effect_id: "api:audit:grant", + device_id: "device-a", + membership_epoch: 2, + manifest_hash: "hash-grant", + actor_id: "another-spoofed-body-actor", + }), + }); + expect(grantReplay.status).toBe(201); + const grantConflict = await app.request("/v1/admin/groups/g1/scopes/scope-acme/members", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Codemem-Coordinator-Admin": "test-secret", + "X-Codemem-Coordinator-Admin-Actor": "admin-alice", + }, + body: JSON.stringify({ + effect_id: "api:audit:grant", + device_id: "device-a", + role: "admin", + membership_epoch: 2, + manifest_hash: "hash-grant", + }), + }); + expect(grantConflict.status).toBe(409); + expect(await grantConflict.json()).toEqual({ error: "scope_membership_effect_conflict" }); const revokeRes = await app.request( "/v1/admin/groups/g1/scopes/scope-acme/members/device-a/revoke", @@ -1756,10 +1831,31 @@ describe("createCoordinatorApp dependency injection", () => { "X-Codemem-Coordinator-Admin": "test-secret", "X-Codemem-Coordinator-Admin-Actor": "admin-bob", }, - body: JSON.stringify({ membership_epoch: 3, manifest_hash: "hash-revoke" }), + body: JSON.stringify({ + effect_id: "api:audit:revoke", + membership_epoch: 3, + manifest_hash: "hash-revoke", + }), }, ); expect(revokeRes.status).toBe(200); + const revokeReplay = await app.request( + "/v1/admin/groups/g1/scopes/scope-acme/members/device-a/revoke", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Codemem-Coordinator-Admin": "test-secret", + "X-Codemem-Coordinator-Admin-Actor": "admin-bob", + }, + body: JSON.stringify({ + effect_id: "api:audit:revoke", + membership_epoch: 3, + manifest_hash: "hash-revoke", + }), + }, + ); + expect(revokeReplay.status).toBe(200); const verifyStore = new BetterSqliteCoordinatorStore(dbPath); try { @@ -1840,7 +1936,7 @@ describe("createCoordinatorApp dependency injection", () => { }); expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "device_id_required" }); + expect(await res.json()).toEqual({ error: "effect_id_required" }); expect(store.grantScopeMembership).not.toHaveBeenCalled(); }); @@ -1866,8 +1962,10 @@ describe("createCoordinatorApp dependency injection", () => { archived_at: null, created_at: "2026-03-28T00:00:00Z", })), - getEnrollment: vi.fn(async () => null), listScopes: vi.fn(async () => [scope]), + grantScopeMembership: vi.fn(async () => { + throw new CoordinatorMembershipError("device_not_enrolled"); + }), }); const app = createCoordinatorApp({ storeFactory: () => store, @@ -1884,12 +1982,12 @@ describe("createCoordinatorApp dependency injection", () => { "Content-Type": "application/json", "X-Codemem-Coordinator-Admin": "test-secret", }, - body: JSON.stringify({ device_id: "device-a" }), + body: JSON.stringify({ effect_id: "api:outside-group:grant", device_id: "device-a" }), }); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: "device_not_enrolled_for_scope_group" }); - expect(store.grantScopeMembership).not.toHaveBeenCalled(); + expect(store.grantScopeMembership).toHaveBeenCalledOnce(); }); it("revokes explicit Sharing domain memberships", async () => { @@ -1947,7 +2045,11 @@ describe("createCoordinatorApp dependency injection", () => { "Content-Type": "application/json", "X-Codemem-Coordinator-Admin": "test-secret", }, - body: JSON.stringify({ membership_epoch: 5, manifest_hash: "hash-revoke" }), + body: JSON.stringify({ + effect_id: "api:revoke:scope-acme:device-a:5", + membership_epoch: 5, + manifest_hash: "hash-revoke", + }), }); expect(res.status).toBe(200); @@ -1966,8 +2068,10 @@ describe("createCoordinatorApp dependency injection", () => { }, }); expect(store.revokeScopeMembership).toHaveBeenCalledWith({ + effectId: "api:revoke:scope-acme:device-a:5", scopeId: "scope-acme", deviceId: "device-a", + groupId: "g1", membershipEpoch: 5, manifestHash: "hash-revoke", signedManifestJson: null, @@ -1977,6 +2081,41 @@ describe("createCoordinatorApp dependency injection", () => { expect(store.listScopeMemberships).toHaveBeenCalledWith("scope-acme", true); }); + it("rejects revokes for archived groups before mutating membership", async () => { + const store = createMockStore({ + getGroup: vi.fn(async () => ({ + group_id: "g1", + display_name: "Archived", + archived_at: "2026-03-28T00:00:00Z", + created_at: "2026-03-27T00:00:00Z", + })), + revokeScopeMembership: vi.fn(async () => { + throw new Error("revoke must not run"); + }), + }); + const app = createCoordinatorApp({ + storeFactory: () => store, + runtime: { + adminSecret: () => "test-secret", + now: () => "2026-03-28T00:00:00Z", + }, + requestVerifier: allowRequest, + }); + + const res = await app.request("/v1/admin/groups/g1/scopes/scope-acme/members/device-a/revoke", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Codemem-Coordinator-Admin": "test-secret", + }, + body: JSON.stringify({ effect_id: "api:archived:revoke" }), + }); + + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "group_archived" }); + expect(store.revokeScopeMembership).not.toHaveBeenCalled(); + }); + it("does not fail a persisted revoke when response enrichment cannot reload it", async () => { const scope: CoordinatorScope = { scope_id: "scope-acme", @@ -2020,7 +2159,10 @@ describe("createCoordinatorApp dependency injection", () => { "Content-Type": "application/json", "X-Codemem-Coordinator-Admin": "test-secret", }, - body: JSON.stringify({ membership_epoch: 5 }), + body: JSON.stringify({ + effect_id: "api:revoke:enrichment-failure", + membership_epoch: 5, + }), }); expect(res.status).toBe(200); @@ -2032,8 +2174,10 @@ describe("createCoordinatorApp dependency injection", () => { }, }); expect(store.revokeScopeMembership).toHaveBeenCalledWith({ + effectId: "api:revoke:enrichment-failure", scopeId: "scope-acme", deviceId: "device-a", + groupId: "g1", membershipEpoch: 5, manifestHash: null, signedManifestJson: null, @@ -2098,7 +2242,7 @@ describe("createCoordinatorApp dependency injection", () => { "Content-Type": "application/json", "X-Codemem-Coordinator-Admin": "test-secret", }, - body: JSON.stringify({}), + body: JSON.stringify({ effect_id: "api:revoke:implicit-epoch" }), }); expect(res.status).toBe(200); diff --git a/packages/core/src/coordinator-api.ts b/packages/core/src/coordinator-api.ts index bf57c04e..4bbb2b64 100644 --- a/packages/core/src/coordinator-api.ts +++ b/packages/core/src/coordinator-api.ts @@ -9,6 +9,10 @@ import type { Context } from "hono"; import { Hono } from "hono"; import type { InvitePayload } from "./coordinator-invites.js"; import { encodeInvitePayload, inviteLink } from "./coordinator-invites.js"; +import { + CoordinatorMembershipError, + SCOPE_MEMBERSHIP_EFFECT_CONFLICT, +} from "./coordinator-membership-effects.js"; import type { CoordinatorBootstrapGrantVerification, CoordinatorEnrollment, @@ -1020,8 +1024,10 @@ export function createCoordinatorApp( const groupId = String(c.req.param("group_id") ?? "").trim(); const scopeId = String(c.req.param("scope_id") ?? "").trim(); + const effectId = optionalString(data, "effect_id"); const deviceId = optionalString(data, "device_id"); const membershipEpoch = optionalNumber(data, "membership_epoch"); + if (!effectId) return c.json({ error: "effect_id_required" }, 400); if (!deviceId) return c.json({ error: "device_id_required" }, 400); if (Number.isNaN(membershipEpoch)) { return c.json({ error: "membership_epoch_must_be_number" }, 400); @@ -1031,10 +1037,8 @@ export function createCoordinatorApp( try { const lookup = await findAdminScope(store, groupId, scopeId, c); if (lookup.response) return lookup.response; - if (lookup.scope?.status !== "active") return c.json({ error: "scope_not_active" }, 409); - const enrollment = await store.getEnrollment(groupId, deviceId); - if (!enrollment) return c.json({ error: "device_not_enrolled_for_scope_group" }, 404); const membership = await store.grantScopeMembership({ + effectId, scopeId, deviceId, role: optionalString(data, "role"), @@ -1049,7 +1053,22 @@ export function createCoordinatorApp( }); return c.json({ ok: true, membership }, 201); } catch (error) { - return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); + const message = error instanceof Error ? error.message : String(error); + if ( + error instanceof CoordinatorMembershipError && + (error.code === "scope_not_found" || error.code === "scope_group_mismatch") + ) { + return c.json({ error: "scope_not_found" }, 404); + } + if (error instanceof CoordinatorMembershipError && error.code === "device_not_enrolled") { + return c.json({ error: "device_not_enrolled_for_scope_group" }, 404); + } + const scopeInactive = + error instanceof CoordinatorMembershipError && error.code === "scope_inactive"; + return c.json( + { error: scopeInactive ? "scope_not_active" : message }, + message === SCOPE_MEMBERSHIP_EFFECT_CONFLICT || scopeInactive ? 409 : 400, + ); } finally { await store.close(); } @@ -1071,7 +1090,9 @@ export function createCoordinatorApp( const groupId = String(c.req.param("group_id") ?? "").trim(); const scopeId = String(c.req.param("scope_id") ?? "").trim(); const deviceId = String(c.req.param("device_id") ?? "").trim(); + const effectId = optionalString(data, "effect_id"); const membershipEpoch = optionalNumber(data, "membership_epoch"); + if (!effectId) return c.json({ error: "effect_id_required" }, 400); if (!deviceId) return c.json({ error: "device_id_required" }, 400); if (Number.isNaN(membershipEpoch)) { return c.json({ error: "membership_epoch_must_be_number" }, 400); @@ -1082,8 +1103,10 @@ export function createCoordinatorApp( const lookup = await findAdminScope(store, groupId, scopeId, c); if (lookup.response) return lookup.response; const ok = await store.revokeScopeMembership({ + effectId, scopeId, deviceId, + groupId, membershipEpoch, manifestHash: optionalString(data, "manifest_hash"), signedManifestJson: optionalString(data, "signed_manifest_json"), @@ -1110,7 +1133,11 @@ export function createCoordinatorApp( }), }); } catch (error) { - return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); + const message = error instanceof Error ? error.message : String(error); + if (error instanceof CoordinatorMembershipError && error.code === "scope_group_mismatch") { + return c.json({ error: "scope_not_found" }, 404); + } + return c.json({ error: message }, message === SCOPE_MEMBERSHIP_EFFECT_CONFLICT ? 409 : 400); } finally { await store.close(); } diff --git a/packages/core/src/coordinator-membership-effects.ts b/packages/core/src/coordinator-membership-effects.ts new file mode 100644 index 00000000..b58f2936 --- /dev/null +++ b/packages/core/src/coordinator-membership-effects.ts @@ -0,0 +1,134 @@ +import type { + CoordinatorGrantScopeMembershipInput, + CoordinatorRevokeScopeMembershipInput, + CoordinatorScopeMembership, +} from "./coordinator-store-contract.js"; + +export const SCOPE_MEMBERSHIP_EFFECT_CONFLICT = "scope_membership_effect_conflict"; + +export type CoordinatorMembershipErrorCode = + | "device_not_enrolled" + | "scope_group_mismatch" + | "scope_inactive" + | "scope_not_found"; + +const MEMBERSHIP_ERROR_MESSAGES: Record = { + device_not_enrolled: "device must be enrolled and enabled in the scope group.", + scope_group_mismatch: "membership groupId must match the scope groupId.", + scope_inactive: "scope is not active.", + scope_not_found: "scopeId must reference an existing scope.", +}; + +export class CoordinatorMembershipError extends Error { + readonly code: CoordinatorMembershipErrorCode; + + constructor(code: CoordinatorMembershipErrorCode) { + super(MEMBERSHIP_ERROR_MESSAGES[code]); + this.name = "CoordinatorMembershipError"; + this.code = code; + } +} + +export interface CoordinatorMembershipEffectReceipt { + effect_id: string; + action: "grant" | "revoke"; + request_json: string; + outcome_applied: number; + scope_id: string; + device_id: string; + role: string | null; + status: string | null; + membership_epoch: number | null; + coordinator_id: string | null; + group_id: string | null; + manifest_issuer_device_id: string | null; + manifest_hash: string | null; + signed_manifest_json: string | null; + updated_at: string | null; + created_at: string; +} + +function clean(value: string | null | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +export function normalizeMembershipEffectId(value: string): string { + const effectId = clean(value); + if (!effectId) throw new Error("effectId is required."); + if (effectId.length > 512 || /[\p{Cc}\p{Cf}]/u.test(effectId)) { + throw new Error("effectId is invalid."); + } + return effectId; +} + +export function grantMembershipEffectRequestJson( + opts: CoordinatorGrantScopeMembershipInput, +): string { + return JSON.stringify({ + scopeId: clean(opts.scopeId), + deviceId: clean(opts.deviceId), + role: clean(opts.role), + membershipEpoch: opts.membershipEpoch ?? null, + coordinatorId: clean(opts.coordinatorId), + groupId: clean(opts.groupId), + manifestIssuerDeviceId: clean(opts.manifestIssuerDeviceId), + manifestHash: clean(opts.manifestHash), + signedManifestJson: clean(opts.signedManifestJson), + actorType: clean(opts.actorType), + actorId: clean(opts.actorId), + }); +} + +export function revokeMembershipEffectRequestJson( + opts: CoordinatorRevokeScopeMembershipInput, +): string { + return JSON.stringify({ + scopeId: clean(opts.scopeId), + deviceId: clean(opts.deviceId), + groupId: clean(opts.groupId), + membershipEpoch: opts.membershipEpoch ?? null, + manifestHash: clean(opts.manifestHash), + signedManifestJson: clean(opts.signedManifestJson), + actorType: clean(opts.actorType), + actorId: clean(opts.actorId), + }); +} + +export function assertMatchingMembershipEffectReceipt( + receipt: CoordinatorMembershipEffectReceipt, + action: CoordinatorMembershipEffectReceipt["action"], + requestJson: string, +): void { + if (receipt.action !== action || receipt.request_json !== requestJson) { + throw new Error(SCOPE_MEMBERSHIP_EFFECT_CONFLICT); + } +} + +export function membershipFromEffectReceipt( + receipt: CoordinatorMembershipEffectReceipt, +): CoordinatorScopeMembership { + if ( + receipt.action !== "grant" || + receipt.outcome_applied !== 1 || + receipt.role == null || + receipt.status == null || + receipt.membership_epoch == null || + receipt.updated_at == null + ) { + throw new Error("scope_membership_effect_receipt_invalid"); + } + return { + scope_id: receipt.scope_id, + device_id: receipt.device_id, + role: receipt.role, + status: receipt.status, + membership_epoch: receipt.membership_epoch, + coordinator_id: receipt.coordinator_id, + group_id: receipt.group_id, + manifest_issuer_device_id: receipt.manifest_issuer_device_id, + manifest_hash: receipt.manifest_hash, + signed_manifest_json: receipt.signed_manifest_json, + updated_at: receipt.updated_at, + }; +} diff --git a/packages/core/src/coordinator-store-contract.ts b/packages/core/src/coordinator-store-contract.ts index 03fef04b..0a245fcd 100644 --- a/packages/core/src/coordinator-store-contract.ts +++ b/packages/core/src/coordinator-store-contract.ts @@ -214,6 +214,7 @@ export type CoordinatorScopeMembershipAuditAction = "grant" | "revoke"; export interface CoordinatorScopeMembershipAuditEvent { event_id: number; + effect_id: string | null; action: CoordinatorScopeMembershipAuditAction; scope_id: string; device_id: string; @@ -360,6 +361,7 @@ export interface CoordinatorListScopesInput { } export interface CoordinatorGrantScopeMembershipInput { + effectId: string; scopeId: string; deviceId: string; role?: string | null; @@ -376,8 +378,11 @@ export interface CoordinatorGrantScopeMembershipInput { } export interface CoordinatorRevokeScopeMembershipInput { + effectId: string; scopeId: string; deviceId: string; + /** Optional assertion; persisted authority is derived from the referenced scope. */ + groupId?: string | null; membershipEpoch?: number | null; manifestHash?: string | null; signedManifestJson?: string | null; diff --git a/packages/core/src/coordinator-store-test-harness.ts b/packages/core/src/coordinator-store-test-harness.ts index 65bb7c21..7114ae11 100644 --- a/packages/core/src/coordinator-store-test-harness.ts +++ b/packages/core/src/coordinator-store-test-harness.ts @@ -18,6 +18,10 @@ export function runCoordinatorStoreContract( setup: () => CoordinatorStoreHarnessContext, ): void { describe(label, () => { + let effectSequence = 0; + const nextEffect = (action: "grant" | "revoke") => + `contract:${label}:${action}:${++effectSequence}`; + async function withContext( run: (ctx: CoordinatorStoreHarnessContext) => Promise | void, ) { @@ -172,7 +176,11 @@ export function runCoordinatorStoreContract( expect(await store.listScopeMemberships("scope-acme")).toEqual([]); expect(await store.listScopeMembershipAuditEvents({ scopeId: "scope-acme" })).toEqual([]); - await store.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }); + await store.grantScopeMembership({ + effectId: nextEffect("grant"), + scopeId: "scope-acme", + deviceId: "device-a", + }); expect(await store.listScopeMemberships("scope-acme")).toEqual([ expect.objectContaining({ @@ -202,6 +210,7 @@ export function runCoordinatorStoreContract( await expect( store.grantScopeMembership({ + effectId: nextEffect("grant"), scopeId: "scope-acme", deviceId: "device-a", coordinatorId: "coord-b", @@ -210,6 +219,7 @@ export function runCoordinatorStoreContract( ).rejects.toThrow("membership coordinatorId must match the scope coordinatorId"); await expect( store.grantScopeMembership({ + effectId: nextEffect("grant"), scopeId: "scope-acme", deviceId: "device-a", coordinatorId: "coord-a", @@ -236,7 +246,11 @@ export function runCoordinatorStoreContract( }); await expect( - store.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }), + store.grantScopeMembership({ + effectId: nextEffect("grant"), + scopeId: "scope-acme", + deviceId: "device-a", + }), ).rejects.toThrow("device must be enrolled and enabled in the scope group"); await store.enrollDevice("group-a", { @@ -246,12 +260,20 @@ export function runCoordinatorStoreContract( }); expect(await store.setDeviceEnabled("group-a", "device-a", false)).toBe(true); await expect( - store.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }), + store.grantScopeMembership({ + effectId: nextEffect("grant"), + scopeId: "scope-acme", + deviceId: "device-a", + }), ).rejects.toThrow("device must be enrolled and enabled in the scope group"); expect(await store.setDeviceEnabled("group-a", "device-a", true)).toBe(true); await expect( - store.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }), + store.grantScopeMembership({ + effectId: nextEffect("grant"), + scopeId: "scope-acme", + deviceId: "device-a", + }), ).resolves.toEqual(expect.objectContaining({ status: "active" })); }); }); @@ -271,6 +293,7 @@ export function runCoordinatorStoreContract( groupId: "group-a", }); const grant = await store.grantScopeMembership({ + effectId: nextEffect("grant"), scopeId: "scope-acme", deviceId: "device-a", role: "admin", @@ -298,6 +321,7 @@ export function runCoordinatorStoreContract( expect( await store.revokeScopeMembership({ + effectId: nextEffect("revoke"), scopeId: "scope-acme", deviceId: "device-a", membershipEpoch: 4, @@ -319,6 +343,7 @@ export function runCoordinatorStoreContract( ]); expect(await store.listScopeMembershipAuditEvents({ scopeId: "scope-acme" })).toEqual([ expect.objectContaining({ + effect_id: expect.stringContaining(":grant:"), action: "grant", scope_id: "scope-acme", device_id: "device-a", @@ -335,6 +360,7 @@ export function runCoordinatorStoreContract( manifest_hash: "hash-grant", }), expect.objectContaining({ + effect_id: expect.stringContaining(":revoke:"), action: "revoke", scope_id: "scope-acme", device_id: "device-a", @@ -371,6 +397,7 @@ export function runCoordinatorStoreContract( await expect( store.grantScopeMembership({ + effectId: "test:grant:scope-acme:device-a:below-epoch", scopeId: "scope-acme", deviceId: "device-a", membershipEpoch: 6, @@ -387,9 +414,17 @@ export function runCoordinatorStoreContract( label: "Acme Work", membershipEpoch: 7, }); - await store.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }); + await store.grantScopeMembership({ + effectId: nextEffect("grant"), + scopeId: "scope-acme", + deviceId: "device-a", + }); expect( - await store.revokeScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }), + await store.revokeScopeMembership({ + effectId: nextEffect("revoke"), + scopeId: "scope-acme", + deviceId: "device-a", + }), ).toBe(true); expect(await store.listScopeMemberships("scope-acme", true)).toEqual([ expect.objectContaining({ @@ -400,6 +435,7 @@ export function runCoordinatorStoreContract( ]); await expect( store.grantScopeMembership({ + effectId: nextEffect("grant"), scopeId: "scope-acme", deviceId: "device-a", membershipEpoch: 8, @@ -414,6 +450,7 @@ export function runCoordinatorStoreContract( ]); const regrant = await store.grantScopeMembership({ + effectId: nextEffect("grant"), scopeId: "scope-acme", deviceId: "device-a", }); @@ -426,6 +463,7 @@ export function runCoordinatorStoreContract( ); await expect( store.revokeScopeMembership({ + effectId: nextEffect("revoke"), scopeId: "scope-acme", deviceId: "device-a", membershipEpoch: 8, @@ -433,6 +471,7 @@ export function runCoordinatorStoreContract( ).rejects.toThrow("membershipEpoch must increase on revoke"); await expect( store.grantScopeMembership({ + effectId: nextEffect("grant"), scopeId: "scope-acme", deviceId: "device-a", membershipEpoch: 8, @@ -462,10 +501,19 @@ export function runCoordinatorStoreContract( membershipEpoch: 2, }); - await store.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }); - await store.grantScopeMembership({ scopeId: "scope-oss", deviceId: "device-a" }); + await store.grantScopeMembership({ + effectId: nextEffect("grant"), + scopeId: "scope-acme", + deviceId: "device-a", + }); + await store.grantScopeMembership({ + effectId: nextEffect("grant"), + scopeId: "scope-oss", + deviceId: "device-a", + }); expect( await store.revokeScopeMembership({ + effectId: nextEffect("revoke"), scopeId: "scope-acme", deviceId: "device-a", membershipEpoch: 3, @@ -508,7 +556,11 @@ export function runCoordinatorStoreContract( label: "Acme Work", groupId: "group-a", }); - await store.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-b" }); + await store.grantScopeMembership({ + effectId: nextEffect("grant"), + scopeId: "scope-acme", + deviceId: "device-b", + }); await store.upsertPresence({ groupId: "group-a", deviceId: "device-b", @@ -517,7 +569,11 @@ export function runCoordinatorStoreContract( }); expect( - await store.revokeScopeMembership({ scopeId: "scope-acme", deviceId: "device-b" }), + await store.revokeScopeMembership({ + effectId: nextEffect("revoke"), + scopeId: "scope-acme", + deviceId: "device-b", + }), ).toBe(true); expect(await store.listScopeMemberships("scope-acme")).toEqual([]); @@ -531,6 +587,82 @@ export function runCoordinatorStoreContract( ]); }); }); + + it("replays identical effects without changing membership or audit history", async () => { + await withContext(async ({ store }) => { + await store.createScope({ scopeId: "scope-replay", label: "Replay" }); + const request = { + effectId: "contract:membership-replay:grant", + scopeId: "scope-replay", + deviceId: "device-a", + membershipEpoch: 4, + }; + const first = await store.grantScopeMembership(request); + const replay = await store.grantScopeMembership(request); + expect(replay).toEqual(first); + expect( + await store.listScopeMembershipAuditEvents({ scopeId: "scope-replay" }), + ).toHaveLength(1); + + const revoke = { + effectId: "contract:membership-replay:revoke", + scopeId: "scope-replay", + deviceId: "device-a", + membershipEpoch: 5, + }; + expect(await store.revokeScopeMembership(revoke)).toBe(true); + expect(await store.revokeScopeMembership(revoke)).toBe(true); + expect(await store.grantScopeMembership(request)).toEqual(first); + expect(await store.listScopeMemberships("scope-replay", true)).toEqual([ + expect.objectContaining({ status: "revoked", membership_epoch: 5 }), + ]); + expect( + await store.listScopeMembershipAuditEvents({ scopeId: "scope-replay" }), + ).toHaveLength(2); + }); + }); + + it("fails closed when an effect id is reused for a different request", async () => { + await withContext(async ({ store }) => { + await store.createScope({ scopeId: "scope-conflict", label: "Conflict" }); + await store.grantScopeMembership({ + effectId: "contract:membership-conflict", + scopeId: "scope-conflict", + deviceId: "device-a", + role: "member", + }); + await expect( + store.grantScopeMembership({ + effectId: "contract:membership-conflict", + scopeId: "scope-conflict", + deviceId: "device-a", + role: "admin", + }), + ).rejects.toThrow("scope_membership_effect_conflict"); + expect(await store.listScopeMemberships("scope-conflict")).toEqual([ + expect.objectContaining({ role: "member", membership_epoch: 0 }), + ]); + expect( + await store.listScopeMembershipAuditEvents({ scopeId: "scope-conflict" }), + ).toHaveLength(1); + + const missingRevoke = { + effectId: "contract:missing-revoke", + scopeId: "scope-conflict", + deviceId: "missing-device", + }; + expect(await store.revokeScopeMembership(missingRevoke)).toBe(false); + expect(await store.revokeScopeMembership(missingRevoke)).toBe(false); + await expect( + store.grantScopeMembership({ + effectId: missingRevoke.effectId, + scopeId: missingRevoke.scopeId, + deviceId: missingRevoke.deviceId, + }), + ).rejects.toThrow("scope_membership_effect_conflict"); + expect(await store.listScopeMemberships("scope-conflict")).toHaveLength(1); + }); + }); }); describe("devices", () => { diff --git a/packages/core/src/coordinator-store.test.ts b/packages/core/src/coordinator-store.test.ts index e8ab1b22..635190a1 100644 --- a/packages/core/src/coordinator-store.test.ts +++ b/packages/core/src/coordinator-store.test.ts @@ -77,6 +77,7 @@ describe("CoordinatorStore", () => { "coordinator_join_requests", "coordinator_reciprocal_approvals", "coordinator_scope_membership_audit_log", + "coordinator_scope_membership_effect_receipts", "coordinator_scope_memberships", "coordinator_scopes", "enrolled_devices", diff --git a/packages/core/src/d1-coordinator-store.test.ts b/packages/core/src/d1-coordinator-store.test.ts index 6177cd41..29ed03e3 100644 --- a/packages/core/src/d1-coordinator-store.test.ts +++ b/packages/core/src/d1-coordinator-store.test.ts @@ -178,6 +178,7 @@ describe("D1CoordinatorStore", () => { const tmpDir = mkdtempSync(join(tmpdir(), "d1-coord-test-")); const db = connectCoordinator(join(tmpDir, "coordinator.sqlite")); db.exec(` + DROP TABLE IF EXISTS coordinator_scope_membership_effect_receipts; DROP TABLE IF EXISTS coordinator_scope_membership_audit_log; DROP TABLE IF EXISTS coordinator_scope_memberships; DROP TABLE IF EXISTS coordinator_scopes; @@ -357,13 +358,25 @@ describe("D1CoordinatorStore", () => { }); await expect( - failingStore.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }), + failingStore.grantScopeMembership({ + effectId: "d1:failed-audit:grant", + scopeId: "scope-acme", + deviceId: "device-a", + }), ).rejects.toThrow("audit insert failed"); expect(await normalStore.listScopeMemberships("scope-acme", true)).toEqual([]); - await normalStore.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }); + await normalStore.grantScopeMembership({ + effectId: "d1:failed-audit:baseline-grant", + scopeId: "scope-acme", + deviceId: "device-a", + }); await expect( - failingStore.revokeScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }), + failingStore.revokeScopeMembership({ + effectId: "d1:failed-audit:revoke", + scopeId: "scope-acme", + deviceId: "device-a", + }), ).rejects.toThrow("audit insert failed"); expect(await normalStore.listScopeMemberships("scope-acme", true)).toEqual([ expect.objectContaining({ device_id: "device-a", status: "active" }), @@ -394,12 +407,20 @@ describe("D1CoordinatorStore", () => { label: "Acme Work", groupId: "group-a", }); - await normalStore.grantScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }); + await normalStore.grantScopeMembership({ + effectId: "d1:race:baseline-grant", + scopeId: "scope-acme", + deviceId: "device-a", + }); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-02T00:00:00.000Z")); await expect( - racingStore.revokeScopeMembership({ scopeId: "scope-acme", deviceId: "device-a" }), + racingStore.revokeScopeMembership({ + effectId: "d1:race:revoke", + scopeId: "scope-acme", + deviceId: "device-a", + }), ).resolves.toBe(false); vi.useRealTimers(); @@ -421,6 +442,42 @@ describe("D1CoordinatorStore", () => { } }); + it("converges concurrent identical D1 effects to one receipt and one audit event", async () => { + const { db, cleanup } = setupStore(); + const firstStore = new D1CoordinatorStore(new SqliteD1Database(db)); + const secondStore = new D1CoordinatorStore(new SqliteD1Database(db)); + try { + await firstStore.createScope({ scopeId: "scope-effect-race", label: "Effect race" }); + const request = { + effectId: "d1:effect-race:grant", + scopeId: "scope-effect-race", + deviceId: "device-a", + membershipEpoch: 3, + }; + + const [first, second] = await Promise.all([ + firstStore.grantScopeMembership(request), + secondStore.grantScopeMembership(request), + ]); + + expect(second).toEqual(first); + expect( + await firstStore.listScopeMembershipAuditEvents({ scopeId: "scope-effect-race" }), + ).toEqual([expect.objectContaining({ effect_id: request.effectId, membership_epoch: 3 })]); + expect( + db + .prepare( + "SELECT COUNT(*) AS count FROM coordinator_scope_membership_effect_receipts WHERE effect_id = ?", + ) + .get(request.effectId), + ).toEqual({ count: 1 }); + } finally { + await secondStore.close(); + await firstStore.close(); + await cleanup(); + } + }); + it("removes presence, reciprocal approvals, and enrollment atomically", async () => { const { db, cleanup } = setupStore(); const store = new D1CoordinatorStore(new SqliteD1Database(db)); diff --git a/packages/core/src/d1-coordinator-store.ts b/packages/core/src/d1-coordinator-store.ts index 0ca1acb4..c9ef1ad7 100644 --- a/packages/core/src/d1-coordinator-store.ts +++ b/packages/core/src/d1-coordinator-store.ts @@ -1,3 +1,12 @@ +import { + assertMatchingMembershipEffectReceipt, + type CoordinatorMembershipEffectReceipt, + CoordinatorMembershipError, + grantMembershipEffectRequestJson, + membershipFromEffectReceipt, + normalizeMembershipEffectId, + revokeMembershipEffectRequestJson, +} from "./coordinator-membership-effects.js"; import type { CoordinatorBootstrapGrant, CoordinatorConsumeProjectInviteInput, @@ -344,7 +353,7 @@ function normalizeGrantInput( throw new Error("membership coordinatorId must match the scope coordinatorId."); } if (groupId && groupId !== scope?.group_id) { - throw new Error("membership groupId must match the scope groupId."); + throw new CoordinatorMembershipError("scope_group_mismatch"); } const requestedEpoch = opts.membershipEpoch == null ? null : normalizeEpoch(opts.membershipEpoch); const inheritedEpoch = scope?.membership_epoch ?? 0; @@ -391,6 +400,8 @@ function normalizeAuditLimit(limit: number | null | undefined): number { function prepareMembershipAuditFromCurrentRow( db: D1DatabaseLike, input: { + effectId: string; + requestJson: string; action: "grant" | "revoke"; previous: CoordinatorScopeMembership | null; scopeId: string; @@ -403,22 +414,22 @@ function prepareMembershipAuditFromCurrentRow( createdAt: string; }, ): D1PreparedStatementLike { - // D1 batch() is the transaction boundary for audited membership changes. - // changes() ties the audit insert to the immediately preceding mutation in - // the same batch. Losing guarded updates insert no audit row, while winning - // mutations still emit one required row; if the post-mutation membership row - // is then missing, NOT NULL audit columns make the statement fail and roll - // back the batch instead of committing an unaudited grant/revoke. + // The immediately preceding statement inserts the immutable effect receipt. + // changes() therefore emits an audit row only for the request that won the + // receipt insert, while outcome_applied excludes persisted no-op revokes. return db .prepare(`INSERT INTO coordinator_scope_membership_audit_log( - action, scope_id, device_id, role, status, membership_epoch, + effect_id, action, scope_id, device_id, role, status, membership_epoch, previous_role, previous_status, previous_membership_epoch, coordinator_id, group_id, actor_type, actor_id, manifest_hash, created_at ) - SELECT ?, membership.scope_id, membership.device_id, membership.role, membership.status, + SELECT receipt.effect_id, ?, membership.scope_id, membership.device_id, membership.role, membership.status, membership.membership_epoch, ?, ?, ?, membership.coordinator_id, membership.group_id, ?, ?, membership.manifest_hash, ? FROM (SELECT 1 WHERE changes() > 0) AS required + JOIN coordinator_scope_membership_effect_receipts AS receipt + ON receipt.effect_id = ? AND receipt.action = ? AND receipt.request_json = ? + AND receipt.outcome_applied = 1 LEFT JOIN coordinator_scope_memberships AS membership ON membership.scope_id = ? AND membership.device_id = ? @@ -433,6 +444,9 @@ function prepareMembershipAuditFromCurrentRow( input.actorType, input.actorId, input.createdAt, + input.effectId, + input.action, + input.requestJson, input.scopeId, input.deviceId, input.status, @@ -441,6 +455,83 @@ function prepareMembershipAuditFromCurrentRow( ); } +async function getMembershipEffectReceipt( + db: D1DatabaseLike, + effectId: string, +): Promise { + return await firstRow( + db + .prepare(`SELECT effect_id, action, request_json, outcome_applied, scope_id, device_id, + role, status, membership_epoch, coordinator_id, group_id, manifest_issuer_device_id, + manifest_hash, signed_manifest_json, updated_at, created_at + FROM coordinator_scope_membership_effect_receipts WHERE effect_id = ?`) + .bind(effectId), + ); +} + +function prepareGrantEffectReceipt( + db: D1DatabaseLike, + input: { + effectId: string; + requestJson: string; + scopeId: string; + deviceId: string; + createdAt: string; + }, +): D1PreparedStatementLike { + return db + .prepare(`INSERT INTO coordinator_scope_membership_effect_receipts( + effect_id, action, request_json, outcome_applied, scope_id, device_id, + role, status, membership_epoch, coordinator_id, group_id, manifest_issuer_device_id, + manifest_hash, signed_manifest_json, updated_at, created_at + ) + SELECT ?, 'grant', ?, 1, membership.scope_id, membership.device_id, + membership.role, membership.status, membership.membership_epoch, + membership.coordinator_id, membership.group_id, membership.manifest_issuer_device_id, + membership.manifest_hash, membership.signed_manifest_json, membership.updated_at, ? + FROM coordinator_scope_memberships AS membership + WHERE changes() > 0 AND membership.scope_id = ? AND membership.device_id = ?`) + .bind(input.effectId, input.requestJson, input.createdAt, input.scopeId, input.deviceId); +} + +function prepareRevokeEffectReceipt( + db: D1DatabaseLike, + input: { + effectId: string; + requestJson: string; + scopeId: string; + deviceId: string; + createdAt: string; + }, +): D1PreparedStatementLike { + return db + .prepare(`INSERT INTO coordinator_scope_membership_effect_receipts( + effect_id, action, request_json, outcome_applied, scope_id, device_id, + role, status, membership_epoch, coordinator_id, group_id, manifest_issuer_device_id, + manifest_hash, signed_manifest_json, updated_at, created_at + ) + SELECT ?, 'revoke', ?, changed.applied, ?, ?, membership.role, membership.status, + membership.membership_epoch, membership.coordinator_id, membership.group_id, + membership.manifest_issuer_device_id, membership.manifest_hash, + membership.signed_manifest_json, membership.updated_at, ? + FROM (SELECT CASE WHEN changes() > 0 THEN 1 ELSE 0 END AS applied) AS changed + LEFT JOIN coordinator_scope_memberships AS membership + ON changed.applied = 1 AND membership.scope_id = ? AND membership.device_id = ? + WHERE NOT EXISTS ( + SELECT 1 FROM coordinator_scope_membership_effect_receipts WHERE effect_id = ? + )`) + .bind( + input.effectId, + input.requestJson, + input.scopeId, + input.deviceId, + input.createdAt, + input.scopeId, + input.deviceId, + input.effectId, + ); +} + async function runAuditedBatch( db: D1DatabaseLike, statements: D1PreparedStatementLike[], @@ -1550,25 +1641,36 @@ export class D1CoordinatorStore implements CoordinatorStore { async grantScopeMembership( opts: CoordinatorGrantScopeMembershipInput, ): Promise { + const effectId = normalizeMembershipEffectId(opts.effectId); + const requestJson = grantMembershipEffectRequestJson(opts); + const priorReceipt = await getMembershipEffectReceipt(this.db, effectId); + if (priorReceipt) { + assertMatchingMembershipEffectReceipt(priorReceipt, "grant", requestJson); + return membershipFromEffectReceipt(priorReceipt); + } const scopeId = clean(opts.scopeId); const deviceId = clean(opts.deviceId); const scope = scopeId ? await this.getScope(scopeId) : null; - if (!scope) throw new Error("scopeId must reference an existing scope."); + if (!scope) throw new CoordinatorMembershipError("scope_not_found"); + if (scope.status !== "active") throw new CoordinatorMembershipError("scope_inactive"); const existing = scopeId && deviceId ? await this.getScopeMembership(scopeId, deviceId) : null; const normalized = normalizeGrantInput(opts, scope, existing); if (normalized.groupId) { const enrollment = await this.getEnrollment(normalized.groupId, normalized.deviceId); if (!enrollment) { - throw new Error("device must be enrolled and enabled in the scope group."); + throw new CoordinatorMembershipError("device_not_enrolled"); } } const now = nowISO(); - const batchResults = await runAuditedBatch(this.db, [ + await runAuditedBatch(this.db, [ this.db .prepare(`INSERT INTO coordinator_scope_memberships( scope_id, device_id, role, status, membership_epoch, coordinator_id, group_id, manifest_issuer_device_id, manifest_hash, signed_manifest_json, updated_at - ) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?) + ) SELECT ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM coordinator_scope_membership_effect_receipts WHERE effect_id = ? + ) ON CONFLICT(scope_id, device_id) DO UPDATE SET role = excluded.role, status = 'active', @@ -1595,8 +1697,18 @@ export class D1CoordinatorStore implements CoordinatorStore { normalized.manifestHash, normalized.signedManifestJson, now, + effectId, ), + prepareGrantEffectReceipt(this.db, { + effectId, + requestJson, + scopeId: normalized.scopeId, + deviceId: normalized.deviceId, + createdAt: now, + }), prepareMembershipAuditFromCurrentRow(this.db, { + effectId, + requestJson, action: "grant", previous: existing, scopeId: normalized.scopeId, @@ -1609,33 +1721,36 @@ export class D1CoordinatorStore implements CoordinatorStore { createdAt: now, }), ]); - if (batchResultChanges(batchResults[0]) <= 0) { - throw new Error("scope membership grant was not applied."); - } - const row = await firstRow( - this.db - .prepare(`SELECT scope_id, device_id, role, status, membership_epoch, coordinator_id, group_id, - manifest_issuer_device_id, manifest_hash, signed_manifest_json, updated_at - FROM coordinator_scope_memberships - WHERE scope_id = ? AND device_id = ?`) - .bind(normalized.scopeId, normalized.deviceId), - ); - return rowToRecord(row); + const receipt = await getMembershipEffectReceipt(this.db, effectId); + if (!receipt) throw new Error("scope membership grant was not applied."); + assertMatchingMembershipEffectReceipt(receipt, "grant", requestJson); + return membershipFromEffectReceipt(receipt); } async revokeScopeMembership(opts: CoordinatorRevokeScopeMembershipInput): Promise { const scopeId = clean(opts.scopeId); const deviceId = clean(opts.deviceId); if (!scopeId || !deviceId) throw new Error("scopeId and deviceId are required."); + const effectId = normalizeMembershipEffectId(opts.effectId); + const requestJson = revokeMembershipEffectRequestJson(opts); + const priorReceipt = await getMembershipEffectReceipt(this.db, effectId); + if (priorReceipt) { + assertMatchingMembershipEffectReceipt(priorReceipt, "revoke", requestJson); + return priorReceipt.outcome_applied === 1; + } const membershipEpoch = opts.membershipEpoch == null ? null : normalizeEpoch(opts.membershipEpoch); + const requestedGroupId = clean(opts.groupId); + const scope = requestedGroupId ? await this.getScope(scopeId) : null; + if (requestedGroupId && requestedGroupId !== scope?.group_id) { + throw new CoordinatorMembershipError("scope_group_mismatch"); + } const existing = await this.getScopeMembership(scopeId, deviceId); - if (!existing) return false; if (membershipEpoch != null && existing && membershipEpoch <= existing.membership_epoch) { throw new Error("membershipEpoch must increase on revoke."); } const now = nowISO(); - const revokedEpoch = membershipEpoch ?? existing.membership_epoch + 1; + const revokedEpoch = membershipEpoch ?? (existing?.membership_epoch ?? -1) + 1; const updateStatement = this.db .prepare(`UPDATE coordinator_scope_memberships SET status = 'revoked', @@ -1643,7 +1758,10 @@ export class D1CoordinatorStore implements CoordinatorStore { manifest_hash = COALESCE(?, manifest_hash), signed_manifest_json = COALESCE(?, signed_manifest_json), updated_at = ? - WHERE scope_id = ? AND device_id = ? AND membership_epoch = ? AND status = ?`) + WHERE scope_id = ? AND device_id = ? AND membership_epoch = ? AND status = ? + AND NOT EXISTS ( + SELECT 1 FROM coordinator_scope_membership_effect_receipts WHERE effect_id = ? + )`) .bind( membershipEpoch, membershipEpoch, @@ -1652,10 +1770,20 @@ export class D1CoordinatorStore implements CoordinatorStore { now, scopeId, deviceId, - existing.membership_epoch, - existing.status, + existing?.membership_epoch ?? -1, + existing?.status ?? "", + effectId, ); + const receiptStatement = prepareRevokeEffectReceipt(this.db, { + effectId, + requestJson, + scopeId, + deviceId, + createdAt: now, + }); const auditStatement = prepareMembershipAuditFromCurrentRow(this.db, { + effectId, + requestJson, action: "revoke", previous: existing, scopeId, @@ -1667,22 +1795,24 @@ export class D1CoordinatorStore implements CoordinatorStore { actorId: clean(opts.actorId), createdAt: now, }); - let batchResults: unknown[]; try { - batchResults = await runAuditedBatch(this.db, [updateStatement, auditStatement]); + await runAuditedBatch(this.db, [updateStatement, receiptStatement, auditStatement]); } catch (err) { const current = await this.getScopeMembership(scopeId, deviceId); if ( - !current || - current.membership_epoch !== existing.membership_epoch || - current.status !== existing.status + existing && + (!current || + current.membership_epoch !== existing.membership_epoch || + current.status !== existing.status) ) { return false; } throw err; } - if (batchResultChanges(batchResults[0]) <= 0) return false; - return true; + const receipt = await getMembershipEffectReceipt(this.db, effectId); + if (!receipt) throw new Error("scope membership revoke receipt was not persisted."); + assertMatchingMembershipEffectReceipt(receipt, "revoke", requestJson); + return receipt.outcome_applied === 1; } async listScopeMemberships( @@ -1717,7 +1847,7 @@ export class D1CoordinatorStore implements CoordinatorStore { return ( await allRows( this.db - .prepare(`SELECT event_id, action, scope_id, device_id, role, status, membership_epoch, + .prepare(`SELECT event_id, effect_id, action, scope_id, device_id, role, status, membership_epoch, previous_role, previous_status, previous_membership_epoch, coordinator_id, group_id, actor_type, actor_id, manifest_hash, created_at FROM coordinator_scope_membership_audit_log diff --git a/packages/core/src/db.test.ts b/packages/core/src/db.test.ts index 0b310005..32f40394 100644 --- a/packages/core/src/db.test.ts +++ b/packages/core/src/db.test.ts @@ -133,6 +133,9 @@ describe("connect", () => { expect(tableExists(db, "sync_reset_state_v2")).toBe(true); expect(tableExists(db, "sync_retention_state_v2")).toBe(true); expect(tableExists(db, "replication_cursors_v2")).toBe(true); + expect(tableExists(db, "recipient_policy_authority_states")).toBe(true); + expect(tableExists(db, "recipient_policy_reconciliation_steps")).toBe(true); + expect(tableExists(db, "recipient_policy_deny_overlays")).toBe(true); expect(columnExists(db, "memory_items", "scope_id")).toBe(true); expect(columnExists(db, "replication_ops", "scope_id")).toBe(true); expect(hasIndex(db, "idx_memory_items_scope_visibility_created")).toBe(true); @@ -1052,6 +1055,9 @@ describe("ensureAdditiveSchemaCompatibility schema-compat gate", () => { "policy_team_memberships", "identity_devices", "project_recipients", + "recipient_policy_authority_states", + "recipient_policy_reconciliation_steps", + "recipient_policy_deny_overlays", ]) { expect(tableExists(db, table)).toBe(true); } @@ -1085,6 +1091,9 @@ describe("ensureAdditiveSchemaCompatibility schema-compat gate", () => { "policy_team_memberships", "identity_devices", "project_recipients", + "recipient_policy_authority_states", + "recipient_policy_reconciliation_steps", + "recipient_policy_deny_overlays", ]) { expect(tableExists(previous, table)).toBe(true); } diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 1c3d6002..c2d0f1c7 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -32,7 +32,7 @@ import { canAutoBootstrapSchema, ensureSchemaBootstrapped } from "./schema-boots export type { DatabaseType as Database }; /** Current schema version this TS runtime was built against. */ -export const SCHEMA_VERSION = 12; +export const SCHEMA_VERSION = 13; /** * Minimum schema version the TS runtime can operate with. @@ -794,6 +794,64 @@ export function ensureAdditiveSchemaCompatibility(db: DatabaseType): void { updated_at TEXT NOT NULL, PRIMARY KEY (canonical_project_identity, recipient_kind, recipient_id) ); + CREATE TABLE IF NOT EXISTS recipient_policy_authority_states ( + canonical_project_identity TEXT PRIMARY KEY NOT NULL, + authority_state TEXT NOT NULL DEFAULT 'legacy', + generation INTEGER NOT NULL DEFAULT 0, + desired_devices_digest TEXT, + current_devices_digest TEXT, + stable_parity_evidence_digest TEXT, + stable_parity_passed_at TEXT, + fresh_snapshot_fingerprint TEXT, + fresh_snapshot_observed_at TEXT, + safe_error_code TEXT, + state_changed_at TEXT NOT NULL, + last_error_at TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempt_at TEXT, + last_completed_at TEXT, + lease_owner TEXT, + lease_acquired_at TEXT, + lease_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS recipient_policy_reconciliation_steps ( + canonical_project_identity TEXT NOT NULL, + generation INTEGER NOT NULL, + step_key TEXT NOT NULL, + effect_id TEXT NOT NULL, + payload_digest TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + started_at TEXT, + completed_at TEXT, + last_attempt_at TEXT, + safe_error_code TEXT, + error_at TEXT, + lease_owner TEXT, + lease_acquired_at TEXT, + lease_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (canonical_project_identity, generation, step_key) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_recipient_policy_reconciliation_steps_effect + ON recipient_policy_reconciliation_steps(effect_id); + CREATE INDEX IF NOT EXISTS idx_recipient_policy_reconciliation_steps_status + ON recipient_policy_reconciliation_steps(canonical_project_identity, status); + CREATE TABLE IF NOT EXISTS recipient_policy_deny_overlays ( + canonical_project_identity TEXT NOT NULL, + scope_id TEXT NOT NULL, + device_id TEXT NOT NULL, + generation INTEGER NOT NULL, + reason_code TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (canonical_project_identity, scope_id, device_id) + ); + CREATE INDEX IF NOT EXISTS idx_recipient_policy_deny_overlays_scope_device + ON recipient_policy_deny_overlays(scope_id, device_id); CREATE INDEX IF NOT EXISTS idx_policy_team_memberships_identity_status ON policy_team_memberships(identity_id, status); CREATE INDEX IF NOT EXISTS idx_identity_devices_identity_status @@ -1365,6 +1423,9 @@ export function ensureAdditiveSchemaCompatibility(db: DatabaseType): void { "policy_team_memberships", "identity_devices", "project_recipients", + "recipient_policy_authority_states", + "recipient_policy_reconciliation_steps", + "recipient_policy_deny_overlays", ].every((table) => tableExists(db, table)); const currentVersion = getSchemaVersion(db); if (recipientPolicyTablesReady && currentVersion > 0 && currentVersion < SCHEMA_VERSION) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 91fa96ca..70cd99ad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -640,6 +640,55 @@ export { previewRecipientPolicyOnboarding, RecipientPolicyOnboardingRequestError, } from "./recipient-policy-onboarding.js"; +export type { + RecipientPolicyCoordinatorEffectReceipt, + RecipientPolicyCoordinatorSnapshot, + RecipientPolicyPeerCapability, + RecipientPolicyReconcileResult, + RecipientPolicyReconcilerEffects, + RecipientPolicyReconcileStatus, + ReconcileRecipientPolicyProjectInput, +} from "./recipient-policy-reconciler.js"; +export { + assertLegacyShareGrantAllowed, + reconcileRecipientPolicyProject, +} from "./recipient-policy-reconciler.js"; +export type { + DeriveRecipientPolicyEffectiveDevicesInput, + RecipientPolicyAuthorityState, + RecipientPolicyAuthorityStateRecord, + RecipientPolicyDenyOverlayRecord, + RecipientPolicyDerivationBlock, + RecipientPolicyDerivationBlockCode, + RecipientPolicyDerivationIdentity, + RecipientPolicyDerivationIdentityDevice, + RecipientPolicyDerivationProjectRecipient, + RecipientPolicyDerivationTeam, + RecipientPolicyDerivationTeamMembership, + RecipientPolicyEffectiveDeviceSource, + RecipientPolicyReconciliationStepRecord, + RecordRecipientPolicyAuthorityExecutionInput, + RecordRecipientPolicyReconciliationStepStateInput, + StrictRecipientPolicyEffectiveDevice, + StrictRecipientPolicyEffectiveDeviceDerivation, + UpsertRecipientPolicyAuthorityObservationInput, +} from "./recipient-policy-reconciliation.js"; +export { + clearRecipientPolicyDenyOverlay, + deriveRecipientPolicyEffectiveDevices, + deriveRecipientPolicyEffectiveDevicesFromDatabase, + deterministicRecipientPolicyReconciliationEffectId, + ensureRecipientPolicyReconciliationStep, + getAnyRecipientPolicyDenyOverlayForScopeDevice, + getRecipientPolicyAuthorityState, + listRecipientPolicyDenyOverlays, + putRecipientPolicyDenyOverlay, + recipientPolicyDevicesDigest, + recordRecipientPolicyAuthorityExecution, + recordRecipientPolicyReconciliationStepState, + recordRecipientPolicyStableParityPass, + upsertRecipientPolicyAuthorityObservation, +} from "./recipient-policy-reconciliation.js"; export type { RecipientPolicyActionableReviewItemV1, RecipientPolicyDerivedReviewState, @@ -675,10 +724,16 @@ export type { NewPolicyTeam, NewPolicyTeamMembership, NewProjectRecipient, + NewRecipientPolicyAuthorityStateRow, + NewRecipientPolicyDenyOverlay, + NewRecipientPolicyReconciliationStep, NewRecipientPolicyReviewResolution, PolicyTeam, PolicyTeamMembership, ProjectRecipient, + RecipientPolicyAuthorityStateRow, + RecipientPolicyDenyOverlay, + RecipientPolicyReconciliationStep, RecipientPolicyReviewResolution, } from "./schema.js"; export * as schema from "./schema.js"; diff --git a/packages/core/src/recipient-policy-reconciler.test.ts b/packages/core/src/recipient-policy-reconciler.test.ts new file mode 100644 index 00000000..471ebea8 --- /dev/null +++ b/packages/core/src/recipient-policy-reconciler.test.ts @@ -0,0 +1,484 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type RecipientPolicyReconcilerEffects, + reconcileRecipientPolicyProject, +} from "./recipient-policy-reconciler.js"; +import { + getRecipientPolicyAuthorityState, + listRecipientPolicyDenyOverlays, + putRecipientPolicyDenyOverlay, +} from "./recipient-policy-reconciliation.js"; +import { initTestSchema } from "./test-utils.js"; + +const PROJECT = "https://git.example.invalid/acme/reconciled.git"; +const SCOPE = "managed-project-scope"; +const BASE_TIME = Date.parse("2026-07-22T10:00:00.000Z"); + +function insertPolicyGraph(db: InstanceType): void { + const now = new Date(BASE_TIME).toISOString(); + db.prepare( + `INSERT INTO actors(actor_id, display_name, is_local, status, created_at, updated_at) + VALUES ('identity-a', 'Identity A', 1, 'active', ?, ?)`, + ).run(now, now); + const insertDevice = db.prepare( + `INSERT INTO identity_devices( + device_id, identity_id, display_name, status, provenance, revision, migration_state, + idempotency_key, created_at, updated_at + ) VALUES (?, 'identity-a', ?, 'active', 'test', '1', 'native', ?, ?, ?)`, + ); + insertDevice.run("device-keep", "Keep", "device:keep", now, now); + insertDevice.run("device-new", "New", "device:new", now, now); + db.prepare( + `INSERT INTO project_recipients( + canonical_project_identity, recipient_kind, recipient_id, status, provenance, + policy_revision, migration_state, idempotency_key, created_at, updated_at + ) VALUES (?, 'identity', 'identity-a', 'active', 'test', '1', 'native', 'recipient:a', ?, ?)`, + ).run(PROJECT, now, now); + db.prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, coordinator_id, group_id, membership_epoch, + status, created_at, updated_at + ) VALUES (?, 'Managed Project', 'managed_project', 'coordinator', 'coord', 'group', 1, + 'active', ?, ?)`, + ).run(SCOPE, now, now); + db.prepare( + `INSERT INTO project_scope_mappings( + workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + ) VALUES (?, ?, ?, 1000, 'test', ?, ?)`, + ).run(PROJECT, PROJECT, SCOPE, now, now); +} + +function harness(active: string[]) { + let tick = 0; + const members = new Set(active); + const calls: string[] = []; + const now = () => new Date(BASE_TIME + tick++ * 1_000).toISOString(); + const effects: RecipientPolicyReconcilerEffects = { + now, + snapshot: vi.fn(async () => { + calls.push("snapshot"); + const deviceIds = [...members].toSorted(); + return { + authoritative: true, + scopeId: SCOPE, + fingerprint: `snapshot:${deviceIds.join(",")}`, + observedAt: now(), + memberships: deviceIds.map((deviceId) => ({ deviceId, status: "active" as const })), + }; + }), + probeCapability: vi.fn(async (deviceId) => { + calls.push(`probe:${deviceId}`); + return "supported"; + }), + revoke: vi.fn(async (input) => { + calls.push(`revoke:${input.deviceId}`); + members.delete(input.deviceId); + return { + effectId: input.effectId, + scopeId: input.scopeId, + deviceId: input.deviceId, + status: "revoked", + }; + }), + grant: vi.fn(async (input) => { + calls.push(`grant:${input.deviceId}`); + members.add(input.deviceId); + return { + effectId: input.effectId, + scopeId: input.scopeId, + deviceId: input.deviceId, + status: "active", + }; + }), + refresh: vi.fn(async () => { + calls.push("refresh"); + }), + }; + return { calls, effects, members }; +} + +function insertActiveAuthority(db: InstanceType): void { + const now = new Date(BASE_TIME).toISOString(); + db.prepare( + `INSERT INTO recipient_policy_authority_states( + canonical_project_identity, authority_state, generation, desired_devices_digest, + state_changed_at, created_at, updated_at + ) VALUES (?, 'active', 1, 'old-desired', ?, ?, ?)`, + ).run(PROJECT, now, now, now); +} + +describe("recipient-policy reconciler executor", () => { + let db: InstanceType; + + beforeEach(() => { + db = new Database(":memory:"); + initTestSchema(db); + insertPolicyGraph(db); + }); + + afterEach(() => db.close()); + + it("revokes before grants, verifies parity, and activates only on a later no-op pass", async () => { + const { calls, effects } = harness(["device-keep", "device-old"]); + + const first = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(first).toMatchObject({ + status: "parity_pending", + revokedDeviceIds: ["device-old"], + grantedDeviceIds: ["device-new"], + deliveredCopiesMayRemain: true, + }); + expect(calls).toEqual([ + "snapshot", + "probe:device-keep", + "probe:device-new", + "probe:device-old", + "revoke:device-old", + "grant:device-new", + "refresh", + "snapshot", + ]); + expect(getRecipientPolicyAuthorityState(db, PROJECT)?.authorityState).toBe("eligible"); + expect(listRecipientPolicyDenyOverlays(db, PROJECT)).toEqual([]); + + const second = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-b" }, + effects, + ); + + expect(second.status).toBe("active"); + expect(second.revokedDeviceIds).toEqual([]); + expect(second.grantedDeviceIds).toEqual([]); + expect(vi.mocked(effects.revoke)).toHaveBeenCalledTimes(1); + expect(vi.mocked(effects.grant)).toHaveBeenCalledTimes(1); + expect(vi.mocked(effects.refresh)).toHaveBeenCalledTimes(2); + expect(getRecipientPolicyAuthorityState(db, PROJECT)?.authorityState).toBe("active"); + }); + + it("preflights every peer and needs attention without mutations for confirmed unsupported peers", async () => { + const { effects } = harness(["device-keep", "device-old"]); + vi.mocked(effects.probeCapability).mockImplementation(async (deviceId) => + deviceId === "device-new" ? "unsupported" : "supported", + ); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome).toMatchObject({ + status: "needs_attention", + safeErrorCode: "recipient_policy_capability_unsupported", + }); + expect(effects.revoke).not.toHaveBeenCalled(); + expect(effects.grant).not.toHaveBeenCalled(); + expect(effects.refresh).not.toHaveBeenCalled(); + expect(vi.mocked(effects.probeCapability).mock.calls.map(([deviceId]) => deviceId)).toEqual([ + "device-keep", + "device-new", + "device-old", + ]); + expect(listRecipientPolicyDenyOverlays(db, PROJECT)).toEqual([ + expect.objectContaining({ scopeId: SCOPE, deviceId: "device-old" }), + ]); + }); + + it("retries a failed coordinator mutation with the same deterministic effect identity", async () => { + const { effects, members } = harness(["device-keep"]); + const effectIds: string[] = []; + let fail = true; + vi.mocked(effects.grant).mockImplementation(async (input) => { + effectIds.push(input.effectId); + if (fail) { + fail = false; + throw new Error("response_lost"); + } + members.add(input.deviceId); + return { + effectId: input.effectId, + scopeId: input.scopeId, + deviceId: input.deviceId, + status: "active", + }; + }); + + const failed = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + const retried = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-b" }, + effects, + ); + + expect(failed.safeErrorCode).toBe("recipient_policy_effect_failed"); + expect(retried.safeErrorCode).toBeNull(); + expect(retried.status).toBe("parity_pending"); + expect(effectIds).toHaveLength(2); + expect(effectIds[0]).toBe(effectIds[1]); + }); + + it("waits without mutations when a capability is undetermined", async () => { + const { effects } = harness(["device-keep"]); + vi.mocked(effects.probeCapability).mockResolvedValue("undetermined"); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome).toMatchObject({ + status: "waiting", + safeErrorCode: "recipient_policy_capability_undetermined", + }); + expect(effects.revoke).not.toHaveBeenCalled(); + expect(effects.grant).not.toHaveBeenCalled(); + }); + + it("preserves active authority while capability evidence is undetermined", async () => { + const { effects } = harness(["device-keep"]); + vi.mocked(effects.probeCapability).mockResolvedValue("undetermined"); + insertActiveAuthority(db); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome.safeErrorCode).toBe("recipient_policy_capability_undetermined"); + expect(getRecipientPolicyAuthorityState(db, PROJECT)?.authorityState).toBe("active"); + }); + + it("preserves active authority while the coordinator snapshot is not fresh", async () => { + const { effects } = harness(["device-keep", "device-new"]); + vi.mocked(effects.snapshot).mockResolvedValue({ + authoritative: true, + scopeId: SCOPE, + fingerprint: "snapshot:stale", + observedAt: new Date(BASE_TIME - 1_000).toISOString(), + memberships: [], + }); + insertActiveAuthority(db); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome.safeErrorCode).toBe("recipient_policy_snapshot_not_fresh"); + expect(getRecipientPolicyAuthorityState(db, PROJECT)?.authorityState).toBe("active"); + }); + + it("keeps a deny overlay until a fresh snapshot actually proves revocation", async () => { + const { effects } = harness(["device-keep", "device-old"]); + vi.mocked(effects.revoke).mockImplementation(async (input) => ({ + effectId: input.effectId, + scopeId: input.scopeId, + deviceId: input.deviceId, + status: "revoked", + })); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome).toMatchObject({ + status: "waiting", + safeErrorCode: "recipient_policy_parity_incomplete", + }); + expect(listRecipientPolicyDenyOverlays(db, PROJECT)).toEqual([ + expect.objectContaining({ deviceId: "device-old" }), + ]); + }); + + it("preserves active authority while fresh parity remains incomplete", async () => { + const { effects } = harness(["device-keep", "device-old"]); + vi.mocked(effects.revoke).mockImplementation(async (input) => ({ + effectId: input.effectId, + scopeId: input.scopeId, + deviceId: input.deviceId, + status: "revoked", + })); + insertActiveAuthority(db); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome.safeErrorCode).toBe("recipient_policy_parity_incomplete"); + expect(getRecipientPolicyAuthorityState(db, PROJECT)?.authorityState).toBe("active"); + }); + + it("re-grants stale active membership and withholds authority until its epoch is current", async () => { + const { effects } = harness(["device-keep", "device-new"]); + vi.mocked(effects.snapshot).mockImplementation(async () => ({ + authoritative: true, + scopeId: SCOPE, + scopeMembershipEpoch: 2, + fingerprint: "snapshot:stale-active-device-new", + observedAt: effects.now(), + memberships: [ + { deviceId: "device-keep", status: "active", membershipEpoch: 2 }, + { deviceId: "device-new", status: "active", membershipEpoch: 1 }, + ], + })); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(effects.grant).toHaveBeenCalledWith( + expect.objectContaining({ scopeId: SCOPE, deviceId: "device-new" }), + ); + expect(outcome).toMatchObject({ + status: "waiting", + safeErrorCode: "recipient_policy_parity_incomplete", + grantedDeviceIds: ["device-new"], + }); + expect(getRecipientPolicyAuthorityState(db, PROJECT)?.authorityState).not.toBe("active"); + }); + + it("clears an abandoned deny overlay after a re-desired device is freshly verified active", async () => { + putRecipientPolicyDenyOverlay(db, { + canonicalProjectIdentity: PROJECT, + scopeId: SCOPE, + deviceId: "device-new", + generation: 1, + reasonCode: "pending_revoke", + now: new Date(BASE_TIME).toISOString(), + }); + const { effects } = harness(["device-keep", "device-new"]); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome.status).toBe("parity_pending"); + expect(listRecipientPolicyDenyOverlays(db, PROJECT)).toEqual([]); + }); + + it("cancels a stale generation after revokes and before any grant", async () => { + const { effects } = harness(["device-old"]); + vi.mocked(effects.probeCapability).mockImplementation(async (deviceId) => { + if (deviceId === "device-old") { + db.prepare( + "UPDATE identity_devices SET status = 'revoked' WHERE device_id = 'device-new'", + ).run(); + } + return "supported"; + }); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome).toMatchObject({ + status: "stale", + safeErrorCode: "recipient_policy_generation_stale", + revokedDeviceIds: ["device-old"], + grantedDeviceIds: [], + }); + expect(effects.grant).not.toHaveBeenCalled(); + }); + + it("rejects ambiguous exact-Project mappings before reading a coordinator snapshot", async () => { + db.prepare( + `INSERT INTO project_scope_mappings( + workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + ) VALUES (?, ?, ?, 999, 'test', ?, ?)`, + ).run( + PROJECT, + PROJECT, + SCOPE, + new Date(BASE_TIME).toISOString(), + new Date(BASE_TIME).toISOString(), + ); + const { effects } = harness(["device-keep"]); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome.safeErrorCode).toBe("recipient_policy_exact_mapping_required"); + expect(effects.snapshot).not.toHaveBeenCalled(); + }); + + it("recovers an expired lease but leaves an unexpired foreign lease untouched", async () => { + const { effects } = harness(["device-keep", "device-new"]); + db.prepare( + `INSERT INTO recipient_policy_authority_states( + canonical_project_identity, authority_state, generation, state_changed_at, lease_owner, + lease_acquired_at, lease_expires_at, created_at, updated_at + ) VALUES (?, 'legacy', 0, ?, 'other-worker', ?, ?, ?, ?)`, + ).run( + PROJECT, + new Date(BASE_TIME).toISOString(), + new Date(BASE_TIME).toISOString(), + new Date(BASE_TIME + 30_000).toISOString(), + new Date(BASE_TIME).toISOString(), + new Date(BASE_TIME).toISOString(), + ); + + const busy = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + expect(busy.status).toBe("busy"); + expect(effects.snapshot).not.toHaveBeenCalled(); + + db.prepare( + "UPDATE recipient_policy_authority_states SET lease_expires_at = ? WHERE canonical_project_identity = ?", + ).run(new Date(BASE_TIME - 1_000).toISOString(), PROJECT); + const recovered = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + expect(recovered.status).toBe("parity_pending"); + }); + + it("rolls active authority back without granting or clearing a pending deny", async () => { + const { effects } = harness(["device-keep", "device-old"]); + vi.mocked(effects.probeCapability).mockResolvedValue("unsupported"); + insertActiveAuthority(db); + + const outcome = await reconcileRecipientPolicyProject( + db, + { canonicalProjectIdentity: PROJECT, leaseOwner: "worker-a" }, + effects, + ); + + expect(outcome.status).toBe("needs_attention"); + expect(getRecipientPolicyAuthorityState(db, PROJECT)?.authorityState).toBe("rolled_back"); + expect(effects.grant).not.toHaveBeenCalled(); + expect(listRecipientPolicyDenyOverlays(db, PROJECT)).toEqual([ + expect.objectContaining({ deviceId: "device-old" }), + ]); + }); +}); diff --git a/packages/core/src/recipient-policy-reconciler.ts b/packages/core/src/recipient-policy-reconciler.ts new file mode 100644 index 00000000..f65a8bd8 --- /dev/null +++ b/packages/core/src/recipient-policy-reconciler.ts @@ -0,0 +1,860 @@ +import { createHash } from "node:crypto"; +import type { Database } from "./db.js"; +import { + clearRecipientPolicyDenyOverlay, + deriveRecipientPolicyEffectiveDevicesFromDatabase, + ensureRecipientPolicyReconciliationStep, + getRecipientPolicyAuthorityState, + listRecipientPolicyDenyOverlays, + putRecipientPolicyDenyOverlay, + recordRecipientPolicyReconciliationStepState, + recordRecipientPolicyStableParityPass, + upsertRecipientPolicyAuthorityObservation, +} from "./recipient-policy-reconciliation.js"; +import { SCOPE_MEMBERSHIP_REVOCATION_LIMITATION } from "./scope-membership-semantics.js"; + +export type RecipientPolicyPeerCapability = "supported" | "unsupported" | "undetermined"; + +export interface RecipientPolicyCoordinatorSnapshot { + authoritative: boolean; + scopeId: string; + scopeMembershipEpoch?: number; + fingerprint: string; + observedAt: string; + memberships: Array<{ + deviceId: string; + status: "active" | "revoked"; + membershipEpoch?: number; + }>; +} + +export interface RecipientPolicyCoordinatorEffectReceipt { + effectId: string; + scopeId: string; + deviceId: string; + status: "active" | "revoked"; +} + +export interface RecipientPolicyReconcilerEffects { + now(): string; + snapshot(input: { + canonicalProjectIdentity: string; + scopeId: string; + }): Promise; + probeCapability(deviceId: string): Promise; + revoke(input: { + effectId: string; + canonicalProjectIdentity: string; + generation: number; + scopeId: string; + deviceId: string; + }): Promise; + grant(input: { + effectId: string; + canonicalProjectIdentity: string; + generation: number; + scopeId: string; + deviceId: string; + role: "member"; + }): Promise; + refresh(input: { canonicalProjectIdentity: string; scopeId: string }): Promise; +} + +export type RecipientPolicyReconcileStatus = + | "active" + | "busy" + | "needs_attention" + | "parity_pending" + | "stale" + | "waiting"; + +export interface RecipientPolicyReconcileResult { + canonicalProjectIdentity: string; + status: RecipientPolicyReconcileStatus; + generation: number; + safeErrorCode: string | null; + revokedDeviceIds: string[]; + grantedDeviceIds: string[]; + deliveredCopiesMayRemain: true; + revocationWarning: string; +} + +export interface ReconcileRecipientPolicyProjectInput { + canonicalProjectIdentity: string; + leaseOwner: string; + leaseDurationMs?: number; +} + +interface ManagedProjectBoundary { + scopeId: string; +} + +interface Lease { + acquiredAt: string; + expiresAt: string; +} + +const DEFAULT_LEASE_DURATION_MS = 60_000; +const DELIVERED_COPY_WARNING = true as const; +const CONTROL_CHARACTER = /\p{Cc}/u; +const RETRYABLE_ACTIVE_AUTHORITY_ERRORS = new Set([ + "recipient_policy_capability_undetermined", + "recipient_policy_parity_incomplete", + "recipient_policy_snapshot_not_fresh", +]); +const SAFE_RECONCILIATION_ERRORS = new Set([ + "recipient_policy_active_managed_scope_required", + "recipient_policy_authority_state_missing", + "recipient_policy_capability_undetermined", + "recipient_policy_capability_unsupported", + "recipient_policy_deny_overlay_conflict", + "recipient_policy_deny_overlay_stale", + "recipient_policy_effect_failed", + "recipient_policy_effect_receipt_invalid", + "recipient_policy_exact_mapping_required", + "recipient_policy_generation_conflict", + "recipient_policy_generation_stale", + "recipient_policy_lease_lost", + "recipient_policy_parity_evidence_conflict", + "recipient_policy_parity_evidence_invalid", + "recipient_policy_reconciliation_step_conflict", + "recipient_policy_snapshot_invalid", + "recipient_policy_snapshot_not_fresh", +]); + +function validId(value: string): boolean { + return value.length > 0 && value === value.trim() && !CONTROL_CHARACTER.test(value); +} + +function timestamp(value: string, errorCode: string): number { + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) throw new Error(errorCode); + return parsed; +} + +function digest(prefix: string, value: unknown): string { + return `${prefix}:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`; +} + +function safeError(error: unknown, fallback: string): string { + const message = error instanceof Error ? error.message : ""; + return SAFE_RECONCILIATION_ERRORS.has(message) ? message : fallback; +} + +function deviceDigest(deviceIds: readonly string[]): string { + return digest("recipient-policy-current-devices-v1", deviceIds.toSorted()); +} + +function result( + projectId: string, + status: RecipientPolicyReconcileStatus, + generation: number, + safeErrorCode: string | null, + revokedDeviceIds: string[] = [], + grantedDeviceIds: string[] = [], +): RecipientPolicyReconcileResult { + return { + canonicalProjectIdentity: projectId, + status, + generation, + safeErrorCode, + revokedDeviceIds, + grantedDeviceIds, + deliveredCopiesMayRemain: DELIVERED_COPY_WARNING, + revocationWarning: SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, + }; +} + +function acquireLease( + db: Database, + input: ReconcileRecipientPolicyProjectInput, + now: string, +): Lease | null { + if (!validId(input.canonicalProjectIdentity) || !validId(input.leaseOwner)) { + throw new Error("recipient_policy_reconciliation_input_invalid"); + } + const duration = input.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS; + if (!Number.isSafeInteger(duration) || duration <= 0) { + throw new Error("recipient_policy_reconciliation_lease_invalid"); + } + const expiresAt = new Date( + timestamp(now, "recipient_policy_reconciliation_time_invalid") + duration, + ).toISOString(); + return db.transaction(() => { + db.prepare( + `INSERT OR IGNORE INTO recipient_policy_authority_states( + canonical_project_identity, authority_state, generation, state_changed_at, created_at, updated_at + ) VALUES (?, 'legacy', 0, ?, ?, ?)`, + ).run(input.canonicalProjectIdentity, now, now, now); + const state = getRecipientPolicyAuthorityState(db, input.canonicalProjectIdentity); + if (!state) throw new Error("recipient_policy_authority_state_missing"); + const heldByOther = + state.leaseOwner !== null && + state.leaseOwner !== input.leaseOwner && + state.leaseExpiresAt !== null && + timestamp(state.leaseExpiresAt, "recipient_policy_reconciliation_lease_invalid") > + timestamp(now, "recipient_policy_reconciliation_time_invalid"); + if (heldByOther) return null; + db.prepare( + `UPDATE recipient_policy_authority_states SET lease_owner = ?, lease_acquired_at = ?, + lease_expires_at = ?, updated_at = ? WHERE canonical_project_identity = ?`, + ).run(input.leaseOwner, now, expiresAt, now, input.canonicalProjectIdentity); + return { acquiredAt: now, expiresAt }; + })(); +} + +function releaseLease(db: Database, projectId: string, leaseOwner: string, now: string): void { + db.prepare( + `UPDATE recipient_policy_authority_states SET lease_owner = NULL, lease_acquired_at = NULL, + lease_expires_at = NULL, updated_at = ? + WHERE canonical_project_identity = ? AND lease_owner = ?`, + ).run(now, projectId, leaseOwner); +} + +function assertLease(db: Database, projectId: string, leaseOwner: string, now: string): void { + const state = getRecipientPolicyAuthorityState(db, projectId); + if ( + state?.leaseOwner !== leaseOwner || + state.leaseExpiresAt === null || + timestamp(state.leaseExpiresAt, "recipient_policy_reconciliation_lease_invalid") <= + timestamp(now, "recipient_policy_reconciliation_time_invalid") + ) { + throw new Error("recipient_policy_lease_lost"); + } +} + +function boundary(db: Database, projectId: string): ManagedProjectBoundary { + const mappings = db + .prepare( + `SELECT workspace_identity, project_pattern, scope_id FROM project_scope_mappings + WHERE workspace_identity = ? ORDER BY id`, + ) + .all(projectId) as Array<{ + workspace_identity: string | null; + project_pattern: string; + scope_id: string; + }>; + const mapping = mappings[0]; + if ( + !mapping || + mappings.length !== 1 || + mapping.workspace_identity !== projectId || + mapping.project_pattern !== projectId || + !validId(mapping.scope_id) + ) { + throw new Error("recipient_policy_exact_mapping_required"); + } + const scopes = db + .prepare( + `SELECT scope_id, coordinator_id, group_id FROM replication_scopes + WHERE scope_id = ? AND kind = 'managed_project' AND authority_type = 'coordinator' + AND status = 'active'`, + ) + .all(mapping.scope_id) as Array<{ + scope_id: string; + coordinator_id: string | null; + group_id: string | null; + }>; + const mappingCount = Number( + db + .prepare("SELECT COUNT(*) FROM project_scope_mappings WHERE scope_id = ?") + .pluck() + .get(mapping.scope_id) ?? 0, + ); + if ( + scopes.length !== 1 || + mappingCount !== 1 || + !validId(scopes[0]?.coordinator_id ?? "") || + !validId(scopes[0]?.group_id ?? "") + ) { + throw new Error("recipient_policy_active_managed_scope_required"); + } + return { scopeId: scopes[0]?.scope_id ?? "" }; +} + +function activeSnapshotDevices( + snapshot: RecipientPolicyCoordinatorSnapshot, + expectedScopeId: string, + requestedAt: string, +): string[] { + if ( + !snapshot.authoritative || + snapshot.scopeId !== expectedScopeId || + !validId(snapshot.fingerprint) || + timestamp(snapshot.observedAt, "recipient_policy_snapshot_invalid") < + timestamp(requestedAt, "recipient_policy_reconciliation_time_invalid") + ) { + throw new Error("recipient_policy_snapshot_not_fresh"); + } + const scopeMembershipEpoch = snapshot.scopeMembershipEpoch ?? 0; + if (!Number.isSafeInteger(scopeMembershipEpoch) || scopeMembershipEpoch < 0) { + throw new Error("recipient_policy_snapshot_invalid"); + } + const seen = new Set(); + for (const membership of snapshot.memberships) { + const membershipEpoch = membership.membershipEpoch ?? 0; + if ( + !validId(membership.deviceId) || + !(["active", "revoked"] as const).includes(membership.status) || + !Number.isSafeInteger(membershipEpoch) || + membershipEpoch < 0 || + seen.has(membership.deviceId) + ) { + throw new Error("recipient_policy_snapshot_invalid"); + } + seen.add(membership.deviceId); + } + return snapshot.memberships + .filter( + (membership) => + membership.status === "active" && (membership.membershipEpoch ?? 0) >= scopeMembershipEpoch, + ) + .map((membership) => membership.deviceId) + .toSorted(); +} + +function generation(db: Database, projectId: string, desiredDigest: string): number { + const state = getRecipientPolicyAuthorityState(db, projectId); + if (!state || state.desiredDevicesDigest === null) return 1; + return state.desiredDevicesDigest === desiredDigest ? state.generation : state.generation + 1; +} + +function authority( + db: Database, + input: { + projectId: string; + state?: "active" | "eligible" | "legacy" | "rolled_back"; + safeErrorCode: string | null; + now: string; + completed?: boolean; + }, +): void { + const current = getRecipientPolicyAuthorityState(db, input.projectId); + const preserveActiveAuthority = + input.safeErrorCode !== null && RETRYABLE_ACTIVE_AUTHORITY_ERRORS.has(input.safeErrorCode); + const nextState = + input.state ?? + (current?.authorityState === "active" && !preserveActiveAuthority ? "rolled_back" : undefined); + db.prepare( + `UPDATE recipient_policy_authority_states SET + authority_state = COALESCE(?, authority_state), + state_changed_at = CASE WHEN ? IS NULL OR ? = authority_state THEN state_changed_at ELSE ? END, + safe_error_code = ?, last_error_at = CASE WHEN ? IS NULL THEN NULL ELSE ? END, + last_completed_at = CASE WHEN ? THEN ? ELSE last_completed_at END, + attempt_count = attempt_count + 1, last_attempt_at = ?, updated_at = ? + WHERE canonical_project_identity = ?`, + ).run( + nextState ?? null, + nextState ?? null, + nextState ?? null, + input.now, + input.safeErrorCode, + input.safeErrorCode, + input.now, + input.completed ? 1 : 0, + input.now, + input.now, + input.now, + input.projectId, + ); +} + +function resetParity(db: Database, projectId: string, now: string): void { + db.prepare( + `UPDATE recipient_policy_authority_states SET stable_parity_evidence_digest = NULL, + stable_parity_passed_at = NULL, updated_at = ? WHERE canonical_project_identity = ?`, + ).run(now, projectId); +} + +async function step( + db: Database, + input: { + projectId: string; + generation: number; + stepKey: string; + payload: unknown; + leaseOwner: string; + lease: Lease; + now: () => string; + }, + work: (effectId: string) => Promise, +): Promise { + const createdAt = input.now(); + assertLease(db, input.projectId, input.leaseOwner, createdAt); + const persisted = ensureRecipientPolicyReconciliationStep(db, { + canonicalProjectIdentity: input.projectId, + generation: input.generation, + stepKey: input.stepKey, + payloadDigest: digest("recipient-policy-step-payload-v1", input.payload), + now: createdAt, + }); + if (persisted.status === "completed") return false; + recordRecipientPolicyReconciliationStepState(db, { + canonicalProjectIdentity: input.projectId, + generation: input.generation, + stepKey: input.stepKey, + effectId: persisted.effectId, + status: "running", + attemptCount: persisted.attemptCount + 1, + startedAt: persisted.startedAt ?? createdAt, + completedAt: null, + lastAttemptAt: createdAt, + safeErrorCode: null, + errorAt: null, + leaseOwner: input.leaseOwner, + leaseAcquiredAt: input.lease.acquiredAt, + leaseExpiresAt: input.lease.expiresAt, + updatedAt: createdAt, + }); + try { + await work(persisted.effectId); + } catch (error) { + const failedAt = input.now(); + const safeErrorCode = safeError(error, "recipient_policy_effect_failed"); + recordRecipientPolicyReconciliationStepState(db, { + canonicalProjectIdentity: input.projectId, + generation: input.generation, + stepKey: input.stepKey, + effectId: persisted.effectId, + status: "failed", + attemptCount: persisted.attemptCount + 1, + startedAt: persisted.startedAt ?? createdAt, + completedAt: null, + lastAttemptAt: failedAt, + safeErrorCode, + errorAt: failedAt, + leaseOwner: input.leaseOwner, + leaseAcquiredAt: input.lease.acquiredAt, + leaseExpiresAt: input.lease.expiresAt, + updatedAt: failedAt, + }); + throw new Error(safeErrorCode); + } + const completedAt = input.now(); + recordRecipientPolicyReconciliationStepState(db, { + canonicalProjectIdentity: input.projectId, + generation: input.generation, + stepKey: input.stepKey, + effectId: persisted.effectId, + status: "completed", + attemptCount: persisted.attemptCount + 1, + startedAt: persisted.startedAt ?? createdAt, + completedAt, + lastAttemptAt: completedAt, + safeErrorCode: null, + errorAt: null, + leaseOwner: input.leaseOwner, + leaseAcquiredAt: input.lease.acquiredAt, + leaseExpiresAt: input.lease.expiresAt, + updatedAt: completedAt, + }); + return true; +} + +function validateReceipt( + receipt: RecipientPolicyCoordinatorEffectReceipt, + expected: { effectId: string; scopeId: string; deviceId: string; status: "active" | "revoked" }, +): void { + if ( + receipt.effectId !== expected.effectId || + receipt.scopeId !== expected.scopeId || + receipt.deviceId !== expected.deviceId || + receipt.status !== expected.status + ) { + throw new Error("recipient_policy_effect_receipt_invalid"); + } +} + +async function preflight( + db: Database, + input: { + projectId: string; + generation: number; + deviceIds: string[]; + passKey: string; + leaseOwner: string; + lease: Lease; + effects: RecipientPolicyReconcilerEffects; + }, +): Promise<"supported" | RecipientPolicyPeerCapability> { + const capabilities: RecipientPolicyPeerCapability[] = []; + for (const deviceId of input.deviceIds) { + let capability: RecipientPolicyPeerCapability = "undetermined"; + const executed = await step( + db, + { + projectId: input.projectId, + generation: input.generation, + stepKey: `capability:${input.passKey}:${deviceId}`, + payload: { deviceId, passKey: input.passKey }, + leaseOwner: input.leaseOwner, + lease: input.lease, + now: input.effects.now, + }, + async () => { + const observed = await input.effects.probeCapability(deviceId); + capability = ["supported", "unsupported", "undetermined"].includes(observed) + ? observed + : "undetermined"; + if (capability === "unsupported") { + throw new Error("recipient_policy_capability_unsupported"); + } + if (capability === "undetermined") { + throw new Error("recipient_policy_capability_undetermined"); + } + }, + ).catch(() => undefined); + if (executed === false) capability = "supported"; + capabilities.push(capability); + } + if (capabilities.includes("unsupported")) return "unsupported"; + if (capabilities.includes("undetermined")) return "undetermined"; + return "supported"; +} + +export function assertLegacyShareGrantAllowed( + db: Database, + input: { canonicalProjectIdentity: string; deviceId: string }, +): void { + const state = getRecipientPolicyAuthorityState(db, input.canonicalProjectIdentity); + if (state?.authorityState !== "active") return; + const desired = deriveRecipientPolicyEffectiveDevicesFromDatabase( + db, + input.canonicalProjectIdentity, + ); + if ( + desired.status !== "eligible" || + !desired.devices.some((item) => item.deviceId === input.deviceId) + ) { + throw new Error("recipient_policy_legacy_grant_blocked"); + } +} + +export async function reconcileRecipientPolicyProject( + db: Database, + input: ReconcileRecipientPolicyProjectInput, + effects: RecipientPolicyReconcilerEffects, +): Promise { + const projectId = input.canonicalProjectIdentity; + const startedAt = effects.now(); + const lease = acquireLease(db, input, startedAt); + if (!lease) { + return result( + projectId, + "busy", + getRecipientPolicyAuthorityState(db, projectId)?.generation ?? 0, + "recipient_policy_lease_held", + ); + } + let activeGeneration = getRecipientPolicyAuthorityState(db, projectId)?.generation ?? 0; + const revokedDeviceIds: string[] = []; + const grantedDeviceIds: string[] = []; + try { + const managedBoundary = boundary(db, projectId); + const desired = deriveRecipientPolicyEffectiveDevicesFromDatabase(db, projectId); + if (desired.status !== "eligible") { + authority(db, { + projectId, + safeErrorCode: "recipient_policy_desired_state_invalid", + now: effects.now(), + }); + return result( + projectId, + "needs_attention", + activeGeneration, + "recipient_policy_desired_state_invalid", + ); + } + activeGeneration = generation(db, projectId, desired.desiredDevicesDigest); + const initialSnapshot = await effects.snapshot({ + canonicalProjectIdentity: projectId, + scopeId: managedBoundary.scopeId, + }); + const currentDeviceIds = activeSnapshotDevices( + initialSnapshot, + managedBoundary.scopeId, + startedAt, + ); + const desiredDeviceIds = desired.devices.map((device) => device.deviceId).toSorted(); + const desiredSet = new Set(desiredDeviceIds); + const currentSet = new Set(currentDeviceIds); + const revokeDeviceIds = currentDeviceIds.filter((deviceId) => !desiredSet.has(deviceId)); + const grantDeviceIds = desiredDeviceIds.filter((deviceId) => !currentSet.has(deviceId)); + upsertRecipientPolicyAuthorityObservation(db, { + canonicalProjectIdentity: projectId, + generation: activeGeneration, + desiredDevicesDigest: desired.desiredDevicesDigest, + currentDevicesDigest: + revokeDeviceIds.length === 0 && grantDeviceIds.length === 0 + ? desired.desiredDevicesDigest + : deviceDigest(currentDeviceIds), + freshSnapshotFingerprint: initialSnapshot.fingerprint, + freshSnapshotObservedAt: initialSnapshot.observedAt, + now: effects.now(), + }); + if (revokeDeviceIds.length > 0 || grantDeviceIds.length > 0) { + resetParity(db, projectId, effects.now()); + } + for (const deviceId of revokeDeviceIds) { + putRecipientPolicyDenyOverlay(db, { + canonicalProjectIdentity: projectId, + scopeId: managedBoundary.scopeId, + deviceId, + generation: activeGeneration, + reasonCode: "pending_revoke", + now: effects.now(), + }); + } + const snapshotStateKey = digest("recipient-policy-snapshot-state-v1", { + fingerprint: initialSnapshot.fingerprint, + }); + const passKey = digest("recipient-policy-pass-v1", { + fingerprint: initialSnapshot.fingerprint, + observedAt: initialSnapshot.observedAt, + }); + const capability = await preflight(db, { + projectId, + generation: activeGeneration, + deviceIds: [...new Set([...currentDeviceIds, ...desiredDeviceIds])].toSorted(), + passKey, + leaseOwner: input.leaseOwner, + lease, + effects, + }); + if (capability !== "supported") { + const safeErrorCode = + capability === "unsupported" + ? "recipient_policy_capability_unsupported" + : "recipient_policy_capability_undetermined"; + authority(db, { projectId, safeErrorCode, now: effects.now() }); + return result( + projectId, + capability === "unsupported" ? "needs_attention" : "waiting", + activeGeneration, + safeErrorCode, + ); + } + for (const deviceId of revokeDeviceIds) { + const changed = await step( + db, + { + projectId, + generation: activeGeneration, + stepKey: `revoke:${snapshotStateKey}:${deviceId}`, + payload: { scopeId: managedBoundary.scopeId, deviceId, status: "revoked" }, + leaseOwner: input.leaseOwner, + lease, + now: effects.now, + }, + async (effectId) => { + const receipt = await effects.revoke({ + effectId, + canonicalProjectIdentity: projectId, + generation: activeGeneration, + scopeId: managedBoundary.scopeId, + deviceId, + }); + validateReceipt(receipt, { + effectId, + scopeId: managedBoundary.scopeId, + deviceId, + status: "revoked", + }); + }, + ); + if (changed) revokedDeviceIds.push(deviceId); + } + const rederived = deriveRecipientPolicyEffectiveDevicesFromDatabase(db, projectId); + if ( + rederived.status !== "eligible" || + rederived.desiredDevicesDigest !== desired.desiredDevicesDigest + ) { + authority(db, { + projectId, + safeErrorCode: "recipient_policy_generation_stale", + now: effects.now(), + }); + return result( + projectId, + "stale", + activeGeneration, + "recipient_policy_generation_stale", + revokedDeviceIds, + ); + } + for (const deviceId of grantDeviceIds) { + const changed = await step( + db, + { + projectId, + generation: activeGeneration, + stepKey: `grant:${snapshotStateKey}:${deviceId}`, + payload: { scopeId: managedBoundary.scopeId, deviceId, role: "member" }, + leaseOwner: input.leaseOwner, + lease, + now: effects.now, + }, + async (effectId) => { + const receipt = await effects.grant({ + effectId, + canonicalProjectIdentity: projectId, + generation: activeGeneration, + scopeId: managedBoundary.scopeId, + deviceId, + role: "member", + }); + validateReceipt(receipt, { + effectId, + scopeId: managedBoundary.scopeId, + deviceId, + status: "active", + }); + }, + ); + if (changed) grantedDeviceIds.push(deviceId); + } + await step( + db, + { + projectId, + generation: activeGeneration, + stepKey: `refresh:${passKey}`, + payload: { + scopeId: managedBoundary.scopeId, + fingerprint: initialSnapshot.fingerprint, + observedAt: initialSnapshot.observedAt, + }, + leaseOwner: input.leaseOwner, + lease, + now: effects.now, + }, + async () => + effects.refresh({ canonicalProjectIdentity: projectId, scopeId: managedBoundary.scopeId }), + ); + const verificationRequestedAt = effects.now(); + const verifiedSnapshot = await effects.snapshot({ + canonicalProjectIdentity: projectId, + scopeId: managedBoundary.scopeId, + }); + const verifiedDeviceIds = activeSnapshotDevices( + verifiedSnapshot, + managedBoundary.scopeId, + verificationRequestedAt, + ); + const verifiedSet = new Set(verifiedDeviceIds); + for (const overlay of listRecipientPolicyDenyOverlays(db, projectId)) { + const revokeVerified = !verifiedSet.has(overlay.deviceId); + const desiredActiveVerified = + desiredSet.has(overlay.deviceId) && verifiedSet.has(overlay.deviceId); + if ( + overlay.scopeId === managedBoundary.scopeId && + (revokeVerified || desiredActiveVerified) + ) { + clearRecipientPolicyDenyOverlay(db, { + canonicalProjectIdentity: projectId, + scopeId: overlay.scopeId, + deviceId: overlay.deviceId, + verifiedGeneration: activeGeneration, + }); + } + } + const parity = + verifiedDeviceIds.length === desiredDeviceIds.length && + verifiedDeviceIds.every((deviceId, index) => deviceId === desiredDeviceIds[index]); + upsertRecipientPolicyAuthorityObservation(db, { + canonicalProjectIdentity: projectId, + generation: activeGeneration, + desiredDevicesDigest: desired.desiredDevicesDigest, + currentDevicesDigest: parity ? desired.desiredDevicesDigest : deviceDigest(verifiedDeviceIds), + freshSnapshotFingerprint: verifiedSnapshot.fingerprint, + freshSnapshotObservedAt: verifiedSnapshot.observedAt, + now: effects.now(), + }); + if (!parity) { + resetParity(db, projectId, effects.now()); + authority(db, { + projectId, + safeErrorCode: "recipient_policy_parity_incomplete", + now: effects.now(), + }); + return result( + projectId, + "waiting", + activeGeneration, + "recipient_policy_parity_incomplete", + revokedDeviceIds, + grantedDeviceIds, + ); + } + const evidenceDigest = digest("recipient-policy-parity-v1", { + canonicalProjectIdentity: projectId, + generation: activeGeneration, + scopeId: managedBoundary.scopeId, + desiredDevicesDigest: desired.desiredDevicesDigest, + deviceIds: desiredDeviceIds, + }); + const state = getRecipientPolicyAuthorityState(db, projectId); + const laterUnchangedNoOp = + revokeDeviceIds.length === 0 && + grantDeviceIds.length === 0 && + state?.stableParityEvidenceDigest === evidenceDigest && + state.stableParityPassedAt !== null && + timestamp(verifiedSnapshot.observedAt, "recipient_policy_snapshot_invalid") >= + timestamp(state.stableParityPassedAt, "recipient_policy_parity_evidence_invalid"); + if (laterUnchangedNoOp) { + authority(db, { + projectId, + state: "active", + safeErrorCode: null, + now: effects.now(), + completed: true, + }); + return result( + projectId, + "active", + activeGeneration, + null, + revokedDeviceIds, + grantedDeviceIds, + ); + } + if (state?.stableParityEvidenceDigest !== evidenceDigest) { + resetParity(db, projectId, effects.now()); + recordRecipientPolicyStableParityPass(db, { + canonicalProjectIdentity: projectId, + generation: activeGeneration, + evidenceDigest, + snapshotFingerprint: verifiedSnapshot.fingerprint, + passedAt: verifiedSnapshot.observedAt, + }); + } + authority(db, { + projectId, + state: "eligible", + safeErrorCode: null, + now: effects.now(), + completed: true, + }); + return result( + projectId, + "parity_pending", + activeGeneration, + null, + revokedDeviceIds, + grantedDeviceIds, + ); + } catch (error) { + const safeErrorCode = safeError(error, "recipient_policy_reconciliation_failed"); + authority(db, { projectId, safeErrorCode, now: effects.now() }); + return result( + projectId, + safeErrorCode === "recipient_policy_snapshot_not_fresh" ? "waiting" : "needs_attention", + activeGeneration, + safeErrorCode, + revokedDeviceIds, + grantedDeviceIds, + ); + } finally { + releaseLease(db, projectId, input.leaseOwner, effects.now()); + } +} diff --git a/packages/core/src/recipient-policy-reconciliation.test.ts b/packages/core/src/recipient-policy-reconciliation.test.ts new file mode 100644 index 00000000..705b4ced --- /dev/null +++ b/packages/core/src/recipient-policy-reconciliation.test.ts @@ -0,0 +1,301 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + clearRecipientPolicyDenyOverlay, + type DeriveRecipientPolicyEffectiveDevicesInput, + deriveRecipientPolicyEffectiveDevices, + ensureRecipientPolicyReconciliationStep, + getRecipientPolicyAuthorityState, + listRecipientPolicyDenyOverlays, + putRecipientPolicyDenyOverlay, + recordRecipientPolicyAuthorityExecution, + recordRecipientPolicyReconciliationStepState, + recordRecipientPolicyStableParityPass, + upsertRecipientPolicyAuthorityObservation, +} from "./recipient-policy-reconciliation.js"; +import { initTestSchema } from "./test-utils.js"; + +const PROJECT = "https://git.example.invalid/acme/project.git"; +const NOW = "2026-07-22T10:00:00.000Z"; + +function graph(): DeriveRecipientPolicyEffectiveDevicesInput { + return { + canonicalProjectIdentity: PROJECT, + projectRecipients: [ + { + canonicalProjectIdentity: PROJECT, + recipientKind: "identity", + recipientId: "identity-a", + status: "active", + }, + { + canonicalProjectIdentity: PROJECT, + recipientKind: "team", + recipientId: "team-a", + status: "active", + }, + ], + identities: [ + { identityId: "identity-a", status: "active", mergedIntoIdentityId: null }, + { identityId: "identity-b", status: "active", mergedIntoIdentityId: null }, + ], + teams: [{ teamId: "team-a", status: "active" }], + teamMemberships: [{ teamId: "team-a", identityId: "identity-b", status: "active" }], + identityDevices: [ + { identityId: "identity-a", deviceId: "device-a", status: "active" }, + { identityId: "identity-b", deviceId: "device-b", status: "active" }, + ], + }; +} + +describe("strict recipient-policy effective-device derivation", () => { + it("derives direct and team devices without enrollment, trust, or filter inputs", () => { + const result = deriveRecipientPolicyEffectiveDevices(graph()); + + expect(result.status).toBe("eligible"); + expect(result.devices).toEqual([ + { + canonicalProjectIdentity: PROJECT, + identityId: "identity-a", + deviceId: "device-a", + sources: [{ kind: "direct_identity" }], + }, + { + canonicalProjectIdentity: PROJECT, + identityId: "identity-b", + deviceId: "device-b", + sources: [{ kind: "team_membership", teamId: "team-a" }], + }, + ]); + }); + + it("deduplicates an exact device reached directly and through a team", () => { + const input = graph(); + input.teamMemberships.push({ teamId: "team-a", identityId: "identity-a", status: "active" }); + + const result = deriveRecipientPolicyEffectiveDevices(input); + + expect(result.devices[0]?.sources).toEqual([ + { kind: "direct_identity" }, + { kind: "team_membership", teamId: "team-a" }, + ]); + expect(result.devices).toHaveLength(2); + }); + + it.each([ + ["missing", undefined, "identity_missing"], + ["pending", { identityId: "identity-a", status: "pending" }, "identity_not_active"], + [ + "merged", + { identityId: "identity-a", status: "active", mergedIntoIdentityId: "identity-b" }, + "identity_merged", + ], + ["deactivated", { identityId: "identity-a", status: "deactivated" }, "identity_not_active"], + ] as const)("blocks the whole Project for a %s direct identity", (_label, replacement, code) => { + const input = graph(); + input.identities = input.identities.filter((identity) => identity.identityId !== "identity-a"); + if (replacement) input.identities.push(replacement); + + const result = deriveRecipientPolicyEffectiveDevices(input); + + expect(result.status).toBe("blocked"); + expect(result.devices).toEqual([]); + expect(result.blocked).toContainEqual({ code, referenceId: "identity-a" }); + }); + + it("blocks all grant candidates when a team has an orphan active member", () => { + const input = graph(); + input.teamMemberships.push({ + teamId: "team-a", + identityId: "identity-orphan", + status: "active", + }); + + const result = deriveRecipientPolicyEffectiveDevices(input); + + expect(result.devices).toEqual([]); + expect(result.blocked).toContainEqual({ + code: "team_member_identity_missing", + referenceId: "identity-orphan", + }); + }); + + it("uses exact canonical Project identity and ignores sibling recipients", () => { + const input = graph(); + input.projectRecipients.push({ + canonicalProjectIdentity: `${PROJECT}-sibling`, + recipientKind: "identity", + recipientId: "identity-orphan", + status: "active", + }); + + const result = deriveRecipientPolicyEffectiveDevices(input); + + expect(result.status).toBe("eligible"); + expect(result.devices.map((device) => device.deviceId)).toEqual(["device-a", "device-b"]); + }); +}); + +describe("recipient-policy reconciliation persistence", () => { + let db: InstanceType; + + beforeEach(() => { + db = new Database(":memory:"); + initTestSchema(db); + }); + + afterEach(() => db.close()); + + it("persists observations idempotently without promoting legacy authority", () => { + const input = { + canonicalProjectIdentity: PROJECT, + generation: 1, + desiredDevicesDigest: "desired:one", + currentDevicesDigest: "desired:one", + freshSnapshotFingerprint: "snapshot:one", + freshSnapshotObservedAt: NOW, + now: NOW, + }; + + upsertRecipientPolicyAuthorityObservation(db, input); + upsertRecipientPolicyAuthorityObservation(db, input); + + const state = getRecipientPolicyAuthorityState(db, PROJECT); + expect(state?.authorityState).toBe("legacy"); + expect(state?.generation).toBe(1); + expect(db.prepare("SELECT COUNT(*) FROM recipient_policy_authority_states").pluck().get()).toBe( + 1, + ); + }); + + it("stores stable parity evidence without changing authority", () => { + upsertRecipientPolicyAuthorityObservation(db, { + canonicalProjectIdentity: PROJECT, + generation: 2, + desiredDevicesDigest: "devices:same", + currentDevicesDigest: "devices:same", + freshSnapshotFingerprint: "snapshot:fresh", + freshSnapshotObservedAt: NOW, + now: NOW, + }); + + const state = recordRecipientPolicyStableParityPass(db, { + canonicalProjectIdentity: PROJECT, + generation: 2, + evidenceDigest: "parity:stable", + snapshotFingerprint: "snapshot:fresh", + passedAt: NOW, + }); + + expect(state.stableParityEvidenceDigest).toBe("parity:stable"); + expect(state.authorityState).toBe("legacy"); + }); + + it("persists attempt, error, and lease timestamps without changing authority", () => { + upsertRecipientPolicyAuthorityObservation(db, { + canonicalProjectIdentity: PROJECT, + generation: 2, + desiredDevicesDigest: "devices:desired", + currentDevicesDigest: null, + freshSnapshotFingerprint: null, + freshSnapshotObservedAt: null, + now: NOW, + }); + + const state = recordRecipientPolicyAuthorityExecution(db, { + canonicalProjectIdentity: PROJECT, + generation: 2, + attemptCount: 1, + lastAttemptAt: NOW, + lastCompletedAt: null, + safeErrorCode: "snapshot_stale", + lastErrorAt: NOW, + leaseOwner: "worker-a", + leaseAcquiredAt: NOW, + leaseExpiresAt: "2026-07-22T10:01:00.000Z", + updatedAt: NOW, + }); + + expect(state).toMatchObject({ + authorityState: "legacy", + attemptCount: 1, + safeErrorCode: "snapshot_stale", + leaseOwner: "worker-a", + }); + }); + + it("creates deterministic generation-scoped steps and rejects conflicting reuse", () => { + const input = { + canonicalProjectIdentity: PROJECT, + generation: 3, + stepKey: "revoke:device-a", + payloadDigest: "payload:one", + now: NOW, + }; + + const first = ensureRecipientPolicyReconciliationStep(db, input); + const replay = ensureRecipientPolicyReconciliationStep(db, input); + const running = recordRecipientPolicyReconciliationStepState(db, { + canonicalProjectIdentity: PROJECT, + generation: 3, + stepKey: input.stepKey, + effectId: first.effectId, + status: "running", + attemptCount: 1, + startedAt: NOW, + completedAt: null, + lastAttemptAt: NOW, + safeErrorCode: null, + errorAt: null, + leaseOwner: "worker-a", + leaseAcquiredAt: NOW, + leaseExpiresAt: "2026-07-22T10:01:00.000Z", + updatedAt: NOW, + }); + + expect(replay.effectId).toBe(first.effectId); + expect(running).toMatchObject({ + status: "running", + attemptCount: 1, + leaseOwner: "worker-a", + }); + expect( + db.prepare("SELECT COUNT(*) FROM recipient_policy_reconciliation_steps").pluck().get(), + ).toBe(1); + expect(() => + ensureRecipientPolicyReconciliationStep(db, { ...input, payloadDigest: "payload:changed" }), + ).toThrow("recipient_policy_reconciliation_step_conflict"); + }); + + it("keeps deny overlays keyed by exact Project, scope, and device until verified", () => { + const input = { + canonicalProjectIdentity: PROJECT, + scopeId: "scope-project", + deviceId: "device-a", + generation: 4, + reasonCode: "pending_revoke", + now: NOW, + }; + putRecipientPolicyDenyOverlay(db, input); + putRecipientPolicyDenyOverlay(db, input); + + expect(listRecipientPolicyDenyOverlays(db, PROJECT)).toHaveLength(1); + expect( + clearRecipientPolicyDenyOverlay(db, { + canonicalProjectIdentity: PROJECT, + scopeId: "scope-project", + deviceId: "device-a", + verifiedGeneration: 3, + }), + ).toBe(false); + expect(listRecipientPolicyDenyOverlays(db, PROJECT)).toHaveLength(1); + expect( + clearRecipientPolicyDenyOverlay(db, { + canonicalProjectIdentity: PROJECT, + scopeId: "scope-project", + deviceId: "device-a", + verifiedGeneration: 4, + }), + ).toBe(true); + }); +}); diff --git a/packages/core/src/recipient-policy-reconciliation.ts b/packages/core/src/recipient-policy-reconciliation.ts new file mode 100644 index 00000000..bc996918 --- /dev/null +++ b/packages/core/src/recipient-policy-reconciliation.ts @@ -0,0 +1,916 @@ +import { createHash } from "node:crypto"; +import type { Database } from "./db.js"; + +export type RecipientPolicyAuthorityState = "legacy" | "eligible" | "active" | "rolled_back"; + +export type RecipientPolicyDerivationBlockCode = + | "canonical_project_identity_invalid" + | "project_recipient_invalid" + | "identity_missing" + | "identity_not_active" + | "identity_merged" + | "team_missing" + | "team_not_active" + | "team_membership_invalid" + | "team_member_identity_missing" + | "team_member_identity_not_active" + | "team_member_identity_merged" + | "identity_device_invalid" + | "device_identity_conflict"; + +export interface RecipientPolicyDerivationIdentity { + identityId: string; + status: string; + mergedIntoIdentityId?: string | null; +} + +export interface RecipientPolicyDerivationTeam { + teamId: string; + status: string; +} + +export interface RecipientPolicyDerivationTeamMembership { + teamId: string; + identityId: string; + status: string; +} + +export interface RecipientPolicyDerivationIdentityDevice { + identityId: string; + deviceId: string; + status: string; +} + +export interface RecipientPolicyDerivationProjectRecipient { + canonicalProjectIdentity: string; + recipientKind: string; + recipientId: string; + status: string; +} + +export interface RecipientPolicyEffectiveDeviceSource { + kind: "direct_identity" | "team_membership"; + teamId?: string; +} + +export interface StrictRecipientPolicyEffectiveDevice { + canonicalProjectIdentity: string; + identityId: string; + deviceId: string; + sources: RecipientPolicyEffectiveDeviceSource[]; +} + +export interface RecipientPolicyDerivationBlock { + code: RecipientPolicyDerivationBlockCode; + referenceId: string; +} + +export interface DeriveRecipientPolicyEffectiveDevicesInput { + canonicalProjectIdentity: string; + projectRecipients: RecipientPolicyDerivationProjectRecipient[]; + identities: RecipientPolicyDerivationIdentity[]; + teams: RecipientPolicyDerivationTeam[]; + teamMemberships: RecipientPolicyDerivationTeamMembership[]; + identityDevices: RecipientPolicyDerivationIdentityDevice[]; +} + +export interface StrictRecipientPolicyEffectiveDeviceDerivation { + canonicalProjectIdentity: string; + status: "eligible" | "blocked"; + devices: StrictRecipientPolicyEffectiveDevice[]; + blocked: RecipientPolicyDerivationBlock[]; + desiredDevicesDigest: string; +} + +export interface RecipientPolicyAuthorityStateRecord { + canonicalProjectIdentity: string; + authorityState: RecipientPolicyAuthorityState; + generation: number; + desiredDevicesDigest: string | null; + currentDevicesDigest: string | null; + stableParityEvidenceDigest: string | null; + stableParityPassedAt: string | null; + freshSnapshotFingerprint: string | null; + freshSnapshotObservedAt: string | null; + safeErrorCode: string | null; + stateChangedAt: string; + lastErrorAt: string | null; + attemptCount: number; + lastAttemptAt: string | null; + lastCompletedAt: string | null; + leaseOwner: string | null; + leaseAcquiredAt: string | null; + leaseExpiresAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface UpsertRecipientPolicyAuthorityObservationInput { + canonicalProjectIdentity: string; + generation: number; + desiredDevicesDigest: string; + currentDevicesDigest: string | null; + freshSnapshotFingerprint: string | null; + freshSnapshotObservedAt: string | null; + now: string; +} + +export interface RecordRecipientPolicyAuthorityExecutionInput { + canonicalProjectIdentity: string; + generation: number; + attemptCount: number; + lastAttemptAt: string | null; + lastCompletedAt: string | null; + safeErrorCode: string | null; + lastErrorAt: string | null; + leaseOwner: string | null; + leaseAcquiredAt: string | null; + leaseExpiresAt: string | null; + updatedAt: string; +} + +export interface EnsureRecipientPolicyReconciliationStepInput { + canonicalProjectIdentity: string; + generation: number; + stepKey: string; + payloadDigest: string; + now: string; +} + +export interface RecordRecipientPolicyReconciliationStepStateInput { + canonicalProjectIdentity: string; + generation: number; + stepKey: string; + effectId: string; + status: "pending" | "running" | "waiting" | "completed" | "failed"; + attemptCount: number; + startedAt: string | null; + completedAt: string | null; + lastAttemptAt: string | null; + safeErrorCode: string | null; + errorAt: string | null; + leaseOwner: string | null; + leaseAcquiredAt: string | null; + leaseExpiresAt: string | null; + updatedAt: string; +} + +export interface RecipientPolicyReconciliationStepRecord { + canonicalProjectIdentity: string; + generation: number; + stepKey: string; + effectId: string; + payloadDigest: string; + status: string; + attemptCount: number; + startedAt: string | null; + completedAt: string | null; + lastAttemptAt: string | null; + safeErrorCode: string | null; + errorAt: string | null; + leaseOwner: string | null; + leaseAcquiredAt: string | null; + leaseExpiresAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface RecipientPolicyDenyOverlayRecord { + canonicalProjectIdentity: string; + scopeId: string; + deviceId: string; + generation: number; + reasonCode: string; + createdAt: string; + updatedAt: string; +} + +const CONTROL_CHARACTER = /\p{Cc}/u; +const KNOWN_MEMBERSHIP_STATUSES = new Set(["active", "pending", "revoked"]); +const KNOWN_DEVICE_STATUSES = new Set(["active", "revoked"]); +const KNOWN_RECIPIENT_STATUSES = new Set(["active", "revoked"]); + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function strictId(value: string): boolean { + return value.length > 0 && value === value.trim() && !CONTROL_CHARACTER.test(value); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .toSorted(([left], [right]) => compareText(left, right)) + .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +function digest(prefix: string, value: unknown): string { + return `${prefix}:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +function sourceKey(source: RecipientPolicyEffectiveDeviceSource): string { + return source.kind === "direct_identity" ? source.kind : `${source.kind}\u0000${source.teamId}`; +} + +function block( + blocked: Map, + code: RecipientPolicyDerivationBlockCode, + referenceId: string, +): void { + blocked.set(`${code}\u0000${referenceId}`, { code, referenceId }); +} + +function activeIdentityCode( + identity: RecipientPolicyDerivationIdentity | undefined, + missing: RecipientPolicyDerivationBlockCode, + inactive: RecipientPolicyDerivationBlockCode, + merged: RecipientPolicyDerivationBlockCode, +): RecipientPolicyDerivationBlockCode | null { + if (!identity) return missing; + if (identity.status === "merged" || identity.mergedIntoIdentityId) return merged; + if (identity.status !== "active") return inactive; + return null; +} + +function addEffectiveDevice( + effective: Map, + deviceOwners: Map, + projectId: string, + identityId: string, + deviceId: string, + source: RecipientPolicyEffectiveDeviceSource, + blocked: Map, +): void { + const owner = deviceOwners.get(deviceId); + if (owner && owner !== identityId) { + block(blocked, "device_identity_conflict", deviceId); + return; + } + deviceOwners.set(deviceId, identityId); + const current = effective.get(deviceId); + const sources = new Map((current?.sources ?? []).map((item) => [sourceKey(item), item])); + sources.set(sourceKey(source), source); + effective.set(deviceId, { + canonicalProjectIdentity: projectId, + identityId, + deviceId, + sources: [...sources.values()].toSorted((left, right) => + compareText(sourceKey(left), sourceKey(right)), + ), + }); +} + +export function recipientPolicyDevicesDigest( + devices: readonly StrictRecipientPolicyEffectiveDevice[], +): string { + return digest( + "recipient-policy-devices-v1", + devices + .toSorted( + (left, right) => + compareText(left.canonicalProjectIdentity, right.canonicalProjectIdentity) || + compareText(left.deviceId, right.deviceId) || + compareText(left.identityId, right.identityId), + ) + .map((device) => ({ + canonicalProjectIdentity: device.canonicalProjectIdentity, + identityId: device.identityId, + deviceId: device.deviceId, + sources: device.sources.toSorted((left, right) => + compareText(sourceKey(left), sourceKey(right)), + ), + })), + ); +} + +export function deriveRecipientPolicyEffectiveDevices( + input: DeriveRecipientPolicyEffectiveDevicesInput, +): StrictRecipientPolicyEffectiveDeviceDerivation { + const blocked = new Map(); + if (!strictId(input.canonicalProjectIdentity)) { + block(blocked, "canonical_project_identity_invalid", input.canonicalProjectIdentity); + } + const identities = new Map(input.identities.map((identity) => [identity.identityId, identity])); + const teams = new Map(input.teams.map((team) => [team.teamId, team])); + const membershipsByTeam = new Map(); + for (const membership of input.teamMemberships) { + const current = membershipsByTeam.get(membership.teamId) ?? []; + current.push(membership); + membershipsByTeam.set(membership.teamId, current); + } + const devicesByIdentity = new Map(); + for (const device of input.identityDevices) { + const current = devicesByIdentity.get(device.identityId) ?? []; + current.push(device); + devicesByIdentity.set(device.identityId, current); + } + const effective = new Map(); + const deviceOwners = new Map(); + const expandIdentity = ( + identityId: string, + source: RecipientPolicyEffectiveDeviceSource, + codes: readonly [ + RecipientPolicyDerivationBlockCode, + RecipientPolicyDerivationBlockCode, + RecipientPolicyDerivationBlockCode, + ], + ): void => { + const identityCode = activeIdentityCode(identities.get(identityId), ...codes); + if (identityCode) { + block(blocked, identityCode, identityId); + return; + } + for (const device of devicesByIdentity.get(identityId) ?? []) { + if (!KNOWN_DEVICE_STATUSES.has(device.status)) { + block(blocked, "identity_device_invalid", device.deviceId); + continue; + } + if (device.status !== "active") continue; + if (!strictId(device.identityId) || !strictId(device.deviceId)) { + block(blocked, "identity_device_invalid", device.deviceId); + continue; + } + addEffectiveDevice( + effective, + deviceOwners, + input.canonicalProjectIdentity, + identityId, + device.deviceId, + source, + blocked, + ); + } + }; + const projectRecipients = input.projectRecipients.filter( + (recipient) => recipient.canonicalProjectIdentity === input.canonicalProjectIdentity, + ); + for (const recipient of projectRecipients) { + if (!KNOWN_RECIPIENT_STATUSES.has(recipient.status)) { + block(blocked, "project_recipient_invalid", recipient.recipientId); + continue; + } + if (recipient.status !== "active") continue; + if (!strictId(recipient.recipientId)) { + block(blocked, "project_recipient_invalid", recipient.recipientId); + continue; + } + if (recipient.recipientKind === "identity") { + expandIdentity(recipient.recipientId, { kind: "direct_identity" }, [ + "identity_missing", + "identity_not_active", + "identity_merged", + ]); + continue; + } + if (recipient.recipientKind !== "team") { + block(blocked, "project_recipient_invalid", recipient.recipientId); + continue; + } + const team = teams.get(recipient.recipientId); + if (!team) { + block(blocked, "team_missing", recipient.recipientId); + continue; + } + if (team.status !== "active") { + block(blocked, "team_not_active", recipient.recipientId); + continue; + } + for (const membership of membershipsByTeam.get(team.teamId) ?? []) { + if (!KNOWN_MEMBERSHIP_STATUSES.has(membership.status)) { + block(blocked, "team_membership_invalid", `${membership.teamId}:${membership.identityId}`); + continue; + } + if (membership.status !== "active") continue; + if (!strictId(membership.teamId) || !strictId(membership.identityId)) { + block(blocked, "team_membership_invalid", `${membership.teamId}:${membership.identityId}`); + continue; + } + expandIdentity(membership.identityId, { kind: "team_membership", teamId: team.teamId }, [ + "team_member_identity_missing", + "team_member_identity_not_active", + "team_member_identity_merged", + ]); + } + } + const blockedItems = [...blocked.values()].toSorted( + (left, right) => + compareText(left.code, right.code) || compareText(left.referenceId, right.referenceId), + ); + const devices = [...effective.values()].toSorted( + (left, right) => + compareText(left.deviceId, right.deviceId) || compareText(left.identityId, right.identityId), + ); + const grantCandidates = blockedItems.length === 0 ? devices : []; + return { + canonicalProjectIdentity: input.canonicalProjectIdentity, + status: blockedItems.length === 0 ? "eligible" : "blocked", + devices: grantCandidates, + blocked: blockedItems, + desiredDevicesDigest: recipientPolicyDevicesDigest(grantCandidates), + }; +} + +export function deriveRecipientPolicyEffectiveDevicesFromDatabase( + db: Database, + canonicalProjectIdentity: string, +): StrictRecipientPolicyEffectiveDeviceDerivation { + const projectRecipients = db + .prepare( + `SELECT canonical_project_identity, recipient_kind, recipient_id, status + FROM project_recipients WHERE canonical_project_identity = ? + ORDER BY recipient_kind, recipient_id`, + ) + .all(canonicalProjectIdentity) as Array<{ + canonical_project_identity: string; + recipient_kind: string; + recipient_id: string; + status: string; + }>; + const identities = db + .prepare("SELECT actor_id, status, merged_into_actor_id FROM actors ORDER BY actor_id") + .all() as Array<{ actor_id: string; status: string; merged_into_actor_id: string | null }>; + const teams = db + .prepare("SELECT team_id, status FROM policy_teams ORDER BY team_id") + .all() as Array<{ + team_id: string; + status: string; + }>; + const teamMemberships = db + .prepare( + "SELECT team_id, identity_id, status FROM policy_team_memberships ORDER BY team_id, identity_id", + ) + .all() as Array<{ team_id: string; identity_id: string; status: string }>; + const identityDevices = db + .prepare( + "SELECT identity_id, device_id, status FROM identity_devices ORDER BY identity_id, device_id", + ) + .all() as Array<{ identity_id: string; device_id: string; status: string }>; + return deriveRecipientPolicyEffectiveDevices({ + canonicalProjectIdentity, + projectRecipients: projectRecipients.map((row) => ({ + canonicalProjectIdentity: row.canonical_project_identity, + recipientKind: row.recipient_kind, + recipientId: row.recipient_id, + status: row.status, + })), + identities: identities.map((row) => ({ + identityId: row.actor_id, + status: row.status, + mergedIntoIdentityId: row.merged_into_actor_id, + })), + teams: teams.map((row) => ({ teamId: row.team_id, status: row.status })), + teamMemberships: teamMemberships.map((row) => ({ + teamId: row.team_id, + identityId: row.identity_id, + status: row.status, + })), + identityDevices: identityDevices.map((row) => ({ + identityId: row.identity_id, + deviceId: row.device_id, + status: row.status, + })), + }); +} + +function authorityRow(row: Record): RecipientPolicyAuthorityStateRecord { + return { + canonicalProjectIdentity: String(row.canonical_project_identity), + authorityState: row.authority_state as RecipientPolicyAuthorityState, + generation: Number(row.generation), + desiredDevicesDigest: row.desired_devices_digest as string | null, + currentDevicesDigest: row.current_devices_digest as string | null, + stableParityEvidenceDigest: row.stable_parity_evidence_digest as string | null, + stableParityPassedAt: row.stable_parity_passed_at as string | null, + freshSnapshotFingerprint: row.fresh_snapshot_fingerprint as string | null, + freshSnapshotObservedAt: row.fresh_snapshot_observed_at as string | null, + safeErrorCode: row.safe_error_code as string | null, + stateChangedAt: String(row.state_changed_at), + lastErrorAt: row.last_error_at as string | null, + attemptCount: Number(row.attempt_count), + lastAttemptAt: row.last_attempt_at as string | null, + lastCompletedAt: row.last_completed_at as string | null, + leaseOwner: row.lease_owner as string | null, + leaseAcquiredAt: row.lease_acquired_at as string | null, + leaseExpiresAt: row.lease_expires_at as string | null, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; +} + +export function getRecipientPolicyAuthorityState( + db: Database, + canonicalProjectIdentity: string, +): RecipientPolicyAuthorityStateRecord | null { + const row = db + .prepare("SELECT * FROM recipient_policy_authority_states WHERE canonical_project_identity = ?") + .get(canonicalProjectIdentity) as Record | undefined; + return row ? authorityRow(row) : null; +} + +export function upsertRecipientPolicyAuthorityObservation( + db: Database, + input: UpsertRecipientPolicyAuthorityObservationInput, +): RecipientPolicyAuthorityStateRecord { + if ( + !strictId(input.canonicalProjectIdentity) || + !Number.isSafeInteger(input.generation) || + input.generation < 0 + ) { + throw new Error("recipient_policy_authority_observation_invalid"); + } + const existing = getRecipientPolicyAuthorityState(db, input.canonicalProjectIdentity); + if (existing && input.generation < existing.generation) { + throw new Error("recipient_policy_generation_stale"); + } + if ( + existing && + input.generation === existing.generation && + existing.desiredDevicesDigest !== input.desiredDevicesDigest + ) { + throw new Error("recipient_policy_generation_conflict"); + } + db.prepare( + `INSERT INTO recipient_policy_authority_states( + canonical_project_identity, authority_state, generation, desired_devices_digest, + current_devices_digest, fresh_snapshot_fingerprint, fresh_snapshot_observed_at, + state_changed_at, created_at, updated_at + ) VALUES (?, 'legacy', ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(canonical_project_identity) DO UPDATE SET + generation = excluded.generation, + desired_devices_digest = excluded.desired_devices_digest, + current_devices_digest = excluded.current_devices_digest, + fresh_snapshot_fingerprint = excluded.fresh_snapshot_fingerprint, + fresh_snapshot_observed_at = excluded.fresh_snapshot_observed_at, + stable_parity_evidence_digest = CASE + WHEN recipient_policy_authority_states.generation = excluded.generation + THEN recipient_policy_authority_states.stable_parity_evidence_digest ELSE NULL END, + stable_parity_passed_at = CASE + WHEN recipient_policy_authority_states.generation = excluded.generation + THEN recipient_policy_authority_states.stable_parity_passed_at ELSE NULL END, + updated_at = excluded.updated_at`, + ).run( + input.canonicalProjectIdentity, + input.generation, + input.desiredDevicesDigest, + input.currentDevicesDigest, + input.freshSnapshotFingerprint, + input.freshSnapshotObservedAt, + input.now, + input.now, + input.now, + ); + const state = getRecipientPolicyAuthorityState(db, input.canonicalProjectIdentity); + if (!state) throw new Error("recipient_policy_authority_state_missing"); + return state; +} + +export function recordRecipientPolicyStableParityPass( + db: Database, + input: { + canonicalProjectIdentity: string; + generation: number; + evidenceDigest: string; + snapshotFingerprint: string; + passedAt: string; + }, +): RecipientPolicyAuthorityStateRecord { + const state = getRecipientPolicyAuthorityState(db, input.canonicalProjectIdentity); + if ( + !state || + state.generation !== input.generation || + state.desiredDevicesDigest === null || + state.desiredDevicesDigest !== state.currentDevicesDigest || + state.freshSnapshotFingerprint !== input.snapshotFingerprint + ) { + throw new Error("recipient_policy_parity_evidence_invalid"); + } + if ( + state.stableParityEvidenceDigest && + (state.stableParityEvidenceDigest !== input.evidenceDigest || + state.stableParityPassedAt !== input.passedAt) + ) { + throw new Error("recipient_policy_parity_evidence_conflict"); + } + db.prepare( + `UPDATE recipient_policy_authority_states + SET stable_parity_evidence_digest = ?, stable_parity_passed_at = ?, updated_at = ? + WHERE canonical_project_identity = ?`, + ).run(input.evidenceDigest, input.passedAt, input.passedAt, input.canonicalProjectIdentity); + const updated = getRecipientPolicyAuthorityState(db, input.canonicalProjectIdentity); + if (!updated) throw new Error("recipient_policy_authority_state_missing"); + return updated; +} + +export function recordRecipientPolicyAuthorityExecution( + db: Database, + input: RecordRecipientPolicyAuthorityExecutionInput, +): RecipientPolicyAuthorityStateRecord { + const state = getRecipientPolicyAuthorityState(db, input.canonicalProjectIdentity); + if (!state || state.generation !== input.generation) { + throw new Error("recipient_policy_authority_generation_missing"); + } + if (!Number.isSafeInteger(input.attemptCount) || input.attemptCount < state.attemptCount) { + throw new Error("recipient_policy_reconciliation_attempt_stale"); + } + db.prepare( + `UPDATE recipient_policy_authority_states SET + attempt_count = ?, last_attempt_at = ?, last_completed_at = ?, safe_error_code = ?, + last_error_at = ?, lease_owner = ?, lease_acquired_at = ?, lease_expires_at = ?, + updated_at = ? + WHERE canonical_project_identity = ? AND generation = ?`, + ).run( + input.attemptCount, + input.lastAttemptAt, + input.lastCompletedAt, + input.safeErrorCode, + input.lastErrorAt, + input.leaseOwner, + input.leaseAcquiredAt, + input.leaseExpiresAt, + input.updatedAt, + input.canonicalProjectIdentity, + input.generation, + ); + const updated = getRecipientPolicyAuthorityState(db, input.canonicalProjectIdentity); + if (!updated) throw new Error("recipient_policy_authority_state_missing"); + return updated; +} + +export function deterministicRecipientPolicyReconciliationEffectId(input: { + canonicalProjectIdentity: string; + generation: number; + stepKey: string; + payloadDigest: string; +}): string { + return digest("recipient-policy-reconciliation-effect-v1", { + canonicalProjectIdentity: input.canonicalProjectIdentity, + generation: input.generation, + stepKey: input.stepKey, + payloadDigest: input.payloadDigest, + }); +} + +function stepRow(row: Record): RecipientPolicyReconciliationStepRecord { + return { + canonicalProjectIdentity: String(row.canonical_project_identity), + generation: Number(row.generation), + stepKey: String(row.step_key), + effectId: String(row.effect_id), + payloadDigest: String(row.payload_digest), + status: String(row.status), + attemptCount: Number(row.attempt_count), + startedAt: row.started_at as string | null, + completedAt: row.completed_at as string | null, + lastAttemptAt: row.last_attempt_at as string | null, + safeErrorCode: row.safe_error_code as string | null, + errorAt: row.error_at as string | null, + leaseOwner: row.lease_owner as string | null, + leaseAcquiredAt: row.lease_acquired_at as string | null, + leaseExpiresAt: row.lease_expires_at as string | null, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; +} + +export function ensureRecipientPolicyReconciliationStep( + db: Database, + input: EnsureRecipientPolicyReconciliationStepInput, +): RecipientPolicyReconciliationStepRecord { + if ( + !strictId(input.canonicalProjectIdentity) || + !strictId(input.stepKey) || + !Number.isSafeInteger(input.generation) || + input.generation < 0 + ) { + throw new Error("recipient_policy_reconciliation_step_invalid"); + } + const effectId = deterministicRecipientPolicyReconciliationEffectId(input); + const existing = db + .prepare( + `SELECT * FROM recipient_policy_reconciliation_steps + WHERE canonical_project_identity = ? AND generation = ? AND step_key = ?`, + ) + .get(input.canonicalProjectIdentity, input.generation, input.stepKey) as + | Record + | undefined; + if (existing) { + if (existing.effect_id !== effectId || existing.payload_digest !== input.payloadDigest) { + throw new Error("recipient_policy_reconciliation_step_conflict"); + } + return stepRow(existing); + } + db.prepare( + `INSERT INTO recipient_policy_reconciliation_steps( + canonical_project_identity, generation, step_key, effect_id, payload_digest, + status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)`, + ).run( + input.canonicalProjectIdentity, + input.generation, + input.stepKey, + effectId, + input.payloadDigest, + input.now, + input.now, + ); + const row = db + .prepare( + `SELECT * FROM recipient_policy_reconciliation_steps + WHERE canonical_project_identity = ? AND generation = ? AND step_key = ?`, + ) + .get(input.canonicalProjectIdentity, input.generation, input.stepKey) as Record< + string, + unknown + >; + return stepRow(row); +} + +export function recordRecipientPolicyReconciliationStepState( + db: Database, + input: RecordRecipientPolicyReconciliationStepStateInput, +): RecipientPolicyReconciliationStepRecord { + const existing = db + .prepare( + `SELECT * FROM recipient_policy_reconciliation_steps + WHERE canonical_project_identity = ? AND generation = ? AND step_key = ?`, + ) + .get(input.canonicalProjectIdentity, input.generation, input.stepKey) as + | Record + | undefined; + if (!existing || existing.effect_id !== input.effectId) { + throw new Error("recipient_policy_reconciliation_step_missing"); + } + if ( + !Number.isSafeInteger(input.attemptCount) || + input.attemptCount < Number(existing.attempt_count) + ) { + throw new Error("recipient_policy_reconciliation_attempt_stale"); + } + db.prepare( + `UPDATE recipient_policy_reconciliation_steps SET + status = ?, attempt_count = ?, started_at = ?, completed_at = ?, last_attempt_at = ?, + safe_error_code = ?, error_at = ?, lease_owner = ?, lease_acquired_at = ?, + lease_expires_at = ?, updated_at = ? + WHERE canonical_project_identity = ? AND generation = ? AND step_key = ? AND effect_id = ?`, + ).run( + input.status, + input.attemptCount, + input.startedAt, + input.completedAt, + input.lastAttemptAt, + input.safeErrorCode, + input.errorAt, + input.leaseOwner, + input.leaseAcquiredAt, + input.leaseExpiresAt, + input.updatedAt, + input.canonicalProjectIdentity, + input.generation, + input.stepKey, + input.effectId, + ); + const row = db + .prepare( + `SELECT * FROM recipient_policy_reconciliation_steps + WHERE canonical_project_identity = ? AND generation = ? AND step_key = ?`, + ) + .get(input.canonicalProjectIdentity, input.generation, input.stepKey) as Record< + string, + unknown + >; + return stepRow(row); +} + +function denyOverlayRow(row: Record): RecipientPolicyDenyOverlayRecord { + return { + canonicalProjectIdentity: String(row.canonical_project_identity), + scopeId: String(row.scope_id), + deviceId: String(row.device_id), + generation: Number(row.generation), + reasonCode: String(row.reason_code), + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + }; +} + +export function putRecipientPolicyDenyOverlay( + db: Database, + input: { + canonicalProjectIdentity: string; + scopeId: string; + deviceId: string; + generation: number; + reasonCode: string; + now: string; + }, +): RecipientPolicyDenyOverlayRecord { + if ( + ![input.canonicalProjectIdentity, input.scopeId, input.deviceId, input.reasonCode].every( + strictId, + ) || + !Number.isSafeInteger(input.generation) || + input.generation < 0 + ) { + throw new Error("recipient_policy_deny_overlay_invalid"); + } + const existing = db + .prepare( + `SELECT * FROM recipient_policy_deny_overlays + WHERE canonical_project_identity = ? AND scope_id = ? AND device_id = ?`, + ) + .get(input.canonicalProjectIdentity, input.scopeId, input.deviceId) as + | Record + | undefined; + if (existing && input.generation < Number(existing.generation)) { + throw new Error("recipient_policy_deny_overlay_stale"); + } + if ( + existing && + input.generation === Number(existing.generation) && + input.reasonCode !== existing.reason_code + ) { + throw new Error("recipient_policy_deny_overlay_conflict"); + } + db.prepare( + `INSERT INTO recipient_policy_deny_overlays( + canonical_project_identity, scope_id, device_id, generation, reason_code, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(canonical_project_identity, scope_id, device_id) DO UPDATE SET + generation = excluded.generation, + reason_code = excluded.reason_code, + updated_at = excluded.updated_at`, + ).run( + input.canonicalProjectIdentity, + input.scopeId, + input.deviceId, + input.generation, + input.reasonCode, + input.now, + input.now, + ); + const row = db + .prepare( + `SELECT * FROM recipient_policy_deny_overlays + WHERE canonical_project_identity = ? AND scope_id = ? AND device_id = ?`, + ) + .get(input.canonicalProjectIdentity, input.scopeId, input.deviceId) as Record; + return denyOverlayRow(row); +} + +export function listRecipientPolicyDenyOverlays( + db: Database, + canonicalProjectIdentity: string, +): RecipientPolicyDenyOverlayRecord[] { + return ( + db + .prepare( + `SELECT * FROM recipient_policy_deny_overlays + WHERE canonical_project_identity = ? ORDER BY scope_id, device_id`, + ) + .all(canonicalProjectIdentity) as Array> + ).map(denyOverlayRow); +} + +/** + * Returns any deny for a scope/device pair. Active policy reconciliation requires + * one exact Project per managed scope, so enforcement intentionally fails closed + * if corrupt legacy state contains more than one Project overlay for the pair. + */ +export function getAnyRecipientPolicyDenyOverlayForScopeDevice( + db: Database, + input: { scopeId: string; deviceId: string }, +): RecipientPolicyDenyOverlayRecord | null { + const row = db + .prepare( + `SELECT * FROM recipient_policy_deny_overlays + WHERE scope_id = ? AND device_id = ? + ORDER BY canonical_project_identity + LIMIT 1`, + ) + .get(input.scopeId, input.deviceId) as Record | undefined; + return row ? denyOverlayRow(row) : null; +} + +export function clearRecipientPolicyDenyOverlay( + db: Database, + input: { + canonicalProjectIdentity: string; + scopeId: string; + deviceId: string; + verifiedGeneration: number; + }, +): boolean { + const result = db + .prepare( + `DELETE FROM recipient_policy_deny_overlays + WHERE canonical_project_identity = ? AND scope_id = ? AND device_id = ? + AND generation <= ?`, + ) + .run(input.canonicalProjectIdentity, input.scopeId, input.deviceId, input.verifiedGeneration); + return result.changes > 0; +} diff --git a/packages/core/src/schema-bootstrap.ts b/packages/core/src/schema-bootstrap.ts index e16480cf..f63e188f 100644 --- a/packages/core/src/schema-bootstrap.ts +++ b/packages/core/src/schema-bootstrap.ts @@ -23,6 +23,65 @@ CREATE TABLE IF NOT EXISTS recipient_policy_review_resolutions ( PRIMARY KEY (review_item_id, source_fingerprint) ); +CREATE TABLE IF NOT EXISTS recipient_policy_authority_states ( + canonical_project_identity TEXT PRIMARY KEY NOT NULL, + authority_state TEXT NOT NULL DEFAULT 'legacy', + generation INTEGER NOT NULL DEFAULT 0, + desired_devices_digest TEXT, + current_devices_digest TEXT, + stable_parity_evidence_digest TEXT, + stable_parity_passed_at TEXT, + fresh_snapshot_fingerprint TEXT, + fresh_snapshot_observed_at TEXT, + safe_error_code TEXT, + state_changed_at TEXT NOT NULL, + last_error_at TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempt_at TEXT, + last_completed_at TEXT, + lease_owner TEXT, + lease_acquired_at TEXT, + lease_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS recipient_policy_reconciliation_steps ( + canonical_project_identity TEXT NOT NULL, + generation INTEGER NOT NULL, + step_key TEXT NOT NULL, + effect_id TEXT NOT NULL, + payload_digest TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + started_at TEXT, + completed_at TEXT, + last_attempt_at TEXT, + safe_error_code TEXT, + error_at TEXT, + lease_owner TEXT, + lease_acquired_at TEXT, + lease_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (canonical_project_identity, generation, step_key) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_recipient_policy_reconciliation_steps_effect + ON recipient_policy_reconciliation_steps(effect_id); +CREATE INDEX IF NOT EXISTS idx_recipient_policy_reconciliation_steps_status + ON recipient_policy_reconciliation_steps(canonical_project_identity, status); +CREATE TABLE IF NOT EXISTS recipient_policy_deny_overlays ( + canonical_project_identity TEXT NOT NULL, + scope_id TEXT NOT NULL, + device_id TEXT NOT NULL, + generation INTEGER NOT NULL, + reason_code TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (canonical_project_identity, scope_id, device_id) +); +CREATE INDEX IF NOT EXISTS idx_recipient_policy_deny_overlays_scope_device + ON recipient_policy_deny_overlays(scope_id, device_id); + CREATE TABLE IF NOT EXISTS share_operations ( operation_id TEXT PRIMARY KEY NOT NULL, state TEXT NOT NULL, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 0edd431d..0ef1d900 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -833,6 +833,93 @@ export const projectRecipients = sqliteTable( export type ProjectRecipient = typeof projectRecipients.$inferSelect; export type NewProjectRecipient = typeof projectRecipients.$inferInsert; +export const recipientPolicyAuthorityStates = sqliteTable("recipient_policy_authority_states", { + canonical_project_identity: text("canonical_project_identity").primaryKey(), + authority_state: text("authority_state").notNull().default("legacy"), + generation: integer("generation").notNull().default(0), + desired_devices_digest: text("desired_devices_digest"), + current_devices_digest: text("current_devices_digest"), + stable_parity_evidence_digest: text("stable_parity_evidence_digest"), + stable_parity_passed_at: text("stable_parity_passed_at"), + fresh_snapshot_fingerprint: text("fresh_snapshot_fingerprint"), + fresh_snapshot_observed_at: text("fresh_snapshot_observed_at"), + safe_error_code: text("safe_error_code"), + state_changed_at: text("state_changed_at").notNull(), + last_error_at: text("last_error_at"), + attempt_count: integer("attempt_count").notNull().default(0), + last_attempt_at: text("last_attempt_at"), + last_completed_at: text("last_completed_at"), + lease_owner: text("lease_owner"), + lease_acquired_at: text("lease_acquired_at"), + lease_expires_at: text("lease_expires_at"), + created_at: text("created_at").notNull(), + updated_at: text("updated_at").notNull(), +}); + +export type RecipientPolicyAuthorityStateRow = typeof recipientPolicyAuthorityStates.$inferSelect; +export type NewRecipientPolicyAuthorityStateRow = + typeof recipientPolicyAuthorityStates.$inferInsert; + +export const recipientPolicyReconciliationSteps = sqliteTable( + "recipient_policy_reconciliation_steps", + { + canonical_project_identity: text("canonical_project_identity").notNull(), + generation: integer("generation").notNull(), + step_key: text("step_key").notNull(), + effect_id: text("effect_id").notNull(), + payload_digest: text("payload_digest").notNull(), + status: text("status").notNull().default("pending"), + attempt_count: integer("attempt_count").notNull().default(0), + started_at: text("started_at"), + completed_at: text("completed_at"), + last_attempt_at: text("last_attempt_at"), + safe_error_code: text("safe_error_code"), + error_at: text("error_at"), + lease_owner: text("lease_owner"), + lease_acquired_at: text("lease_acquired_at"), + lease_expires_at: text("lease_expires_at"), + created_at: text("created_at").notNull(), + updated_at: text("updated_at").notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.canonical_project_identity, table.generation, table.step_key], + }), + uniqueIndex("idx_recipient_policy_reconciliation_steps_effect").on(table.effect_id), + index("idx_recipient_policy_reconciliation_steps_status").on( + table.canonical_project_identity, + table.status, + ), + ], +); + +export type RecipientPolicyReconciliationStep = + typeof recipientPolicyReconciliationSteps.$inferSelect; +export type NewRecipientPolicyReconciliationStep = + typeof recipientPolicyReconciliationSteps.$inferInsert; + +export const recipientPolicyDenyOverlays = sqliteTable( + "recipient_policy_deny_overlays", + { + canonical_project_identity: text("canonical_project_identity").notNull(), + scope_id: text("scope_id").notNull(), + device_id: text("device_id").notNull(), + generation: integer("generation").notNull(), + reason_code: text("reason_code").notNull(), + created_at: text("created_at").notNull(), + updated_at: text("updated_at").notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.canonical_project_identity, table.scope_id, table.device_id], + }), + index("idx_recipient_policy_deny_overlays_scope_device").on(table.scope_id, table.device_id), + ], +); + +export type RecipientPolicyDenyOverlay = typeof recipientPolicyDenyOverlays.$inferSelect; +export type NewRecipientPolicyDenyOverlay = typeof recipientPolicyDenyOverlays.$inferInsert; + export const schema = { sessions, replicationScopes, @@ -870,4 +957,7 @@ export const schema = { policyTeamMemberships, identityDevices, projectRecipients, + recipientPolicyAuthorityStates, + recipientPolicyReconciliationSteps, + recipientPolicyDenyOverlays, }; diff --git a/packages/core/src/scope-membership-cache.test.ts b/packages/core/src/scope-membership-cache.test.ts index c7fe7ac5..2e753544 100644 --- a/packages/core/src/scope-membership-cache.test.ts +++ b/packages/core/src/scope-membership-cache.test.ts @@ -5,6 +5,10 @@ import Database from "better-sqlite3"; import { afterEach, describe, expect, it } from "vitest"; import type { CoordinatorScope, CoordinatorScopeMembership } from "./coordinator-store-contract.js"; import type { Database as CoreDatabase } from "./db.js"; +import { + clearRecipientPolicyDenyOverlay, + putRecipientPolicyDenyOverlay, +} from "./recipient-policy-reconciliation.js"; import { getCachedScopeAuthorization, listCachedScopesForDevice, @@ -193,6 +197,89 @@ describe("scope membership cache", () => { ).toBe("fresh"); }); + it("keeps an exact policy deny ahead of stale active membership until foundation clearing", async () => { + const local = setup(); + await refreshScopeMembershipCache(local, { + groupIds: ["team-a"], + coordinatorId: "coord-a", + now: new Date(now()), + fetchers: { + listScopes: async () => [scope(), scope({ scope_id: "scope-oss" })], + listMemberships: async (_groupId, scopeId) => [ + membership({ scope_id: scopeId }), + membership({ scope_id: scopeId, device_id: "device-b" }), + ], + }, + }); + putRecipientPolicyDenyOverlay(local, { + canonicalProjectIdentity: "project:acme", + scopeId: "scope-acme", + deviceId: "device-a", + generation: 7, + reasonCode: "pending_revoke", + now: now(1), + }); + + await refreshScopeMembershipCache(local, { + groupIds: ["team-a"], + coordinatorId: "coord-a", + now: new Date(now(2)), + fetchers: { + listScopes: async () => { + throw new Error("coordinator offline"); + }, + listMemberships: async () => [], + }, + }); + + expect( + getCachedScopeAuthorization(local, { + deviceId: "device-a", + scopeId: "scope-acme", + now: new Date(now(60_000)), + }), + ).toMatchObject({ authorized: false, state: "policy_denied", freshness: "stale" }); + expect( + getCachedScopeAuthorization(local, { deviceId: "device-a", scopeId: "scope-oss" }), + ).toMatchObject({ authorized: true, state: "authorized" }); + expect( + getCachedScopeAuthorization(local, { deviceId: "device-b", scopeId: "scope-acme" }), + ).toMatchObject({ authorized: true, state: "authorized" }); + expect( + listCachedScopesForDevice(local, "device-a").memberships.map((item) => item.scope_id), + ).toEqual(["scope-oss"]); + await refreshScopeMembershipCache(local, { + groupIds: ["team-a"], + coordinatorId: "coord-a", + now: new Date(now(3)), + fetchers: { + listScopes: async () => [scope({ membership_epoch: 8 }), scope({ scope_id: "scope-oss" })], + listMemberships: async (_groupId, scopeId) => + scopeId === "scope-acme" + ? [membership({ device_id: "device-b", membership_epoch: 8 })] + : [ + membership({ scope_id: scopeId }), + membership({ scope_id: scopeId, device_id: "device-b" }), + ], + }, + }); + expect( + getCachedScopeAuthorization(local, { deviceId: "device-a", scopeId: "scope-acme" }), + ).toMatchObject({ authorized: false, state: "policy_denied" }); + + expect( + clearRecipientPolicyDenyOverlay(local, { + canonicalProjectIdentity: "project:acme", + scopeId: "scope-acme", + deviceId: "device-a", + verifiedGeneration: 8, + }), + ).toBe(true); + expect( + getCachedScopeAuthorization(local, { deviceId: "device-a", scopeId: "scope-acme" }), + ).toMatchObject({ authorized: false, state: "revoked" }); + }); + it("filters device lookups by requested coordinator and group authority", async () => { const local = setup(); await refreshScopeMembershipCache(local, { diff --git a/packages/core/src/scope-membership-cache.ts b/packages/core/src/scope-membership-cache.ts index ebf58fc8..4225bb63 100644 --- a/packages/core/src/scope-membership-cache.ts +++ b/packages/core/src/scope-membership-cache.ts @@ -9,6 +9,7 @@ import { } from "./coordinator-runtime.js"; import type { CoordinatorScope, CoordinatorScopeMembership } from "./coordinator-store-contract.js"; import type { Database } from "./db.js"; +import { getAnyRecipientPolicyDenyOverlayForScopeDevice } from "./recipient-policy-reconciliation.js"; import { explainScopeMembershipRevocation, type ScopeMembershipEpochStatus, @@ -28,6 +29,7 @@ export type ScopeMembershipAuthorizationState = | "not_authorized" | "revoked" | "stale_epoch" + | "policy_denied" | "scope_unknown" | "scope_inactive"; @@ -698,10 +700,17 @@ export function listCachedScopesForDevice( ) .all(...params) as JoinedMembershipRow[]; const cacheStates = loadCacheStates(db, opts.authority ?? null); + const memberships = rows.filter( + (row) => + getAnyRecipientPolicyDenyOverlayForScopeDevice(db, { + scopeId: row.scope_id, + deviceId: cleanDeviceId, + }) == null, + ); return { deviceId: cleanDeviceId, freshness: freshness(cacheStates, opts), - memberships: rows.map((row) => ({ + memberships: memberships.map((row) => ({ ...membershipFromJoinedRow(row), scope: scopeFromJoinedRow(row), })), @@ -740,6 +749,20 @@ export function getCachedScopeAuthorization( membershipEpoch: membership?.membership_epoch ?? null, requiredEpoch: scope?.membership_epoch ?? null, }); + if (getAnyRecipientPolicyDenyOverlayForScopeDevice(db, { deviceId, scopeId })) { + return { + deviceId, + scopeId, + authorized: false, + state: "policy_denied", + freshness: currentFreshness, + epoch, + revocation: null, + membership, + scope, + cacheStates, + }; + } if (!membership) { return { deviceId, diff --git a/packages/core/src/share-provisioning.test.ts b/packages/core/src/share-provisioning.test.ts index 8364698e..e5a3cfb1 100644 --- a/packages/core/src/share-provisioning.test.ts +++ b/packages/core/src/share-provisioning.test.ts @@ -708,9 +708,15 @@ describe("exact project share provisioning", () => { it("preserves a pre-existing step effect_id while executing it", async () => { // Arrange const preservedEffectId = "persisted-effect-before-resume"; + const preservedGrantEffectId = "persisted-grant-effect-before-resume"; db.prepare( "UPDATE share_operation_steps SET effect_id = ? WHERE operation_id = ? AND step_key = 'authorization_refresh'", ).run(preservedEffectId, operationId); + db.prepare(`UPDATE share_operation_steps SET effect_id = ? + WHERE operation_id = ? AND step_key = ( + SELECT step_key FROM share_operation_steps + WHERE operation_id = ? AND step_key LIKE 'space_grant:%' ORDER BY step_key LIMIT 1 + )`).run(preservedGrantEffectId, operationId, operationId); const { deps } = dependencies(); // Act @@ -724,6 +730,9 @@ describe("exact project share provisioning", () => { ) .get(operationId), ).toEqual({ effect_id: preservedEffectId, status: "completed" }); + expect(vi.mocked(deps.grantMembership).mock.calls.map(([input]) => input.effectId)).toContain( + preservedGrantEffectId, + ); }); it("resumes idempotently across migration, mapping, refresh, and initial-sync failures", async () => { @@ -761,4 +770,37 @@ describe("exact project share provisioning", () => { .get(operationId), ).toBe("active"); }); + + it("blocks stale legacy grant retries when active recipient policy no longer desires the device", async () => { + const now = createdAt; + db.prepare( + `INSERT INTO recipient_policy_authority_states( + canonical_project_identity, authority_state, generation, desired_devices_digest, + state_changed_at, created_at, updated_at + ) VALUES (?, 'active', 1, 'desired', ?, ?, ?)`, + ).run(remote, now, now, now); + db.prepare( + `UPDATE project_recipients SET status = 'revoked', updated_at = ? + WHERE canonical_project_identity = ? AND recipient_kind = 'identity' + AND recipient_id = 'actor-recipient'`, + ).run(now, remote); + const { deps } = dependencies(); + + await expect( + executeShareProvisioning(db, { operationId, initiatingDeviceId: "owner" }, deps), + ).rejects.toThrow("recipient_policy_legacy_grant_blocked"); + + expect(vi.mocked(deps.grantMembership).mock.calls.map(([input]) => input.deviceId)).toEqual([ + "owner", + "owner-proven", + ]); + expect( + db + .prepare( + "SELECT safe_error_code FROM share_operation_steps WHERE operation_id = ? AND step_key = ?", + ) + .pluck() + .get(operationId, `space_grant:${remote}:recipient`), + ).toBe("recipient_policy_legacy_grant_blocked"); + }); }); diff --git a/packages/core/src/share-provisioning.ts b/packages/core/src/share-provisioning.ts index ebbcb4e4..9c9a4fab 100644 --- a/packages/core/src/share-provisioning.ts +++ b/packages/core/src/share-provisioning.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import type { CoordinatorScope, CoordinatorScopeMembership } from "./coordinator-store-contract.js"; import { type Database, fromJson } from "./db.js"; +import { assertLegacyShareGrantAllowed } from "./recipient-policy-reconciler.js"; import { canonicalWorkspaceIdentity } from "./scope-resolution.js"; import { DEFAULT_SYNC_SCOPE_ID, @@ -35,6 +36,7 @@ export interface ShareProvisioningDependencies { beforeStep?(stepKey: string): Promise | void; createOrGetBoundary(project: ManagedProjectPlan, groupId: string): Promise; grantMembership(input: { + effectId: string; groupId: string; scopeId: string; deviceId: string; @@ -672,8 +674,19 @@ export async function executeShareProvisioning( for (const deviceId of project.memberDeviceIds) { const stepKey = `space_grant:${project.canonicalIdentity}:${deviceId}`; const expectedRole = deviceId === input.initiatingDeviceId ? "admin" : "member"; - await executeStep(stepKey, `space-grant:${project.boundaryId}:${deviceId}:1`, async () => { + const effectId = persistedEffectId( + db, + plan.operationId, + stepKey, + `space-grant:${project.boundaryId}:${deviceId}:1`, + ); + await executeStep(stepKey, effectId, async () => { + assertLegacyShareGrantAllowed(db, { + canonicalProjectIdentity: project.canonicalIdentity, + deviceId, + }); const membership = await dependencies.grantMembership({ + effectId, groupId: plan.groupId, scopeId: project.boundaryId, deviceId, diff --git a/packages/core/src/sync-mixed-scope.test.ts b/packages/core/src/sync-mixed-scope.test.ts index 47f9baa4..0c7a6fce 100644 --- a/packages/core/src/sync-mixed-scope.test.ts +++ b/packages/core/src/sync-mixed-scope.test.ts @@ -12,7 +12,9 @@ import Database from "better-sqlite3"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { toJson } from "./db.js"; +import { putRecipientPolicyDenyOverlay } from "./recipient-policy-reconciliation.js"; import { + applyReplicationOps, DEFAULT_SYNC_SCOPE_ID, filterReplicationOpsForSyncWithStatus, getReplicationCursor, @@ -430,6 +432,82 @@ describe("mixed personal/work/OSS sync — boundary enforcement", () => { expect(personalAfter.map((op) => op.op_id)).toEqual(["op-personal-1"]); }); + it("a pending policy deny blocks exact outbound ops and snapshots without touching sibling scopes", () => { + putRecipientPolicyDenyOverlay(fixture.db, { + canonicalProjectIdentity: "project:acme", + scopeId: ACME_SCOPE, + deviceId: PEER_ACME, + generation: 4, + reasonCode: "pending_revoke", + now: "2026-05-03T00:00:05Z", + }); + + const [acmeOps] = filterForPeer(fixture.db, [fixture.ops.acme], PEER_ACME); + expect(acmeOps).toEqual([]); + const acmeSnapshot = loadMemorySnapshotPageForPeer(fixture.db, { + peerDeviceId: PEER_ACME, + scopeId: ACME_SCOPE, + generation: 1, + snapshotId: "snap-1", + baselineCursor: "2026-05-03T00:00:00Z|baseline-op", + }); + expect(acmeSnapshot.items).toEqual([]); + + const [ossOps] = filterForPeer(fixture.db, [fixture.ops.oss], PEER_OSS); + expect(ossOps.map((op) => op.op_id)).toEqual(["op-oss-1"]); + const ossSnapshot = loadMemorySnapshotPageForPeer(fixture.db, { + peerDeviceId: PEER_OSS, + scopeId: OSS_SCOPE, + generation: 1, + snapshotId: "snap-1", + baselineCursor: "2026-05-03T00:00:00Z|baseline-op", + }); + expect(ossSnapshot.items.map((item) => item.entity_id)).toEqual(["key:oss-1"]); + }); + + it("a pending sender deny rejects inbound ops only for the exact scope and device", () => { + putRecipientPolicyDenyOverlay(fixture.db, { + canonicalProjectIdentity: "project:acme", + scopeId: ACME_SCOPE, + deviceId: PEER_ACME, + generation: 4, + reasonCode: "pending_revoke", + now: "2026-05-03T00:00:05Z", + }); + const inbound = (deviceId: string, scopeId: string, opId: string): ReplicationOp => ({ + ...makeOp({ + opId, + entityId: `key:${opId}`, + scopeId, + createdAt: "2026-05-03T00:00:06Z", + payload: { project: opId, visibility: "shared", scope_id: scopeId }, + }), + clock_device_id: deviceId, + device_id: deviceId, + }); + + const denied = applyReplicationOps( + fixture.db, + [inbound(PEER_ACME, ACME_SCOPE, "inbound-acme")], + LOCAL_DEVICE, + undefined, + { inboundScopeValidation: { peerDeviceId: PEER_ACME } }, + ); + expect(denied.rejections).toEqual([ + expect.objectContaining({ reason: "sender_not_member", scope_id: ACME_SCOPE }), + ]); + + const allowed = applyReplicationOps( + fixture.db, + [inbound(PEER_OSS, OSS_SCOPE, "inbound-oss")], + LOCAL_DEVICE, + undefined, + { inboundScopeValidation: { peerDeviceId: PEER_OSS } }, + ); + expect(allowed.rejected).toBe(0); + expect(allowed.applied).toBe(1); + }); + it("requires an explicit personal-scope grant for same-actor private sync", () => { const status = personalScopeGrantStatusForPeer(fixture.db, { peerDeviceId: PEER_PERSONAL, diff --git a/packages/core/src/sync-replication.ts b/packages/core/src/sync-replication.ts index b4211f38..63d6d020 100644 --- a/packages/core/src/sync-replication.ts +++ b/packages/core/src/sync-replication.ts @@ -15,6 +15,7 @@ import type { Database } from "./db.js"; import { fromJson, fromJsonStrict, toJson, toJsonNullable } from "./db.js"; import { readCodememConfigFile } from "./observer-config.js"; import { projectBasename } from "./project.js"; +import { getAnyRecipientPolicyDenyOverlayForScopeDevice } from "./recipient-policy-reconciliation.js"; import { clearMemoryRefs, populateMemoryRefs } from "./ref-populate.js"; import * as schema from "./schema.js"; import { getCachedScopeAuthorization } from "./scope-membership-cache.js"; @@ -1309,6 +1310,17 @@ export function loadMemorySnapshotPageForPeer( if (normalizeCursor(options.baselineCursor) !== boundary.baseline_cursor) { throw new Error("boundary_mismatch"); } + const peerDeviceId = cleanText(options.peerDeviceId); + if ( + scopeFilterRequested && + peerDeviceId && + getAnyRecipientPolicyDenyOverlayForScopeDevice(db, { + scopeId: effectiveScopeId, + deviceId: peerDeviceId, + }) + ) { + return { boundary, items: [], nextPageToken: null, hasMore: false }; + } // Cap raised to 5000 to support elevated bootstrap page sizes (default 2000). const limit = Math.max(1, Math.min(options.limit ?? 200, 5000)); diff --git a/packages/core/src/sync-scope-protocol.test.ts b/packages/core/src/sync-scope-protocol.test.ts index 0e8abdae..8be34d44 100644 --- a/packages/core/src/sync-scope-protocol.test.ts +++ b/packages/core/src/sync-scope-protocol.test.ts @@ -1,5 +1,6 @@ import Database from "better-sqlite3"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { putRecipientPolicyDenyOverlay } from "./recipient-policy-reconciliation.js"; import { SCOPED_NULL_BASELINE_BOOTSTRAP_CURSOR_MARKER, setReplicationCursor, @@ -220,6 +221,29 @@ describe("parseSyncScopeRequest scoped path", () => { expect(result).toEqual({ ok: false, reason: "scope_inactive" }); }); + it("rejects a scoped request while an exact peer deny overlay is pending", () => { + insertScope(SCOPE_ID); + grantMembership(SCOPE_ID, LOCAL_DEVICE); + grantMembership(SCOPE_ID, PEER_DEVICE); + putRecipientPolicyDenyOverlay(db, { + canonicalProjectIdentity: "project:acme", + scopeId: SCOPE_ID, + deviceId: PEER_DEVICE, + generation: 2, + reasonCode: "pending_revoke", + now: NOW, + }); + + expect( + parseSyncScopeRequest(SCOPE_ID, true, { + db, + localDeviceId: LOCAL_DEVICE, + negotiatedCapability: "scoped", + peerDeviceId: PEER_DEVICE, + }), + ).toEqual({ ok: false, reason: "missing_scope" }); + }); + it("falls back to unsupported_scope when caller advertises a lower capability", () => { insertScope(SCOPE_ID); grantMembership(SCOPE_ID, LOCAL_DEVICE); @@ -374,6 +398,51 @@ describe("listAuthorizedScopesForPeer", () => { expect(scopes).toEqual([]); }); + it("does not advertise an exact denied scope and leaves sibling scopes available", () => { + insertScope("acme-work"); + insertScope("oss"); + for (const scopeId of ["acme-work", "oss"]) { + grantMembership(scopeId, LOCAL_DEVICE); + grantMembership(scopeId, PEER_DEVICE); + } + putRecipientPolicyDenyOverlay(db, { + canonicalProjectIdentity: "project:acme", + scopeId: "acme-work", + deviceId: PEER_DEVICE, + generation: 2, + reasonCode: "pending_revoke", + now: NOW, + }); + + expect( + listAuthorizedScopesForPeer(db, { + localDeviceId: LOCAL_DEVICE, + peerDeviceId: PEER_DEVICE, + }).map((scope) => scope.scope_id), + ).toEqual(["oss"]); + }); + + it("does not advertise a scope denied for the local device", () => { + insertScope("acme-work"); + grantMembership("acme-work", LOCAL_DEVICE); + grantMembership("acme-work", PEER_DEVICE); + putRecipientPolicyDenyOverlay(db, { + canonicalProjectIdentity: "project:acme", + scopeId: "acme-work", + deviceId: LOCAL_DEVICE, + generation: 2, + reasonCode: "pending_revoke", + now: NOW, + }); + + expect( + listAuthorizedScopesForPeer(db, { + localDeviceId: LOCAL_DEVICE, + peerDeviceId: PEER_DEVICE, + }), + ).toEqual([]); + }); + it("returns an empty list when local and peer device ids are equal", () => { insertScope("acme-work"); grantMembership("acme-work", LOCAL_DEVICE); diff --git a/packages/core/src/sync-scope-protocol.ts b/packages/core/src/sync-scope-protocol.ts index 6ad10995..92310249 100644 --- a/packages/core/src/sync-scope-protocol.ts +++ b/packages/core/src/sync-scope-protocol.ts @@ -233,6 +233,11 @@ export function listAuthorizedScopesForPeer( const entries: AuthorizedScopeEntry[] = []; for (const local of localMemberships) { if (!local.scope_id || local.scope_id === DEFAULT_SYNC_SCOPE_ID) continue; + const localAuth = getCachedScopeAuthorization(db, { + deviceId: localDeviceId, + scopeId: local.scope_id, + }); + if (!localAuth.authorized) continue; // Peer must also be an active member of the same scope, at the same // or higher membership_epoch as the local row, before we advertise. diff --git a/packages/core/src/test-schema.generated.ts b/packages/core/src/test-schema.generated.ts index ebe06800..a9324005 100644 --- a/packages/core/src/test-schema.generated.ts +++ b/packages/core/src/test-schema.generated.ts @@ -6,4 +6,4 @@ */ export const TEST_SCHEMA_BASE_DDL = - "CREATE TABLE IF NOT EXISTS `sessions` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`started_at` text NOT NULL,\n\t`ended_at` text,\n\t`cwd` text,\n\t`project` text,\n\t`git_remote` text,\n\t`git_branch` text,\n\t`user` text,\n\t`tool_version` text,\n\t`metadata_json` text,\n\t`import_key` text\n);\n\nCREATE TABLE IF NOT EXISTS `replication_scopes` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`label` text NOT NULL,\n\t`kind` text DEFAULT 'user' NOT NULL,\n\t`authority_type` text DEFAULT 'local' NOT NULL,\n\t`coordinator_id` text,\n\t`group_id` text,\n\t`manifest_issuer_device_id` text,\n\t`membership_epoch` integer DEFAULT 0 NOT NULL,\n\t`manifest_hash` text,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_scopes_status` ON `replication_scopes` (`status`);\nCREATE INDEX IF NOT EXISTS `idx_replication_scopes_authority_group` ON `replication_scopes` (`coordinator_id`,`group_id`);\nCREATE TABLE IF NOT EXISTS `project_scope_mappings` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`workspace_identity` text,\n\t`project_pattern` text NOT NULL,\n\t`scope_id` text NOT NULL,\n\t`priority` integer DEFAULT 0 NOT NULL,\n\t`source` text DEFAULT 'user' NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_workspace_priority` ON `project_scope_mappings` (`workspace_identity`,`priority`);\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_pattern_priority` ON `project_scope_mappings` (`project_pattern`,`priority`);\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_scope` ON `project_scope_mappings` (`scope_id`);\nCREATE TABLE IF NOT EXISTS `scope_memberships` (\n\t`scope_id` text NOT NULL,\n\t`device_id` text NOT NULL,\n\t`role` text DEFAULT 'member' NOT NULL,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`membership_epoch` integer DEFAULT 0 NOT NULL,\n\t`coordinator_id` text,\n\t`group_id` text,\n\t`manifest_issuer_device_id` text,\n\t`manifest_hash` text,\n\t`signed_manifest_json` text,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`scope_id`, `device_id`)\n);\n\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_device_status` ON `scope_memberships` (`device_id`,`status`);\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_scope_status` ON `scope_memberships` (`scope_id`,`status`);\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_authority_group` ON `scope_memberships` (`coordinator_id`,`group_id`);\nCREATE TABLE IF NOT EXISTS `artifacts` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer NOT NULL,\n\t`kind` text NOT NULL,\n\t`path` text,\n\t`content_text` text,\n\t`content_hash` text,\n\t`created_at` text NOT NULL,\n\t`metadata_json` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_artifacts_session_kind` ON `artifacts` (`session_id`,`kind`);\nCREATE TABLE IF NOT EXISTS `memory_items` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer NOT NULL,\n\t`kind` text NOT NULL,\n\t`title` text NOT NULL,\n\t`subtitle` text,\n\t`body_text` text NOT NULL,\n\t`confidence` real DEFAULT 0.5,\n\t`tags_text` text DEFAULT '',\n\t`active` integer DEFAULT 1,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\t`metadata_json` text,\n\t`actor_id` text,\n\t`actor_display_name` text,\n\t`visibility` text,\n\t`workspace_id` text,\n\t`workspace_kind` text,\n\t`origin_device_id` text,\n\t`origin_source` text,\n\t`trust_state` text,\n\t`facts` text,\n\t`narrative` text,\n\t`concepts` text,\n\t`files_read` text,\n\t`files_modified` text,\n\t`user_prompt_id` integer,\n\t`prompt_number` integer,\n\t`deleted_at` text,\n\t`rev` integer DEFAULT 0,\n\t`dedup_key` text,\n\t`import_key` text,\n\t`scope_id` text,\n\t`project` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_items_active_created` ON `memory_items` (`active`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_session` ON `memory_items` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_project` ON `memory_items` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_scope_visibility_created` ON `memory_items` (`scope_id`,`visibility`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_scope_backfill_pending` ON `memory_items` (`id`) WHERE scope_id IS NULL OR scope_id = '';\nCREATE INDEX IF NOT EXISTS `idx_memory_items_dedup_key_active_created` ON `memory_items` (`dedup_key`,`active`,`created_at`);\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_memory_items_same_session_dedup_unique` ON `memory_items` (`session_id`,`kind`,`visibility`,`workspace_id`,`dedup_key`) WHERE active = 1 AND dedup_key IS NOT NULL;\nCREATE TABLE IF NOT EXISTS `memory_file_refs` (\n\t`memory_id` integer NOT NULL,\n\t`file_path` text NOT NULL,\n\t`relation` text NOT NULL,\n\tPRIMARY KEY(`memory_id`, `file_path`, `relation`),\n\tFOREIGN KEY (`memory_id`) REFERENCES `memory_items`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_file_refs_path` ON `memory_file_refs` (`file_path`);\nCREATE TABLE IF NOT EXISTS `memory_concept_refs` (\n\t`memory_id` integer NOT NULL,\n\t`concept` text NOT NULL,\n\tPRIMARY KEY(`memory_id`, `concept`),\n\tFOREIGN KEY (`memory_id`) REFERENCES `memory_items`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_concept_refs_concept` ON `memory_concept_refs` (`concept`);\nCREATE TABLE IF NOT EXISTS `usage_events` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`event` text NOT NULL,\n\t`tokens_read` integer DEFAULT 0,\n\t`tokens_written` integer DEFAULT 0,\n\t`tokens_saved` integer DEFAULT 0,\n\t`created_at` text NOT NULL,\n\t`metadata_json` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null\n);\n\nCREATE INDEX IF NOT EXISTS `idx_usage_events_event_created` ON `usage_events` (`event`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_usage_events_session` ON `usage_events` (`session_id`);\nCREATE TABLE IF NOT EXISTS `raw_events` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`event_id` text,\n\t`event_seq` integer NOT NULL,\n\t`event_type` text NOT NULL,\n\t`ts_wall_ms` integer,\n\t`ts_mono_ms` real,\n\t`payload_json` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_raw_events_source_stream_seq` ON `raw_events` (`source`,`stream_id`,`event_seq`);\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_raw_events_source_stream_event_id` ON `raw_events` (`source`,`stream_id`,`event_id`);\nCREATE INDEX IF NOT EXISTS `idx_raw_events_session_seq` ON `raw_events` (`opencode_session_id`,`event_seq`);\nCREATE INDEX IF NOT EXISTS `idx_raw_events_created` ON `raw_events` (`created_at`);\nCREATE TABLE IF NOT EXISTS `raw_event_sessions` (\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`cwd` text,\n\t`project` text,\n\t`started_at` text,\n\t`last_seen_ts_wall_ms` integer,\n\t`last_received_event_seq` integer DEFAULT -1 NOT NULL,\n\t`last_flushed_event_seq` integer DEFAULT -1 NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`source`, `stream_id`)\n);\n\nCREATE TABLE IF NOT EXISTS `opencode_sessions` (\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`session_id` integer,\n\t`created_at` text NOT NULL,\n\tPRIMARY KEY(`source`, `stream_id`),\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_opencode_sessions_session` ON `opencode_sessions` (`session_id`);\nCREATE TABLE IF NOT EXISTS `raw_event_flush_batches` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`start_event_seq` integer NOT NULL,\n\t`end_event_seq` integer NOT NULL,\n\t`extractor_version` text NOT NULL,\n\t`status` text NOT NULL,\n\t`error_message` text,\n\t`error_type` text,\n\t`observer_provider` text,\n\t`observer_model` text,\n\t`observer_runtime` text,\n\t`observer_auth_source` text,\n\t`observer_auth_type` text,\n\t`observer_error_code` text,\n\t`observer_error_message` text,\n\t`attempt_count` integer DEFAULT 0 NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_flush_batches_source_stream_seq_ver` ON `raw_event_flush_batches` (`source`,`stream_id`,`start_event_seq`,`end_event_seq`,`extractor_version`);\nCREATE INDEX IF NOT EXISTS `idx_flush_batches_session_created` ON `raw_event_flush_batches` (`opencode_session_id`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_flush_batches_status_updated` ON `raw_event_flush_batches` (`status`,`updated_at`);\nCREATE TABLE IF NOT EXISTS `user_prompts` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`project` text,\n\t`prompt_text` text NOT NULL,\n\t`prompt_number` integer,\n\t`created_at` text NOT NULL,\n\t`created_at_epoch` integer NOT NULL,\n\t`metadata_json` text,\n\t`import_key` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_session` ON `user_prompts` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_project` ON `user_prompts` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_epoch` ON `user_prompts` (`created_at_epoch`);\nCREATE TABLE IF NOT EXISTS `session_summaries` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`project` text,\n\t`request` text,\n\t`investigated` text,\n\t`learned` text,\n\t`completed` text,\n\t`next_steps` text,\n\t`notes` text,\n\t`files_read` text,\n\t`files_edited` text,\n\t`prompt_number` integer,\n\t`created_at` text NOT NULL,\n\t`created_at_epoch` integer NOT NULL,\n\t`metadata_json` text,\n\t`import_key` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_session` ON `session_summaries` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_project` ON `session_summaries` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_epoch` ON `session_summaries` (`created_at_epoch`);\nCREATE TABLE IF NOT EXISTS `replication_ops` (\n\t`op_id` text PRIMARY KEY NOT NULL,\n\t`entity_type` text NOT NULL,\n\t`entity_id` text NOT NULL,\n\t`op_type` text NOT NULL,\n\t`payload_json` text,\n\t`clock_rev` integer NOT NULL,\n\t`clock_updated_at` text NOT NULL,\n\t`clock_device_id` text NOT NULL,\n\t`device_id` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`scope_id` text\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_created` ON `replication_ops` (`created_at`,`op_id`);\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_entity` ON `replication_ops` (`entity_type`,`entity_id`);\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_scope_created` ON `replication_ops` (`scope_id`,`created_at`,`op_id`);\nCREATE TABLE IF NOT EXISTS `replication_cursors` (\n\t`peer_device_id` text PRIMARY KEY NOT NULL,\n\t`last_applied_cursor` text,\n\t`last_acked_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `replication_cursors_v2` (\n\t`peer_device_id` text NOT NULL,\n\t`scope_id` text NOT NULL,\n\t`last_applied_cursor` text,\n\t`last_acked_cursor` text,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`peer_device_id`, `scope_id`)\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_cursors_v2_scope` ON `replication_cursors_v2` (`scope_id`);\nCREATE TABLE IF NOT EXISTS `sync_peers` (\n\t`peer_device_id` text PRIMARY KEY NOT NULL,\n\t`name` text,\n\t`pinned_fingerprint` text,\n\t`public_key` text,\n\t`addresses_json` text,\n\t`claimed_local_actor` integer DEFAULT 0 NOT NULL,\n\t`actor_id` text,\n\t`projects_include_json` text,\n\t`projects_exclude_json` text,\n\t`created_at` text NOT NULL,\n\t`last_seen_at` text,\n\t`last_sync_at` text,\n\t`last_error` text,\n\t`discovered_via_coordinator_id` text,\n\t`discovered_via_group_id` text,\n\t`pending_bootstrap_grant_id` text\n);\n\nCREATE TABLE IF NOT EXISTS `sync_nonces` (\n\t`nonce` text PRIMARY KEY NOT NULL,\n\t`device_id` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_device` (\n\t`device_id` text PRIMARY KEY NOT NULL,\n\t`public_key` text NOT NULL,\n\t`fingerprint` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_attempts` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`peer_device_id` text NOT NULL,\n\t`started_at` text NOT NULL,\n\t`finished_at` text,\n\t`ok` integer NOT NULL,\n\t`ops_in` integer NOT NULL,\n\t`ops_out` integer NOT NULL,\n\t`error` text,\n\t`local_sync_capability` text,\n\t`peer_sync_capability` text,\n\t`negotiated_sync_capability` text\n);\n\nCREATE INDEX IF NOT EXISTS `idx_sync_attempts_peer_started` ON `sync_attempts` (`peer_device_id`,`started_at`);\nCREATE TABLE IF NOT EXISTS `sync_scope_rejections` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`peer_device_id` text,\n\t`op_id` text NOT NULL,\n\t`entity_type` text NOT NULL,\n\t`entity_id` text NOT NULL,\n\t`scope_id` text,\n\t`reason` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_sync_scope_rejections_peer_created` ON `sync_scope_rejections` (`peer_device_id`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_sync_scope_rejections_scope_created` ON `sync_scope_rejections` (`scope_id`,`created_at`);\nCREATE TABLE IF NOT EXISTS `sync_daemon_state` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`last_error` text,\n\t`last_traceback` text,\n\t`last_error_at` text,\n\t`last_ok_at` text,\n\t`phase` text\n);\n\nCREATE TABLE IF NOT EXISTS `sync_reset_state` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`generation` integer NOT NULL,\n\t`snapshot_id` text NOT NULL,\n\t`baseline_cursor` text,\n\t`retained_floor_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_reset_state_v2` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`generation` integer NOT NULL,\n\t`snapshot_id` text NOT NULL,\n\t`baseline_cursor` text,\n\t`retained_floor_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_retention_state_v2` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`last_run_at` text,\n\t`last_duration_ms` integer,\n\t`last_deleted_ops` integer DEFAULT 0 NOT NULL,\n\t`last_estimated_bytes_before` integer,\n\t`last_estimated_bytes_after` integer,\n\t`retained_floor_cursor` text,\n\t`last_error` text,\n\t`last_error_at` text\n);\n\nCREATE TABLE IF NOT EXISTS `raw_event_ingest_samples` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`created_at` text NOT NULL,\n\t`inserted_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_invalid` integer DEFAULT 0 NOT NULL,\n\t`skipped_duplicate` integer DEFAULT 0 NOT NULL,\n\t`skipped_conflict` integer DEFAULT 0 NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `raw_event_ingest_stats` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`inserted_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_invalid` integer DEFAULT 0 NOT NULL,\n\t`skipped_duplicate` integer DEFAULT 0 NOT NULL,\n\t`skipped_conflict` integer DEFAULT 0 NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `coordinator_group_preferences` (\n\t`coordinator_id` text NOT NULL,\n\t`group_id` text NOT NULL,\n\t`projects_include_json` text,\n\t`projects_exclude_json` text,\n\t`auto_seed_scope` integer DEFAULT 1 NOT NULL,\n\t`default_space_scope_id` text,\n\t`auto_grant_default_space_on_join` integer DEFAULT 0 NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`coordinator_id`, `group_id`)\n);\n\nCREATE TABLE IF NOT EXISTS `actors` (\n\t`actor_id` text PRIMARY KEY NOT NULL,\n\t`display_name` text NOT NULL,\n\t`is_local` integer DEFAULT 0 NOT NULL,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`merged_into_actor_id` text,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_actors_is_local` ON `actors` (`is_local`);\nCREATE INDEX IF NOT EXISTS `idx_actors_status` ON `actors` (`status`);\nCREATE TABLE IF NOT EXISTS `recipient_policy_review_resolutions` (\n\t`review_item_id` text NOT NULL,\n\t`source_fingerprint` text NOT NULL,\n\t`decision` text NOT NULL,\n\t`decision_input_json` text NOT NULL,\n\t`preview_json` text NOT NULL,\n\t`decided_by_identity_id` text NOT NULL,\n\t`decided_by_device_id` text NOT NULL,\n\t`resolved_at` text NOT NULL,\n\tPRIMARY KEY(`review_item_id`, `source_fingerprint`)\n);\n\nCREATE TABLE IF NOT EXISTS `policy_teams` (\n\t`team_id` text PRIMARY KEY NOT NULL,\n\t`display_name` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `policy_teams_idempotency_key_unique` ON `policy_teams` (`idempotency_key`);\nCREATE TABLE IF NOT EXISTS `policy_team_memberships` (\n\t`team_id` text NOT NULL,\n\t`identity_id` text NOT NULL,\n\t`role` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`team_id`, `identity_id`)\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `policy_team_memberships_idempotency_key_unique` ON `policy_team_memberships` (`idempotency_key`);\nCREATE INDEX IF NOT EXISTS `idx_policy_team_memberships_identity_status` ON `policy_team_memberships` (`identity_id`,`status`);\nCREATE TABLE IF NOT EXISTS `identity_devices` (\n\t`device_id` text PRIMARY KEY NOT NULL,\n\t`identity_id` text NOT NULL,\n\t`display_name` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `identity_devices_idempotency_key_unique` ON `identity_devices` (`idempotency_key`);\nCREATE INDEX IF NOT EXISTS `idx_identity_devices_identity_status` ON `identity_devices` (`identity_id`,`status`);\nCREATE TABLE IF NOT EXISTS `project_recipients` (\n\t`canonical_project_identity` text NOT NULL,\n\t`recipient_kind` text NOT NULL,\n\t`recipient_id` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`policy_revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`canonical_project_identity`, `recipient_kind`, `recipient_id`)\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `project_recipients_idempotency_key_unique` ON `project_recipients` (`idempotency_key`);\nCREATE INDEX IF NOT EXISTS `idx_project_recipients_project_status` ON `project_recipients` (`canonical_project_identity`,`status`);"; + "CREATE TABLE IF NOT EXISTS `sessions` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`started_at` text NOT NULL,\n\t`ended_at` text,\n\t`cwd` text,\n\t`project` text,\n\t`git_remote` text,\n\t`git_branch` text,\n\t`user` text,\n\t`tool_version` text,\n\t`metadata_json` text,\n\t`import_key` text\n);\n\nCREATE TABLE IF NOT EXISTS `replication_scopes` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`label` text NOT NULL,\n\t`kind` text DEFAULT 'user' NOT NULL,\n\t`authority_type` text DEFAULT 'local' NOT NULL,\n\t`coordinator_id` text,\n\t`group_id` text,\n\t`manifest_issuer_device_id` text,\n\t`membership_epoch` integer DEFAULT 0 NOT NULL,\n\t`manifest_hash` text,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_scopes_status` ON `replication_scopes` (`status`);\nCREATE INDEX IF NOT EXISTS `idx_replication_scopes_authority_group` ON `replication_scopes` (`coordinator_id`,`group_id`);\nCREATE TABLE IF NOT EXISTS `project_scope_mappings` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`workspace_identity` text,\n\t`project_pattern` text NOT NULL,\n\t`scope_id` text NOT NULL,\n\t`priority` integer DEFAULT 0 NOT NULL,\n\t`source` text DEFAULT 'user' NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_workspace_priority` ON `project_scope_mappings` (`workspace_identity`,`priority`);\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_pattern_priority` ON `project_scope_mappings` (`project_pattern`,`priority`);\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_scope` ON `project_scope_mappings` (`scope_id`);\nCREATE TABLE IF NOT EXISTS `scope_memberships` (\n\t`scope_id` text NOT NULL,\n\t`device_id` text NOT NULL,\n\t`role` text DEFAULT 'member' NOT NULL,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`membership_epoch` integer DEFAULT 0 NOT NULL,\n\t`coordinator_id` text,\n\t`group_id` text,\n\t`manifest_issuer_device_id` text,\n\t`manifest_hash` text,\n\t`signed_manifest_json` text,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`scope_id`, `device_id`)\n);\n\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_device_status` ON `scope_memberships` (`device_id`,`status`);\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_scope_status` ON `scope_memberships` (`scope_id`,`status`);\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_authority_group` ON `scope_memberships` (`coordinator_id`,`group_id`);\nCREATE TABLE IF NOT EXISTS `artifacts` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer NOT NULL,\n\t`kind` text NOT NULL,\n\t`path` text,\n\t`content_text` text,\n\t`content_hash` text,\n\t`created_at` text NOT NULL,\n\t`metadata_json` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_artifacts_session_kind` ON `artifacts` (`session_id`,`kind`);\nCREATE TABLE IF NOT EXISTS `memory_items` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer NOT NULL,\n\t`kind` text NOT NULL,\n\t`title` text NOT NULL,\n\t`subtitle` text,\n\t`body_text` text NOT NULL,\n\t`confidence` real DEFAULT 0.5,\n\t`tags_text` text DEFAULT '',\n\t`active` integer DEFAULT 1,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\t`metadata_json` text,\n\t`actor_id` text,\n\t`actor_display_name` text,\n\t`visibility` text,\n\t`workspace_id` text,\n\t`workspace_kind` text,\n\t`origin_device_id` text,\n\t`origin_source` text,\n\t`trust_state` text,\n\t`facts` text,\n\t`narrative` text,\n\t`concepts` text,\n\t`files_read` text,\n\t`files_modified` text,\n\t`user_prompt_id` integer,\n\t`prompt_number` integer,\n\t`deleted_at` text,\n\t`rev` integer DEFAULT 0,\n\t`dedup_key` text,\n\t`import_key` text,\n\t`scope_id` text,\n\t`project` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_items_active_created` ON `memory_items` (`active`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_session` ON `memory_items` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_project` ON `memory_items` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_scope_visibility_created` ON `memory_items` (`scope_id`,`visibility`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_scope_backfill_pending` ON `memory_items` (`id`) WHERE scope_id IS NULL OR scope_id = '';\nCREATE INDEX IF NOT EXISTS `idx_memory_items_dedup_key_active_created` ON `memory_items` (`dedup_key`,`active`,`created_at`);\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_memory_items_same_session_dedup_unique` ON `memory_items` (`session_id`,`kind`,`visibility`,`workspace_id`,`dedup_key`) WHERE active = 1 AND dedup_key IS NOT NULL;\nCREATE TABLE IF NOT EXISTS `memory_file_refs` (\n\t`memory_id` integer NOT NULL,\n\t`file_path` text NOT NULL,\n\t`relation` text NOT NULL,\n\tPRIMARY KEY(`memory_id`, `file_path`, `relation`),\n\tFOREIGN KEY (`memory_id`) REFERENCES `memory_items`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_file_refs_path` ON `memory_file_refs` (`file_path`);\nCREATE TABLE IF NOT EXISTS `memory_concept_refs` (\n\t`memory_id` integer NOT NULL,\n\t`concept` text NOT NULL,\n\tPRIMARY KEY(`memory_id`, `concept`),\n\tFOREIGN KEY (`memory_id`) REFERENCES `memory_items`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_concept_refs_concept` ON `memory_concept_refs` (`concept`);\nCREATE TABLE IF NOT EXISTS `usage_events` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`event` text NOT NULL,\n\t`tokens_read` integer DEFAULT 0,\n\t`tokens_written` integer DEFAULT 0,\n\t`tokens_saved` integer DEFAULT 0,\n\t`created_at` text NOT NULL,\n\t`metadata_json` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null\n);\n\nCREATE INDEX IF NOT EXISTS `idx_usage_events_event_created` ON `usage_events` (`event`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_usage_events_session` ON `usage_events` (`session_id`);\nCREATE TABLE IF NOT EXISTS `raw_events` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`event_id` text,\n\t`event_seq` integer NOT NULL,\n\t`event_type` text NOT NULL,\n\t`ts_wall_ms` integer,\n\t`ts_mono_ms` real,\n\t`payload_json` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_raw_events_source_stream_seq` ON `raw_events` (`source`,`stream_id`,`event_seq`);\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_raw_events_source_stream_event_id` ON `raw_events` (`source`,`stream_id`,`event_id`);\nCREATE INDEX IF NOT EXISTS `idx_raw_events_session_seq` ON `raw_events` (`opencode_session_id`,`event_seq`);\nCREATE INDEX IF NOT EXISTS `idx_raw_events_created` ON `raw_events` (`created_at`);\nCREATE TABLE IF NOT EXISTS `raw_event_sessions` (\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`cwd` text,\n\t`project` text,\n\t`started_at` text,\n\t`last_seen_ts_wall_ms` integer,\n\t`last_received_event_seq` integer DEFAULT -1 NOT NULL,\n\t`last_flushed_event_seq` integer DEFAULT -1 NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`source`, `stream_id`)\n);\n\nCREATE TABLE IF NOT EXISTS `opencode_sessions` (\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`session_id` integer,\n\t`created_at` text NOT NULL,\n\tPRIMARY KEY(`source`, `stream_id`),\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_opencode_sessions_session` ON `opencode_sessions` (`session_id`);\nCREATE TABLE IF NOT EXISTS `raw_event_flush_batches` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`start_event_seq` integer NOT NULL,\n\t`end_event_seq` integer NOT NULL,\n\t`extractor_version` text NOT NULL,\n\t`status` text NOT NULL,\n\t`error_message` text,\n\t`error_type` text,\n\t`observer_provider` text,\n\t`observer_model` text,\n\t`observer_runtime` text,\n\t`observer_auth_source` text,\n\t`observer_auth_type` text,\n\t`observer_error_code` text,\n\t`observer_error_message` text,\n\t`attempt_count` integer DEFAULT 0 NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_flush_batches_source_stream_seq_ver` ON `raw_event_flush_batches` (`source`,`stream_id`,`start_event_seq`,`end_event_seq`,`extractor_version`);\nCREATE INDEX IF NOT EXISTS `idx_flush_batches_session_created` ON `raw_event_flush_batches` (`opencode_session_id`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_flush_batches_status_updated` ON `raw_event_flush_batches` (`status`,`updated_at`);\nCREATE TABLE IF NOT EXISTS `user_prompts` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`project` text,\n\t`prompt_text` text NOT NULL,\n\t`prompt_number` integer,\n\t`created_at` text NOT NULL,\n\t`created_at_epoch` integer NOT NULL,\n\t`metadata_json` text,\n\t`import_key` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_session` ON `user_prompts` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_project` ON `user_prompts` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_epoch` ON `user_prompts` (`created_at_epoch`);\nCREATE TABLE IF NOT EXISTS `session_summaries` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`project` text,\n\t`request` text,\n\t`investigated` text,\n\t`learned` text,\n\t`completed` text,\n\t`next_steps` text,\n\t`notes` text,\n\t`files_read` text,\n\t`files_edited` text,\n\t`prompt_number` integer,\n\t`created_at` text NOT NULL,\n\t`created_at_epoch` integer NOT NULL,\n\t`metadata_json` text,\n\t`import_key` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_session` ON `session_summaries` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_project` ON `session_summaries` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_epoch` ON `session_summaries` (`created_at_epoch`);\nCREATE TABLE IF NOT EXISTS `replication_ops` (\n\t`op_id` text PRIMARY KEY NOT NULL,\n\t`entity_type` text NOT NULL,\n\t`entity_id` text NOT NULL,\n\t`op_type` text NOT NULL,\n\t`payload_json` text,\n\t`clock_rev` integer NOT NULL,\n\t`clock_updated_at` text NOT NULL,\n\t`clock_device_id` text NOT NULL,\n\t`device_id` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`scope_id` text\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_created` ON `replication_ops` (`created_at`,`op_id`);\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_entity` ON `replication_ops` (`entity_type`,`entity_id`);\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_scope_created` ON `replication_ops` (`scope_id`,`created_at`,`op_id`);\nCREATE TABLE IF NOT EXISTS `replication_cursors` (\n\t`peer_device_id` text PRIMARY KEY NOT NULL,\n\t`last_applied_cursor` text,\n\t`last_acked_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `replication_cursors_v2` (\n\t`peer_device_id` text NOT NULL,\n\t`scope_id` text NOT NULL,\n\t`last_applied_cursor` text,\n\t`last_acked_cursor` text,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`peer_device_id`, `scope_id`)\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_cursors_v2_scope` ON `replication_cursors_v2` (`scope_id`);\nCREATE TABLE IF NOT EXISTS `sync_peers` (\n\t`peer_device_id` text PRIMARY KEY NOT NULL,\n\t`name` text,\n\t`pinned_fingerprint` text,\n\t`public_key` text,\n\t`addresses_json` text,\n\t`claimed_local_actor` integer DEFAULT 0 NOT NULL,\n\t`actor_id` text,\n\t`projects_include_json` text,\n\t`projects_exclude_json` text,\n\t`created_at` text NOT NULL,\n\t`last_seen_at` text,\n\t`last_sync_at` text,\n\t`last_error` text,\n\t`discovered_via_coordinator_id` text,\n\t`discovered_via_group_id` text,\n\t`pending_bootstrap_grant_id` text\n);\n\nCREATE TABLE IF NOT EXISTS `sync_nonces` (\n\t`nonce` text PRIMARY KEY NOT NULL,\n\t`device_id` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_device` (\n\t`device_id` text PRIMARY KEY NOT NULL,\n\t`public_key` text NOT NULL,\n\t`fingerprint` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_attempts` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`peer_device_id` text NOT NULL,\n\t`started_at` text NOT NULL,\n\t`finished_at` text,\n\t`ok` integer NOT NULL,\n\t`ops_in` integer NOT NULL,\n\t`ops_out` integer NOT NULL,\n\t`error` text,\n\t`local_sync_capability` text,\n\t`peer_sync_capability` text,\n\t`negotiated_sync_capability` text\n);\n\nCREATE INDEX IF NOT EXISTS `idx_sync_attempts_peer_started` ON `sync_attempts` (`peer_device_id`,`started_at`);\nCREATE TABLE IF NOT EXISTS `sync_scope_rejections` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`peer_device_id` text,\n\t`op_id` text NOT NULL,\n\t`entity_type` text NOT NULL,\n\t`entity_id` text NOT NULL,\n\t`scope_id` text,\n\t`reason` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_sync_scope_rejections_peer_created` ON `sync_scope_rejections` (`peer_device_id`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_sync_scope_rejections_scope_created` ON `sync_scope_rejections` (`scope_id`,`created_at`);\nCREATE TABLE IF NOT EXISTS `sync_daemon_state` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`last_error` text,\n\t`last_traceback` text,\n\t`last_error_at` text,\n\t`last_ok_at` text,\n\t`phase` text\n);\n\nCREATE TABLE IF NOT EXISTS `sync_reset_state` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`generation` integer NOT NULL,\n\t`snapshot_id` text NOT NULL,\n\t`baseline_cursor` text,\n\t`retained_floor_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_reset_state_v2` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`generation` integer NOT NULL,\n\t`snapshot_id` text NOT NULL,\n\t`baseline_cursor` text,\n\t`retained_floor_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_retention_state_v2` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`last_run_at` text,\n\t`last_duration_ms` integer,\n\t`last_deleted_ops` integer DEFAULT 0 NOT NULL,\n\t`last_estimated_bytes_before` integer,\n\t`last_estimated_bytes_after` integer,\n\t`retained_floor_cursor` text,\n\t`last_error` text,\n\t`last_error_at` text\n);\n\nCREATE TABLE IF NOT EXISTS `raw_event_ingest_samples` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`created_at` text NOT NULL,\n\t`inserted_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_invalid` integer DEFAULT 0 NOT NULL,\n\t`skipped_duplicate` integer DEFAULT 0 NOT NULL,\n\t`skipped_conflict` integer DEFAULT 0 NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `raw_event_ingest_stats` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`inserted_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_invalid` integer DEFAULT 0 NOT NULL,\n\t`skipped_duplicate` integer DEFAULT 0 NOT NULL,\n\t`skipped_conflict` integer DEFAULT 0 NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `coordinator_group_preferences` (\n\t`coordinator_id` text NOT NULL,\n\t`group_id` text NOT NULL,\n\t`projects_include_json` text,\n\t`projects_exclude_json` text,\n\t`auto_seed_scope` integer DEFAULT 1 NOT NULL,\n\t`default_space_scope_id` text,\n\t`auto_grant_default_space_on_join` integer DEFAULT 0 NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`coordinator_id`, `group_id`)\n);\n\nCREATE TABLE IF NOT EXISTS `actors` (\n\t`actor_id` text PRIMARY KEY NOT NULL,\n\t`display_name` text NOT NULL,\n\t`is_local` integer DEFAULT 0 NOT NULL,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`merged_into_actor_id` text,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_actors_is_local` ON `actors` (`is_local`);\nCREATE INDEX IF NOT EXISTS `idx_actors_status` ON `actors` (`status`);\nCREATE TABLE IF NOT EXISTS `recipient_policy_review_resolutions` (\n\t`review_item_id` text NOT NULL,\n\t`source_fingerprint` text NOT NULL,\n\t`decision` text NOT NULL,\n\t`decision_input_json` text NOT NULL,\n\t`preview_json` text NOT NULL,\n\t`decided_by_identity_id` text NOT NULL,\n\t`decided_by_device_id` text NOT NULL,\n\t`resolved_at` text NOT NULL,\n\tPRIMARY KEY(`review_item_id`, `source_fingerprint`)\n);\n\nCREATE TABLE IF NOT EXISTS `policy_teams` (\n\t`team_id` text PRIMARY KEY NOT NULL,\n\t`display_name` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `policy_teams_idempotency_key_unique` ON `policy_teams` (`idempotency_key`);\nCREATE TABLE IF NOT EXISTS `policy_team_memberships` (\n\t`team_id` text NOT NULL,\n\t`identity_id` text NOT NULL,\n\t`role` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`team_id`, `identity_id`)\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `policy_team_memberships_idempotency_key_unique` ON `policy_team_memberships` (`idempotency_key`);\nCREATE INDEX IF NOT EXISTS `idx_policy_team_memberships_identity_status` ON `policy_team_memberships` (`identity_id`,`status`);\nCREATE TABLE IF NOT EXISTS `identity_devices` (\n\t`device_id` text PRIMARY KEY NOT NULL,\n\t`identity_id` text NOT NULL,\n\t`display_name` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `identity_devices_idempotency_key_unique` ON `identity_devices` (`idempotency_key`);\nCREATE INDEX IF NOT EXISTS `idx_identity_devices_identity_status` ON `identity_devices` (`identity_id`,`status`);\nCREATE TABLE IF NOT EXISTS `project_recipients` (\n\t`canonical_project_identity` text NOT NULL,\n\t`recipient_kind` text NOT NULL,\n\t`recipient_id` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`policy_revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`canonical_project_identity`, `recipient_kind`, `recipient_id`)\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `project_recipients_idempotency_key_unique` ON `project_recipients` (`idempotency_key`);\nCREATE INDEX IF NOT EXISTS `idx_project_recipients_project_status` ON `project_recipients` (`canonical_project_identity`,`status`);\nCREATE TABLE IF NOT EXISTS `recipient_policy_authority_states` (\n\t`canonical_project_identity` text PRIMARY KEY NOT NULL,\n\t`authority_state` text DEFAULT 'legacy' NOT NULL,\n\t`generation` integer DEFAULT 0 NOT NULL,\n\t`desired_devices_digest` text,\n\t`current_devices_digest` text,\n\t`stable_parity_evidence_digest` text,\n\t`stable_parity_passed_at` text,\n\t`fresh_snapshot_fingerprint` text,\n\t`fresh_snapshot_observed_at` text,\n\t`safe_error_code` text,\n\t`state_changed_at` text NOT NULL,\n\t`last_error_at` text,\n\t`attempt_count` integer DEFAULT 0 NOT NULL,\n\t`last_attempt_at` text,\n\t`last_completed_at` text,\n\t`lease_owner` text,\n\t`lease_acquired_at` text,\n\t`lease_expires_at` text,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `recipient_policy_reconciliation_steps` (\n\t`canonical_project_identity` text NOT NULL,\n\t`generation` integer NOT NULL,\n\t`step_key` text NOT NULL,\n\t`effect_id` text NOT NULL,\n\t`payload_digest` text NOT NULL,\n\t`status` text DEFAULT 'pending' NOT NULL,\n\t`attempt_count` integer DEFAULT 0 NOT NULL,\n\t`started_at` text,\n\t`completed_at` text,\n\t`last_attempt_at` text,\n\t`safe_error_code` text,\n\t`error_at` text,\n\t`lease_owner` text,\n\t`lease_acquired_at` text,\n\t`lease_expires_at` text,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`canonical_project_identity`, `generation`, `step_key`)\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_recipient_policy_reconciliation_steps_effect` ON `recipient_policy_reconciliation_steps` (`effect_id`);\nCREATE INDEX IF NOT EXISTS `idx_recipient_policy_reconciliation_steps_status` ON `recipient_policy_reconciliation_steps` (`canonical_project_identity`,`status`);\nCREATE TABLE IF NOT EXISTS `recipient_policy_deny_overlays` (\n\t`canonical_project_identity` text NOT NULL,\n\t`scope_id` text NOT NULL,\n\t`device_id` text NOT NULL,\n\t`generation` integer NOT NULL,\n\t`reason_code` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`canonical_project_identity`, `scope_id`, `device_id`)\n);\n\nCREATE INDEX IF NOT EXISTS `idx_recipient_policy_deny_overlays_scope_device` ON `recipient_policy_deny_overlays` (`scope_id`,`device_id`);"; diff --git a/packages/viewer-server/src/index.test.ts b/packages/viewer-server/src/index.test.ts index 81f0aec9..73d45fb6 100644 --- a/packages/viewer-server/src/index.test.ts +++ b/packages/viewer-server/src/index.test.ts @@ -7926,6 +7926,106 @@ describe("viewer-server", () => { } }); + it("returns safe recipient-policy reconciliation states with the delivered-copy warning", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + const now = "2026-07-22T12:00:00.000Z"; + const waitingProject = "https://git.example.invalid/acme/waiting.git"; + const unsupportedProject = "https://git.example.invalid/acme/unsupported.git"; + const insertRecipient = store.db.prepare( + `INSERT INTO project_recipients( + canonical_project_identity, recipient_kind, recipient_id, status, provenance, + policy_revision, migration_state, idempotency_key, created_at, updated_at + ) VALUES (?, 'identity', ?, 'active', 'test', 'private-revision', 'native', ?, ?, ?)`, + ); + insertRecipient.run( + waitingProject, + "identity-waiting", + "recipient-status-waiting", + now, + now, + ); + insertRecipient.run( + unsupportedProject, + "identity-unsupported", + "recipient-status-unsupported", + now, + now, + ); + const insertAuthority = store.db.prepare( + `INSERT INTO recipient_policy_authority_states( + canonical_project_identity, authority_state, generation, desired_devices_digest, + current_devices_digest, fresh_snapshot_fingerprint, safe_error_code, + state_changed_at, last_error_at, attempt_count, last_attempt_at, created_at, updated_at + ) VALUES (?, 'legacy', 2, 'private-desired-digest', 'private-current-digest', + 'private-snapshot-fingerprint', ?, ?, ?, 1, ?, ?, ?)`, + ); + insertAuthority.run( + waitingProject, + "recipient_policy_capability_undetermined", + now, + now, + now, + now, + now, + ); + insertAuthority.run( + unsupportedProject, + "recipient_policy_capability_unsupported", + now, + now, + now, + now, + now, + ); + store.db.pragma("query_only = ON"); + + const response = await app.request("/api/sync/recipient-policy/v1/reconciliation-status"); + const text = await response.text(); + const payload = JSON.parse(text) as { + version: number; + items: Array<{ + canonicalProjectIdentity: string; + state: string; + deliveredCopiesMayRemain: boolean; + revocationWarning: string; + }>; + }; + + expect(response.status).toBe(200); + expect(payload.version).toBe(1); + expect(payload.items).toEqual([ + expect.objectContaining({ + canonicalProjectIdentity: unsupportedProject, + state: "needs_attention", + deliveredCopiesMayRemain: true, + }), + expect.objectContaining({ + canonicalProjectIdentity: waitingProject, + state: "waiting", + deliveredCopiesMayRemain: true, + }), + ]); + expect(payload.items.every((item) => item.revocationWarning.length > 0)).toBe(true); + for (const privateValue of [ + "private-revision", + "private-desired-digest", + "private-current-digest", + "private-snapshot-fingerprint", + "recipient_policy_capability_undetermined", + "recipient_policy_capability_unsupported", + ]) { + expect(text).not.toContain(privateValue); + } + } finally { + getStore()?.db.pragma("query_only = OFF"); + cleanup(); + } + }); + it("serves and resolves recipient-policy review without mutating protected state", async () => { const { app, getStore, cleanup } = createTestApp(); try { @@ -12602,25 +12702,55 @@ describe("viewer-server", () => { }, ); expect(granted.status).toBe(200); - const revoked = await app.request( "/api/coordinator/admin/groups/team-a/scopes/scope-a/members/device-1/revoke", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ membership_epoch: 4, memory_items: [{ id: 1 }] }), + body: JSON.stringify({ + effect_id: "caller-supplied-revoke", + membership_epoch: 4, + memory_items: [{ id: 1 }], + }), }, ); expect(revoked.status).toBe(200); + const repeatedGrant = await app.request( + "/api/coordinator/admin/groups/team-a/scopes/scope-a/members", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_id: "device-1", + role: "reader", + membership_epoch: 3, + memory_payload: { id: 1 }, + }), + }, + ); + expect(repeatedGrant.status).toBe(200); + const calls = fetchMock.mock.calls; - expect(calls).toHaveLength(3); + expect(calls).toHaveLength(4); const grantBody = bodyJson(calls[1]?.[1] as RequestInit | undefined); - expect(grantBody).toMatchObject({ device_id: "device-1", role: "reader" }); + expect(grantBody).toMatchObject({ + device_id: "device-1", + role: "reader", + }); + expect(grantBody.effect_id).toMatch(/^viewer-admin-membership:grant:[a-f0-9]{64}$/); expect(grantBody).not.toHaveProperty("memory_payload"); const revokeBody = bodyJson(calls[2]?.[1] as RequestInit | undefined); - expect(revokeBody).toMatchObject({ membership_epoch: 4 }); + expect(revokeBody).toMatchObject({ + effect_id: "caller-supplied-revoke", + membership_epoch: 4, + }); expect(revokeBody).not.toHaveProperty("memory_items"); + const repeatedGrantBody = bodyJson(calls[3]?.[1] as RequestInit | undefined); + expect(repeatedGrantBody.effect_id).toMatch( + /^viewer-admin-membership:grant:[a-f0-9]{64}$/, + ); + expect(repeatedGrantBody.effect_id).not.toBe(grantBody.effect_id); } finally { cleanup(); } diff --git a/packages/viewer-server/src/index.ts b/packages/viewer-server/src/index.ts index 3ef25e73..5d084b2f 100644 --- a/packages/viewer-server/src/index.ts +++ b/packages/viewer-server/src/index.ts @@ -29,10 +29,17 @@ import { syncProtocolRoutes, syncRoutes } from "./routes/sync.js"; export type { AdvancePendingProjectSharesResult, AdvanceProjectShareOperationResult, + RecipientPolicyReconciliationReadModel, + RecipientPolicyReconciliationReadState, + ReconcileRecipientPolicyProjectsResult, } from "./routes/sync.js"; export { advancePendingProjectShares, advanceProjectShareOperation, + createRecipientPolicyReconcilerEffects, + listRecipientPolicyReconciliationStatus, + recipientPolicyCapabilityFromStatus, + reconcileRecipientPolicyProjects, } from "./routes/sync.js"; export { VERSION }; diff --git a/packages/viewer-server/src/routes/sync.ts b/packages/viewer-server/src/routes/sync.ts index 3672bcab..23e9f4e7 100644 --- a/packages/viewer-server/src/routes/sync.ts +++ b/packages/viewer-server/src/routes/sync.ts @@ -2,7 +2,7 @@ * Sync routes — status, peers, actors, attempts, pairing, mutations. */ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { hostname, networkInterfaces } from "node:os"; import { dirname, join } from "node:path"; @@ -14,6 +14,10 @@ import type { MemoryStore, ProjectScopeGuardrailWarning, ReassignScopeCapability, + RecipientPolicyCoordinatorEffectReceipt, + RecipientPolicyPeerCapability, + RecipientPolicyReconcileResult, + RecipientPolicyReconcilerEffects, RecipientPolicyReviewDecisionV1, RecipientPolicyReviewResolveRequestV1, ReplicationOp, @@ -73,6 +77,7 @@ import { formatHostPort, friendlyDeviceName, getCoordinatorGroupPreference, + getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, type InboundScopeRejectionPeerSummary, @@ -117,6 +122,7 @@ import { readCodememConfigFile, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, + reconcileRecipientPolicyProject, reconcileShareOperationAcceptance, recordNonce, refreshConfiguredScopeMembershipCache, @@ -125,6 +131,7 @@ import { resolveRecipientPolicyReview, resolveRecipientPolicyReviewBulk, runSyncPass, + SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, SYNC_AUTHORIZATION_REFRESH_HEADER, SYNC_CAPABILITY_HEADER, SYNC_FEATURES_HEADER, @@ -325,6 +332,7 @@ async function ensureDefaultSpaceForTeam(opts: { })); const [deviceId] = ensureDeviceIdentity(opts.store.db, { keysDir: syncKeysDir() }); const membership = await coordinatorGrantScopeMembershipAction({ + effectId: `team-default-grant:${opts.groupId}:${scopeId}:${deviceId}:1`, groupId: opts.groupId, scopeId, deviceId, @@ -347,6 +355,7 @@ async function maybeGrantDefaultSpaceOnJoin(opts: { store: MemoryStore; config: ReturnType; groupId: string; + requestId: string; deviceId: string; }): Promise { const coordinatorId = opts.config.syncCoordinatorUrl || null; @@ -365,6 +374,7 @@ async function maybeGrantDefaultSpaceOnJoin(opts: { const defaultScope = scopes.find((scope) => scope.scope_id === scopeId) ?? null; if (defaultScope?.kind !== "team_default") return null; return await coordinatorGrantScopeMembershipAction({ + effectId: `team-default-join:${opts.requestId}:${scopeId}:${opts.deviceId}:1`, groupId: opts.groupId, scopeId, deviceId: opts.deviceId, @@ -464,6 +474,41 @@ function optionalViewerNumber(body: Record, key: string): numbe return Number.isFinite(number) ? Math.trunc(number) : Number.NaN; } +function coordinatorAdminMembershipEffectId( + action: "grant" | "revoke", + attemptId: string, + input: { + groupId: string; + scopeId: string; + deviceId: string; + role?: string | null; + membershipEpoch: number | null; + coordinatorId?: string | null; + manifestIssuerDeviceId?: string | null; + manifestHash: string | null; + signedManifestJson: string | null; + }, +): string { + const fingerprint = createHash("sha256") + .update( + JSON.stringify({ + action, + attemptId, + groupId: input.groupId, + scopeId: input.scopeId, + deviceId: input.deviceId, + role: input.role ?? null, + membershipEpoch: input.membershipEpoch, + coordinatorId: input.coordinatorId ?? null, + manifestIssuerDeviceId: input.manifestIssuerDeviceId ?? null, + manifestHash: input.manifestHash, + signedManifestJson: input.signedManifestJson, + }), + ) + .digest("hex"); + return `viewer-admin-membership:${action}:${fingerprint}`; +} + function optionalViewerInteger(body: Record, key: string): number | null { const value = body[key]; if (value == null || value === "") return null; @@ -513,6 +558,7 @@ function coordinatorAdminMutationStatus(message: string): 400 | 404 | 409 | 502 if ( message.includes("not active") || message.includes("scope_not_active") || + message.includes("scope_membership_effect_conflict") || message.includes("group_archived") || message.includes("Group is archived") ) { @@ -693,10 +739,21 @@ function coordinatorPreviewDigest(reviewedOnboardingDigest: string): string { return match[1]; } -async function peerSupportsReassignScope( +export function recipientPolicyCapabilityFromStatus(payload: { + sync_capability?: unknown; + sync_features?: unknown; +}): RecipientPolicyPeerCapability { + if (!isScopedSyncCapability(normalizeSyncCapability(payload.sync_capability))) { + return "unsupported"; + } + return supportsSyncFeature(payload.sync_features, "reassign_scope") ? "supported" : "unsupported"; +} + +async function peerSupportsSyncRequirements( store: MemoryStore, deviceId: string, -): Promise { + requirements: { scoped: boolean; reassignScope: boolean }, +): Promise { const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() }); if (deviceId === localDeviceId) return "supported"; const row = store.db @@ -729,18 +786,34 @@ async function peerSupportsReassignScope( [SYNC_FEATURES_HEADER]: LOCAL_SYNC_FEATURES.join(","), }; const [status, payload] = await requestJson("GET", url, { headers, timeoutS: 5 }); - if (status >= 200 && status < 300) { - if (String(payload?.device_id ?? "").trim() !== deviceId) continue; - if ( - expectedFingerprint && - String(payload?.fingerprint ?? "").trim() !== expectedFingerprint - ) { - continue; - } - return supportsSyncFeature(payload?.sync_features, "reassign_scope") - ? "supported" - : "unsupported"; + if (status < 200 || status >= 300) continue; + if (String(payload?.device_id ?? "").trim() !== deviceId) continue; + if ( + expectedFingerprint && + String(payload?.fingerprint ?? "").trim() !== expectedFingerprint + ) { + continue; + } + if ( + requirements.scoped && + requirements.reassignScope && + recipientPolicyCapabilityFromStatus(payload ?? {}) !== "supported" + ) { + return "unsupported"; + } + if ( + requirements.scoped && + !isScopedSyncCapability(normalizeSyncCapability(payload?.sync_capability)) + ) { + return "unsupported"; } + if ( + requirements.reassignScope && + !supportsSyncFeature(payload?.sync_features, "reassign_scope") + ) { + return "unsupported"; + } + return "supported"; } catch { // Try the next reviewed address. Ambiguous/unreachable capability fails closed. } @@ -748,6 +821,16 @@ async function peerSupportsReassignScope( return "undetermined"; } +async function peerSupportsReassignScope( + store: MemoryStore, + deviceId: string, +): Promise { + return peerSupportsSyncRequirements(store, deviceId, { + scoped: false, + reassignScope: true, + }); +} + function resolveProjectInviteSelection( store: MemoryStore, requestedIds: string[], @@ -1146,9 +1229,10 @@ async function executeProjectShareProvisioning(store: MemoryStore, operationId: throw error; } }, - grantMembership: ({ groupId: expectedGroupId, scopeId, deviceId, role }) => { + grantMembership: ({ effectId, groupId: expectedGroupId, scopeId, deviceId, role }) => { if (expectedGroupId !== groupId) throw new Error("operation_scope_mismatch"); return coordinatorGrantScopeMembershipAction({ + effectId, groupId, scopeId, deviceId, @@ -1428,6 +1512,389 @@ export async function advancePendingProjectShares( return result; } +const RECIPIENT_POLICY_MAINTENANCE_MAX_LIMIT = 10; +const RECIPIENT_POLICY_MAINTENANCE_DEFAULT_LIMIT = 3; +const RECIPIENT_POLICY_MAINTENANCE_BACKOFF_MS = 60_000; +const RECIPIENT_POLICY_WAITING_ERRORS = new Set([ + "recipient_policy_capability_undetermined", + "recipient_policy_parity_incomplete", + "recipient_policy_snapshot_not_fresh", +]); + +interface RecipientPolicyCoordinatorBoundary { + coordinatorId: string; + groupId: string; + membershipEpoch: number; +} + +function recipientPolicyCoordinatorBoundary( + store: MemoryStore, + scopeId: string, +): RecipientPolicyCoordinatorBoundary { + const row = store.db + .prepare( + `SELECT coordinator_id, group_id, membership_epoch FROM replication_scopes + WHERE scope_id = ? AND kind = 'managed_project' AND authority_type = 'coordinator' + AND status = 'active'`, + ) + .get(scopeId) as + | { + coordinator_id: string | null; + group_id: string | null; + membership_epoch: number; + } + | undefined; + const coordinatorId = String(row?.coordinator_id ?? "").trim(); + const groupId = String(row?.group_id ?? "").trim(); + const membershipEpoch = Number(row?.membership_epoch); + if (!coordinatorId || !groupId || !Number.isSafeInteger(membershipEpoch) || membershipEpoch < 0) { + throw new Error("recipient_policy_active_managed_scope_required"); + } + return { coordinatorId, groupId, membershipEpoch }; +} + +function recipientPolicySnapshotFingerprint( + scopeId: string, + scopeMembershipEpoch: number, + memberships: Array<{ + deviceId: string; + status: "active" | "revoked"; + membershipEpoch: number; + }>, +): string { + const canonical = memberships + .toSorted( + (left, right) => + left.deviceId.localeCompare(right.deviceId) || + left.status.localeCompare(right.status) || + left.membershipEpoch - right.membershipEpoch, + ) + .map( + (membership) => + `${membership.deviceId}\u0000${membership.status}\u0000${membership.membershipEpoch}`, + ) + .join("\u0001"); + return `recipient-policy-coordinator-snapshot-v2:${createHash("sha256") + .update(`${scopeId}\u0000${scopeMembershipEpoch}\u0000${canonical}`) + .digest("hex")}`; +} + +export function createRecipientPolicyReconcilerEffects( + store: MemoryStore, + options: { + config?: ReturnType; + now?: () => string; + } = {}, +): RecipientPolicyReconcilerEffects { + const config = options.config ?? readCoordinatorSyncConfig(); + const now = options.now ?? (() => new Date().toISOString()); + const target = (scopeId: string) => recipientPolicyCoordinatorBoundary(store, scopeId); + const coordinatorOptions = (scopeId: string) => { + const boundary = target(scopeId); + const remoteUrl = config.syncCoordinatorUrl?.trim(); + const adminSecret = config.syncCoordinatorAdminSecret?.trim(); + if (!remoteUrl || !adminSecret) throw new Error("recipient_policy_effect_failed"); + if (!config.syncCoordinatorGroups.includes(boundary.groupId)) { + throw new Error("recipient_policy_effect_failed"); + } + try { + if (buildBaseUrl(boundary.coordinatorId) !== buildBaseUrl(remoteUrl)) { + throw new Error("recipient_policy_effect_failed"); + } + } catch { + throw new Error("recipient_policy_effect_failed"); + } + return { + ...boundary, + remoteUrl, + adminSecret, + }; + }; + return { + now, + snapshot: async ({ scopeId }) => { + const targetOptions = coordinatorOptions(scopeId); + const memberships = await coordinatorListScopeMembershipsAction({ + groupId: targetOptions.groupId, + scopeId, + includeRevoked: true, + remoteUrl: targetOptions.remoteUrl, + adminSecret: targetOptions.adminSecret, + }).catch(() => { + throw new Error("recipient_policy_snapshot_not_fresh"); + }); + const snapshotMemberships = memberships.map((membership) => { + if (membership.status !== "active" && membership.status !== "revoked") { + throw new Error("recipient_policy_snapshot_invalid"); + } + const status: "active" | "revoked" = membership.status; + return { + deviceId: membership.device_id, + status, + membershipEpoch: membership.membership_epoch, + }; + }); + return { + authoritative: true, + scopeId, + scopeMembershipEpoch: targetOptions.membershipEpoch, + fingerprint: recipientPolicySnapshotFingerprint( + scopeId, + targetOptions.membershipEpoch, + snapshotMemberships, + ), + observedAt: now(), + memberships: snapshotMemberships, + }; + }, + probeCapability: (deviceId) => + peerSupportsSyncRequirements(store, deviceId, { + scoped: true, + reassignScope: true, + }), + revoke: async (input): Promise => { + const targetOptions = coordinatorOptions(input.scopeId); + await coordinatorRevokeScopeMembershipAction({ + effectId: input.effectId, + groupId: targetOptions.groupId, + scopeId: input.scopeId, + deviceId: input.deviceId, + remoteUrl: targetOptions.remoteUrl, + adminSecret: targetOptions.adminSecret, + }); + return { + effectId: input.effectId, + scopeId: input.scopeId, + deviceId: input.deviceId, + status: "revoked", + }; + }, + grant: async (input): Promise => { + const targetOptions = coordinatorOptions(input.scopeId); + const membership = await coordinatorGrantScopeMembershipAction({ + effectId: input.effectId, + groupId: targetOptions.groupId, + scopeId: input.scopeId, + deviceId: input.deviceId, + role: input.role, + coordinatorId: targetOptions.coordinatorId, + remoteUrl: targetOptions.remoteUrl, + adminSecret: targetOptions.adminSecret, + }); + return { + effectId: input.effectId, + scopeId: membership.scope_id, + deviceId: membership.device_id, + status: membership.status === "active" ? "active" : "revoked", + }; + }, + refresh: async ({ scopeId }) => { + const boundary = target(scopeId); + const refreshed = await refreshConfiguredScopeMembershipCache(store.db, config, { + keysDir: syncKeysDir(), + }); + const group = refreshed.groups.find((item) => item.groupId === boundary.groupId); + if (group?.status !== "refreshed") throw new Error("recipient_policy_effect_failed"); + }, + }; +} + +export interface ReconcileRecipientPolicyProjectsResult { + processed: number; + active: number; + waiting: number; + attention: number; + failed: number; + items: Array<{ + canonicalProjectIdentity: string; + status: RecipientPolicyReconcileResult["status"] | "failed"; + safeErrorCode: string | null; + }>; +} + +export async function reconcileRecipientPolicyProjects( + store: MemoryStore, + options: { + limit?: number; + now?: Date; + backoffMs?: number; + leaseOwner?: string; + effects?: RecipientPolicyReconcilerEffects; + reconcileProject?: typeof reconcileRecipientPolicyProject; + } = {}, +): Promise { + const limit = Math.max( + 1, + Math.min( + Math.trunc(options.limit ?? RECIPIENT_POLICY_MAINTENANCE_DEFAULT_LIMIT), + RECIPIENT_POLICY_MAINTENANCE_MAX_LIMIT, + ), + ); + const maintenanceNow = options.now ?? new Date(); + const backoffMs = Math.max( + 0, + Math.trunc(options.backoffMs ?? RECIPIENT_POLICY_MAINTENANCE_BACKOFF_MS), + ); + const retryBefore = new Date(maintenanceNow.getTime() - backoffMs).toISOString(); + const rows = store.db + .prepare( + `WITH projects AS ( + SELECT DISTINCT canonical_project_identity FROM project_recipients + UNION + SELECT canonical_project_identity FROM recipient_policy_authority_states + ) + SELECT projects.canonical_project_identity + FROM projects + LEFT JOIN recipient_policy_authority_states authority + ON authority.canonical_project_identity = projects.canonical_project_identity + WHERE authority.safe_error_code IS NULL OR authority.last_attempt_at IS NULL + OR authority.last_attempt_at <= ? + ORDER BY CASE WHEN authority.last_attempt_at IS NULL THEN 0 ELSE 1 END, + authority.last_attempt_at, projects.canonical_project_identity + LIMIT ?`, + ) + .all(retryBefore, limit) as Array<{ canonical_project_identity: string }>; + const effects = options.effects ?? createRecipientPolicyReconcilerEffects(store); + const reconcileProject = options.reconcileProject ?? reconcileRecipientPolicyProject; + const result: ReconcileRecipientPolicyProjectsResult = { + processed: 0, + active: 0, + waiting: 0, + attention: 0, + failed: 0, + items: [], + }; + for (const row of rows) { + result.processed += 1; + try { + const outcome = await reconcileProject( + store.db, + { + canonicalProjectIdentity: row.canonical_project_identity, + leaseOwner: + options.leaseOwner ?? `recipient-policy-maintenance:${store.deviceId || process.pid}`, + }, + effects, + ); + if (outcome.status === "active") result.active += 1; + else if (outcome.status === "needs_attention") result.attention += 1; + else result.waiting += 1; + result.items.push({ + canonicalProjectIdentity: row.canonical_project_identity, + status: outcome.status, + safeErrorCode: outcome.safeErrorCode, + }); + } catch { + result.failed += 1; + result.items.push({ + canonicalProjectIdentity: row.canonical_project_identity, + status: "failed", + safeErrorCode: "recipient_policy_reconciliation_failed", + }); + } + } + return result; +} + +export type RecipientPolicyReconciliationReadState = + | "active" + | "needs_attention" + | "pending" + | "verifying" + | "waiting"; + +export interface RecipientPolicyReconciliationReadModel { + version: 1; + items: Array<{ + canonicalProjectIdentity: string; + state: RecipientPolicyReconciliationReadState; + label: string; + explanation: string; + deliveredCopiesMayRemain: true; + revocationWarning: string; + }>; +} + +function recipientPolicyReadState( + authority: ReturnType, +): RecipientPolicyReconciliationReadState { + if (!authority) return "pending"; + if (authority.safeErrorCode && RECIPIENT_POLICY_WAITING_ERRORS.has(authority.safeErrorCode)) { + return "waiting"; + } + if (authority.safeErrorCode || authority.authorityState === "rolled_back") { + return "needs_attention"; + } + if (authority.authorityState === "active") return "active"; + if (authority.authorityState === "eligible") return "verifying"; + return "pending"; +} + +function recipientPolicyReadCopy(state: RecipientPolicyReconciliationReadState): { + label: string; + explanation: string; +} { + if (state === "active") { + return { + label: "Recipient policy active", + explanation: "Recipient policy now controls future access for this Project.", + }; + } + if (state === "verifying") { + return { + label: "Verifying recipient policy", + explanation: "Current access matches recipient policy and needs one more stable check.", + }; + } + if (state === "waiting") { + return { + label: "Waiting to reconcile", + explanation: + "Waiting for devices or a fresh coordinator snapshot. No partial grant is applied.", + }; + } + if (state === "needs_attention") { + return { + label: "Reconciliation needs attention", + explanation: + "Legacy scope enforcement remains in control until this Project is safe to retry.", + }; + } + return { + label: "Recipient policy pending", + explanation: "Legacy scope enforcement remains in control while reconciliation is pending.", + }; +} + +export function listRecipientPolicyReconciliationStatus( + store: MemoryStore, +): RecipientPolicyReconciliationReadModel { + const projectIds = ( + store.db + .prepare( + `SELECT DISTINCT canonical_project_identity FROM project_recipients WHERE status = 'active' + UNION SELECT canonical_project_identity FROM recipient_policy_authority_states + ORDER BY canonical_project_identity`, + ) + .all() as Array<{ canonical_project_identity: string }> + ).map((row) => row.canonical_project_identity); + return { + version: 1, + items: projectIds.map((canonicalProjectIdentity) => { + const state = recipientPolicyReadState( + getRecipientPolicyAuthorityState(store.db, canonicalProjectIdentity), + ); + return { + canonicalProjectIdentity, + state, + ...recipientPolicyReadCopy(state), + deliveredCopiesMayRemain: true, + revocationWarning: SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, + }; + }), + }; +} + function sortActiveMaintenanceJobs< T extends { started_at: string | null; updated_at: string; kind: string }, >(jobs: T[]): T[] { @@ -4187,6 +4654,10 @@ export function syncRoutes( return c.json(listRecipientPolicyIntent(store.db)); }); + app.get("/api/sync/recipient-policy/v1/reconciliation-status", (c) => { + return c.json(listRecipientPolicyReconciliationStatus(getStore())); + }); + app.post("/api/sync/recipient-policy/v1/edges/preview", async (c) => { const store = getStore(); const body = await parseViewerJsonBody(c); @@ -5638,17 +6109,36 @@ export function syncRoutes( if (Number.isNaN(membershipEpoch)) { return c.json({ error: "membership_epoch must be number", status }, 400); } + const role = optionalViewerString(body, "role"); + const coordinatorId = optionalViewerString(body, "coordinator_id"); + const manifestIssuerDeviceId = optionalViewerString(body, "manifest_issuer_device_id"); + const manifestHash = optionalViewerString(body, "manifest_hash"); + const signedManifestJson = optionalViewerString(body, "signed_manifest_json"); + const effectId = + optionalViewerString(body, "effect_id") ?? + coordinatorAdminMembershipEffectId("grant", randomUUID(), { + groupId, + scopeId, + deviceId, + role, + membershipEpoch, + coordinatorId, + manifestIssuerDeviceId, + manifestHash, + signedManifestJson, + }); try { const membership = await coordinatorGrantScopeMembershipAction({ + effectId, groupId, scopeId, deviceId, - role: optionalViewerString(body, "role"), + role, membershipEpoch, - coordinatorId: optionalViewerString(body, "coordinator_id"), - manifestIssuerDeviceId: optionalViewerString(body, "manifest_issuer_device_id"), - manifestHash: optionalViewerString(body, "manifest_hash"), - signedManifestJson: optionalViewerString(body, "signed_manifest_json"), + coordinatorId, + manifestIssuerDeviceId, + manifestHash, + signedManifestJson, remoteUrl: config.syncCoordinatorUrl || null, adminSecret: config.syncCoordinatorAdminSecret || null, }); @@ -5678,14 +6168,27 @@ export function syncRoutes( if (Number.isNaN(membershipEpoch)) { return c.json({ error: "membership_epoch must be number", status }, 400); } + const manifestHash = optionalViewerString(body, "manifest_hash"); + const signedManifestJson = optionalViewerString(body, "signed_manifest_json"); + const effectId = + optionalViewerString(body, "effect_id") ?? + coordinatorAdminMembershipEffectId("revoke", randomUUID(), { + groupId, + scopeId, + deviceId, + membershipEpoch, + manifestHash, + signedManifestJson, + }); try { const ok = await coordinatorRevokeScopeMembershipAction({ + effectId, groupId, scopeId, deviceId, membershipEpoch, - manifestHash: optionalViewerString(body, "manifest_hash"), - signedManifestJson: optionalViewerString(body, "signed_manifest_json"), + manifestHash, + signedManifestJson, remoteUrl: config.syncCoordinatorUrl || null, adminSecret: config.syncCoordinatorAdminSecret || null, }); @@ -5800,6 +6303,7 @@ export function syncRoutes( store: getStore(), config, groupId: result.group_id, + requestId, deviceId: result.device_id, }); } catch (grantError) { diff --git a/packages/viewer-server/src/share-operation-maintenance.test.ts b/packages/viewer-server/src/share-operation-maintenance.test.ts index b56074ed..4a8a7a7a 100644 --- a/packages/viewer-server/src/share-operation-maintenance.test.ts +++ b/packages/viewer-server/src/share-operation-maintenance.test.ts @@ -1,7 +1,17 @@ -import { initTestSchema, type MemoryStore } from "@codemem/core"; +import { + assertLegacyShareGrantAllowed, + initTestSchema, + type MemoryStore, + type RecipientPolicyReconcilerEffects, + reconcileRecipientPolicyProject, +} from "@codemem/core"; import Database from "better-sqlite3"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { advancePendingProjectShares } from "./routes/sync.js"; +import { + advancePendingProjectShares, + recipientPolicyCapabilityFromStatus, + reconcileRecipientPolicyProjects, +} from "./routes/sync.js"; describe("advancePendingProjectShares", () => { let db: InstanceType; @@ -40,6 +50,27 @@ describe("advancePendingProjectShares", () => { afterEach(() => db.close()); + it("requires scoped enforcement and reassign_scope capability", () => { + expect( + recipientPolicyCapabilityFromStatus({ + sync_capability: "enforcing", + sync_features: ["reassign_scope"], + }), + ).toBe("unsupported"); + expect( + recipientPolicyCapabilityFromStatus({ + sync_capability: "scoped", + sync_features: [], + }), + ).toBe("unsupported"); + expect( + recipientPolicyCapabilityFromStatus({ + sync_capability: "scoped", + sync_features: ["reassign_scope"], + }), + ).toBe("supported"); + }); + it("processes a bounded oldest-first locally owned set and isolates failures", async () => { seedOperation({ id: "share-oldest", state: "accepted", createdAt: "2026-07-20T00:00:00Z" }); seedOperation({ @@ -413,3 +444,384 @@ describe("advancePendingProjectShares", () => { expect(recoveredAdvance).toHaveBeenCalledWith(store, "share-awaiting-acceptance"); }); }); + +describe("recipient-policy maintenance", () => { + let db: InstanceType; + let store: MemoryStore; + + const now = "2026-07-22T10:00:00.000Z"; + + function seedRecipientProject(projectId: string, deviceId = `device:${projectId}`): void { + const identityId = `identity:${projectId}`; + db.prepare( + `INSERT INTO actors(actor_id, display_name, is_local, status, created_at, updated_at) + VALUES (?, ?, 0, 'active', ?, ?)`, + ).run(identityId, identityId, now, now); + db.prepare( + `INSERT INTO identity_devices( + device_id, identity_id, display_name, status, provenance, revision, migration_state, + idempotency_key, created_at, updated_at + ) VALUES (?, ?, ?, 'active', 'test', '1', 'native', ?, ?, ?)`, + ).run(deviceId, identityId, deviceId, `device-edge:${projectId}`, now, now); + db.prepare( + `INSERT INTO project_recipients( + canonical_project_identity, recipient_kind, recipient_id, status, provenance, + policy_revision, migration_state, idempotency_key, created_at, updated_at + ) VALUES (?, 'identity', ?, 'active', 'test', '1', 'native', ?, ?, ?)`, + ).run(projectId, identityId, `recipient-edge:${projectId}`, now, now); + } + + function seedManagedBoundary(projectId: string, scopeId: string): void { + db.prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, coordinator_id, group_id, membership_epoch, + status, created_at, updated_at + ) VALUES (?, ?, 'managed_project', 'coordinator', 'coordinator', 'group', 1, + 'active', ?, ?)`, + ).run(scopeId, projectId, now, now); + db.prepare( + `INSERT INTO project_scope_mappings( + workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + ) VALUES (?, ?, ?, 1000, 'test', ?, ?)`, + ).run(projectId, projectId, scopeId, now, now); + } + + function seedShareOperation(operationId: string): void { + db.prepare( + `INSERT INTO share_operations( + operation_id, state, inviter_actor_id, inviter_device_ids_json, person_id, + person_kind, teammate_name, history_policy, reviewed_project_set_digest, + coordinator_group_id, invite_token_digest, invite_expires_at, created_at, updated_at + ) VALUES (?, 'accepted', 'actor-local', '[]', ?, 'existing', 'Brian', + 'existing_and_future', ?, 'group', ?, '2099-01-01T00:00:00.000Z', ?, ?)`, + ).run( + operationId, + `person:${operationId}`, + `digest:${operationId}`, + `token:${operationId}`, + now, + now, + ); + } + + function unusedEffects(): RecipientPolicyReconcilerEffects { + return { + now: () => now, + snapshot: vi.fn(async () => { + throw new Error("unused"); + }), + probeCapability: vi.fn(async () => "supported"), + revoke: vi.fn(async () => { + throw new Error("unused"); + }), + grant: vi.fn(async () => { + throw new Error("unused"); + }), + refresh: vi.fn(async () => undefined), + }; + } + + beforeEach(() => { + db = new Database(":memory:"); + initTestSchema(db); + store = { actorId: "actor-local", db, deviceId: "device-local" } as unknown as MemoryStore; + }); + + afterEach(() => db.close()); + + it("bounds work and isolates one Project failure from the next", async () => { + seedRecipientProject("project-a"); + seedRecipientProject("project-b"); + seedRecipientProject("project-c"); + const visited: string[] = []; + const reconcileProject: typeof reconcileRecipientPolicyProject = vi.fn(async (_db, input) => { + visited.push(input.canonicalProjectIdentity); + if (input.canonicalProjectIdentity === "project-a") { + throw new Error("injected failure"); + } + return { + canonicalProjectIdentity: input.canonicalProjectIdentity, + status: "waiting" as const, + generation: 1, + safeErrorCode: "recipient_policy_capability_undetermined", + revokedDeviceIds: [], + grantedDeviceIds: [], + deliveredCopiesMayRemain: true as const, + revocationWarning: "Delivered copies may remain.", + }; + }); + + const result = await reconcileRecipientPolicyProjects(store, { + limit: 2, + effects: unusedEffects(), + reconcileProject, + }); + + expect(visited).toEqual(["project-a", "project-b"]); + expect(result).toMatchObject({ processed: 2, waiting: 1, failed: 1 }); + expect(result.items.map((item) => item.status)).toEqual(["failed", "waiting"]); + }); + + it("backs off persisted failures and resumes them after the retry window", async () => { + seedRecipientProject("project-backoff"); + db.prepare( + `INSERT INTO recipient_policy_authority_states( + canonical_project_identity, authority_state, generation, safe_error_code, + state_changed_at, attempt_count, last_attempt_at, created_at, updated_at + ) VALUES ('project-backoff', 'legacy', 0, 'recipient_policy_capability_undetermined', + ?, 1, ?, ?, ?)`, + ).run(now, now, now, now); + const reconcileProject: typeof reconcileRecipientPolicyProject = vi.fn(async () => { + throw new Error("backoff should skip reconciliation"); + }); + + const backedOff = await reconcileRecipientPolicyProjects(store, { + now: new Date("2026-07-22T10:00:30.000Z"), + effects: unusedEffects(), + reconcileProject, + }); + expect(backedOff.processed).toBe(0); + + vi.mocked(reconcileProject).mockResolvedValue({ + canonicalProjectIdentity: "project-backoff", + status: "waiting", + generation: 0, + safeErrorCode: "recipient_policy_capability_undetermined", + revokedDeviceIds: [], + grantedDeviceIds: [], + deliveredCopiesMayRemain: true, + revocationWarning: "Delivered copies may remain.", + }); + const resumed = await reconcileRecipientPolicyProjects(store, { + now: new Date("2026-07-22T10:01:01.000Z"), + effects: unusedEffects(), + reconcileProject, + }); + + expect(resumed).toMatchObject({ processed: 1, waiting: 1, failed: 0 }); + expect(reconcileProject).toHaveBeenCalledTimes(1); + }); + + it("reconciles a first all-revoked transition without an authority row", async () => { + const projectId = "project-all-revoked"; + const scopeId = "scope-all-revoked"; + const unrelatedProjectId = "project-unrelated"; + seedRecipientProject(projectId, "device-revoked"); + db.prepare( + "UPDATE project_recipients SET status = 'revoked' WHERE canonical_project_identity = ?", + ).run(projectId); + seedManagedBoundary(projectId, scopeId); + seedManagedBoundary(unrelatedProjectId, "scope-unrelated"); + + let tick = 0; + const nextTime = () => new Date(Date.parse(now) + tick++ * 1000).toISOString(); + const members = new Set(["device-revoked"]); + const effects: RecipientPolicyReconcilerEffects = { + now: nextTime, + snapshot: vi.fn(async () => { + const deviceIds = [...members].toSorted(); + return { + authoritative: true, + scopeId, + fingerprint: `snapshot:${deviceIds.join(",") || "empty"}`, + observedAt: nextTime(), + memberships: deviceIds.map((deviceId) => ({ + deviceId, + status: "active" as const, + })), + }; + }), + probeCapability: vi.fn(async () => "supported"), + revoke: vi.fn(async (input) => { + members.delete(input.deviceId); + return { + effectId: input.effectId, + scopeId: input.scopeId, + deviceId: input.deviceId, + status: "revoked" as const, + }; + }), + grant: vi.fn(async () => { + throw new Error("empty desired set must not grant"); + }), + refresh: vi.fn(async () => undefined), + }; + const reconcileProject: typeof reconcileRecipientPolicyProject = vi.fn( + reconcileRecipientPolicyProject, + ); + expect(db.prepare("SELECT COUNT(*) FROM recipient_policy_authority_states").pluck().get()).toBe( + 0, + ); + + const first = await reconcileRecipientPolicyProjects(store, { + backoffMs: 0, + effects, + leaseOwner: "worker-all-revoked-first", + reconcileProject, + }); + + expect(first).toMatchObject({ processed: 1, waiting: 1, failed: 0 }); + expect(first.items).toEqual([ + { + canonicalProjectIdentity: projectId, + status: "parity_pending", + safeErrorCode: null, + }, + ]); + expect(reconcileProject).toHaveBeenCalledTimes(1); + expect(reconcileProject).toHaveBeenCalledWith( + db, + expect.objectContaining({ canonicalProjectIdentity: projectId }), + effects, + ); + expect(effects.revoke).toHaveBeenCalledTimes(1); + expect(effects.revoke).toHaveBeenCalledWith( + expect.objectContaining({ scopeId, deviceId: "device-revoked" }), + ); + expect(effects.grant).not.toHaveBeenCalled(); + expect([...members]).toEqual([]); + expect( + db + .prepare( + `SELECT authority_state, safe_error_code, last_completed_at + FROM recipient_policy_authority_states + WHERE canonical_project_identity = ?`, + ) + .get(projectId), + ).toMatchObject({ + authority_state: "eligible", + safe_error_code: null, + last_completed_at: expect.any(String), + }); + expect( + db + .prepare( + `SELECT DISTINCT status FROM recipient_policy_reconciliation_steps + WHERE canonical_project_identity = ? ORDER BY status`, + ) + .all(projectId), + ).toEqual([{ status: "completed" }]); + + const retry = await reconcileRecipientPolicyProjects(store, { + backoffMs: 0, + effects, + leaseOwner: "worker-all-revoked-retry", + reconcileProject, + }); + + expect(retry).toMatchObject({ processed: 1, active: 1, failed: 0 }); + expect(retry.items[0]).toMatchObject({ + canonicalProjectIdentity: projectId, + status: "active", + safeErrorCode: null, + }); + expect(reconcileProject).toHaveBeenCalledTimes(2); + expect(effects.revoke).toHaveBeenCalledTimes(1); + expect(effects.grant).not.toHaveBeenCalled(); + expect( + db + .prepare( + `SELECT authority_state, safe_error_code FROM recipient_policy_authority_states + WHERE canonical_project_identity = ?`, + ) + .get(projectId), + ).toEqual({ authority_state: "active", safe_error_code: null }); + expect( + db + .prepare( + `SELECT COUNT(*) FROM recipient_policy_authority_states + WHERE canonical_project_identity = ?`, + ) + .pluck() + .get(unrelatedProjectId), + ).toBe(0); + }); + + it("uses persisted steps for two-pass cutover without duplicate coordinator effects", async () => { + const projectId = "project-two-pass"; + const scopeId = "scope-two-pass"; + seedRecipientProject(projectId, "device-recipient"); + seedManagedBoundary(projectId, scopeId); + let tick = 0; + const members = new Set(); + const effectIds: string[] = []; + const effects: RecipientPolicyReconcilerEffects = { + now: () => new Date(Date.parse(now) + tick++ * 1000).toISOString(), + snapshot: vi.fn(async () => { + const deviceIds = [...members].toSorted(); + return { + authoritative: true, + scopeId, + fingerprint: `snapshot:${deviceIds.join(",") || "empty"}`, + observedAt: new Date(Date.parse(now) + tick++ * 1000).toISOString(), + memberships: deviceIds.map((deviceId) => ({ deviceId, status: "active" as const })), + }; + }), + probeCapability: vi.fn(async () => "supported"), + revoke: vi.fn(async (input) => ({ + effectId: input.effectId, + scopeId: input.scopeId, + deviceId: input.deviceId, + status: "revoked" as const, + })), + grant: vi.fn(async (input) => { + effectIds.push(input.effectId); + members.add(input.deviceId); + return { + effectId: input.effectId, + scopeId: input.scopeId, + deviceId: input.deviceId, + status: "active" as const, + }; + }), + refresh: vi.fn(async () => undefined), + }; + + const first = await reconcileRecipientPolicyProjects(store, { + backoffMs: 0, + effects, + leaseOwner: "worker-first", + }); + const second = await reconcileRecipientPolicyProjects(store, { + backoffMs: 0, + effects, + leaseOwner: "worker-second", + }); + + expect(first.items[0]?.status).toBe("parity_pending"); + expect(second.items[0]?.status).toBe("active"); + expect(effectIds).toHaveLength(1); + expect(new Set(effectIds).size).toBe(1); + }); + + it("blocks a stale legacy share from regranting after active policy removal", async () => { + const projectId = "project-removed"; + seedRecipientProject(projectId, "device-removed"); + db.prepare( + "UPDATE project_recipients SET status = 'revoked' WHERE canonical_project_identity = ?", + ).run(projectId); + db.prepare( + `INSERT INTO recipient_policy_authority_states( + canonical_project_identity, authority_state, generation, state_changed_at, created_at, updated_at + ) VALUES (?, 'active', 1, ?, ?, ?)`, + ).run(projectId, now, now, now); + seedShareOperation("stale-share"); + let grants = 0; + const advanceOperation = vi.fn(async () => { + assertLegacyShareGrantAllowed(db, { + canonicalProjectIdentity: projectId, + deviceId: "device-removed", + }); + grants += 1; + return { advanced: true, state: "active" as const }; + }); + + const result = await advancePendingProjectShares(store, { + now: new Date("2026-07-22T11:00:00.000Z"), + advanceOperation, + }); + + expect(result).toMatchObject({ processed: 1, advanced: 0, failed: 1 }); + expect(grants).toBe(0); + }); +});