diff --git a/packages/cloudflare-coordinator-worker/migrations/0009_add_recipient_invite_kinds.sql b/packages/cloudflare-coordinator-worker/migrations/0009_add_recipient_invite_kinds.sql new file mode 100644 index 00000000..540d3f80 --- /dev/null +++ b/packages/cloudflare-coordinator-worker/migrations/0009_add_recipient_invite_kinds.sql @@ -0,0 +1,11 @@ +ALTER TABLE coordinator_invites ADD COLUMN invite_kind TEXT; +ALTER TABLE coordinator_invites ADD COLUMN policy_team_id TEXT; +ALTER TABLE coordinator_invites ADD COLUMN target_identity_id TEXT; +ALTER TABLE coordinator_invites ADD COLUMN reviewed_preview_digest TEXT; + +UPDATE coordinator_invites +SET invite_kind = CASE + WHEN operation_id IS NOT NULL THEN 'project_share' + ELSE 'legacy_enrollment' +END +WHERE invite_kind IS NULL; diff --git a/packages/cloudflare-coordinator-worker/schema.sql b/packages/cloudflare-coordinator-worker/schema.sql index 27590480..c129c359 100644 --- a/packages/cloudflare-coordinator-worker/schema.sql +++ b/packages/cloudflare-coordinator-worker/schema.sql @@ -60,7 +60,11 @@ CREATE TABLE IF NOT EXISTS coordinator_invites ( recipient_display_name TEXT, recipient_device_display_name TEXT, trust_state TEXT, - bootstrap_grant_id TEXT + bootstrap_grant_id TEXT, + invite_kind TEXT, + policy_team_id TEXT, + target_identity_id TEXT, + reviewed_preview_digest TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_coordinator_invites_operation_id diff --git a/packages/cloudflare-coordinator-worker/src/index.test.ts b/packages/cloudflare-coordinator-worker/src/index.test.ts index 9d2be16c..33f96cc1 100644 --- a/packages/cloudflare-coordinator-worker/src/index.test.ts +++ b/packages/cloudflare-coordinator-worker/src/index.test.ts @@ -199,6 +199,50 @@ describe("createCloudflareCoordinatorWorker", () => { }); }); + it("migration 0009 classifies existing invites and adds recipient invitation metadata", () => { + db.exec(` + DROP TABLE coordinator_invites; + CREATE TABLE coordinator_invites ( + invite_id TEXT PRIMARY KEY, group_id TEXT NOT NULL, token TEXT NOT NULL UNIQUE, + policy TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, + created_by TEXT, team_name_snapshot TEXT, revoked_at TEXT, operation_id TEXT + ); + INSERT INTO coordinator_invites(invite_id, group_id, token, policy, expires_at, created_at, operation_id) + VALUES + ('legacy-invite', 'g1', 'legacy-token', 'auto_admit', '2099-01-01T00:00:00Z', + '2026-03-28T00:00:00Z', NULL), + ('project-invite', 'g1', 'project-token', 'auto_admit', '2099-01-01T00:00:00Z', + '2026-03-28T00:00:00Z', 'share_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + `); + const migration = readFileSync( + join(import.meta.dirname, "../migrations/0009_add_recipient_invite_kinds.sql"), + "utf8", + ); + db.exec(migration); + + expect( + db + .prepare(`SELECT invite_id, invite_kind, policy_team_id, target_identity_id, + reviewed_preview_digest FROM coordinator_invites ORDER BY invite_id`) + .all(), + ).toEqual([ + { + invite_id: "legacy-invite", + invite_kind: "legacy_enrollment", + policy_team_id: null, + target_identity_id: null, + reviewed_preview_digest: null, + }, + { + invite_id: "project-invite", + invite_kind: "project_share", + policy_team_id: null, + target_identity_id: null, + reviewed_preview_digest: null, + }, + ]); + }); + it("serves coordinator admin data through the worker entrypoint", async () => { const store = new D1CoordinatorStore(d1db); await store.createGroup("g1", "Team Alpha"); diff --git a/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts b/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts index ede5ad51..31a33683 100644 --- a/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts +++ b/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts @@ -757,4 +757,128 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: ); expect(payload.items.some((item) => item.device_id === devices[3]?.deviceId)).toBe(false); }); + + it("persists explicit Team and add-device invitation bindings without mutating coordinator memberships", async () => { + const device = createIdentity(); + const otherDevice = createIdentity(); + const adminHeaders = { + "content-type": "application/json", + "X-Codemem-Coordinator-Admin": "test-secret", + }; + await env.COORDINATOR_DB.prepare( + "INSERT INTO groups (group_id, display_name, created_at) VALUES ('g1', 'Coordinator Team', ?)", + ) + .bind("2026-07-21T00:00:00Z") + .run(); + const createInvite = async (body: Record) => { + const response = await exports.default.fetch("https://example.com/v1/admin/invites", { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ + group_id: "g1", + policy: "auto_admit", + expires_at: "2099-01-01T00:00:00Z", + coordinator_url: "https://example.com", + ...body, + }), + }); + expect(response.status).toBe(200); + return (await response.json()) as { payload: InvitePayload }; + }; + + const team = await createInvite({ + invite_kind: "team_member", + policy_team_id: "policy-team-1", + reviewed_preview_digest: "a".repeat(64), + }); + expect(team.payload).toMatchObject({ + kind: "team_member", + policy_team_id: "policy-team-1", + reviewed_preview_digest: "a".repeat(64), + }); + const inspect = await exports.default.fetch("https://example.com/v1/invites/inspect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: team.payload.token }), + }); + expect(await inspect.json()).toMatchObject({ + kind: "team_member", + policy_team_id: "policy-team-1", + bound: false, + }); + const acceptBody = { + token: team.payload.token, + invite_kind: "team_member", + identity_id: "identity-brian", + device_id: device.deviceId, + public_key: device.publicKey, + fingerprint: device.fingerprint, + }; + const accept = () => + exports.default.fetch("https://example.com/v1/join", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(acceptBody), + }); + expect(await (await accept()).json()).toMatchObject({ status: "accepted" }); + expect(await (await accept()).json()).toMatchObject({ status: "existing" }); + const changedDevice = await exports.default.fetch("https://example.com/v1/join", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...acceptBody, + device_id: otherDevice.deviceId, + public_key: otherDevice.publicKey, + fingerprint: otherDevice.fingerprint, + }), + }); + expect(changedDevice.status).toBe(409); + expect(await changedDevice.json()).toEqual({ error: "invite_already_bound" }); + + const addDevice = await createInvite({ + invite_kind: "add_device", + target_identity_id: "identity-brian", + reviewed_preview_digest: "b".repeat(64), + }); + const wrongIdentity = await exports.default.fetch("https://example.com/v1/join", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + token: addDevice.payload.token, + invite_kind: "add_device", + identity_id: "identity-other", + device_id: otherDevice.deviceId, + public_key: otherDevice.publicKey, + fingerprint: otherDevice.fingerprint, + }), + }); + expect(wrongIdentity.status).toBe(409); + expect(await wrongIdentity.json()).toEqual({ error: "invite_identity_conflict" }); + + expect( + await env.COORDINATOR_DB.prepare("SELECT COUNT(*) AS count FROM enrolled_devices") + .first<{ count: number }>(), + ).toEqual({ count: 0 }); + expect( + await env.COORDINATOR_DB.prepare("SELECT COUNT(*) AS count FROM coordinator_scope_memberships") + .first<{ count: number }>(), + ).toEqual({ count: 0 }); + + await env.COORDINATOR_DB.prepare( + "UPDATE coordinator_invites SET revoked_at = ? WHERE token_digest IS NOT NULL AND invite_kind = 'team_member'", + ) + .bind("2026-07-21T01:00:00Z") + .run(); + expect((await accept()).status).toBe(400); + await env.COORDINATOR_DB.prepare("UPDATE groups SET archived_at = ? WHERE group_id = 'g1'") + .bind("2026-07-21T01:00:00Z") + .run(); + const archived = await exports.default.fetch("https://example.com/v1/invites/inspect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: addDevice.payload.token }), + }); + expect(archived.status).toBe(409); + expect(await archived.json()).toEqual({ error: "group_archived" }); + }); }); diff --git a/packages/core/src/better-sqlite-coordinator-store.ts b/packages/core/src/better-sqlite-coordinator-store.ts index c7bda95a..53d62a53 100644 --- a/packages/core/src/better-sqlite-coordinator-store.ts +++ b/packages/core/src/better-sqlite-coordinator-store.ts @@ -19,6 +19,7 @@ import Database from "better-sqlite3"; import type { CoordinatorBootstrapGrant, CoordinatorConsumeProjectInviteInput, + CoordinatorConsumeRecipientInviteInput, CoordinatorCreateBootstrapGrantInput, CoordinatorCreateInviteInput, CoordinatorCreateJoinRequestInput, @@ -28,7 +29,9 @@ import type { CoordinatorEnrollment, CoordinatorGrantScopeMembershipInput, CoordinatorGroup, + CoordinatorInspectRecipientInviteInput, CoordinatorInvite, + CoordinatorInviteKind, CoordinatorJoinRequest, CoordinatorJoinRequestReviewResult, CoordinatorListReciprocalApprovalsInput, @@ -37,6 +40,8 @@ import type { CoordinatorPeerRecord, CoordinatorPresenceRecord, CoordinatorProjectInviteAcceptance, + CoordinatorRecipientInviteAcceptance, + CoordinatorRecipientInviteInspection, CoordinatorReciprocalApproval, CoordinatorReviewJoinRequestBootstrapGrantInput, CoordinatorReviewJoinRequestInput, @@ -114,7 +119,7 @@ const INVITE_COLUMNS = `invite_id, group_id, token, policy, expires_at, created_ inviter_actor_id, inviter_display_name, inviter_device_id, pending_person_id, project_summaries_json, project_intent_json, consumed_at, bound_device_id, bound_public_key, bound_fingerprint, recipient_actor_id, recipient_display_name, recipient_device_display_name, trust_state, - bootstrap_grant_id`; + bootstrap_grant_id, invite_kind, policy_team_id, target_identity_id, reviewed_preview_digest`; function rowToRecord(row: unknown): T { if (row == null) throw new Error("expected row"); @@ -159,6 +164,85 @@ function clean(value: string | null | undefined): string | null { return trimmed ? trimmed : null; } +function normalizeInviteMetadata(opts: CoordinatorCreateInviteInput): { + inviteKind: CoordinatorInviteKind; + policyTeamId: string | null; + targetIdentityId: string | null; + reviewedPreviewDigest: string | null; +} { + const inviteKind = + opts.inviteKind ?? (clean(opts.operationId) ? "project_share" : "legacy_enrollment"); + const policyTeamId = clean(opts.policyTeamId); + const targetIdentityId = clean(opts.targetIdentityId); + const reviewedPreviewDigest = clean(opts.reviewedPreviewDigest); + if ( + !(["legacy_enrollment", "project_share", "team_member", "add_device"] as const).includes( + inviteKind, + ) + ) { + throw new Error("inviteKind is invalid."); + } + if ( + [policyTeamId, targetIdentityId] + .filter((value): value is string => Boolean(value)) + .some((value) => value.length > 256 || /[\p{Cc}\p{Cf}]/u.test(value)) + ) { + throw new Error("recipient invite identifier is invalid."); + } + if (reviewedPreviewDigest && !/^[a-f0-9]{64}$/u.test(reviewedPreviewDigest)) { + throw new Error("reviewedPreviewDigest must be a SHA-256 digest."); + } + if (inviteKind === "project_share") { + if (!clean(opts.operationId)) throw new Error("project_share invite requires operationId."); + if (policyTeamId || targetIdentityId || reviewedPreviewDigest) { + throw new Error("recipient invite metadata requires a recipient invite kind."); + } + } else if (inviteKind === "legacy_enrollment") { + if (clean(opts.operationId)) + throw new Error("legacy_enrollment invite cannot reference an operation."); + if (policyTeamId || targetIdentityId || reviewedPreviewDigest) { + throw new Error("recipient invite metadata requires a recipient invite kind."); + } + } else if (inviteKind === "team_member") { + if (!policyTeamId || !reviewedPreviewDigest || targetIdentityId || clean(opts.operationId)) { + throw new Error("team_member invite metadata is invalid."); + } + } else if (inviteKind === "add_device") { + if (!targetIdentityId || !reviewedPreviewDigest || policyTeamId || clean(opts.operationId)) { + throw new Error("add_device invite metadata is invalid."); + } + } + return { inviteKind, policyTeamId, targetIdentityId, reviewedPreviewDigest }; +} + +function recipientInspection( + invite: CoordinatorInvite, +): CoordinatorRecipientInviteInspection | null { + if (invite.invite_kind === "team_member") { + if (!invite.policy_team_id || !invite.reviewed_preview_digest) + throw new Error("invite_invalid"); + return { + kind: "team_member", + invite, + policy_team_id: invite.policy_team_id, + reviewed_preview_digest: invite.reviewed_preview_digest, + bound: Boolean(invite.consumed_at), + }; + } + if (invite.invite_kind === "add_device") { + if (!invite.target_identity_id || !invite.reviewed_preview_digest) + throw new Error("invite_invalid"); + return { + kind: "add_device", + invite, + target_identity_id: invite.target_identity_id, + reviewed_preview_digest: invite.reviewed_preview_digest, + bound: Boolean(invite.consumed_at), + }; + } + return null; +} + function normalizeEpoch(value: number | null | undefined, fallback = 0): number { if (value == null) return fallback; if (!Number.isFinite(value) || value < 0) @@ -427,7 +511,11 @@ function initializeSchema(db: DatabaseType): void { recipient_display_name TEXT, recipient_device_display_name TEXT, trust_state TEXT, - bootstrap_grant_id TEXT + bootstrap_grant_id TEXT, + invite_kind TEXT, + policy_team_id TEXT, + target_identity_id TEXT, + reviewed_preview_digest TEXT ); CREATE TABLE IF NOT EXISTS coordinator_join_requests ( @@ -555,6 +643,10 @@ function initializeSchema(db: DatabaseType): void { "recipient_device_display_name", "trust_state", "bootstrap_grant_id", + "invite_kind", + "policy_team_id", + "target_identity_id", + "reviewed_preview_digest", ]) { try { db.prepare(`ALTER TABLE coordinator_invites ADD COLUMN ${column} TEXT`).run(); @@ -566,7 +658,10 @@ function initializeSchema(db: DatabaseType): void { `CREATE UNIQUE INDEX IF NOT EXISTS idx_coordinator_invites_operation_id ON coordinator_invites(operation_id) WHERE operation_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_coordinator_invites_token_digest - ON coordinator_invites(token_digest) WHERE token_digest IS NOT NULL;`, + ON coordinator_invites(token_digest) WHERE token_digest IS NOT NULL; + UPDATE coordinator_invites + SET invite_kind = CASE WHEN operation_id IS NOT NULL THEN 'project_share' ELSE 'legacy_enrollment' END + WHERE invite_kind IS NULL;`, ); } @@ -754,6 +849,7 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { const group = await this.getGroup(opts.groupId); const operationId = clean(opts.operationId); const reviewedProjectSetDigest = clean(opts.reviewedProjectSetDigest); + const metadata = normalizeInviteMetadata(opts); if (Boolean(operationId) !== Boolean(reviewedProjectSetDigest)) { throw new Error("operationId and reviewedProjectSetDigest must be provided together."); } @@ -770,6 +866,7 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { if ( existing.group_id !== opts.groupId || existing.policy !== opts.policy || + existing.invite_kind !== metadata.inviteKind || existing.reviewed_project_set_digest !== reviewedProjectSetDigest || existing.inviter_actor_id !== clean(opts.inviterActorId) || existing.inviter_display_name !== clean(opts.inviterDisplayName) || @@ -778,7 +875,10 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { existing.project_summaries_json !== (opts.projectSummaries ? JSON.stringify(opts.projectSummaries) : null) || existing.project_intent_json !== - (opts.projectIntent ? JSON.stringify(opts.projectIntent) : null) + (opts.projectIntent ? JSON.stringify(opts.projectIntent) : null) || + existing.policy_team_id !== metadata.policyTeamId || + existing.target_identity_id !== metadata.targetIdentityId || + existing.reviewed_preview_digest !== metadata.reviewedPreviewDigest ) { throw new Error("invite_operation_intent_conflict"); } @@ -817,8 +917,9 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { invite_id, group_id, token, policy, expires_at, created_at, created_by, team_name_snapshot, revoked_at, operation_id, reviewed_project_set_digest, token_digest, inviter_actor_id, inviter_display_name, inviter_device_id, - pending_person_id, project_summaries_json, project_intent_json, trust_state - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + pending_person_id, project_summaries_json, project_intent_json, trust_state, + invite_kind, policy_team_id, target_identity_id, reviewed_preview_digest + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) .run( inviteId, opts.groupId, @@ -838,6 +939,10 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { opts.projectSummaries ? JSON.stringify(opts.projectSummaries) : null, opts.projectIntent ? JSON.stringify(opts.projectIntent) : null, operationId ? "pending" : null, + metadata.inviteKind, + metadata.policyTeamId, + metadata.targetIdentityId, + metadata.reviewedPreviewDigest, ); } catch (error) { if (operationId) { @@ -872,6 +977,111 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { return row ? rowToRecord(row) : null; } + async inspectRecipientInvite( + opts: CoordinatorInspectRecipientInviteInput, + ): Promise { + const invite = await this.getInviteByTokenForInspection(opts.token); + if (!invite) return null; + const inspection = recipientInspection(invite); + if (!inspection) return null; + if (invite.revoked_at) throw new Error("invite_invalid"); + if ( + !invite.consumed_at && + new Date(invite.expires_at) <= new Date(normalizeInviteExpiresAt(opts.now)) + ) { + throw new Error("invite_expired"); + } + const group = await this.getGroup(invite.group_id); + if (!group) throw new Error("group_not_found"); + if (group.archived_at) throw new Error("group_archived"); + return inspection; + } + + async consumeRecipientInvite( + opts: CoordinatorConsumeRecipientInviteInput, + ): Promise { + const consumedAt = normalizeInviteExpiresAt(opts.now); + if ( + !opts.identityId || + !opts.deviceId || + !opts.publicKey || + !opts.fingerprint || + opts.identityId !== opts.identityId.trim() || + opts.deviceId !== opts.deviceId.trim() || + opts.identityId.length > 256 || + opts.deviceId.length > 256 || + /[\p{Cc}\p{Cf}]/u.test(opts.identityId) || + /[\p{Cc}\p{Cf}]/u.test(opts.deviceId) + ) { + throw new Error("invite_identity_conflict"); + } + return this.db.transaction((): CoordinatorRecipientInviteAcceptance => { + const invite = this.db + .prepare( + `SELECT ${INVITE_COLUMNS} FROM coordinator_invites WHERE token_digest = ? OR token = ?`, + ) + .get(tokenDigest(opts.token), opts.token) as CoordinatorInvite | undefined; + const inspection = invite ? recipientInspection(invite) : null; + if (!invite || !inspection || inspection.kind !== opts.inviteKind || invite.revoked_at) { + throw new Error("invite_invalid"); + } + const group = this.db + .prepare("SELECT archived_at FROM groups WHERE group_id = ?") + .get(invite.group_id) as { archived_at: string | null } | undefined; + if (!group) throw new Error("group_not_found"); + if (group.archived_at) throw new Error("group_archived"); + if (!invite.consumed_at && new Date(invite.expires_at) <= new Date(consumedAt)) { + throw new Error("invite_expired"); + } + if (fingerprintPublicKey(opts.publicKey) !== opts.fingerprint) { + throw new Error("fingerprint_mismatch"); + } + if (inspection.kind === "add_device" && inspection.target_identity_id !== opts.identityId) { + throw new Error("invite_identity_conflict"); + } + const sameBinding = + invite.bound_device_id === opts.deviceId && + invite.bound_public_key === opts.publicKey && + invite.bound_fingerprint === opts.fingerprint; + if (invite.consumed_at && !sameBinding) throw new Error("invite_already_bound"); + if (invite.consumed_at && invite.recipient_actor_id !== opts.identityId) { + throw new Error("invite_identity_conflict"); + } + const changed = invite.consumed_at + ? 0 + : this.db + .prepare(`UPDATE coordinator_invites SET token = ?, consumed_at = ?, bound_device_id = ?, + bound_public_key = ?, bound_fingerprint = ?, recipient_actor_id = ? + WHERE invite_id = ? AND consumed_at IS NULL AND revoked_at IS NULL + AND expires_at > ? AND invite_kind = ? + AND EXISTS (SELECT 1 FROM groups g WHERE g.group_id = coordinator_invites.group_id + AND g.archived_at IS NULL)`) + .run( + `consumed:${invite.invite_id}`, + consumedAt, + opts.deviceId, + opts.publicKey, + opts.fingerprint, + opts.identityId, + invite.invite_id, + consumedAt, + opts.inviteKind, + ).changes; + const saved = this.db + .prepare(`SELECT ${INVITE_COLUMNS} FROM coordinator_invites WHERE invite_id = ?`) + .get(invite.invite_id) as CoordinatorInvite; + if ( + saved.bound_device_id !== opts.deviceId || + saved.bound_public_key !== opts.publicKey || + saved.bound_fingerprint !== opts.fingerprint + ) { + throw new Error("invite_already_bound"); + } + if (saved.recipient_actor_id !== opts.identityId) throw new Error("invite_identity_conflict"); + return { status: changed === 1 ? "accepted" : "existing", invite: saved }; + })(); + } + async consumeProjectInvite( opts: CoordinatorConsumeProjectInviteInput, ): Promise { diff --git a/packages/core/src/coordinator-actions.test.ts b/packages/core/src/coordinator-actions.test.ts index 3c6485e0..aaa2589d 100644 --- a/packages/core/src/coordinator-actions.test.ts +++ b/packages/core/src/coordinator-actions.test.ts @@ -23,7 +23,10 @@ import { } from "./coordinator-actions.js"; import { encodeInvitePayload } from "./coordinator-invites.js"; import { connect } from "./db.js"; -import { fingerprintPublicKey, loadPublicKey } from "./sync-identity.js"; +import { initDatabase } from "./maintenance.js"; +import { readCodememConfigFileAtPath, writeCodememConfigFile } from "./observer-config.js"; +import { previewRecipientPolicyOnboarding } from "./recipient-policy-onboarding.js"; +import { ensureDeviceIdentity, fingerprintPublicKey, loadPublicKey } from "./sync-identity.js"; describe("coordinator local admin actions", () => { let tmpDir: string; @@ -589,6 +592,325 @@ describe("coordinator local admin actions", () => { } }); + it("adopts the add-device target identity on a fresh profile", async () => { + const actionDbPath = join(tmpDir, "fresh-add-device.sqlite"); + const keysDir = join(tmpDir, "fresh-add-device-keys"); + const configPath = join(tmpDir, "fresh-add-device-config.json"); + const targetIdentityId = "identity-existing"; + const capturedBodies: Record[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + const body = + init?.body instanceof Uint8Array ? Buffer.from(init.body).toString("utf8") : "{}"; + capturedBodies.push(JSON.parse(body) as Record); + return new Response( + JSON.stringify({ + ok: true, + status: "accepted", + kind: "add_device", + group_id: "coordinator-a", + identity_id: targetIdentityId, + policy_team_id: null, + target_identity_id: targetIdentityId, + reviewed_preview_digest: "coordinator-review", + }), + { status: 200 }, + ); + }), + ); + const invite = encodeInvitePayload({ + v: 1, + kind: "add_device", + coordinator_url: "https://coord.example.test", + group_id: "coordinator-a", + policy: "auto_admit", + token: "fresh-add-device-token", + expires_at: "2099-01-01T00:00:00.000Z", + team_name: null, + target_identity_id: targetIdentityId, + reviewed_preview_digest: "coordinator-review", + }); + + await expect( + coordinatorImportInviteAction({ + inviteValue: invite, + dbPath: actionDbPath, + keysDir, + configPath, + }), + ).resolves.toEqual({ + group_id: "coordinator-a", + coordinator_url: "https://coord.example.test", + status: "accepted", + invite_kind: "add_device", + identity_id: targetIdentityId, + policy_team_id: null, + target_identity_id: targetIdentityId, + reviewed_preview_digest: "coordinator-review", + }); + expect(capturedBodies[0]?.identity_id).toBe(targetIdentityId); + expect(readCodememConfigFileAtPath(configPath)).toMatchObject({ + actor_id: targetIdentityId, + }); + const conn = connect(actionDbPath); + try { + expect(conn.prepare("SELECT identity_id FROM identity_devices").get()).toEqual({ + identity_id: targetIdentityId, + }); + } finally { + conn.close(); + } + }); + + it("rejects a configured add-device identity conflict before fetch or onboarding writes", async () => { + const actionDbPath = join(tmpDir, "conflicting-add-device.sqlite"); + const keysDir = join(tmpDir, "conflicting-add-device-keys"); + const configPath = join(tmpDir, "conflicting-add-device-config.json"); + const originalConfig = { + actor_id: "identity-configured", + sync_coordinator_groups: ["existing-group"], + }; + writeCodememConfigFile(originalConfig, configPath); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const invite = encodeInvitePayload({ + v: 1, + kind: "add_device", + coordinator_url: "https://coord.example.test", + group_id: "coordinator-a", + policy: "auto_admit", + token: "conflicting-add-device-token", + expires_at: "2099-01-01T00:00:00.000Z", + team_name: null, + target_identity_id: "identity-target", + reviewed_preview_digest: "coordinator-review", + }); + + await expect( + coordinatorImportInviteAction({ + inviteValue: invite, + dbPath: actionDbPath, + keysDir, + configPath, + }), + ).rejects.toThrow("invite_identity_conflict"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(readCodememConfigFileAtPath(configPath)).toEqual(originalConfig); + const conn = connect(actionDbPath); + try { + expect(conn.prepare("SELECT COUNT(*) FROM identity_devices").pluck().get()).toBe(0); + } finally { + conn.close(); + } + }); + + it.each([ + { + label: "Team", + kind: "team_member" as const, + identityId: "identity-team", + initialConfig: { + sync_coordinator_groups: ["existing-group", "coordinator-a", "existing-group"], + sync_coordinator_group: "legacy-group", + }, + expectedGroups: ["existing-group", "coordinator-a"], + }, + { + label: "add-device", + kind: "add_device" as const, + identityId: "identity-add-device", + initialConfig: { sync_coordinator_group: "existing-group" }, + expectedGroups: ["existing-group", "coordinator-a"], + }, + ])("persists and deduplicates coordinator config after $label onboarding", async (testCase) => { + const actionDbPath = join(tmpDir, `${testCase.kind}-config.sqlite`); + const keysDir = join(tmpDir, `${testCase.kind}-config-keys`); + const configPath = join(tmpDir, `${testCase.kind}-config.json`); + writeCodememConfigFile(testCase.initialConfig, configPath); + if (testCase.kind === "team_member") { + initDatabase(actionDbPath); + const conn = connect(actionDbPath); + try { + const now = "2026-07-21T12:00:00.000Z"; + conn + .prepare( + `INSERT INTO policy_teams( + team_id, display_name, status, provenance, revision, migration_state, + idempotency_key, created_at, updated_at + ) VALUES ('team-a', 'Team A', 'active', 'user', 'r1', 'user_managed', + 'team-a', ?, ?)`, + ) + .run(now, now); + } finally { + conn.close(); + } + } + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + ok: true, + status: "accepted", + kind: testCase.kind, + group_id: "coordinator-a", + identity_id: testCase.identityId, + policy_team_id: testCase.kind === "team_member" ? "team-a" : null, + target_identity_id: testCase.kind === "add_device" ? testCase.identityId : null, + reviewed_preview_digest: "coordinator-review", + }), + { status: 200 }, + ), + ), + ); + const invite = encodeInvitePayload({ + v: 1, + kind: testCase.kind, + coordinator_url: "https://coord.example.test", + group_id: "coordinator-a", + policy: "auto_admit", + token: `${testCase.kind}-config-token`, + expires_at: "2099-01-01T00:00:00.000Z", + team_name: null, + ...(testCase.kind === "team_member" + ? { policy_team_id: "team-a" } + : { target_identity_id: testCase.identityId }), + reviewed_preview_digest: "coordinator-review", + }); + + await coordinatorImportInviteAction({ + inviteValue: invite, + dbPath: actionDbPath, + keysDir, + configPath, + recipientActorId: testCase.identityId, + }); + + expect(readCodememConfigFileAtPath(configPath)).toMatchObject({ + actor_id: testCase.identityId, + sync_coordinator_url: "https://coord.example.test", + sync_coordinator_groups: testCase.expectedGroups, + sync_coordinator_group: "existing-group", + }); + }); + + it("rejects recipient onboarding when local access changes after the reviewed preview", async () => { + const actionDbPath = join(tmpDir, "recipient-invite-stale.sqlite"); + const keysDir = join(tmpDir, "recipient-invite-stale-keys"); + const configPath = join(tmpDir, "recipient-invite-stale-config.json"); + const originalConfig = { sync_coordinator_groups: ["existing-group"] }; + writeCodememConfigFile(originalConfig, configPath); + const identityId = "identity-recipient"; + initDatabase(actionDbPath); + const conn = connect(actionDbPath); + let deviceId = ""; + let reviewedOnboardingDigest = ""; + try { + [deviceId] = ensureDeviceIdentity(conn, { keysDir }); + const now = "2026-07-21T12:00:00.000Z"; + conn + .prepare( + `INSERT INTO actors(actor_id, display_name, is_local, status, created_at, updated_at) + VALUES (?, 'Recipient', 1, 'active', ?, ?)`, + ) + .run(identityId, now, now); + conn + .prepare( + `INSERT INTO policy_teams( + team_id, display_name, status, provenance, revision, migration_state, + idempotency_key, created_at, updated_at + ) VALUES ('team-a', 'Team A', 'active', 'user', 'r1', 'user_managed', 'team-a', ?, ?)`, + ) + .run(now, now); + conn + .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 ('project-one', 'team', 'team-a', 'active', 'user', 'r1', + 'user_managed', 'project-one-team-a', ?, ?)`, + ) + .run(now, now); + const publicKey = loadPublicKey(keysDir); + if (!publicKey) throw new Error("public key missing"); + reviewedOnboardingDigest = previewRecipientPolicyOnboarding(conn, { + version: 1, + journey: "team", + invitationId: "recipient-token", + identityId, + deviceId, + devicePublicKey: publicKey, + deviceDisplayName: "Recipient laptop", + teamId: "team-a", + }).reviewedOnboardingDigest; + conn + .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 ('project-two', 'team', 'team-a', 'active', 'user', 'r2', + 'user_managed', 'project-two-team-a', ?, ?)`, + ) + .run(now, now); + } finally { + conn.close(); + } + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + ok: true, + status: "accepted", + kind: "team_member", + group_id: "coordinator-a", + identity_id: identityId, + policy_team_id: "team-a", + target_identity_id: null, + reviewed_preview_digest: "coordinator-review", + }), + { status: 200 }, + ), + ), + ); + const invite = encodeInvitePayload({ + v: 1, + kind: "team_member", + coordinator_url: "https://coord.example.test", + group_id: "coordinator-a", + policy: "auto_admit", + token: "recipient-token", + expires_at: "2099-01-01T00:00:00.000Z", + team_name: null, + policy_team_id: "team-a", + reviewed_preview_digest: "coordinator-review", + }); + + await expect( + coordinatorImportInviteAction({ + inviteValue: invite, + dbPath: actionDbPath, + keysDir, + configPath, + recipientActorId: identityId, + recipientDisplayName: "Recipient", + deviceDisplayName: "Recipient laptop", + reviewedOnboardingDigest, + }), + ).rejects.toThrow("reviewed_onboarding_stale"); + expect(readCodememConfigFileAtPath(configPath)).toEqual(originalConfig); + const after = connect(actionDbPath); + try { + expect(after.prepare("SELECT COUNT(*) FROM identity_devices").pluck().get()).toBe(0); + expect(after.prepare("SELECT COUNT(*) FROM policy_team_memberships").pluck().get()).toBe(0); + } finally { + after.close(); + } + }); + it("warns when local invite coordinator URL uses private IPv6 space", async () => { await coordinatorCreateGroupAction({ groupId: "team-a", dbPath }); const invite = await coordinatorCreateInviteAction({ diff --git a/packages/core/src/coordinator-actions.ts b/packages/core/src/coordinator-actions.ts index 1e02994e..1fd311dd 100644 --- a/packages/core/src/coordinator-actions.ts +++ b/packages/core/src/coordinator-actions.ts @@ -29,6 +29,11 @@ import { writeCodememConfigFile, } from "./observer-config.js"; import { friendlyDeviceName, normalizeIdentityDisplayName } from "./project-invite-identity.js"; +import { + commitRecipientPolicyOnboarding, + previewRecipientPolicyOnboarding, + type RecipientPolicyOnboardingPreviewRequestV1, +} from "./recipient-policy-onboarding.js"; import { updatePeerAddresses } from "./sync-discovery.js"; import { fingerprintPublicKey } from "./sync-fingerprint.js"; import { buildBaseUrl, requestJson } from "./sync-http-client.js"; @@ -962,6 +967,10 @@ export async function coordinatorCreateInviteAction(opts: { display_name: string; existing_memory_count: number; }> | null; + inviteKind?: "legacy_enrollment" | "project_share" | "team_member" | "add_device" | null; + policyTeamId?: string | null; + targetIdentityId?: string | null; + reviewedPreviewDigest?: string | null; }): Promise> { if (!VALID_INVITE_POLICIES.has(opts.policy)) throw new Error(`Invalid policy: ${opts.policy}`); if ( @@ -976,6 +985,15 @@ export async function coordinatorCreateInviteAction(opts: { ) { throw new Error("project_invite_context_required"); } + const inviteKind = opts.inviteKind ?? (opts.operationId ? "project_share" : "legacy_enrollment"); + if ( + (inviteKind === "team_member" && + (!opts.policyTeamId || !opts.reviewedPreviewDigest || opts.targetIdentityId)) || + (inviteKind === "add_device" && + (!opts.targetIdentityId || !opts.reviewedPreviewDigest || opts.policyTeamId)) + ) { + throw new Error("recipient_invite_context_required"); + } const expiresAt = new Date(Date.now() + opts.ttlHours * 3600 * 1000).toISOString(); const remote = opts.remoteUrl ?? coordinatorRemoteTarget().remoteUrl; const adminSecret = opts.adminSecret ?? coordinatorRemoteTarget().adminSecret; @@ -1000,6 +1018,10 @@ export async function coordinatorCreateInviteAction(opts: { pending_person_id: opts.pendingPersonId ?? null, project_summaries: opts.projectSummaries ?? null, project_intent: opts.projectIntent ?? null, + invite_kind: inviteKind, + policy_team_id: opts.policyTeamId ?? null, + target_identity_id: opts.targetIdentityId ?? null, + reviewed_preview_digest: opts.reviewedPreviewDigest ?? null, }, ); const invite = payload?.invite; @@ -1012,6 +1034,11 @@ export async function coordinatorCreateInviteAction(opts: { invite_id: inviteRecord?.invite_id, operation_id: inviteRecord?.operation_id ?? null, reviewed_project_set_digest: inviteRecord?.reviewed_project_set_digest ?? null, + invite_kind: inviteRecord?.invite_kind ?? inviteKind, + policy_team_id: inviteRecord?.policy_team_id ?? opts.policyTeamId ?? null, + target_identity_id: inviteRecord?.target_identity_id ?? opts.targetIdentityId ?? null, + reviewed_preview_digest: + inviteRecord?.reviewed_preview_digest ?? opts.reviewedPreviewDigest ?? null, encoded: payload?.encoded, link: payload?.link, payload: payload?.payload, @@ -1040,10 +1067,17 @@ export async function coordinatorCreateInviteAction(opts: { pendingPersonId: opts.pendingPersonId ?? null, projectSummaries: opts.projectSummaries ?? null, projectIntent: opts.projectIntent ?? null, + inviteKind, + policyTeamId: opts.policyTeamId ?? null, + targetIdentityId: opts.targetIdentityId ?? null, + reviewedPreviewDigest: opts.reviewedPreviewDigest ?? null, }); const payload: InvitePayload = { v: 1, - kind: "coordinator_team_invite", + kind: + invite.invite_kind === "team_member" || invite.invite_kind === "add_device" + ? invite.invite_kind + : "coordinator_team_invite", coordinator_url: resolvedCoordinatorUrl, group_id: opts.groupId, policy: invite.policy, @@ -1057,6 +1091,18 @@ export async function coordinatorCreateInviteAction(opts: { project_summaries: opts.projectSummaries ?? [], } : {}), + ...(invite.invite_kind === "team_member" + ? { + policy_team_id: invite.policy_team_id ?? undefined, + reviewed_preview_digest: invite.reviewed_preview_digest ?? undefined, + } + : {}), + ...(invite.invite_kind === "add_device" + ? { + target_identity_id: invite.target_identity_id ?? undefined, + reviewed_preview_digest: invite.reviewed_preview_digest ?? undefined, + } + : {}), }; const encoded = encodeInvitePayload(payload); return { @@ -1064,6 +1110,10 @@ export async function coordinatorCreateInviteAction(opts: { invite_id: invite.invite_id, operation_id: invite.operation_id ?? null, reviewed_project_set_digest: invite.reviewed_project_set_digest ?? null, + invite_kind: invite.invite_kind ?? inviteKind, + policy_team_id: invite.policy_team_id ?? null, + target_identity_id: invite.target_identity_id ?? null, + reviewed_preview_digest: invite.reviewed_preview_digest ?? null, encoded, link: inviteLink(encoded), payload, @@ -1155,6 +1205,91 @@ function persistProjectInviteTrust(opts: { } } +function recipientInviteOnboardingRequest(opts: { + payload: InvitePayload; + identityId: string; + deviceId: string; + publicKey: string; + deviceDisplayName: string; +}): RecipientPolicyOnboardingPreviewRequestV1 { + const base = { + version: 1 as const, + invitationId: String(opts.payload.token), + identityId: opts.identityId, + deviceId: opts.deviceId, + devicePublicKey: opts.publicKey, + deviceDisplayName: opts.deviceDisplayName, + }; + return opts.payload.kind === "team_member" + ? { + ...base, + journey: "team", + teamId: String(opts.payload.policy_team_id ?? ""), + } + : { ...base, journey: "add_device" }; +} + +function previewRecipientInviteOnboardingDigest(opts: { + dbPath: string; + payload: InvitePayload; + identityId: string; + identityDisplayName: string; + deviceId: string; + publicKey: string; + deviceDisplayName: string; +}): string { + const conn = connect(opts.dbPath); + try { + conn.exec("BEGIN"); + const now = new Date().toISOString(); + conn + .prepare(`INSERT INTO actors( + actor_id, display_name, is_local, status, merged_into_actor_id, created_at, updated_at + ) VALUES (?, ?, 1, 'active', NULL, ?, ?) + ON CONFLICT(actor_id) DO NOTHING`) + .run(opts.identityId, opts.identityDisplayName, now, now); + const preview = previewRecipientPolicyOnboarding(conn, recipientInviteOnboardingRequest(opts)); + conn.exec("ROLLBACK"); + return preview.reviewedOnboardingDigest; + } catch (error) { + if (conn.inTransaction) conn.exec("ROLLBACK"); + throw error; + } finally { + conn.close(); + } +} + +function persistRecipientInviteOnboarding(opts: { + dbPath: string; + payload: InvitePayload; + identityId: string; + identityDisplayName: string; + deviceId: string; + publicKey: string; + deviceDisplayName: string; + reviewedOnboardingDigest: string; +}): void { + const conn = connect(opts.dbPath); + try { + const now = new Date().toISOString(); + conn + .prepare(`INSERT INTO actors( + actor_id, display_name, is_local, status, merged_into_actor_id, created_at, updated_at + ) VALUES (?, ?, 1, 'active', NULL, ?, ?) + ON CONFLICT(actor_id) DO NOTHING`) + .run(opts.identityId, opts.identityDisplayName, now, now); + const request = recipientInviteOnboardingRequest(opts); + const result = commitRecipientPolicyOnboarding(conn, { + ...request, + reviewedOnboardingDigest: opts.reviewedOnboardingDigest, + }); + if (result.status !== "applied") + throw new Error(result.errorCode ?? "onboarding_commit_failed"); + } finally { + conn.close(); + } +} + export async function coordinatorImportInviteAction(opts: { inviteValue: string; dbPath?: string | null; @@ -1163,6 +1298,7 @@ export async function coordinatorImportInviteAction(opts: { recipientActorId?: string | null; recipientDisplayName?: string | null; deviceDisplayName?: string | null; + reviewedOnboardingDigest?: string | null; }): Promise> { const payload = decodeInvitePayload(extractInvitePayload(opts.inviteValue)); const resolvedDbPath = resolveDbPath(opts.dbPath ?? undefined); @@ -1184,25 +1320,55 @@ export async function coordinatorImportInviteAction(opts: { ? readCodememConfigFileAtPath(opts.configPath) : readCodememConfigFile(); const projectInvite = Boolean(payload.operation_id); + const recipientInvite = payload.kind === "team_member" || payload.kind === "add_device"; const fallbackDeviceName = friendlyDeviceName({ explicitName: String(config.sync_device_name ?? ""), osName: hostname(), fallbackSeed: deviceId, }); + const explicitRecipientActorId = String(opts.recipientActorId ?? "").trim(); + const configuredRecipientActorId = String(config.actor_id ?? "").trim(); + const addDeviceTargetIdentityId = + payload.kind === "add_device" ? String(payload.target_identity_id ?? "").trim() : ""; const recipientActorId = - String(opts.recipientActorId ?? config.actor_id ?? "").trim() || `local:${deviceId}`; - const recipientDisplayName = projectInvite - ? normalizeIdentityDisplayName( - String(opts.recipientDisplayName ?? config.actor_display_name ?? fallbackDeviceName), - "recipient_display_name", - ) - : String(config.actor_display_name ?? deviceId).trim() || deviceId; - const displayName = projectInvite - ? normalizeIdentityDisplayName( - String(opts.deviceDisplayName ?? fallbackDeviceName), - "device_display_name", - ) - : recipientDisplayName; + explicitRecipientActorId || + configuredRecipientActorId || + addDeviceTargetIdentityId || + `local:${deviceId}`; + if ( + payload.kind === "add_device" && + [recipientActorId, explicitRecipientActorId, configuredRecipientActorId].some( + (identityId) => identityId.length > 0 && identityId !== addDeviceTargetIdentityId, + ) + ) { + throw new Error("invite_identity_conflict"); + } + const recipientDisplayName = + projectInvite || recipientInvite + ? normalizeIdentityDisplayName( + String(opts.recipientDisplayName ?? config.actor_display_name ?? fallbackDeviceName), + "recipient_display_name", + ) + : String(config.actor_display_name ?? deviceId).trim() || deviceId; + const displayName = + projectInvite || recipientInvite + ? normalizeIdentityDisplayName( + String(opts.deviceDisplayName ?? fallbackDeviceName), + "device_display_name", + ) + : recipientDisplayName; + const reviewedOnboardingDigest = recipientInvite + ? String(opts.reviewedOnboardingDigest ?? "").trim() || + previewRecipientInviteOnboardingDigest({ + dbPath: resolvedDbPath, + payload, + identityId: recipientActorId, + identityDisplayName: recipientDisplayName, + deviceId, + publicKey, + deviceDisplayName: displayName, + }) + : null; // V1 of multi-team assumes one coordinator hosting multiple groups. // If this device is already enrolled in a different coordinator, surface // that as a hard error instead of silently overwriting the existing @@ -1230,7 +1396,13 @@ export async function coordinatorImportInviteAction(opts: { device_id: deviceId, public_key: publicKey, fingerprint, - display_name: displayName, + ...(recipientInvite ? {} : { display_name: displayName }), + ...(recipientInvite + ? { + invite_kind: payload.kind, + identity_id: recipientActorId, + } + : {}), ...(projectInvite ? { operation_id: payload.operation_id, @@ -1269,12 +1441,40 @@ export async function coordinatorImportInviteAction(opts: { response, }); } + if (recipientInvite) { + const responseKind = String(response?.kind ?? "").trim(); + const responseDigest = String(response?.reviewed_preview_digest ?? "").trim(); + if ( + responseKind !== payload.kind || + responseDigest !== String(payload.reviewed_preview_digest ?? "").trim() || + (payload.kind === "team_member" && + String(response?.policy_team_id ?? "").trim() !== + String(payload.policy_team_id ?? "").trim()) || + (payload.kind === "add_device" && + String(response?.target_identity_id ?? "").trim() !== + String(payload.target_identity_id ?? "").trim()) + ) { + throw new Error("recipient_invite_intent_mismatch"); + } + persistRecipientInviteOnboarding({ + dbPath: resolvedDbPath, + payload, + identityId: recipientActorId, + identityDisplayName: recipientDisplayName, + deviceId, + publicKey, + deviceDisplayName: displayName, + reviewedOnboardingDigest: reviewedOnboardingDigest ?? "", + }); + } const nextConfig = opts.configPath ? readCodememConfigFileAtPath(opts.configPath) : readCodememConfigFile(); nextConfig.sync_coordinator_url = coordinatorUrl; - if (projectInvite) { + if (projectInvite || recipientInvite) { nextConfig.actor_id = recipientActorId; + } + if (projectInvite) { nextConfig.actor_display_name = recipientDisplayName; nextConfig.sync_device_name = displayName; } @@ -1299,6 +1499,18 @@ export async function coordinatorImportInviteAction(opts: { nextConfig.sync_coordinator_groups = mergedGroups; nextConfig.sync_coordinator_group = mergedGroups[0] ?? newGroupId; const configPath = writeCodememConfigFile(nextConfig, opts.configPath ?? undefined); + if (recipientInvite) { + return { + group_id: response?.group_id ?? payload.group_id, + coordinator_url: payload.coordinator_url, + status: response?.status ?? null, + invite_kind: response?.kind ?? payload.kind, + identity_id: response?.identity_id ?? recipientActorId, + policy_team_id: response?.policy_team_id ?? payload.policy_team_id ?? null, + target_identity_id: response?.target_identity_id ?? payload.target_identity_id ?? null, + reviewed_preview_digest: response?.reviewed_preview_digest ?? null, + }; + } return { group_id: payload.group_id, coordinator_url: payload.coordinator_url, diff --git a/packages/core/src/coordinator-api.test.ts b/packages/core/src/coordinator-api.test.ts index c3835402..026733e7 100644 --- a/packages/core/src/coordinator-api.test.ts +++ b/packages/core/src/coordinator-api.test.ts @@ -65,6 +65,10 @@ function createMockStore( getInviteByTokenForInspection: vi.fn( async (_: string): Promise => null, ), + inspectRecipientInvite: vi.fn(async () => null), + consumeRecipientInvite: vi.fn(async () => { + throw new Error("not implemented"); + }), consumeProjectInvite: vi.fn(async () => { throw new Error("not implemented"); }), @@ -314,6 +318,84 @@ describe("createCoordinatorApp dependency injection", () => { ); }); + it("inspects and consumes explicit Team invitations without enrollment or scope grants", async () => { + const publicKey = "recipient-public-key"; + const digest = "e".repeat(64); + const invite: CoordinatorInvite = { + invite_id: "invite-team-1", + group_id: "g1", + token: "team-token", + policy: "auto_admit", + expires_at: "2099-01-01T00:00:00Z", + created_at: "2026-03-28T00:00:00Z", + created_by: null, + team_name_snapshot: "Coordinator One", + revoked_at: null, + invite_kind: "team_member", + policy_team_id: "policy-team-1", + reviewed_preview_digest: digest, + }; + const consumeRecipientInvite = vi.fn(async () => ({ + status: "accepted" as const, + invite: { + ...invite, + consumed_at: "2026-03-28T00:00:00Z", + recipient_actor_id: "identity-brian", + }, + })); + const store = createMockStore({ + getInviteByTokenForInspection: vi.fn(async () => invite), + inspectRecipientInvite: vi.fn(async () => ({ + kind: "team_member" as const, + invite, + policy_team_id: "policy-team-1", + reviewed_preview_digest: digest, + bound: false, + })), + consumeRecipientInvite, + }); + const app = createCoordinatorApp({ + storeFactory: () => store, + runtime: { adminSecret: () => "test-secret", now: () => "2026-03-28T00:00:00Z" }, + requestVerifier: allowRequest, + }); + + const inspection = await app.request("/v1/invites/inspect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: invite.token }), + }); + expect(await inspection.json()).toEqual({ + kind: "team_member", + policy_team_id: "policy-team-1", + reviewed_preview_digest: digest, + bound: false, + }); + + const accepted = await app.request("/v1/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token: invite.token, + invite_kind: "team_member", + identity_id: "identity-brian", + device_id: "device-brian", + public_key: publicKey, + fingerprint: fingerprintPublicKey(publicKey), + }), + }); + expect(await accepted.json()).toMatchObject({ + ok: true, + status: "accepted", + kind: "team_member", + identity_id: "identity-brian", + policy_team_id: "policy-team-1", + }); + expect(consumeRecipientInvite).toHaveBeenCalledOnce(); + expect(store.enrollDevice).not.toHaveBeenCalled(); + expect(store.grantScopeMembership).not.toHaveBeenCalled(); + }); + it("fails closed when project-first acceptance omits identity confirmation", async () => { const publicKey = "recipient-public-key"; const store = createMockStore({ diff --git a/packages/core/src/coordinator-api.ts b/packages/core/src/coordinator-api.ts index 761477f5..bf57c04e 100644 --- a/packages/core/src/coordinator-api.ts +++ b/packages/core/src/coordinator-api.ts @@ -12,6 +12,7 @@ import { encodeInvitePayload, inviteLink } from "./coordinator-invites.js"; import type { CoordinatorBootstrapGrantVerification, CoordinatorEnrollment, + CoordinatorInviteKind, CoordinatorScope, CoordinatorScopeMembership, CoordinatorStore, @@ -1301,6 +1302,10 @@ export function createCoordinatorApp( const inviterDisplayName = String(data.inviter_display_name ?? "").trim() || null; const inviterDeviceId = String(data.inviter_device_id ?? "").trim() || null; const pendingPersonId = String(data.pending_person_id ?? "").trim() || null; + const requestedInviteKind = String(data.invite_kind ?? data.kind ?? "").trim(); + const policyTeamId = String(data.policy_team_id ?? "").trim() || null; + const targetIdentityId = String(data.target_identity_id ?? "").trim() || null; + const reviewedPreviewDigest = String(data.reviewed_preview_digest ?? "").trim() || null; let projectSummaries: ReturnType | null = null; let projectIntent: Array<{ canonical_identity: string; @@ -1319,6 +1324,38 @@ export function createCoordinatorApp( if (Boolean(operationId) !== Boolean(reviewedProjectSetDigest)) { return c.json({ error: "operation_intent_reference_incomplete" }, 400); } + const inviteKind = (requestedInviteKind || + (operationId ? "project_share" : "legacy_enrollment")) as CoordinatorInviteKind; + if ( + !(["legacy_enrollment", "project_share", "team_member", "add_device"] as const).includes( + inviteKind, + ) + ) { + return c.json({ error: "invite_kind_invalid" }, 400); + } + if (inviteKind === "project_share" ? !operationId : Boolean(operationId)) { + return c.json({ error: "invite_kind_intent_mismatch" }, 400); + } + if (reviewedPreviewDigest && !/^[a-f0-9]{64}$/u.test(reviewedPreviewDigest)) { + return c.json({ error: "reviewed_preview_digest_invalid" }, 400); + } + if ( + (inviteKind === "team_member" && + (!policyTeamId || !reviewedPreviewDigest || Boolean(targetIdentityId))) || + (inviteKind === "add_device" && + (!targetIdentityId || !reviewedPreviewDigest || Boolean(policyTeamId))) || + (!["team_member", "add_device"].includes(inviteKind) && + Boolean(policyTeamId || targetIdentityId || reviewedPreviewDigest)) + ) { + return c.json({ error: "recipient_invite_metadata_invalid" }, 400); + } + if ( + [policyTeamId, targetIdentityId] + .filter((value): value is string => Boolean(value)) + .some((value) => value.length > 256 || /[\p{Cc}\p{Cf}]/u.test(value)) + ) { + return c.json({ error: "recipient_invite_identifier_invalid" }, 400); + } if ( (operationId && !/^share_[a-f0-9]{40}$/u.test(operationId)) || (reviewedProjectSetDigest && !/^[a-f0-9]{64}$/u.test(reviewedProjectSetDigest)) @@ -1397,11 +1434,18 @@ export function createCoordinatorApp( pendingPersonId, projectSummaries, projectIntent, + inviteKind, + policyTeamId, + targetIdentityId, + reviewedPreviewDigest, }); const payload: InvitePayload = { v: 1, - kind: "coordinator_team_invite", + kind: + invite.invite_kind === "team_member" || invite.invite_kind === "add_device" + ? invite.invite_kind + : "coordinator_team_invite", coordinator_url: String(data.coordinator_url ?? "").trim(), group_id: groupId, policy: invite.policy, @@ -1415,6 +1459,18 @@ export function createCoordinatorApp( project_summaries: projectSummaries ?? [], } : {}), + ...(invite.invite_kind === "team_member" + ? { + policy_team_id: invite.policy_team_id ?? undefined, + reviewed_preview_digest: invite.reviewed_preview_digest ?? undefined, + } + : {}), + ...(invite.invite_kind === "add_device" + ? { + target_identity_id: invite.target_identity_id ?? undefined, + reviewed_preview_digest: invite.reviewed_preview_digest ?? undefined, + } + : {}), }; const encoded = encodeInvitePayload(payload); @@ -1452,6 +1508,31 @@ export function createCoordinatorApp( try { const invite = await store.getInviteByTokenForInspection(token); if (!invite || invite.revoked_at) return c.json({ error: "invite_invalid" }, 404); + if (invite.invite_kind === "team_member" || invite.invite_kind === "add_device") { + try { + const inspection = await store.inspectRecipientInvite({ token, now: runtime.now() }); + if (!inspection) return c.json({ error: "invite_invalid" }, 404); + return c.json( + inspection.kind === "team_member" + ? { + kind: inspection.kind, + policy_team_id: inspection.policy_team_id, + reviewed_preview_digest: inspection.reviewed_preview_digest, + bound: inspection.bound, + } + : { + kind: inspection.kind, + target_identity_id: inspection.target_identity_id, + reviewed_preview_digest: inspection.reviewed_preview_digest, + bound: inspection.bound, + }, + ); + } catch (error) { + const code = error instanceof Error ? error.message : "invite_invalid"; + const status = code === "invite_expired" ? 410 : code === "invite_invalid" ? 404 : 409; + return c.json({ error: code }, status); + } + } const projectInvite = Boolean(invite.operation_id || invite.reviewed_project_set_digest); if ( new Date(invite.expires_at) <= new Date(runtime.now()) && @@ -1708,11 +1789,66 @@ export function createCoordinatorApp( const invite = await store.getInviteByTokenForInspection(token); if (!invite) return c.json({ error: "invite_invalid" }, 404); const projectInvite = Boolean(invite.operation_id || invite.reviewed_project_set_digest); + const recipientInvite = + invite.invite_kind === "team_member" || invite.invite_kind === "add_device"; if (invite.revoked_at) { - return c.json({ error: projectInvite ? "invite_invalid" : "revoked_token" }, 400); + return c.json( + { error: projectInvite || recipientInvite ? "invite_invalid" : "revoked_token" }, + 400, + ); } if (!invite.consumed_at && new Date(invite.expires_at) <= new Date(runtime.now())) { - return c.json({ error: projectInvite ? "invite_expired" : "expired_token" }, 410); + return c.json( + { error: projectInvite || recipientInvite ? "invite_expired" : "expired_token" }, + 410, + ); + } + if (recipientInvite) { + const allowedRecipientAcceptanceFields = new Set([ + "token", + "invite_kind", + "kind", + "identity_id", + "device_id", + "public_key", + "fingerprint", + ]); + if (Object.keys(data).some((key) => !allowedRecipientAcceptanceFields.has(key))) { + return c.json({ error: "unexpected_recipient_invite_fields" }, 400); + } + const requestedKind = String(data.invite_kind ?? data.kind ?? "").trim(); + const identityId = String(data.identity_id ?? "").trim(); + if (!identityId || requestedKind !== invite.invite_kind) { + return c.json({ error: "recipient_invite_binding_required" }, 400); + } + if (identityId.length > 256 || /[\p{Cc}\p{Cf}]/u.test(identityId)) { + return c.json({ error: "identity_id_invalid" }, 400); + } + try { + const acceptance = await store.consumeRecipientInvite({ + token, + inviteKind: invite.invite_kind as "team_member" | "add_device", + identityId, + deviceId, + publicKey, + fingerprint, + now: runtime.now(), + }); + return c.json({ + ok: true, + status: acceptance.status, + kind: acceptance.invite.invite_kind, + group_id: acceptance.invite.group_id, + identity_id: acceptance.invite.recipient_actor_id, + policy_team_id: acceptance.invite.policy_team_id ?? null, + target_identity_id: acceptance.invite.target_identity_id ?? null, + reviewed_preview_digest: acceptance.invite.reviewed_preview_digest, + }); + } catch (error) { + const code = error instanceof Error ? error.message : "invite_invalid"; + const status = code === "invite_expired" ? 410 : code === "invite_invalid" ? 404 : 409; + return c.json({ error: code }, status); + } } if (invite.operation_id || invite.reviewed_project_set_digest) { const allowedProjectAcceptanceFields = new Set([ diff --git a/packages/core/src/coordinator-invites.ts b/packages/core/src/coordinator-invites.ts index 69923ff7..4b516e53 100644 --- a/packages/core/src/coordinator-invites.ts +++ b/packages/core/src/coordinator-invites.ts @@ -6,7 +6,7 @@ export interface InvitePayload { v: number; - kind: string; + kind: "coordinator_team_invite" | "team_member" | "add_device" | string; coordinator_url: string; group_id: string; policy: string; @@ -17,6 +17,10 @@ export interface InvitePayload { operation_id?: string; inviter_name?: string | null; project_summaries?: Array<{ display_name: string; existing_memory_count: number }>; + /** Policy metadata for recipient-aware invites. It is preview-only and never grants scope access. */ + policy_team_id?: string; + target_identity_id?: string; + reviewed_preview_digest?: string; } function bytesToBase64(bytes: Uint8Array): string { diff --git a/packages/core/src/coordinator-store-contract.ts b/packages/core/src/coordinator-store-contract.ts index 6a7c08c9..03fef04b 100644 --- a/packages/core/src/coordinator-store-contract.ts +++ b/packages/core/src/coordinator-store-contract.ts @@ -61,7 +61,26 @@ export interface CoordinatorInvite { recipient_device_display_name?: string | null; trust_state?: string | null; bootstrap_grant_id?: string | null; -} + /** Explicit lifecycle kind. Null is retained only for pre-migration rows. */ + invite_kind?: CoordinatorInviteKind | null; + /** Policy Team identifier for team-member invitations; never a coordinator group grant. */ + policy_team_id?: string | null; + /** Identity fixed by an add-device invitation. */ + target_identity_id?: string | null; + /** Digest of the server-owned preview reviewed before invitation creation. */ + reviewed_preview_digest?: string | null; +} + +export type CoordinatorInviteKind = + | "legacy_enrollment" + | "project_share" + | "team_member" + | "add_device"; + +export type CoordinatorRecipientInviteKind = Extract< + CoordinatorInviteKind, + "team_member" | "add_device" +>; export interface CoordinatorProjectInviteSummary { display_name: string; @@ -76,6 +95,27 @@ export interface CoordinatorProjectInviteAcceptance { bootstrap_grant: CoordinatorBootstrapGrant | null; } +export type CoordinatorRecipientInviteInspection = + | { + kind: "team_member"; + invite: CoordinatorInvite; + policy_team_id: string; + reviewed_preview_digest: string; + bound: boolean; + } + | { + kind: "add_device"; + invite: CoordinatorInvite; + target_identity_id: string; + reviewed_preview_digest: string; + bound: boolean; + }; + +export interface CoordinatorRecipientInviteAcceptance { + status: "accepted" | "existing"; + invite: CoordinatorInvite; +} + export interface CoordinatorJoinRequest { request_id: string; group_id: string; @@ -211,6 +251,10 @@ export interface CoordinatorCreateInviteInput { pendingPersonId?: string | null; projectSummaries?: CoordinatorProjectInviteSummary[] | null; projectIntent?: Array | null; + inviteKind?: CoordinatorInviteKind | null; + policyTeamId?: string | null; + targetIdentityId?: string | null; + reviewedPreviewDigest?: string | null; } export interface CoordinatorConsumeProjectInviteInput { @@ -226,6 +270,22 @@ export interface CoordinatorConsumeProjectInviteInput { now: string; } +export interface CoordinatorInspectRecipientInviteInput { + token: string; + now: string; +} + +export interface CoordinatorConsumeRecipientInviteInput { + token: string; + inviteKind: CoordinatorRecipientInviteKind; + identityId: string; + deviceId: string; + publicKey: string; + fingerprint: string; + /** Runtime-authoritative timestamp used for expiry and binding. */ + now: string; +} + export interface CoordinatorCreateJoinRequestInput { groupId: string; deviceId: string; @@ -357,9 +417,15 @@ export interface CoordinatorStore { createInvite(opts: CoordinatorCreateInviteInput): Promise; getInviteByToken(token: string): Promise; getInviteByTokenForInspection(token: string): Promise; + inspectRecipientInvite( + opts: CoordinatorInspectRecipientInviteInput, + ): Promise; consumeProjectInvite( opts: CoordinatorConsumeProjectInviteInput, ): Promise; + consumeRecipientInvite( + opts: CoordinatorConsumeRecipientInviteInput, + ): Promise; listInvites(groupId: string): Promise; createJoinRequest(opts: CoordinatorCreateJoinRequestInput): Promise; listJoinRequests(groupId: string, status?: string): Promise; diff --git a/packages/core/src/coordinator-store-test-harness.ts b/packages/core/src/coordinator-store-test-harness.ts index c4dfe8ee..65bb7c21 100644 --- a/packages/core/src/coordinator-store-test-harness.ts +++ b/packages/core/src/coordinator-store-test-harness.ts @@ -716,6 +716,236 @@ export function runCoordinatorStoreContract( }); describe("invites", () => { + it("persists and single-use binds explicit Team and add-device invitations without scope membership", async () => { + await withContext(async ({ store }) => { + await store.createGroup("g1", "Coordinator Alpha"); + await store.createScope({ scopeId: "scope-project", label: "Project" }); + const digest = "a".repeat(64); + const teamInvite = await store.createInvite({ + groupId: "g1", + policy: "auto_admit", + expiresAt: "2099-01-01T00:00:00Z", + inviteKind: "team_member", + policyTeamId: "policy-team-1", + reviewedPreviewDigest: digest, + }); + expect(teamInvite).toMatchObject({ + invite_kind: "team_member", + policy_team_id: "policy-team-1", + reviewed_preview_digest: digest, + }); + expect( + await store.inspectRecipientInvite({ + token: teamInvite.token, + now: "2026-07-21T00:00:00.000Z", + }), + ).toMatchObject({ kind: "team_member", policy_team_id: "policy-team-1", bound: false }); + const publicKey = "team-member-key"; + const teamInput = { + token: teamInvite.token, + inviteKind: "team_member" as const, + identityId: "identity-brian", + deviceId: "device-brian", + publicKey, + fingerprint: fingerprintPublicKey(publicKey), + now: "2026-07-21T00:00:00.000Z", + }; + expect((await store.consumeRecipientInvite(teamInput)).status).toBe("accepted"); + expect((await store.consumeRecipientInvite(teamInput)).status).toBe("existing"); + await expect( + store.consumeRecipientInvite({ ...teamInput, identityId: "identity-other" }), + ).rejects.toThrow("invite_identity_conflict"); + await expect( + store.consumeRecipientInvite({ ...teamInput, deviceId: "device-other" }), + ).rejects.toThrow("invite_already_bound"); + await expect( + store.consumeRecipientInvite({ + ...teamInput, + publicKey: "other-key", + fingerprint: fingerprintPublicKey("other-key"), + }), + ).rejects.toThrow("invite_already_bound"); + + const addDeviceInvite = await store.createInvite({ + groupId: "g1", + policy: "auto_admit", + expiresAt: "2099-01-01T00:00:00Z", + inviteKind: "add_device", + targetIdentityId: "identity-brian", + reviewedPreviewDigest: "b".repeat(64), + }); + expect( + await store.inspectRecipientInvite({ + token: addDeviceInvite.token, + now: "2026-07-21T00:00:00.000Z", + }), + ).toMatchObject({ kind: "add_device", target_identity_id: "identity-brian" }); + await expect( + store.consumeRecipientInvite({ + ...teamInput, + token: addDeviceInvite.token, + inviteKind: "add_device", + identityId: "identity-other", + }), + ).rejects.toThrow("invite_identity_conflict"); + const addDeviceInput = { + ...teamInput, + token: addDeviceInvite.token, + inviteKind: "add_device" as const, + deviceId: "device-brian-2", + publicKey: "add-device-key", + fingerprint: fingerprintPublicKey("add-device-key"), + }; + expect((await store.consumeRecipientInvite(addDeviceInput)).status).toBe("accepted"); + expect((await store.consumeRecipientInvite(addDeviceInput)).status).toBe("existing"); + expect(await store.listScopeMemberships("scope-project")).toEqual([]); + }); + }); + + it("replays only the exact consumed recipient binding after expiry", async () => { + await withContext(async ({ store }) => { + await store.createGroup("g1", "Coordinator Alpha"); + const publicKey = "post-expiry-key"; + const invite = await store.createInvite({ + groupId: "g1", + policy: "auto_admit", + expiresAt: "2026-07-21T00:00:01.000Z", + inviteKind: "team_member", + policyTeamId: "policy-team-1", + reviewedPreviewDigest: "e".repeat(64), + }); + const input = { + token: invite.token, + inviteKind: "team_member" as const, + identityId: "identity-brian", + deviceId: "device-brian", + publicKey, + fingerprint: fingerprintPublicKey(publicKey), + now: "2026-07-21T00:00:00.000Z", + }; + + expect((await store.consumeRecipientInvite(input)).status).toBe("accepted"); + expect( + await store.inspectRecipientInvite({ + token: invite.token, + now: "2026-07-21T00:00:02.000Z", + }), + ).toMatchObject({ kind: "team_member", bound: true }); + const replay = await store.consumeRecipientInvite({ + ...input, + now: "2026-07-21T00:00:02.000Z", + }); + expect(replay.status).toBe("existing"); + await expect( + store.consumeRecipientInvite({ + ...input, + deviceId: "device-other", + now: "2026-07-21T00:00:02.000Z", + }), + ).rejects.toThrow("invite_already_bound"); + await expect( + store.consumeRecipientInvite({ + ...input, + identityId: "identity-other", + now: "2026-07-21T00:00:02.000Z", + }), + ).rejects.toThrow("invite_identity_conflict"); + const otherPublicKey = "post-expiry-other-key"; + await expect( + store.consumeRecipientInvite({ + ...input, + publicKey: otherPublicKey, + fingerprint: fingerprintPublicKey(otherPublicKey), + now: "2026-07-21T00:00:02.000Z", + }), + ).rejects.toThrow("invite_already_bound"); + }); + }); + + it("rejects first recipient invite use after expiry", async () => { + await withContext(async ({ store }) => { + await store.createGroup("g1", "Coordinator Alpha"); + const publicKey = "expired-first-use-key"; + const invite = await store.createInvite({ + groupId: "g1", + policy: "auto_admit", + expiresAt: "2026-07-21T00:00:01.000Z", + inviteKind: "add_device", + targetIdentityId: "identity-brian", + reviewedPreviewDigest: "f".repeat(64), + }); + + await expect( + store.consumeRecipientInvite({ + token: invite.token, + inviteKind: "add_device", + identityId: "identity-brian", + deviceId: "device-brian", + publicKey, + fingerprint: fingerprintPublicKey(publicKey), + now: "2026-07-21T00:00:02.000Z", + }), + ).rejects.toThrow("invite_expired"); + }); + }); + + it("fails closed when recipient invitations expire or their coordinator group is archived", async () => { + await withContext(async ({ store }) => { + await store.createGroup("g1", "Coordinator Alpha"); + const expired = await store.createInvite({ + groupId: "g1", + policy: "auto_admit", + expiresAt: "2000-01-01T00:00:00Z", + inviteKind: "team_member", + policyTeamId: "policy-team-1", + reviewedPreviewDigest: "c".repeat(64), + }); + await expect( + store.inspectRecipientInvite({ + token: expired.token, + now: "2026-07-21T00:00:00.000Z", + }), + ).rejects.toThrow("invite_expired"); + await expect( + store.consumeRecipientInvite({ + token: expired.token, + inviteKind: "team_member", + identityId: "identity-brian", + deviceId: "device-brian", + publicKey: "expired-key", + fingerprint: fingerprintPublicKey("expired-key"), + now: "2026-07-21T00:00:00.000Z", + }), + ).rejects.toThrow("invite_expired"); + const active = await store.createInvite({ + groupId: "g1", + policy: "auto_admit", + expiresAt: "2099-01-01T00:00:00Z", + inviteKind: "add_device", + targetIdentityId: "identity-brian", + reviewedPreviewDigest: "d".repeat(64), + }); + await store.archiveGroup("g1", "2026-07-21T00:00:00.000Z"); + await expect( + store.inspectRecipientInvite({ + token: active.token, + now: "2026-07-21T00:00:00.000Z", + }), + ).rejects.toThrow("group_archived"); + await expect( + store.consumeRecipientInvite({ + token: active.token, + inviteKind: "add_device", + identityId: "identity-brian", + deviceId: "device-brian", + publicKey: "archived-key", + fingerprint: fingerprintPublicKey("archived-key"), + now: "2026-07-21T00:00:00.000Z", + }), + ).rejects.toThrow("group_archived"); + }); + }); + it("fails closed for archived groups and mismatched public-key fingerprints", async () => { await withContext(async ({ store }) => { await store.createGroup("g1", "Team Alpha"); diff --git a/packages/core/src/coordinator-store.test.ts b/packages/core/src/coordinator-store.test.ts index fa2b1c0f..e8ab1b22 100644 --- a/packages/core/src/coordinator-store.test.ts +++ b/packages/core/src/coordinator-store.test.ts @@ -40,7 +40,9 @@ describe("CoordinatorStore", () => { try { const row = store.db .prepare( - "SELECT token, token_digest, consumed_at, bound_device_id, trust_state FROM coordinator_invites", + `SELECT token, token_digest, consumed_at, bound_device_id, trust_state, + invite_kind, policy_team_id, target_identity_id, reviewed_preview_digest + FROM coordinator_invites`, ) .get(); expect(row).toEqual({ @@ -49,6 +51,10 @@ describe("CoordinatorStore", () => { consumed_at: null, bound_device_id: null, trust_state: null, + invite_kind: "legacy_enrollment", + policy_team_id: null, + target_identity_id: null, + reviewed_preview_digest: null, }); } finally { await store.close(); diff --git a/packages/core/src/d1-coordinator-store.ts b/packages/core/src/d1-coordinator-store.ts index 2979dfd5..0ca1acb4 100644 --- a/packages/core/src/d1-coordinator-store.ts +++ b/packages/core/src/d1-coordinator-store.ts @@ -1,6 +1,7 @@ import type { CoordinatorBootstrapGrant, CoordinatorConsumeProjectInviteInput, + CoordinatorConsumeRecipientInviteInput, CoordinatorCreateBootstrapGrantInput, CoordinatorCreateInviteInput, CoordinatorCreateJoinRequestInput, @@ -10,7 +11,9 @@ import type { CoordinatorEnrollment, CoordinatorGrantScopeMembershipInput, CoordinatorGroup, + CoordinatorInspectRecipientInviteInput, CoordinatorInvite, + CoordinatorInviteKind, CoordinatorJoinRequest, CoordinatorJoinRequestReviewResult, CoordinatorListReciprocalApprovalsInput, @@ -19,6 +22,8 @@ import type { CoordinatorPeerRecord, CoordinatorPresenceRecord, CoordinatorProjectInviteAcceptance, + CoordinatorRecipientInviteAcceptance, + CoordinatorRecipientInviteInspection, CoordinatorReciprocalApproval, CoordinatorReviewJoinRequestBootstrapGrantInput, CoordinatorReviewJoinRequestInput, @@ -150,7 +155,7 @@ const INVITE_COLUMNS = `invite_id, group_id, token, policy, expires_at, created_ inviter_actor_id, inviter_display_name, inviter_device_id, pending_person_id, project_summaries_json, project_intent_json, consumed_at, bound_device_id, bound_public_key, bound_fingerprint, recipient_actor_id, recipient_display_name, recipient_device_display_name, trust_state, - bootstrap_grant_id`; + bootstrap_grant_id, invite_kind, policy_team_id, target_identity_id, reviewed_preview_digest`; function requireTrimmedBootstrapGrantInput(opts: CoordinatorCreateBootstrapGrantInput): { groupId: string; @@ -175,6 +180,85 @@ function clean(value: string | null | undefined): string | null { return trimmed ? trimmed : null; } +function normalizeInviteMetadata(opts: CoordinatorCreateInviteInput): { + inviteKind: CoordinatorInviteKind; + policyTeamId: string | null; + targetIdentityId: string | null; + reviewedPreviewDigest: string | null; +} { + const inviteKind = + opts.inviteKind ?? (clean(opts.operationId) ? "project_share" : "legacy_enrollment"); + const policyTeamId = clean(opts.policyTeamId); + const targetIdentityId = clean(opts.targetIdentityId); + const reviewedPreviewDigest = clean(opts.reviewedPreviewDigest); + if ( + !(["legacy_enrollment", "project_share", "team_member", "add_device"] as const).includes( + inviteKind, + ) + ) { + throw new Error("inviteKind is invalid."); + } + if ( + [policyTeamId, targetIdentityId] + .filter((value): value is string => Boolean(value)) + .some((value) => value.length > 256 || /[\p{Cc}\p{Cf}]/u.test(value)) + ) { + throw new Error("recipient invite identifier is invalid."); + } + if (reviewedPreviewDigest && !/^[a-f0-9]{64}$/u.test(reviewedPreviewDigest)) { + throw new Error("reviewedPreviewDigest must be a SHA-256 digest."); + } + if (inviteKind === "project_share") { + if (!clean(opts.operationId)) throw new Error("project_share invite requires operationId."); + if (policyTeamId || targetIdentityId || reviewedPreviewDigest) { + throw new Error("recipient invite metadata requires a recipient invite kind."); + } + } else if (inviteKind === "legacy_enrollment") { + if (clean(opts.operationId)) + throw new Error("legacy_enrollment invite cannot reference an operation."); + if (policyTeamId || targetIdentityId || reviewedPreviewDigest) { + throw new Error("recipient invite metadata requires a recipient invite kind."); + } + } else if (inviteKind === "team_member") { + if (!policyTeamId || !reviewedPreviewDigest || targetIdentityId || clean(opts.operationId)) { + throw new Error("team_member invite metadata is invalid."); + } + } else if (inviteKind === "add_device") { + if (!targetIdentityId || !reviewedPreviewDigest || policyTeamId || clean(opts.operationId)) { + throw new Error("add_device invite metadata is invalid."); + } + } + return { inviteKind, policyTeamId, targetIdentityId, reviewedPreviewDigest }; +} + +function recipientInspection( + invite: CoordinatorInvite, +): CoordinatorRecipientInviteInspection | null { + if (invite.invite_kind === "team_member") { + if (!invite.policy_team_id || !invite.reviewed_preview_digest) + throw new Error("invite_invalid"); + return { + kind: "team_member", + invite, + policy_team_id: invite.policy_team_id, + reviewed_preview_digest: invite.reviewed_preview_digest, + bound: Boolean(invite.consumed_at), + }; + } + if (invite.invite_kind === "add_device") { + if (!invite.target_identity_id || !invite.reviewed_preview_digest) + throw new Error("invite_invalid"); + return { + kind: "add_device", + invite, + target_identity_id: invite.target_identity_id, + reviewed_preview_digest: invite.reviewed_preview_digest, + bound: Boolean(invite.consumed_at), + }; + } + return null; +} + function normalizeEpoch(value: number | null | undefined, fallback = 0): number { if (value == null) return fallback; if (!Number.isFinite(value) || value < 0) @@ -601,6 +685,7 @@ export class D1CoordinatorStore implements CoordinatorStore { const group = await this.getGroup(_opts.groupId); const operationId = String(_opts.operationId ?? "").trim() || null; const reviewedProjectSetDigest = String(_opts.reviewedProjectSetDigest ?? "").trim() || null; + const metadata = normalizeInviteMetadata(_opts); if (Boolean(operationId) !== Boolean(reviewedProjectSetDigest)) { throw new Error("operationId and reviewedProjectSetDigest must be provided together."); } @@ -618,6 +703,7 @@ export class D1CoordinatorStore implements CoordinatorStore { if ( existing.group_id !== _opts.groupId || existing.policy !== _opts.policy || + existing.invite_kind !== metadata.inviteKind || existing.reviewed_project_set_digest !== reviewedProjectSetDigest || existing.inviter_actor_id !== (String(_opts.inviterActorId ?? "").trim() || null) || existing.inviter_display_name !== (String(_opts.inviterDisplayName ?? "").trim() || null) || @@ -626,7 +712,10 @@ export class D1CoordinatorStore implements CoordinatorStore { existing.project_summaries_json !== (_opts.projectSummaries ? JSON.stringify(_opts.projectSummaries) : null) || existing.project_intent_json !== - (_opts.projectIntent ? JSON.stringify(_opts.projectIntent) : null) + (_opts.projectIntent ? JSON.stringify(_opts.projectIntent) : null) || + existing.policy_team_id !== metadata.policyTeamId || + existing.target_identity_id !== metadata.targetIdentityId || + existing.reviewed_preview_digest !== metadata.reviewedPreviewDigest ) { throw new Error("invite_operation_intent_conflict"); } @@ -669,8 +758,9 @@ export class D1CoordinatorStore implements CoordinatorStore { invite_id, group_id, token, policy, expires_at, created_at, created_by, team_name_snapshot, revoked_at, operation_id, reviewed_project_set_digest, token_digest, inviter_actor_id, inviter_display_name, inviter_device_id, - pending_person_id, project_summaries_json, project_intent_json, trust_state - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + pending_person_id, project_summaries_json, project_intent_json, trust_state, + invite_kind, policy_team_id, target_identity_id, reviewed_preview_digest + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) .bind( inviteId, _opts.groupId, @@ -690,6 +780,10 @@ export class D1CoordinatorStore implements CoordinatorStore { _opts.projectSummaries ? JSON.stringify(_opts.projectSummaries) : null, _opts.projectIntent ? JSON.stringify(_opts.projectIntent) : null, operationId ? "pending" : null, + metadata.inviteKind, + metadata.policyTeamId, + metadata.targetIdentityId, + metadata.reviewedPreviewDigest, ) .run(); } catch (error) { @@ -733,6 +827,115 @@ export class D1CoordinatorStore implements CoordinatorStore { return row ? rowToRecord(row) : null; } + async inspectRecipientInvite( + _opts: CoordinatorInspectRecipientInviteInput, + ): Promise { + const invite = await this.getInviteByTokenForInspection(_opts.token); + if (!invite) return null; + const inspection = recipientInspection(invite); + if (!inspection) return null; + if (invite.revoked_at) throw new Error("invite_invalid"); + if ( + !invite.consumed_at && + new Date(invite.expires_at) <= new Date(normalizeInviteExpiresAt(_opts.now)) + ) { + throw new Error("invite_expired"); + } + const group = await this.getGroup(invite.group_id); + if (!group) throw new Error("group_not_found"); + if (group.archived_at) throw new Error("group_archived"); + return inspection; + } + + async consumeRecipientInvite( + _opts: CoordinatorConsumeRecipientInviteInput, + ): Promise { + const consumedAt = normalizeInviteExpiresAt(_opts.now); + if ( + !_opts.identityId || + !_opts.deviceId || + !_opts.publicKey || + !_opts.fingerprint || + _opts.identityId !== _opts.identityId.trim() || + _opts.deviceId !== _opts.deviceId.trim() || + _opts.identityId.length > 256 || + _opts.deviceId.length > 256 || + /[\p{Cc}\p{Cf}]/u.test(_opts.identityId) || + /[\p{Cc}\p{Cf}]/u.test(_opts.deviceId) + ) { + throw new Error("invite_identity_conflict"); + } + const digest = await tokenDigest(_opts.token); + const initial = await this.getInviteByTokenForInspection(_opts.token); + const inspection = initial ? recipientInspection(initial) : null; + if (!initial || !inspection || inspection.kind !== _opts.inviteKind || initial.revoked_at) { + throw new Error("invite_invalid"); + } + const group = await this.getGroup(initial.group_id); + if (!group) throw new Error("group_not_found"); + if (group.archived_at) throw new Error("group_archived"); + if (!initial.consumed_at && new Date(initial.expires_at) <= new Date(consumedAt)) { + throw new Error("invite_expired"); + } + if (fingerprintPublicKey(_opts.publicKey) !== _opts.fingerprint) { + throw new Error("fingerprint_mismatch"); + } + if (inspection.kind === "add_device" && inspection.target_identity_id !== _opts.identityId) { + throw new Error("invite_identity_conflict"); + } + const sameBinding = + initial.bound_device_id === _opts.deviceId && + initial.bound_public_key === _opts.publicKey && + initial.bound_fingerprint === _opts.fingerprint; + if (initial.consumed_at && !sameBinding) throw new Error("invite_already_bound"); + if (initial.consumed_at && initial.recipient_actor_id !== _opts.identityId) { + throw new Error("invite_identity_conflict"); + } + const changed = initial.consumed_at + ? 0 + : await runChanges( + this.db + .prepare(`UPDATE coordinator_invites SET token = ?, consumed_at = ?, bound_device_id = ?, + bound_public_key = ?, bound_fingerprint = ?, recipient_actor_id = ? + WHERE (token_digest = ? OR token = ?) AND consumed_at IS NULL + AND revoked_at IS NULL AND expires_at > ? AND invite_kind = ? + AND EXISTS (SELECT 1 FROM groups g WHERE g.group_id = coordinator_invites.group_id + AND g.archived_at IS NULL)`) + .bind( + `consumed:${initial.invite_id}`, + consumedAt, + _opts.deviceId, + _opts.publicKey, + _opts.fingerprint, + _opts.identityId, + digest, + _opts.token, + consumedAt, + _opts.inviteKind, + ), + ); + const currentGroup = await this.getGroup(initial.group_id); + if (!currentGroup) throw new Error("group_not_found"); + if (currentGroup.archived_at) throw new Error("group_archived"); + const saved = await this.getInviteByTokenForInspection(_opts.token); + if ( + !saved || + saved.revoked_at || + (!saved.consumed_at && new Date(saved.expires_at) <= new Date(consumedAt)) + ) { + throw new Error("invite_invalid"); + } + if ( + saved.bound_device_id !== _opts.deviceId || + saved.bound_public_key !== _opts.publicKey || + saved.bound_fingerprint !== _opts.fingerprint + ) { + throw new Error("invite_already_bound"); + } + if (saved.recipient_actor_id !== _opts.identityId) throw new Error("invite_identity_conflict"); + return { status: changed === 1 ? "accepted" : "existing", invite: saved }; + } + async consumeProjectInvite( _opts: CoordinatorConsumeProjectInviteInput, ): Promise { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa890bad..91fa96ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -621,6 +621,25 @@ export { deterministicPolicyTeamId, migrateRecipientPolicyIntent, } from "./recipient-policy-migration.js"; +export type { + RecipientPolicyAddDeviceOnboardingRequestV1, + RecipientPolicyDirectProjectOnboardingRequestV1, + RecipientPolicyOnboardingBindingV1, + RecipientPolicyOnboardingCommitRequestV1, + RecipientPolicyOnboardingCommitResultV1, + RecipientPolicyOnboardingExcludedProjectV1, + RecipientPolicyOnboardingJourneyV1, + RecipientPolicyOnboardingPreviewRequestV1, + RecipientPolicyOnboardingPreviewV1, + RecipientPolicyOnboardingProjectSourceV1, + RecipientPolicyOnboardingProjectV1, + RecipientPolicyTeamOnboardingRequestV1, +} from "./recipient-policy-onboarding.js"; +export { + commitRecipientPolicyOnboarding, + previewRecipientPolicyOnboarding, + RecipientPolicyOnboardingRequestError, +} from "./recipient-policy-onboarding.js"; export type { RecipientPolicyActionableReviewItemV1, RecipientPolicyDerivedReviewState, diff --git a/packages/core/src/recipient-policy-onboarding.test.ts b/packages/core/src/recipient-policy-onboarding.test.ts new file mode 100644 index 00000000..b9a7e7d9 --- /dev/null +++ b/packages/core/src/recipient-policy-onboarding.test.ts @@ -0,0 +1,484 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { listRecipientPolicyIntent } from "./recipient-policy-intent.js"; +import { + commitRecipientPolicyOnboarding, + previewRecipientPolicyOnboarding, + type RecipientPolicyOnboardingPreviewRequestV1, +} from "./recipient-policy-onboarding.js"; +import { initTestSchema } from "./test-utils.js"; + +const NOW = "2026-07-21T12:00:00.000Z"; +const PROJECT_A = "https://git.example.invalid/acme/alpha.git"; +const PROJECT_B = "https://git.example.invalid/acme/beta.git"; +const PROJECT_C = "https://git.example.invalid/acme/gamma.git"; + +function insertActor( + db: InstanceType, + identityId: string, + displayName: string, +): void { + db.prepare( + `INSERT INTO actors(actor_id, display_name, is_local, status, created_at, updated_at) + VALUES (?, ?, 0, 'active', ?, ?)`, + ).run(identityId, displayName, NOW, NOW); +} + +function insertProject( + db: InstanceType, + projectId: string, + displayName: string, + memoryCount: number, +): void { + const sessionId = Number( + db + .prepare( + `INSERT INTO sessions(started_at, cwd, project, git_remote, git_branch) + VALUES (?, ?, ?, ?, 'main')`, + ) + .run(NOW, `/workspace/${displayName}`, displayName, projectId).lastInsertRowid, + ); + for (let index = 0; index < memoryCount; index += 1) { + db.prepare( + `INSERT INTO memory_items( + session_id, kind, title, body_text, active, created_at, updated_at, + visibility, project, scope_id + ) VALUES (?, 'discovery', ?, 'body', 1, ?, ?, 'shared', ?, 'local-default')`, + ).run(sessionId, `${displayName}-${index}`, NOW, NOW, displayName); + } +} + +function insertTeam(db: InstanceType, teamId: string, displayName: string): void { + db.prepare( + `INSERT INTO policy_teams( + team_id, display_name, status, provenance, revision, migration_state, + source_fingerprint, idempotency_key, created_at, updated_at + ) VALUES (?, ?, 'active', 'user', ?, 'user_managed', NULL, ?, ?, ?)`, + ).run(teamId, displayName, `revision-${teamId}`, `idempotency-${teamId}`, NOW, NOW); +} + +function insertMembership( + db: InstanceType, + teamId: string, + identityId: string, +): void { + db.prepare( + `INSERT INTO policy_team_memberships( + team_id, identity_id, role, status, provenance, revision, migration_state, + source_fingerprint, idempotency_key, created_at, updated_at + ) VALUES (?, ?, 'member', 'active', 'user', ?, 'user_managed', NULL, ?, ?, ?)`, + ).run( + teamId, + identityId, + `revision-${teamId}-${identityId}`, + `idempotency-${teamId}-${identityId}`, + NOW, + NOW, + ); +} + +function insertRecipient( + db: InstanceType, + projectId: string, + recipientKind: "identity" | "team", + recipientId: string, +): void { + db.prepare( + `INSERT INTO project_recipients( + canonical_project_identity, recipient_kind, recipient_id, status, provenance, + policy_revision, migration_state, source_fingerprint, idempotency_key, + created_at, updated_at + ) VALUES (?, ?, ?, 'active', 'user', ?, 'user_managed', NULL, ?, ?, ?)`, + ).run( + projectId, + recipientKind, + recipientId, + `revision-${projectId}-${recipientKind}-${recipientId}`, + `idempotency-${projectId}-${recipientKind}-${recipientId}`, + NOW, + NOW, + ); +} + +function baseRequest( + overrides: Partial = {}, +): RecipientPolicyOnboardingPreviewRequestV1 { + return { + version: 1, + journey: "add_device", + invitationId: "invite-device", + identityId: "identity-a", + deviceId: "device-new", + devicePublicKey: "public-key-a", + deviceDisplayName: " Ada’s Laptop ", + ...overrides, + } as RecipientPolicyOnboardingPreviewRequestV1; +} + +function protectedSnapshot(db: InstanceType): string { + const tables = [ + "replication_scopes", + "project_scope_mappings", + "scope_memberships", + "scope_membership_cache_state", + "sync_peers", + "replication_ops", + "replication_cursors", + "replication_cursors_v2", + "sync_reset_state", + "sync_reset_state_v2", + "sync_scope_rejections", + ]; + return JSON.stringify( + Object.fromEntries( + tables.map((table) => [table, db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all()]), + ), + ); +} + +describe("recipient-policy onboarding", () => { + let db: InstanceType; + + beforeEach(() => { + db = new Database(":memory:"); + initTestSchema(db); + insertActor(db, "identity-a", "Ada"); + insertActor(db, "identity-b", "Bea"); + insertProject(db, PROJECT_A, "alpha", 2); + insertProject(db, PROJECT_B, "beta", 1); + insertProject(db, PROJECT_C, "gamma", 3); + insertTeam(db, "team-a", "Core Team"); + insertTeam(db, "team-b", "Docs Team"); + insertRecipient(db, PROJECT_A, "team", "team-a"); + insertRecipient(db, PROJECT_B, "team", "team-a"); + insertRecipient(db, PROJECT_B, "identity", "identity-a"); + insertRecipient(db, PROJECT_C, "team", "team-b"); + insertMembership(db, "team-a", "identity-a"); + }); + + afterEach(() => db.close()); + + it("previews Team Projects, memory counts, exclusions, and future inheritance without writes", () => { + const request = baseRequest({ + journey: "team", + invitationId: "invite-team", + teamId: "team-a", + }); + const before = Number(db.prepare("SELECT total_changes()").pluck().get()); + + const preview = previewRecipientPolicyOnboarding(db, request); + + expect(preview.binding).toMatchObject({ + identityId: "identity-a", + deviceId: "device-new", + deviceDisplayName: "Ada’s Laptop", + }); + expect(preview.team).toEqual({ + teamId: "team-a", + displayName: "Core Team", + futureProjectsInherit: true, + }); + expect(preview.projects).toEqual([ + expect.objectContaining({ + canonicalProjectIdentity: PROJECT_A, + displayName: "alpha", + existingMemoryCount: 2, + futureMemoriesShared: true, + }), + expect.objectContaining({ + canonicalProjectIdentity: PROJECT_B, + displayName: "beta", + existingMemoryCount: 1, + futureMemoriesShared: true, + }), + ]); + expect(preview.excludedProjects).toEqual([ + expect.objectContaining({ canonicalProjectIdentity: PROJECT_C, existingMemoryCount: 3 }), + ]); + expect(Number(db.prepare("SELECT total_changes()").pluck().get())).toBe(before); + }); + + it("previews direct Projects exactly and add-device direct plus Team inheritance", () => { + const direct = previewRecipientPolicyOnboarding( + db, + baseRequest({ + journey: "direct_project", + invitationId: "invite-direct", + canonicalProjectIdentities: [PROJECT_C, PROJECT_A], + }), + ); + expect(direct.projects.map((project) => project.canonicalProjectIdentity)).toEqual([ + PROJECT_A, + PROJECT_C, + ]); + expect(direct.projects.every((project) => project.sources[0]?.kind === "direct")).toBe(true); + expect(direct.excludedProjects.map((project) => project.canonicalProjectIdentity)).toEqual([ + PROJECT_B, + ]); + + const addDevice = previewRecipientPolicyOnboarding(db, baseRequest()); + expect(addDevice.projects).toEqual([ + expect.objectContaining({ + canonicalProjectIdentity: PROJECT_A, + sources: [{ kind: "team", teamId: "team-a", displayName: "Core Team" }], + }), + expect.objectContaining({ + canonicalProjectIdentity: PROJECT_B, + sources: [{ kind: "direct" }, { kind: "team", teamId: "team-a", displayName: "Core Team" }], + }), + ]); + expect(addDevice.excludedProjects.map((project) => project.canonicalProjectIdentity)).toEqual([ + PROJECT_C, + ]); + }); + + it("does not stale a reviewed decision when only an excluded Project count changes", () => { + const request = baseRequest({ + journey: "direct_project", + invitationId: "invite-unrelated-churn", + canonicalProjectIdentities: [PROJECT_A], + }); + const first = previewRecipientPolicyOnboarding(db, request); + const excludedSessionId = db + .prepare("SELECT id FROM sessions WHERE git_remote = ?") + .pluck() + .get(PROJECT_C); + db.prepare( + `INSERT INTO memory_items( + session_id, kind, title, body_text, active, created_at, updated_at, + visibility, project, scope_id + ) VALUES (?, 'discovery', 'unrelated', 'body', 1, ?, ?, 'shared', 'gamma', 'local-default')`, + ).run(excludedSessionId, NOW, NOW); + + const refreshed = previewRecipientPolicyOnboarding(db, request); + + const firstExcluded = first.excludedProjects.find( + (project) => project.canonicalProjectIdentity === PROJECT_C, + ); + const refreshedExcluded = refreshed.excludedProjects.find( + (project) => project.canonicalProjectIdentity === PROJECT_C, + ); + expect(refreshedExcluded?.existingMemoryCount).toBeGreaterThan( + firstExcluded?.existingMemoryCount ?? 0, + ); + expect(refreshed.reviewedOnboardingDigest).toBe(first.reviewedOnboardingDigest); + }); + + it("commits Team membership plus device atomically and exactly retries idempotently", () => { + const request = baseRequest({ + journey: "team", + invitationId: "invite-team-new", + identityId: "identity-b", + teamId: "team-a", + }); + const preview = previewRecipientPolicyOnboarding(db, request); + const protectedBefore = protectedSnapshot(db); + + const first = commitRecipientPolicyOnboarding( + db, + { ...request, reviewedOnboardingDigest: preview.reviewedOnboardingDigest }, + { now: () => NOW }, + ); + const second = commitRecipientPolicyOnboarding( + db, + { ...request, reviewedOnboardingDigest: preview.reviewedOnboardingDigest }, + { now: () => "2026-07-21T13:00:00.000Z" }, + ); + + expect(first).toMatchObject({ status: "applied", writeCount: 2, idempotent: false }); + expect(second).toMatchObject({ status: "applied", writeCount: 0, idempotent: true }); + const intent = listRecipientPolicyIntent(db); + expect(intent.teamMemberships).toContainEqual( + expect.objectContaining({ teamId: "team-a", identityId: "identity-b" }), + ); + expect(intent.identityDevices).toContainEqual( + expect.objectContaining({ deviceId: "device-new", identityId: "identity-b" }), + ); + expect(protectedSnapshot(db)).toBe(protectedBefore); + }); + + it("commits exact direct recipients plus device without Team membership", () => { + const request = baseRequest({ + journey: "direct_project", + invitationId: "invite-direct", + identityId: "identity-b", + canonicalProjectIdentities: [PROJECT_C, PROJECT_A], + }); + const preview = previewRecipientPolicyOnboarding(db, request); + const membershipsBefore = db + .prepare("SELECT * FROM policy_team_memberships ORDER BY rowid") + .all(); + + const result = commitRecipientPolicyOnboarding( + db, + { ...request, reviewedOnboardingDigest: preview.reviewedOnboardingDigest }, + { now: () => NOW }, + ); + + expect(result).toMatchObject({ status: "applied", writeCount: 3 }); + const rows = db + .prepare( + `SELECT canonical_project_identity, recipient_kind, recipient_id + FROM project_recipients WHERE recipient_id = 'identity-b' + ORDER BY canonical_project_identity`, + ) + .all(); + expect(rows).toEqual([ + { + canonical_project_identity: PROJECT_A, + recipient_kind: "identity", + recipient_id: "identity-b", + }, + { + canonical_project_identity: PROJECT_C, + recipient_kind: "identity", + recipient_id: "identity-b", + }, + ]); + expect(db.prepare("SELECT * FROM policy_team_memberships ORDER BY rowid").all()).toEqual( + membershipsBefore, + ); + }); + + it("reuses an identical device binding across direct and Team invitations", () => { + const direct = baseRequest({ + journey: "direct_project", + invitationId: "invite-direct-first", + identityId: "identity-b", + canonicalProjectIdentities: [PROJECT_A], + }); + const directPreview = previewRecipientPolicyOnboarding(db, direct); + commitRecipientPolicyOnboarding( + db, + { ...direct, reviewedOnboardingDigest: directPreview.reviewedOnboardingDigest }, + { now: () => NOW }, + ); + const team = baseRequest({ + journey: "team", + invitationId: "invite-team-second", + identityId: "identity-b", + teamId: "team-a", + }); + const teamPreview = previewRecipientPolicyOnboarding(db, team); + + const result = commitRecipientPolicyOnboarding( + db, + { ...team, reviewedOnboardingDigest: teamPreview.reviewedOnboardingDigest }, + { now: () => NOW }, + ); + + expect(result).toMatchObject({ status: "applied", writeCount: 1 }); + expect(db.prepare("SELECT COUNT(*) FROM identity_devices").pluck().get()).toBe(1); + expect( + db.prepare("SELECT team_id, identity_id FROM policy_team_memberships").all(), + ).toContainEqual({ team_id: "team-a", identity_id: "identity-b" }); + }); + + it("commits add-device as the only intent write", () => { + const request = baseRequest(); + const preview = previewRecipientPolicyOnboarding(db, request); + const recipientsBefore = db.prepare("SELECT * FROM project_recipients ORDER BY rowid").all(); + const membershipsBefore = db + .prepare("SELECT * FROM policy_team_memberships ORDER BY rowid") + .all(); + + const result = commitRecipientPolicyOnboarding( + db, + { ...request, reviewedOnboardingDigest: preview.reviewedOnboardingDigest }, + { now: () => NOW }, + ); + + expect(result).toMatchObject({ status: "applied", writeCount: 1 }); + expect(db.prepare("SELECT device_id, identity_id FROM identity_devices").all()).toEqual([ + { device_id: "device-new", identity_id: "identity-a" }, + ]); + expect(db.prepare("SELECT * FROM project_recipients ORDER BY rowid").all()).toEqual( + recipientsBefore, + ); + expect(db.prepare("SELECT * FROM policy_team_memberships ORDER BY rowid").all()).toEqual( + membershipsBefore, + ); + }); + + it("rejects changed key, device, or Identity on invitation retry", () => { + const request = baseRequest(); + const preview = previewRecipientPolicyOnboarding(db, request); + expect( + commitRecipientPolicyOnboarding( + db, + { ...request, reviewedOnboardingDigest: preview.reviewedOnboardingDigest }, + { now: () => NOW }, + ), + ).toMatchObject({ status: "applied" }); + + for (const changed of [ + baseRequest({ devicePublicKey: "public-key-b" }), + baseRequest({ deviceId: "device-other" }), + baseRequest({ identityId: "identity-b" }), + ]) { + const changedPreview = previewRecipientPolicyOnboarding(db, changed); + expect( + commitRecipientPolicyOnboarding( + db, + { ...changed, reviewedOnboardingDigest: changedPreview.reviewedOnboardingDigest }, + { now: () => NOW }, + ), + ).toMatchObject({ status: "conflict", writeCount: 0 }); + } + expect(db.prepare("SELECT COUNT(*) FROM identity_devices").pluck().get()).toBe(1); + }); + + it("rejects one device mapped to another Identity", () => { + const first = baseRequest({ invitationId: "invite-first" }); + const firstPreview = previewRecipientPolicyOnboarding(db, first); + commitRecipientPolicyOnboarding( + db, + { ...first, reviewedOnboardingDigest: firstPreview.reviewedOnboardingDigest }, + { now: () => NOW }, + ); + const second = baseRequest({ invitationId: "invite-second", identityId: "identity-b" }); + const secondPreview = previewRecipientPolicyOnboarding(db, second); + + expect( + commitRecipientPolicyOnboarding( + db, + { ...second, reviewedOnboardingDigest: secondPreview.reviewedOnboardingDigest }, + { now: () => NOW }, + ), + ).toMatchObject({ status: "conflict", errorCode: "device_binding_conflict" }); + }); + + it("rolls back every intent row when a later write fails", () => { + const request = baseRequest({ + journey: "direct_project", + invitationId: "invite-rollback", + identityId: "identity-b", + canonicalProjectIdentities: [PROJECT_A, PROJECT_C], + }); + const preview = previewRecipientPolicyOnboarding(db, request); + const intentBefore = JSON.stringify({ + devices: db.prepare("SELECT * FROM identity_devices").all(), + recipients: db.prepare("SELECT * FROM project_recipients ORDER BY rowid").all(), + }); + const protectedBefore = protectedSnapshot(db); + db.exec( + `CREATE TRIGGER fail_onboarding_edge BEFORE INSERT ON project_recipients + WHEN NEW.canonical_project_identity = '${PROJECT_C}' + BEGIN SELECT RAISE(ABORT, 'test conflict'); END`, + ); + + expect( + commitRecipientPolicyOnboarding(db, { + ...request, + reviewedOnboardingDigest: preview.reviewedOnboardingDigest, + }), + ).toMatchObject({ status: "conflict", writeCount: 0 }); + expect( + JSON.stringify({ + devices: db.prepare("SELECT * FROM identity_devices").all(), + recipients: db.prepare("SELECT * FROM project_recipients ORDER BY rowid").all(), + }), + ).toBe(intentBefore); + expect(protectedSnapshot(db)).toBe(protectedBefore); + }); +}); diff --git a/packages/core/src/recipient-policy-onboarding.ts b/packages/core/src/recipient-policy-onboarding.ts new file mode 100644 index 00000000..c5f52e66 --- /dev/null +++ b/packages/core/src/recipient-policy-onboarding.ts @@ -0,0 +1,720 @@ +import { createHash } from "node:crypto"; +import type { Database } from "./db.js"; +import { normalizeIdentityDisplayName } from "./project-invite-identity.js"; +import { canonicalWorkspaceIdentity } from "./scope-resolution.js"; +import { SYNC_BOOTSTRAP_CWD_PREFIX } from "./sync-bootstrap.js"; +import { fingerprintPublicKey } from "./sync-fingerprint.js"; + +export type RecipientPolicyOnboardingJourneyV1 = "team" | "direct_project" | "add_device"; + +export interface RecipientPolicyOnboardingBindingV1 { + invitationId: string; + identityId: string; + deviceId: string; + deviceKeyFingerprint: string; + deviceDisplayName: string; +} + +interface RecipientPolicyOnboardingRequestBaseV1 { + version: 1; + invitationId: string; + identityId: string; + deviceId: string; + devicePublicKey: string; + deviceDisplayName: string; +} + +export interface RecipientPolicyTeamOnboardingRequestV1 + extends RecipientPolicyOnboardingRequestBaseV1 { + journey: "team"; + teamId: string; +} + +export interface RecipientPolicyDirectProjectOnboardingRequestV1 + extends RecipientPolicyOnboardingRequestBaseV1 { + journey: "direct_project"; + canonicalProjectIdentities: string[]; +} + +export interface RecipientPolicyAddDeviceOnboardingRequestV1 + extends RecipientPolicyOnboardingRequestBaseV1 { + journey: "add_device"; +} + +export type RecipientPolicyOnboardingPreviewRequestV1 = + | RecipientPolicyTeamOnboardingRequestV1 + | RecipientPolicyDirectProjectOnboardingRequestV1 + | RecipientPolicyAddDeviceOnboardingRequestV1; + +export type RecipientPolicyOnboardingCommitRequestV1 = RecipientPolicyOnboardingPreviewRequestV1 & { + reviewedOnboardingDigest: string; +}; + +export type RecipientPolicyOnboardingProjectSourceV1 = + | { kind: "direct" } + | { kind: "team"; teamId: string; displayName: string }; + +export interface RecipientPolicyOnboardingProjectV1 { + canonicalProjectIdentity: string; + displayName: string; + existingMemoryCount: number; + futureMemoriesShared: true; + sources: RecipientPolicyOnboardingProjectSourceV1[]; +} + +export interface RecipientPolicyOnboardingExcludedProjectV1 { + canonicalProjectIdentity: string; + displayName: string; + existingMemoryCount: number; +} + +export interface RecipientPolicyOnboardingPreviewV1 { + version: 1; + journey: RecipientPolicyOnboardingJourneyV1; + binding: RecipientPolicyOnboardingBindingV1; + team: { teamId: string; displayName: string; futureProjectsInherit: true } | null; + projects: RecipientPolicyOnboardingProjectV1[]; + excludedProjects: RecipientPolicyOnboardingExcludedProjectV1[]; + reviewedOnboardingDigest: string; +} + +export interface RecipientPolicyOnboardingCommitResultV1 { + version: 1; + status: "applied" | "stale" | "invalid" | "not_found" | "conflict"; + journey: RecipientPolicyOnboardingJourneyV1 | null; + reviewedOnboardingDigest: string; + errorCode: string | null; + writeCount: number; + idempotent: boolean; +} + +export class RecipientPolicyOnboardingRequestError extends Error { + readonly status: "invalid" | "not_found"; + readonly errorCode: string; + + constructor(status: "invalid" | "not_found", errorCode: string) { + super(errorCode); + this.name = "RecipientPolicyOnboardingRequestError"; + this.status = status; + this.errorCode = errorCode; + } +} + +interface NormalizedRequestBase { + version: 1; + journey: RecipientPolicyOnboardingJourneyV1; + binding: RecipientPolicyOnboardingBindingV1; +} + +type NormalizedRequest = + | (NormalizedRequestBase & { journey: "team"; teamId: string }) + | (NormalizedRequestBase & { + journey: "direct_project"; + canonicalProjectIdentities: string[]; + }) + | (NormalizedRequestBase & { journey: "add_device" }); + +interface ProjectFact { + canonicalProjectIdentity: string; + displayName: string; + existingMemoryCount: number; +} + +interface IntentRow { + table: "policy_team_memberships" | "identity_devices" | "project_recipients"; + key: Record; + values: Record; +} + +const CONTROL_CHARACTER = /\p{Cc}/u; + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +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 strictId(value: unknown, field: string, maxLength = 512): string { + if ( + typeof value !== "string" || + !value || + value !== value.trim() || + value.length > maxLength || + CONTROL_CHARACTER.test(value) + ) { + throw new RecipientPolicyOnboardingRequestError("invalid", `${field}_invalid`); + } + return value; +} + +function normalizeRequest(request: RecipientPolicyOnboardingPreviewRequestV1): NormalizedRequest { + if (request?.version !== 1) { + throw new RecipientPolicyOnboardingRequestError("invalid", "request_invalid"); + } + const invitationId = strictId(request.invitationId, "invitation_id", 256); + const identityId = strictId(request.identityId, "identity_id", 256); + const deviceId = strictId(request.deviceId, "device_id", 256); + const publicKey = String(request.devicePublicKey ?? "").trim(); + if (!publicKey || publicKey.length > 16_384) { + throw new RecipientPolicyOnboardingRequestError("invalid", "device_public_key_invalid"); + } + let deviceDisplayName: string; + try { + deviceDisplayName = normalizeIdentityDisplayName( + String(request.deviceDisplayName ?? ""), + "device_display_name", + ); + } catch (error) { + throw new RecipientPolicyOnboardingRequestError( + "invalid", + error instanceof Error ? error.message : "device_display_name_invalid", + ); + } + const binding = { + invitationId, + identityId, + deviceId, + deviceKeyFingerprint: fingerprintPublicKey(publicKey), + deviceDisplayName, + }; + if (request.journey === "team") { + return { + version: 1, + journey: "team", + binding, + teamId: strictId(request.teamId, "team_id", 256), + }; + } + if (request.journey === "direct_project") { + if ( + !Array.isArray(request.canonicalProjectIdentities) || + request.canonicalProjectIdentities.length < 1 || + request.canonicalProjectIdentities.length > 100 + ) { + throw new RecipientPolicyOnboardingRequestError("invalid", "project_set_invalid"); + } + const projects = request.canonicalProjectIdentities.map((projectId) => + strictId(projectId, "canonical_project_identity"), + ); + if (new Set(projects).size !== projects.length) { + throw new RecipientPolicyOnboardingRequestError("invalid", "project_set_invalid"); + } + return { + version: 1, + journey: "direct_project", + binding, + canonicalProjectIdentities: projects.toSorted(compareText), + }; + } + if (request.journey === "add_device") return { version: 1, journey: "add_device", binding }; + throw new RecipientPolicyOnboardingRequestError("invalid", "journey_invalid"); +} + +function projectFacts(db: Database): Map { + const rows = db + .prepare( + `SELECT s.id, s.cwd, s.project, s.git_remote, s.git_branch, + (SELECT mi.workspace_id FROM memory_items mi + WHERE mi.session_id = s.id AND mi.workspace_id IS NOT NULL AND TRIM(mi.workspace_id) <> '' + ORDER BY mi.id DESC LIMIT 1) AS workspace_id, + COUNT(mi_count.id) AS memory_count + FROM sessions s + LEFT JOIN memory_items mi_count ON mi_count.session_id = s.id + AND mi_count.active = 1 AND mi_count.deleted_at IS NULL + WHERE (COALESCE(TRIM(s.git_remote), TRIM(s.cwd), TRIM(s.project), '') <> '' OR mi_count.id IS NOT NULL) + AND (s.cwd IS NULL OR substr(s.cwd, 1, length(?)) <> ?) + GROUP BY s.id ORDER BY s.id`, + ) + .all(SYNC_BOOTSTRAP_CWD_PREFIX, SYNC_BOOTSTRAP_CWD_PREFIX) as Array<{ + cwd: string | null; + project: string | null; + git_remote: string | null; + git_branch: string | null; + workspace_id: string | null; + memory_count: number; + }>; + const projects = new Map(); + for (const row of rows) { + const identity = canonicalWorkspaceIdentity({ + cwd: row.cwd, + project: row.project, + gitRemote: row.git_remote, + gitBranch: row.git_branch, + workspaceId: row.workspace_id, + }); + if (identity.value.startsWith("unmapped:")) continue; + const existing = projects.get(identity.value); + projects.set(identity.value, { + canonicalProjectIdentity: identity.value, + displayName: existing?.displayName ?? identity.displayProject ?? identity.value, + existingMemoryCount: (existing?.existingMemoryCount ?? 0) + Number(row.memory_count ?? 0), + }); + } + const add = (projectId: unknown, displayName: unknown): void => { + if (typeof projectId !== "string" || !projectId || projectId.startsWith("unmapped:")) return; + if (projects.has(projectId)) return; + projects.set(projectId, { + canonicalProjectIdentity: projectId, + displayName: + typeof displayName === "string" && displayName.trim() ? displayName.trim() : projectId, + existingMemoryCount: 0, + }); + }; + for (const row of db + .prepare( + `SELECT canonical_project_identity, display_name + FROM share_operation_projects ORDER BY operation_id, ordinal`, + ) + .all() as Array>) { + add(row.canonical_project_identity, row.display_name); + } + for (const row of db + .prepare( + `SELECT canonical_project_identity FROM project_recipients + ORDER BY canonical_project_identity`, + ) + .all() as Array>) { + add(row.canonical_project_identity, row.canonical_project_identity); + } + return projects; +} + +function assertActiveIdentity(db: Database, identityId: string): void { + const row = db.prepare("SELECT status FROM actors WHERE actor_id = ?").get(identityId) as + | { status: string } + | undefined; + if (row?.status !== "active") { + throw new RecipientPolicyOnboardingRequestError("not_found", "identity_not_found"); + } +} + +function sourceKey(source: RecipientPolicyOnboardingProjectSourceV1): string { + return source.kind === "direct" ? "direct" : `team\u0000${source.teamId}`; +} + +function addSource( + sources: Map, + projectId: string, + source: RecipientPolicyOnboardingProjectSourceV1, +): void { + const current = sources.get(projectId) ?? []; + if (!current.some((candidate) => sourceKey(candidate) === sourceKey(source))) + current.push(source); + sources.set(projectId, current); +} + +function teamFact(db: Database, teamId: string): { teamId: string; displayName: string } { + const row = db + .prepare( + "SELECT team_id, display_name FROM policy_teams WHERE team_id = ? AND status = 'active'", + ) + .get(teamId) as { team_id: string; display_name: string } | undefined; + if (!row) throw new RecipientPolicyOnboardingRequestError("not_found", "team_not_found"); + return { teamId: row.team_id, displayName: row.display_name }; +} + +function teamSources( + db: Database, + team: { teamId: string; displayName: string }, +): Map { + const result = new Map(); + for (const row of db + .prepare( + `SELECT canonical_project_identity FROM project_recipients + WHERE recipient_kind = 'team' AND recipient_id = ? AND status = 'active' + ORDER BY canonical_project_identity`, + ) + .all(team.teamId) as Array<{ canonical_project_identity: string }>) { + addSource(result, row.canonical_project_identity, { + kind: "team", + teamId: team.teamId, + displayName: team.displayName, + }); + } + return result; +} + +function inheritedSources( + db: Database, + identityId: string, +): Map { + const result = new Map(); + for (const row of db + .prepare( + `SELECT canonical_project_identity FROM project_recipients + WHERE recipient_kind = 'identity' AND recipient_id = ? AND status = 'active' + ORDER BY canonical_project_identity`, + ) + .all(identityId) as Array<{ canonical_project_identity: string }>) { + addSource(result, row.canonical_project_identity, { kind: "direct" }); + } + for (const row of db + .prepare( + `SELECT pr.canonical_project_identity, pt.team_id, pt.display_name + FROM policy_team_memberships tm + JOIN policy_teams pt ON pt.team_id = tm.team_id AND pt.status = 'active' + JOIN project_recipients pr ON pr.recipient_kind = 'team' + AND pr.recipient_id = tm.team_id AND pr.status = 'active' + WHERE tm.identity_id = ? AND tm.status = 'active' + ORDER BY pr.canonical_project_identity, pt.team_id`, + ) + .all(identityId) as Array<{ + canonical_project_identity: string; + team_id: string; + display_name: string; + }>) { + addSource(result, row.canonical_project_identity, { + kind: "team", + teamId: row.team_id, + displayName: row.display_name, + }); + } + return result; +} + +function buildPreview( + db: Database, + request: NormalizedRequest, +): RecipientPolicyOnboardingPreviewV1 { + assertActiveIdentity(db, request.binding.identityId); + const facts = projectFacts(db); + let team: RecipientPolicyOnboardingPreviewV1["team"] = null; + let sources = new Map(); + if (request.journey === "team") { + const selectedTeam = teamFact(db, request.teamId); + team = { ...selectedTeam, futureProjectsInherit: true }; + sources = teamSources(db, selectedTeam); + } + if (request.journey === "direct_project") { + for (const projectId of request.canonicalProjectIdentities) { + if (!facts.has(projectId)) { + throw new RecipientPolicyOnboardingRequestError("not_found", "project_not_found"); + } + addSource(sources, projectId, { kind: "direct" }); + } + } + if (request.journey === "add_device") { + sources = inheritedSources(db, request.binding.identityId); + } + const projects = [...sources.entries()] + .map(([projectId, projectSources]): RecipientPolicyOnboardingProjectV1 => { + const fact = facts.get(projectId) ?? { + canonicalProjectIdentity: projectId, + displayName: projectId, + existingMemoryCount: 0, + }; + return { + ...fact, + futureMemoriesShared: true, + sources: projectSources.toSorted((left, right) => + compareText(sourceKey(left), sourceKey(right)), + ), + }; + }) + .toSorted((left, right) => + compareText(left.canonicalProjectIdentity, right.canonicalProjectIdentity), + ); + const excludedProjects = [...facts.values()] + .filter((project) => !sources.has(project.canonicalProjectIdentity)) + .toSorted((left, right) => + compareText(left.canonicalProjectIdentity, right.canonicalProjectIdentity), + ); + const reviewedOnboardingDigest = digest("recipient-onboarding-preview-v1", { + journey: request.journey, + binding: request.binding, + team, + projects, + excludedProjectIdentities: excludedProjects.map((project) => project.canonicalProjectIdentity), + }); + return { + version: 1, + journey: request.journey, + binding: request.binding, + team, + projects, + excludedProjects, + reviewedOnboardingDigest, + }; +} + +export function previewRecipientPolicyOnboarding( + db: Database, + request: RecipientPolicyOnboardingPreviewRequestV1, +): RecipientPolicyOnboardingPreviewV1 { + return buildPreview(db, normalizeRequest(request)); +} + +function relationshipMetadata( + kind: string, + revisionIdentity: unknown, + idempotencyIdentity: unknown, +): { revision: string; idempotencyKey: string } { + return { + revision: digest(`recipient-policy-${kind}-revision-v1`, revisionIdentity), + idempotencyKey: digest(`recipient-policy-${kind}-idempotency-v1`, idempotencyIdentity), + }; +} + +function baseValues(input: { + provenance: string; + revision: string; + idempotencyKey: string; + sourceFingerprint: string; + now: string; +}): Record & { revision: string } { + return { + status: "active", + provenance: input.provenance, + migration_state: "user_managed", + source_fingerprint: input.sourceFingerprint, + idempotency_key: input.idempotencyKey, + created_at: input.now, + updated_at: input.now, + revision: input.revision, + }; +} + +function deviceRow(request: NormalizedRequest, now: string): IntentRow { + const stableBinding = { + identityId: request.binding.identityId, + deviceId: request.binding.deviceId, + deviceKeyFingerprint: request.binding.deviceKeyFingerprint, + }; + const sourceFingerprint = digest("recipient-onboarding-binding-v1", stableBinding); + const metadata = relationshipMetadata("identity-device", stableBinding, [ + request.binding.invitationId, + "device", + ]); + return { + table: "identity_devices", + key: { device_id: request.binding.deviceId }, + values: { + identity_id: request.binding.identityId, + display_name: request.binding.deviceDisplayName, + ...baseValues({ + provenance: "recipient_invite", + revision: metadata.revision, + idempotencyKey: metadata.idempotencyKey, + sourceFingerprint, + now, + }), + }, + }; +} + +function membershipRow(request: NormalizedRequest & { journey: "team" }, now: string): IntentRow { + const identity = [request.journey, request.binding, request.teamId]; + const metadata = relationshipMetadata("team-membership", identity, [ + request.journey, + request.binding.invitationId, + "membership", + ]); + return { + table: "policy_team_memberships", + key: { team_id: request.teamId, identity_id: request.binding.identityId }, + values: { + role: "member", + ...baseValues({ + provenance: "team_invite", + revision: metadata.revision, + idempotencyKey: metadata.idempotencyKey, + sourceFingerprint: digest("recipient-onboarding-binding-v1", identity), + now, + }), + }, + }; +} + +function projectRow( + request: NormalizedRequest & { journey: "direct_project" }, + projectId: string, + now: string, +): IntentRow { + const identity = [request.journey, request.binding, projectId]; + const metadata = relationshipMetadata("project-recipient", identity, [ + request.journey, + request.binding.invitationId, + "project", + projectId, + ]); + const values = baseValues({ + provenance: "exact_project_invite", + revision: metadata.revision, + idempotencyKey: metadata.idempotencyKey, + sourceFingerprint: digest("recipient-onboarding-binding-v1", identity), + now, + }); + const { revision, ...rest } = values; + return { + table: "project_recipients", + key: { + canonical_project_identity: projectId, + recipient_kind: "identity", + recipient_id: request.binding.identityId, + }, + values: { ...rest, policy_revision: revision }, + }; +} + +function planRows(request: NormalizedRequest, now: string): IntentRow[] { + const rows = [deviceRow(request, now)]; + if (request.journey === "team") rows.push(membershipRow(request, now)); + if (request.journey === "direct_project") { + rows.push( + ...request.canonicalProjectIdentities.map((projectId) => projectRow(request, projectId, now)), + ); + } + return rows; +} + +function rowWhere(key: Record): { clause: string; parameters: string[] } { + return { + clause: Object.keys(key) + .map((column) => `${column} = ?`) + .join(" AND "), + parameters: Object.values(key), + }; +} + +function validateOrWriteRow(db: Database, row: IntentRow): boolean { + const idempotencyMatch = db + .prepare(`SELECT * FROM ${row.table} WHERE idempotency_key = ?`) + .get(row.values.idempotency_key) as Record | undefined; + const where = rowWhere(row.key); + const keyMatch = db + .prepare(`SELECT * FROM ${row.table} WHERE ${where.clause}`) + .get(...where.parameters) as Record | undefined; + const existing = idempotencyMatch ?? keyMatch; + if (existing) { + const expected = { ...row.key, ...row.values }; + if (row.table === "identity_devices" && keyMatch && !idempotencyMatch) { + if ( + keyMatch.identity_id !== expected.identity_id || + keyMatch.source_fingerprint !== expected.source_fingerprint + ) { + throw new Error("device_binding_conflict"); + } + return false; + } + const comparableColumns = Object.keys(expected).filter( + (column) => column !== "created_at" && column !== "updated_at", + ); + if (comparableColumns.some((column) => existing[column] !== expected[column])) { + throw new Error( + row.table === "identity_devices" ? "device_binding_conflict" : "intent_conflict", + ); + } + return false; + } + const columns = [...Object.keys(row.key), ...Object.keys(row.values)]; + db.prepare( + `INSERT INTO ${row.table}(${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`, + ).run(...Object.values(row.key), ...Object.values(row.values)); + return true; +} + +function emptyResult( + status: RecipientPolicyOnboardingCommitResultV1["status"], + errorCode: string, + journey: RecipientPolicyOnboardingJourneyV1 | null, + reviewedOnboardingDigest: string, +): RecipientPolicyOnboardingCommitResultV1 { + return { + version: 1, + status, + journey, + reviewedOnboardingDigest, + errorCode, + writeCount: 0, + idempotent: false, + }; +} + +function isSqliteBusy(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const code = "code" in error ? String((error as { code?: unknown }).code ?? "") : ""; + return ( + code === "SQLITE_BUSY" || + (error instanceof Error && error.message.includes("database is locked")) + ); +} + +export function commitRecipientPolicyOnboarding( + db: Database, + request: RecipientPolicyOnboardingCommitRequestV1, + options: { now?: () => string } = {}, +): RecipientPolicyOnboardingCommitResultV1 { + let normalized: NormalizedRequest; + try { + normalized = normalizeRequest(request); + } catch (error) { + if (error instanceof RecipientPolicyOnboardingRequestError) { + return emptyResult(error.status, error.errorCode, null, ""); + } + return emptyResult("invalid", "request_invalid", null, ""); + } + if (!/^recipient-onboarding-preview-v1:[a-f0-9]{64}$/u.test(request.reviewedOnboardingDigest)) { + return emptyResult("invalid", "reviewed_onboarding_digest_invalid", normalized.journey, ""); + } + try { + db.exec("BEGIN IMMEDIATE"); + try { + const preview = buildPreview(db, normalized); + if (preview.reviewedOnboardingDigest !== request.reviewedOnboardingDigest) { + db.exec("ROLLBACK"); + return emptyResult( + "stale", + "reviewed_onboarding_stale", + normalized.journey, + preview.reviewedOnboardingDigest, + ); + } + const now = (options.now ?? (() => new Date().toISOString()))(); + let writeCount = 0; + for (const row of planRows(normalized, now)) { + if (validateOrWriteRow(db, row)) writeCount += 1; + } + db.exec("COMMIT"); + return { + version: 1, + status: "applied", + journey: normalized.journey, + reviewedOnboardingDigest: preview.reviewedOnboardingDigest, + errorCode: null, + writeCount, + idempotent: writeCount === 0, + }; + } catch (error) { + if (db.inTransaction) db.exec("ROLLBACK"); + throw error; + } + } catch (error) { + if (isSqliteBusy(error)) throw error; + if (error instanceof RecipientPolicyOnboardingRequestError) { + return emptyResult( + error.status, + error.errorCode, + normalized.journey, + request.reviewedOnboardingDigest, + ); + } + const errorCode = + error instanceof Error && error.message === "device_binding_conflict" + ? "device_binding_conflict" + : "onboarding_intent_conflict"; + return emptyResult("conflict", errorCode, normalized.journey, request.reviewedOnboardingDigest); + } +} diff --git a/packages/core/src/share-operation.test.ts b/packages/core/src/share-operation.test.ts index d95029b7..3f8f6532 100644 --- a/packages/core/src/share-operation.test.ts +++ b/packages/core/src/share-operation.test.ts @@ -388,6 +388,17 @@ describe("share-operation persistence", () => { inviteId: "invite-1", tokenDigest: inviteTokenDigest("token-1"), }); + const protectedBefore = Object.fromEntries( + [ + "replication_scopes", + "scope_memberships", + "scope_membership_cache_state", + "project_scope_mappings", + "replication_ops", + "replication_cursors", + "replication_cursors_v2", + ].map((table) => [table, db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all()]), + ); const publicKey = "recipient-key"; reconcileShareOperationAcceptance(db, { operationId: operation.operationId, @@ -432,6 +443,30 @@ describe("share-operation persistence", () => { .prepare("SELECT actor_id, name FROM sync_peers WHERE peer_device_id = ?") .get("device-brian"), ).toEqual({ actor_id: "actor-brian", name: "Brian's MacBook" }); + expect(db.prepare("SELECT identity_id, device_id FROM identity_devices").all()).toEqual([ + { identity_id: "actor-brian", device_id: "device-brian" }, + ]); + expect( + db + .prepare(`SELECT canonical_project_identity, recipient_kind, recipient_id + FROM project_recipients ORDER BY canonical_project_identity`) + .all(), + ).toEqual( + operation.projects.map((project) => ({ + canonical_project_identity: project.canonicalIdentity, + recipient_kind: "identity", + recipient_id: "actor-brian", + })), + ); + expect(db.prepare("SELECT COUNT(*) FROM policy_team_memberships").pluck().get()).toBe(0); + expect( + Object.fromEntries( + Object.keys(protectedBefore).map((table) => [ + table, + db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(), + ]), + ), + ).toEqual(protectedBefore); }); it("rejects pending acceptance that collides with an existing active Person", () => { diff --git a/packages/core/src/share-operation.ts b/packages/core/src/share-operation.ts index 6a47780e..990a1963 100644 --- a/packages/core/src/share-operation.ts +++ b/packages/core/src/share-operation.ts @@ -1,5 +1,9 @@ import { createHash } from "node:crypto"; import type { Database } from "./db.js"; +import { + commitRecipientPolicyOnboarding, + previewRecipientPolicyOnboarding, +} from "./recipient-policy-onboarding.js"; import { fingerprintPublicKey } from "./sync-fingerprint.js"; export const SHARE_HISTORY_POLICY = "existing_and_future" as const; @@ -617,4 +621,26 @@ export function reconcileShareOperationAcceptance( } }); reconcile(); + const onboardingRequest = { + version: 1 as const, + journey: "direct_project" as const, + invitationId: input.operationId, + identityId: input.recipientActorId, + deviceId: input.recipientDeviceId, + devicePublicKey: input.recipientPublicKey, + deviceDisplayName: input.recipientDeviceDisplayName, + canonicalProjectIdentities: acceptedProjects.map((project) => project.canonical_identity), + }; + const preview = previewRecipientPolicyOnboarding(db, onboardingRequest); + const onboarding = commitRecipientPolicyOnboarding( + db, + { + ...onboardingRequest, + reviewedOnboardingDigest: preview.reviewedOnboardingDigest, + }, + { now: () => input.consumedAt }, + ); + if (onboarding.status !== "applied") { + throw new Error(onboarding.errorCode ?? "onboarding_commit_failed"); + } } diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index 417705f8..36e93ddb 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -42,6 +42,13 @@ export { export { loadProjects, loadRuntimeInfo, pingViewerReady } from "./api/runtime"; export { loadRawEvents, loadSession, loadStats, loadUsage } from "./api/stats"; export type { + CreatedRecipientInvite, + InspectInviteResult, + RecipientInvitationKind, + RecipientInvitePreviewRequest, + RecipientInvitePreviewResult, + RecipientOnboardingPreviewV1, + RecipientOnboardingProjectV1, RecipientPolicyBlockedItemV1, RecipientPolicyEdgeChangeV1, RecipientPolicyEdgeCommitOutcomeV1, @@ -81,6 +88,7 @@ export { commitRecipientPolicyEdges, createActor, createProjectInvite, + createRecipientInvite, deactivateActor, deletePeer, deleteSharingDomainProjectMapping, @@ -102,6 +110,7 @@ export { mergeActor, ProjectForgetConfirmationError, previewProjectInvite, + previewRecipientInvite, previewRecipientPolicyEdges, RecipientPolicyEdgesStaleError, RecipientPolicyReviewStaleError, diff --git a/packages/ui/src/lib/api/sync.test.ts b/packages/ui/src/lib/api/sync.test.ts index fece10b8..95537c7a 100644 --- a/packages/ui/src/lib/api/sync.test.ts +++ b/packages/ui/src/lib/api/sync.test.ts @@ -3,10 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { advanceShareOperation, commitRecipientPolicyEdges, + createRecipientInvite, + inspectCoordinatorInvite, loadRecipientPolicyIntent, loadRecipientPolicyReview, loadShareOperation, loadShareOperations, + previewRecipientInvite, previewRecipientPolicyEdges, RecipientPolicyEdgesStaleError, RecipientPolicyReviewStaleError, @@ -22,6 +25,98 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe("recipient invitation API", () => { + it("sends exact Team preview/create and add-device inspect payloads", async () => { + const preview = { + kind: "team_member", + preview: { reviewedOnboardingDigest: "recipient-onboarding-preview-v1:digest" }, + }; + const created = { ok: true, ...preview, invite: { link: "codemem://join" } }; + const inspected = { kind: "add_device", onboarding: { journey: "add_device" } }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(preview), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(created), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(inspected), { status: 200 })); + globalThis.fetch = fetchMock as typeof fetch; + + await previewRecipientInvite({ kind: "team_member", policy_team_id: "team-one" }); + await createRecipientInvite({ + kind: "team_member", + policy_team_id: "team-one", + reviewed_onboarding_digest: "recipient-onboarding-preview-v1:digest", + }); + await inspectCoordinatorInvite("invite-value", { device_name: "Travel Laptop" }); + + expect(fetchMock.mock.calls).toEqual([ + [ + "/api/sync/recipient-policy/v1/invites/preview", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "team_member", policy_team_id: "team-one" }), + }, + ], + [ + "/api/sync/recipient-policy/v1/invites", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + kind: "team_member", + policy_team_id: "team-one", + reviewed_onboarding_digest: "recipient-onboarding-preview-v1:digest", + }), + }, + ], + [ + "/api/sync/invites/inspect", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ invite: "invite-value", device_name: "Travel Laptop" }), + }, + ], + ]); + }); + + it("sends only the target Identity and reviewed digest for add-device creation", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + kind: "add_device", + preview: { reviewedOnboardingDigest: "recipient-onboarding-preview-v1:device" }, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, kind: "add_device", invite: {} }), { + status: 200, + }), + ); + globalThis.fetch = fetchMock as typeof fetch; + + await previewRecipientInvite({ kind: "add_device", target_identity_id: "identity-one" }); + await createRecipientInvite({ + kind: "add_device", + target_identity_id: "identity-one", + reviewed_onboarding_digest: "recipient-onboarding-preview-v1:device", + }); + + expect(fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)))).toEqual([ + { kind: "add_device", target_identity_id: "identity-one" }, + { + kind: "add_device", + target_identity_id: "identity-one", + reviewed_onboarding_digest: "recipient-onboarding-preview-v1:device", + }, + ]); + }); +}); + describe("triggerSync", () => { it("can scope a manual sync by peer device id when addresses are hidden", async () => { const fetchMock = vi.fn( diff --git a/packages/ui/src/lib/api/sync.ts b/packages/ui/src/lib/api/sync.ts index 1ea6837f..4b43f2ac 100644 --- a/packages/ui/src/lib/api/sync.ts +++ b/packages/ui/src/lib/api/sync.ts @@ -4,12 +4,77 @@ * /api/sync/* or /api/sync/run/* on the viewer. */ import { fetchJson, payloadError, readJsonPayload } from "./internal"; -import type { - AcceptDiscoveredPeerResult, - ImportInviteResult, - InspectInviteResult, - SyncRunResponse, -} from "./types"; +import type { AcceptDiscoveredPeerResult, ImportInviteResult, SyncRunResponse } from "./types"; + +export type RecipientInvitationKind = "team_member" | "add_device"; + +export type RecipientOnboardingProjectSourceV1 = + | { kind: "direct" } + | { kind: "team"; teamId: string; displayName: string }; + +export interface RecipientOnboardingProjectV1 { + canonicalProjectIdentity: string; + displayName: string; + existingMemoryCount: number; + futureMemoriesShared: true; + sources: RecipientOnboardingProjectSourceV1[]; +} + +export interface RecipientOnboardingPreviewV1 { + version: 1; + journey: "team" | "direct_project" | "add_device"; + binding: { + invitationId: string; + identityId: string; + deviceId: string; + deviceKeyFingerprint: string; + deviceDisplayName: string; + }; + team: { teamId: string; displayName: string; futureProjectsInherit: true } | null; + projects: RecipientOnboardingProjectV1[]; + excludedProjects: Array<{ + canonicalProjectIdentity: string; + displayName: string; + existingMemoryCount: number; + }>; + reviewedOnboardingDigest: string; +} + +export type InspectInviteResult = + | { kind: "legacy_team_invite" } + | { + kind: "project_share_invite"; + operation_id?: string; + inviter_name?: string | null; + team_name?: string | null; + recipient_name?: string; + device_name?: string; + projects?: Array<{ display_name: string; existing_memory_count: number }>; + } + | { + kind: RecipientInvitationKind; + recipient_name: string; + device_name: string; + onboarding: RecipientOnboardingPreviewV1; + }; + +export type RecipientInvitePreviewRequest = + | { kind: "team_member"; policy_team_id: string } + | { kind: "add_device"; target_identity_id: string }; + +export interface RecipientInvitePreviewResult { + kind: RecipientInvitationKind; + preview: RecipientOnboardingPreviewV1; +} + +export interface CreatedRecipientInvite extends RecipientInvitePreviewResult { + ok: true; + invite: { + encoded?: string; + link?: string; + invite_id?: string; + }; +} type TriggerSyncTarget = { address?: string; @@ -31,7 +96,11 @@ export async function loadSyncStatus( export async function importCoordinatorInvite( invite: string, - identity?: { recipient_name: string; device_name: string }, + identity?: { + recipient_name: string; + device_name: string; + reviewed_onboarding_digest?: string; + }, ): Promise { const resp = await fetch("/api/sync/invites/import", { method: "POST", @@ -43,11 +112,14 @@ export async function importCoordinatorInvite( return data; } -export async function inspectCoordinatorInvite(invite: string): Promise { +export async function inspectCoordinatorInvite( + invite: string, + options: { device_name?: string } = {}, +): Promise { const resp = await fetch("/api/sync/invites/inspect", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ invite }), + body: JSON.stringify({ invite, ...options }), }); const { text, payload } = await readJsonPayload(resp); if (!resp.ok) throw new Error(payloadError(payload) || text || "request failed"); @@ -739,6 +811,32 @@ export function commitRecipientPolicyEdges( return recipientPolicyEdgeRequest("/api/sync/recipient-policy/v1/edges/commit", input); } +async function recipientInviteRequest( + path: string, + input: RecipientInvitePreviewRequest & { reviewed_onboarding_digest?: string }, +): Promise { + const resp = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const { text, payload } = await readJsonPayload(resp); + if (!resp.ok) throw new Error(payloadError(payload) || text || "request failed"); + return payload as T; +} + +export function previewRecipientInvite( + input: RecipientInvitePreviewRequest, +): Promise { + return recipientInviteRequest("/api/sync/recipient-policy/v1/invites/preview", input); +} + +export function createRecipientInvite( + input: RecipientInvitePreviewRequest & { reviewed_onboarding_digest: string }, +): Promise { + return recipientInviteRequest("/api/sync/recipient-policy/v1/invites", input); +} + export function loadRecipientPolicyReview(): Promise { return fetchJson("/api/sync/recipient-policy/v1/review"); } diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 564e3481..3f2aa130 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -22,16 +22,6 @@ export interface ImportInviteResult { [key: string]: unknown; } -export interface InspectInviteResult { - kind: "legacy_team_invite" | "project_share_invite"; - operation_id?: string; - inviter_name?: string | null; - team_name?: string | null; - recipient_name?: string; - device_name?: string; - projects?: Array<{ display_name: string; existing_memory_count: number }>; -} - export interface AcceptDiscoveredPeerResult { name?: string; [key: string]: unknown; diff --git a/packages/ui/src/tabs/recipient-policy-invitations.test.tsx b/packages/ui/src/tabs/recipient-policy-invitations.test.tsx new file mode 100644 index 00000000..d6564088 --- /dev/null +++ b/packages/ui/src/tabs/recipient-policy-invitations.test.tsx @@ -0,0 +1,295 @@ +import { type ComponentChildren, render } from "preact"; +import { act } from "preact/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const dialogSpy = vi.hoisted(() => vi.fn()); +const openProjectShare = vi.hoisted(() => vi.fn()); + +vi.mock("../components/primitives/radix-dialog", () => ({ + RadixDialog: (props: { + ariaDescribedby?: string; + ariaLabelledby?: string; + children?: ComponentChildren; + contentId: string; + onCloseAutoFocus?: (event: Event) => void; + onOpenAutoFocus?: (event: Event) => void; + onOpenChange: (open: boolean) => void; + open: boolean; + }) => { + dialogSpy(props); + return props.open ? ( + + ) : null; + }, +})); + +vi.mock("../lib/api", () => ({ + createRecipientInvite: vi.fn(), + importCoordinatorInvite: vi.fn(), + inspectCoordinatorInvite: vi.fn(), + previewRecipientInvite: vi.fn(), +})); + +vi.mock("./project-sharing", () => ({ openProjectShareFlow: openProjectShare })); + +import * as api from "../lib/api"; +import type { RecipientOnboardingPreviewV1, RecipientPolicyIntentGraphV1 } from "../lib/api/sync"; +import { RecipientPolicyInvitations } from "./recipient-policy-invitations"; + +const escapedName = ''; +const intent: RecipientPolicyIntentGraphV1 = { + version: 1, + identities: [ + { + version: 1, + identityId: "identity-local", + displayName: "Local Identity", + kind: "personal", + verification: "local", + status: "active", + mergedIntoIdentityId: null, + }, + ], + teams: [{ version: 1, teamId: "team-one", displayName: escapedName, status: "active" }], + teamMemberships: [], + identityDevices: [], + projectRecipients: [], +}; + +const teamPreview: RecipientOnboardingPreviewV1 = { + version: 1, + journey: "team", + binding: { + invitationId: "invite-team", + identityId: "identity-local", + deviceId: "device-one", + deviceKeyFingerprint: "fingerprint", + deviceDisplayName: "Laptop", + }, + team: { teamId: "team-one", displayName: escapedName, futureProjectsInherit: true }, + projects: [ + { + canonicalProjectIdentity: "project-one", + displayName: "Codemem", + existingMemoryCount: 41, + futureMemoriesShared: true, + sources: [{ kind: "team", teamId: "team-one", displayName: escapedName }], + }, + ], + excludedProjects: [ + { canonicalProjectIdentity: "project-other", displayName: "Private", existingMemoryCount: 9 }, + ], + reviewedOnboardingDigest: "recipient-onboarding-preview-v1:team", +}; + +const addDevicePreview: RecipientOnboardingPreviewV1 = { + ...teamPreview, + journey: "add_device", + team: null, + projects: [ + { + canonicalProjectIdentity: "project-direct", + displayName: "Direct work", + existingMemoryCount: 3, + futureMemoriesShared: true, + sources: [{ kind: "direct" }], + }, + { + canonicalProjectIdentity: "project-team", + displayName: "Team work", + existingMemoryCount: 7, + futureMemoriesShared: true, + sources: [{ kind: "team", teamId: "team-one", displayName: "Example Team" }], + }, + ], + reviewedOnboardingDigest: "recipient-onboarding-preview-v1:device", +}; + +function button(label: string, root: ParentNode = document): HTMLButtonElement { + const match = [...root.querySelectorAll("button")].find( + (item) => item.textContent?.trim() === label, + ); + if (!match) throw new Error(`button missing: ${label}`); + return match; +} + +function mount(graph = intent) { + const element = document.getElementById("mount"); + if (!element) throw new Error("mount missing"); + act(() => render(, element)); +} + +describe("recipient-policy invitations", () => { + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + afterEach(() => { + const element = document.getElementById("mount"); + if (element) act(() => render(null, element)); + vi.resetAllMocks(); + document.body.innerHTML = ""; + }); + + it("previews and creates a Team-member invitation with the exact reviewed request", async () => { + vi.mocked(api.previewRecipientInvite).mockResolvedValue({ + kind: "team_member", + preview: teamPreview, + }); + vi.mocked(api.createRecipientInvite).mockResolvedValue({ + ok: true, + kind: "team_member", + preview: teamPreview, + invite: { link: "codemem://join?invite=team" }, + }); + mount(); + + act(() => button("Invite Team member").click()); + const dialog = document.querySelector('[role="dialog"]'); + if (!dialog) throw new Error("dialog missing"); + act(() => button("Review invitation", dialog).click()); + await vi.waitFor(() => expect(api.previewRecipientInvite).toHaveBeenCalledOnce()); + + expect(api.previewRecipientInvite).toHaveBeenCalledWith({ + kind: "team_member", + policy_team_id: "team-one", + }); + await vi.waitFor(() => + expect(dialog.textContent).toContain("41 existing memories and future activity"), + ); + expect(dialog.textContent).toContain( + "Future Projects shared with this Team will also be inherited", + ); + expect(dialog.textContent).toContain( + "No other Projects will be shared through this invitation", + ); + expect(dialog.textContent).toContain(escapedName); + expect(dialog.querySelector("img")).toBeNull(); + + act(() => button("Create invitation", dialog).click()); + await vi.waitFor(() => expect(api.createRecipientInvite).toHaveBeenCalledOnce()); + expect(api.createRecipientInvite).toHaveBeenCalledWith({ + kind: "team_member", + policy_team_id: "team-one", + reviewed_onboarding_digest: teamPreview.reviewedOnboardingDigest, + }); + }); + + it("inspects and accepts add-device access with direct, inherited, and excluded Projects", async () => { + vi.mocked(api.inspectCoordinatorInvite).mockResolvedValue({ + kind: "add_device", + recipient_name: "Local Identity", + device_name: "Travel Laptop", + onboarding: addDevicePreview, + }); + vi.mocked(api.importCoordinatorInvite).mockResolvedValue({ status: "accepted" }); + mount(); + + act(() => button("Review invitation").click()); + const textarea = document.querySelector("textarea"); + if (!textarea) throw new Error("textarea missing"); + act(() => { + textarea.value = "recipient-invite"; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + const dialog = document.querySelector('[role="dialog"]'); + if (!dialog) throw new Error("dialog missing"); + act(() => button("Review invitation", dialog).click()); + await vi.waitFor(() => + expect(api.inspectCoordinatorInvite).toHaveBeenCalledWith("recipient-invite"), + ); + + await vi.waitFor(() => expect(dialog.textContent).toContain("Direct Projects")); + expect(dialog.textContent).toContain("Direct work — 3 existing memories"); + expect(dialog.textContent).toContain("Team work — 7 existing memories"); + expect(dialog.textContent).toContain("through Example Team"); + expect(dialog.textContent).toContain("Private — 9 existing memories"); + expect(dialog.textContent).toContain("This device will not receive the Projects listed here"); + + act(() => button("Accept invitation", dialog).click()); + await vi.waitFor(() => expect(api.importCoordinatorInvite).toHaveBeenCalledOnce()); + expect(api.importCoordinatorInvite).toHaveBeenCalledWith("recipient-invite", { + recipient_name: "Local Identity", + device_name: "Travel Laptop", + reviewed_onboarding_digest: addDevicePreview.reviewedOnboardingDigest, + }); + }); + + it("shows loading, error, and empty states without exposing internal language", async () => { + let rejectPreview: (cause: Error) => void = () => undefined; + vi.mocked(api.previewRecipientInvite).mockImplementation( + () => new Promise((_, reject) => (rejectPreview = reject)), + ); + mount(); + act(() => button("Invite Team member").click()); + const dialog = document.querySelector('[role="dialog"]'); + if (!dialog) throw new Error("dialog missing"); + act(() => button("Review invitation", dialog).click()); + expect(dialog.querySelector('[role="status"]')?.textContent).toContain("Reviewing invitation"); + rejectPreview(new Error("internal_failure")); + await vi.waitFor(() => + expect(dialog.querySelector('[role="alert"]')?.textContent).toContain( + "Unable to review this invitation", + ), + ); + + act(() => + render( + , + document.getElementById("mount") as HTMLElement, + ), + ); + expect(document.body.textContent).toContain("No active Teams or Identities are available"); + expect(document.body.textContent).not.toMatch(/\b(scope|grant|actor|filter|epoch|cursor)\b/i); + }); + + it("moves focus to the heading and restores it after Radix keyboard close", () => { + mount(); + const trigger = button("Add a device"); + trigger.focus(); + act(() => trigger.click()); + const props = dialogSpy.mock.calls.at(-1)?.[0] as { + onCloseAutoFocus: (event: Event) => void; + onOpenAutoFocus: (event: Event) => void; + onOpenChange: (open: boolean) => void; + }; + const event = new Event("focus", { cancelable: true }); + props.onOpenAutoFocus(event); + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(document.getElementById("recipient-invitation-title")); + + act(() => props.onOpenChange(false)); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + props.onCloseAutoFocus(event); + expect(document.activeElement).toBe(trigger); + }); + + it("keeps direct Project review and legacy import routed to their established journeys", async () => { + vi.mocked(api.inspectCoordinatorInvite).mockResolvedValue({ kind: "legacy_team_invite" }); + mount(); + act(() => button("Share exact Projects").click()); + expect(openProjectShare).toHaveBeenCalledOnce(); + + act(() => button("Review invitation").click()); + const textarea = document.querySelector("textarea"); + if (!textarea) throw new Error("textarea missing"); + act(() => { + textarea.value = "legacy"; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + const dialog = document.querySelector('[role="dialog"]'); + if (!dialog) throw new Error("dialog missing"); + act(() => button("Review invitation", dialog).click()); + await vi.waitFor(() => + expect(dialog.textContent).toContain("Use Advanced Team administration to review and import"), + ); + expect(api.importCoordinatorInvite).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/tabs/recipient-policy-invitations.tsx b/packages/ui/src/tabs/recipient-policy-invitations.tsx new file mode 100644 index 00000000..b2500f0b --- /dev/null +++ b/packages/ui/src/tabs/recipient-policy-invitations.tsx @@ -0,0 +1,457 @@ +import { useRef, useState } from "preact/hooks"; +import { RadixDialog } from "../components/primitives/radix-dialog"; +import * as api from "../lib/api"; +import type { + CreatedRecipientInvite, + InspectInviteResult, + RecipientInvitePreviewRequest, + RecipientOnboardingPreviewV1, + RecipientPolicyIntentGraphV1, +} from "../lib/api/sync"; +import { openProjectShareFlow } from "./project-sharing"; + +type CreateKind = "team_member" | "add_device"; +type DialogMode = "create" | "accept"; + +function errorMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message === "reviewed_onboarding_stale") { + return "Invitation details changed. Review them again before creating it."; + } + return fallback; +} + +function memoryLabel(count: number): string { + return `${count.toLocaleString()} existing ${count === 1 ? "memory" : "memories"}`; +} + +function ProjectList({ preview }: { preview: RecipientOnboardingPreviewV1 }) { + if (preview.projects.length === 0) { + return

No Projects are currently shared with this Team.

; + } + return ( +
    + {preview.projects.map((project) => ( +
  • + {project.displayName} — {memoryLabel(project.existingMemoryCount)} and + future activity +
  • + ))} +
+ ); +} + +function TeamConfirmation({ preview }: { preview: RecipientOnboardingPreviewV1 }) { + return ( +
+

+ Current Projects for {preview.team?.displayName ?? "this Team"} +

+ +

Future Projects shared with this Team will also be inherited by this member.

+

+ No other Projects will be shared through this invitation. +

+
+ ); +} + +function AddDeviceConfirmation({ preview }: { preview: RecipientOnboardingPreviewV1 }) { + const direct = preview.projects.filter((project) => + project.sources.some((source) => source.kind === "direct"), + ); + const inherited = preview.projects.filter((project) => + project.sources.some((source) => source.kind === "team"), + ); + return ( +
+
+

Direct Projects

+ {direct.length ? ( +
    + {direct.map((project) => ( +
  • + {project.displayName} — {memoryLabel(project.existingMemoryCount)} and future + activity +
  • + ))} +
+ ) : ( +

No Projects are shared directly.

+ )} +
+
+

Projects through Teams

+ {inherited.length ? ( +
    + {inherited.map((project) => { + const teams = project.sources + .filter((source) => source.kind === "team") + .map((source) => source.displayName); + return ( +
  • + {project.displayName} — {memoryLabel(project.existingMemoryCount)} and future + activity + {teams.length ? ` through ${teams.join(", ")}` : " through a Team"} +
  • + ); + })} +
+ ) : ( +

No Projects are inherited through Teams.

+ )} +
+
+

Not included

+ {preview.excludedProjects.length ? ( +
    + {preview.excludedProjects.map((project) => ( +
  • + {project.displayName} — {memoryLabel(project.existingMemoryCount)} +
  • + ))} +
+ ) : ( +

No other Projects are excluded.

+ )} +

This device will not receive the Projects listed here.

+
+
+ ); +} + +function Confirmation({ preview }: { preview: RecipientOnboardingPreviewV1 }) { + return preview.journey === "team" ? ( + + ) : ( + + ); +} + +function request(kind: CreateKind, targetId: string): RecipientInvitePreviewRequest { + return kind === "team_member" + ? { kind, policy_team_id: targetId } + : { kind, target_identity_id: targetId }; +} + +export function RecipientPolicyInvitations({ intent }: { intent: RecipientPolicyIntentGraphV1 }) { + const teams = intent.teams.filter((team) => team.status === "active"); + const identities = intent.identities.filter((identity) => identity.status === "active"); + const [mode, setMode] = useState(null); + const [kind, setKind] = useState("team_member"); + const [targetId, setTargetId] = useState(teams[0]?.teamId ?? ""); + const [invite, setInvite] = useState(""); + const [preview, setPreview] = useState(null); + const [inspected, setInspected] = useState(null); + const [created, setCreated] = useState(null); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(""); + const [error, setError] = useState(""); + const returnFocus = useRef(null); + + const reset = () => { + setPreview(null); + setInspected(null); + setCreated(null); + setStatus(""); + setError(""); + }; + const open = (nextMode: DialogMode, trigger: HTMLElement) => { + reset(); + returnFocus.current = trigger; + setMode(nextMode); + }; + const close = () => { + if (!busy) setMode(null); + }; + const chooseKind = (nextKind: CreateKind) => { + setKind(nextKind); + setTargetId( + nextKind === "team_member" ? (teams[0]?.teamId ?? "") : (identities[0]?.identityId ?? ""), + ); + reset(); + }; + const reviewCreate = async () => { + if (!targetId) return; + setBusy(true); + setError(""); + setStatus("Reviewing invitation…"); + try { + const result = await api.previewRecipientInvite(request(kind, targetId)); + setPreview(result.preview); + setStatus("Review ready. Confirm the invitation details."); + } catch (cause) { + setError(errorMessage(cause, "Unable to review this invitation.")); + setStatus(""); + } finally { + setBusy(false); + } + }; + const create = async () => { + if (!preview) return; + setBusy(true); + setError(""); + setStatus("Creating invitation…"); + try { + const result = await api.createRecipientInvite({ + ...request(kind, targetId), + reviewed_onboarding_digest: preview.reviewedOnboardingDigest, + }); + setCreated(result); + setStatus("Invitation created."); + } catch (cause) { + if (cause instanceof Error && cause.message === "reviewed_onboarding_stale") { + setPreview(null); + } + setError(errorMessage(cause, "Unable to create this invitation.")); + setStatus(""); + } finally { + setBusy(false); + } + }; + const inspect = async () => { + if (!invite.trim()) { + setError("Paste an invitation first."); + return; + } + setBusy(true); + setError(""); + setStatus("Reviewing invitation…"); + try { + const result = await api.inspectCoordinatorInvite(invite.trim()); + setInspected(result); + setStatus( + result.kind === "team_member" || result.kind === "add_device" + ? "Review ready. Confirm before accepting." + : "Open Advanced Team administration to continue with this invitation.", + ); + } catch (cause) { + setError(errorMessage(cause, "Unable to review this invitation.")); + setStatus(""); + } finally { + setBusy(false); + } + }; + const accept = async () => { + if (!inspected || (inspected.kind !== "team_member" && inspected.kind !== "add_device")) return; + setBusy(true); + setError(""); + setStatus("Accepting invitation…"); + try { + await api.importCoordinatorInvite(invite.trim(), { + recipient_name: inspected.recipient_name, + device_name: inspected.device_name, + reviewed_onboarding_digest: inspected.onboarding.reviewedOnboardingDigest, + }); + setStatus(inspected.kind === "team_member" ? "Team invitation accepted." : "Device added."); + setInspected(null); + setInvite(""); + } catch (cause) { + setError(errorMessage(cause, "Unable to accept this invitation.")); + setStatus(""); + } finally { + setBusy(false); + } + }; + const copy = async () => { + const value = created?.invite.link || created?.invite.encoded || ""; + if (!value) { + setError("The invitation text is unavailable."); + return; + } + try { + await navigator.clipboard.writeText(value); + setStatus("Invitation copied."); + } catch { + setError("Unable to copy the invitation."); + } + }; + + const recipientPreview = + inspected?.kind === "team_member" || inspected?.kind === "add_device" + ? inspected.onboarding + : null; + return ( +
+
+

Create an invitation

+

Invite a Team member, add a device, or share an exact set of Projects.

+
+ + + +
+ {teams.length === 0 && identities.length === 0 ? ( +

+ No active Teams or Identities are available. +

+ ) : null} +
+
+

Accept an invitation

+

Review Team membership or device access before accepting.

+ +

+ Legacy invitation import remains under Advanced Team administration. +

+
+ {mode ? ( + { + event.preventDefault(); + returnFocus.current?.focus(); + returnFocus.current = null; + }} + onOpenAutoFocus={(event) => { + event.preventDefault(); + document.getElementById("recipient-invitation-title")?.focus(); + }} + onOpenChange={(nextOpen) => { + if (!nextOpen) close(); + }} + open + overlayClassName="modal-backdrop" + overlayId="recipientInvitationDialogBackdrop" + > +
+
+

+ {mode === "create" ? "Create invitation" : "Review invitation"} +

+ +
+
+

+ Confirm exactly what this invitation includes before continuing. +

+ {mode === "create" && !preview && !created ? ( + + ) : mode === "accept" && !inspected ? ( +