From ab64d8d2394d795c45554d0572c79535143158ee Mon Sep 17 00:00:00 2001 From: Adam Kunicki Date: Sat, 25 Jul 2026 11:00:07 +0200 Subject: [PATCH] feat(sharing): authorize identity-owned device invites --- ...-07-25-signed-add-device-invites-design.md | 56 +++ ...igned-add-device-invites-implementation.md | 39 ++ .../test/worker.integration.test.ts | 149 ++++++++ .../src/better-sqlite-coordinator-store.ts | 9 +- packages/core/src/coordinator-actions.test.ts | 121 +++++++ packages/core/src/coordinator-actions.ts | 66 ++++ packages/core/src/coordinator-api.test.ts | 340 ++++++++++++++++++ packages/core/src/coordinator-api.ts | 146 +++++++- .../core/src/coordinator-store-contract.ts | 6 +- .../src/coordinator-store-test-harness.ts | 4 + packages/core/src/d1-coordinator-store.ts | 9 +- packages/core/src/index.ts | 1 + packages/viewer-server/src/index.test.ts | 154 +++++++- packages/viewer-server/src/routes/sync.ts | 82 +++-- 14 files changed, 1136 insertions(+), 46 deletions(-) create mode 100644 docs/plans/2026-07-25-signed-add-device-invites-design.md create mode 100644 docs/plans/2026-07-25-signed-add-device-invites-implementation.md diff --git a/docs/plans/2026-07-25-signed-add-device-invites-design.md b/docs/plans/2026-07-25-signed-add-device-invites-design.md new file mode 100644 index 000000000..deb843854 --- /dev/null +++ b/docs/plans/2026-07-25-signed-add-device-invites-design.md @@ -0,0 +1,56 @@ +# Signed identity-owned add-device invitations + +**Date:** 2026-07-25 +**Status:** approved +**Bead:** `codemem-wosg.2` + +## Goal + +Allow an active enrolled device to create an add-device invitation for its own coordinator-bound Identity without possessing the coordinator admin secret. The caller must not be able to select another Identity or create Team invitations. + +## Authority boundary + +The coordinator enrollment is the only authorization source. A signed request identifies a device, and `authorizeRequest` resolves its active enrollment. The endpoint derives `target_identity_id` from that enrollment's non-null `identity_id`; it never accepts a target Identity or invitation kind from the client. + +Team-member invitation creation remains on the admin-only endpoint. Legacy, admin, and Project enrollments have null Identity bindings and cannot use signed add-device issuance. + +## Endpoint + +Add `POST /v1/invites/add-device` with the existing signed request headers. The body contains: + +- `group_id`; +- `expires_at`; +- `reviewed_preview_digest`; +- canonical add-device `reviewed_intent`. + +The coordinator URL is derived from the request origin, the policy is fixed to `auto_admit`, and `created_by` is the bound Identity. Unknown fields fail closed so callers cannot smuggle `kind`, `policy_team_id`, `target_identity_id`, or `assigned_identity_id` into the request. + +After signature and nonce validation, the coordinator requires an enabled enrollment with a valid non-null Identity binding. It verifies the reviewed intent and digest against that Identity, creates the invitation through the existing store contract, and returns the normal digest-only payload/link response. + +## Viewer flow + +The viewer keeps local preview and stale-review checks. Team invitation preview and creation still require coordinator-admin readiness. Add-device preview requires only a configured coordinator URL/group and the current local Identity. A non-admin device creates through the signed endpoint without reading or sending an admin secret. An explicitly admin-configured owner continues to use admin issuance, which supports the intentionally unbound initial-owner enrollment; selection happens before the request and never falls back after a signed failure. + +## Error handling + +- Missing or invalid signed headers: existing `authorizeRequest` 401 errors. +- Disabled device: `device_disabled` (403). +- Null enrollment binding: `identity_binding_required` (403). +- Unknown request fields: `unexpected_add_device_invite_fields` (400). +- Invalid expiry, reviewed intent, or digest: existing stable 400/409 recipient-invite errors. +- Replayed nonce: `nonce_replay` (401). + +No failed request falls back to coordinator-admin authorization. + +## Alternatives rejected + +1. **Dual-auth the admin invite endpoint.** Rejected because kind-specific authorization would share one broad mutation boundary and make accidental Team issuance easier. +2. **Accept a target Identity and compare it with enrollment.** Rejected because the client does not need to provide an authorization principal; deriving it removes an entire confused-deputy input. +3. **General signed recipient-invite endpoint.** Rejected as unnecessary scope. Signed callers need only add-device issuance. + +## Validation + +- API tests cover valid signed issuance, bad signatures, nonce replay, disabled/unbound devices, unexpected fields, Team-kind attempts, cross-Identity reviewed intent, and malformed reviews. +- Viewer tests prove add-device preview/creation works without an admin secret and signs the exact body; Team creation still fails without admin credentials. +- Worker integration proves the real request verifier and D1 store produce an invitation bound to the caller's persisted Identity. +- Full TypeScript, lint, workspace tests, and Worker tests pass. diff --git a/docs/plans/2026-07-25-signed-add-device-invites-implementation.md b/docs/plans/2026-07-25-signed-add-device-invites-implementation.md new file mode 100644 index 000000000..9d7fd6aa7 --- /dev/null +++ b/docs/plans/2026-07-25-signed-add-device-invites-implementation.md @@ -0,0 +1,39 @@ +# Signed identity-owned add-device invitations implementation plan + +**Date:** 2026-07-25 +**Design:** `docs/plans/2026-07-25-signed-add-device-invites-design.md` +**Bead:** `codemem-wosg.2` + +## 1. Signed coordinator boundary + +- Treat disabled enrollments as unauthorized in `authorizeRequest`. +- Add a fixed-purpose signed add-device invitation endpoint. +- Allowlist body fields and derive the target Identity, creator, policy, and coordinator URL server-side. +- Reuse reviewed-intent verification and existing invite persistence. + +## 2. Core client action + +- Add a signed add-device invitation action that serializes the body once. +- Sign those exact bytes with the local device key. +- Return the same payload/link shape as admin invitation creation. +- Surface stable coordinator errors without admin fallback. + +## 3. Viewer routing + +- Split Team admin readiness from add-device signed readiness. +- Keep local preview and reviewed-onboarding digest checks unchanged. +- Route Team creation through the admin action, non-admin add-device creation through the signed action, and explicitly admin-configured owner issuance through the existing admin action. +- Require the requested add-device Identity to equal the current local Identity before signing. + +## 4. Tests + +- Add coordinator API authorization and input-boundary tests. +- Add core action signing/error tests. +- Add viewer tests for non-admin add-device creation and unchanged Team denial. +- Add real Worker/D1 signed issuance coverage. + +## 5. Gate + +- Run focused API/action/viewer/Worker tests. +- Run `pnpm run check` and the Worker test suite. +- Run security, coverage, and maintainability reviews before submission. diff --git a/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts b/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts index 12426e1c7..2e7722b6f 100644 --- a/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts +++ b/packages/cloudflare-coordinator-worker/test/worker.integration.test.ts @@ -853,6 +853,155 @@ function signHeaders(identity: TestIdentity, method: string, url: string, body: expect(payload.items.some((item) => item.device_id === devices[3]?.deviceId)).toBe(false); }); + it("creates signed add-device invitations for the caller's persisted Identity", async () => { + const device = createIdentity(); + const identityId = "identity-owner"; + const reviewedIntent = addDeviceReviewedIntent(identityId); + const digest = await recipientReviewedIntentDigest(reviewedIntent); + await env.COORDINATOR_DB.prepare( + "INSERT INTO groups (group_id, display_name, created_at) VALUES ('g1', 'Coordinator Team', ?)", + ) + .bind("2026-07-25T00:00:00Z") + .run(); + const enrollment = await exports.default.fetch("https://example.com/v1/admin/devices", { + method: "POST", + headers: { + "content-type": "application/json", + "X-Codemem-Coordinator-Admin": "test-secret", + }, + body: JSON.stringify({ + group_id: "g1", + device_id: device.deviceId, + fingerprint: device.fingerprint, + public_key: device.publicKey, + display_name: "Owner Device", + }), + }); + expect(enrollment.status).toBe(200); + await env.COORDINATOR_DB.prepare( + "UPDATE enrolled_devices SET identity_id = ? WHERE group_id = 'g1' AND device_id = ?", + ) + .bind(identityId, device.deviceId) + .run(); + + const url = "https://example.com/v1/invites/add-device"; + const body = JSON.stringify({ + group_id: "g1", + expires_at: "2099-01-01T00:00:00Z", + reviewed_preview_digest: digest, + reviewed_intent: reviewedIntent, + }); + const signedRequest = (identity: TestIdentity, requestBody: string) => ({ + method: "POST", + headers: { + "content-type": "application/json", + ...signHeaders(identity, "POST", url, requestBody), + }, + body: requestBody, + }); + const unknown = createIdentity(); + const unknownResponse = await exports.default.fetch(url, signedRequest(unknown, body)); + expect(unknownResponse.status).toBe(401); + expect(await unknownResponse.json()).toEqual({ error: "unknown_device" }); + + await env.COORDINATOR_DB.prepare( + "UPDATE enrolled_devices SET identity_id = NULL WHERE group_id = 'g1' AND device_id = ?", + ) + .bind(device.deviceId) + .run(); + const unboundResponse = await exports.default.fetch(url, signedRequest(device, body)); + expect(unboundResponse.status).toBe(403); + expect(await unboundResponse.json()).toEqual({ error: "identity_binding_required" }); + + await env.COORDINATOR_DB.prepare( + "UPDATE enrolled_devices SET identity_id = ?, enabled = 0 WHERE group_id = 'g1' AND device_id = ?", + ) + .bind(identityId, device.deviceId) + .run(); + const disabledResponse = await exports.default.fetch(url, signedRequest(device, body)); + expect(disabledResponse.status).toBe(403); + expect(await disabledResponse.json()).toEqual({ error: "device_disabled" }); + await env.COORDINATOR_DB.prepare( + "UPDATE enrolled_devices SET enabled = 1 WHERE group_id = 'g1' AND device_id = ?", + ) + .bind(device.deviceId) + .run(); + + const invalidSignatureRequest = signedRequest(device, body); + invalidSignatureRequest.headers["X-Opencode-Signature"] = `${SIGNATURE_VERSION}:AAAA`; + const invalidSignature = await exports.default.fetch(url, invalidSignatureRequest); + expect(invalidSignature.status).toBe(401); + expect(await invalidSignature.json()).toEqual({ error: "invalid_signature" }); + + const crossIdentityIntent = addDeviceReviewedIntent("identity-other"); + const crossIdentityBody = JSON.stringify({ + group_id: "g1", + expires_at: "2099-01-01T00:00:00Z", + reviewed_preview_digest: await recipientReviewedIntentDigest(crossIdentityIntent), + reviewed_intent: crossIdentityIntent, + }); + const crossIdentityResponse = await exports.default.fetch( + url, + signedRequest(device, crossIdentityBody), + ); + expect(crossIdentityResponse.status).toBe(409); + expect(await crossIdentityResponse.json()).toEqual({ + error: "recipient_invite_intent_mismatch", + }); + + const teamIntent = teamReviewedIntent(); + const teamBody = JSON.stringify({ + group_id: "g1", + expires_at: "2099-01-01T00:00:00Z", + reviewed_preview_digest: await recipientReviewedIntentDigest(teamIntent), + reviewed_intent: teamIntent, + }); + const teamResponse = await exports.default.fetch(url, signedRequest(device, teamBody)); + expect(teamResponse.status).toBe(409); + expect(await teamResponse.json()).toEqual({ error: "recipient_invite_intent_mismatch" }); + + const kindFieldBody = JSON.stringify({ + ...JSON.parse(body), + invite_kind: "team_member", + }); + const kindFieldResponse = await exports.default.fetch( + url, + signedRequest(device, kindFieldBody), + ); + expect(kindFieldResponse.status).toBe(400); + expect(await kindFieldResponse.json()).toEqual({ + error: "unexpected_add_device_invite_fields", + }); + + const successfulRequest = signedRequest(device, body); + const response = await exports.default.fetch(url, successfulRequest); + + expect(response.status).toBe(200); + const result = (await response.json()) as { payload: InvitePayload }; + expect(result.payload).toMatchObject({ + kind: "add_device", + coordinator_url: "https://example.com", + group_id: "g1", + policy: "auto_admit", + target_identity_id: identityId, + reviewed_preview_digest: digest, + }); + const replay = await exports.default.fetch(url, successfulRequest); + expect(replay.status).toBe(401); + expect(await replay.json()).toEqual({ error: "nonce_replay" }); + expect( + await env.COORDINATOR_DB.prepare( + `SELECT invite_kind, target_identity_id, created_by, policy + FROM coordinator_invites WHERE token_digest IS NOT NULL`, + ).first(), + ).toEqual({ + invite_kind: "add_device", + target_identity_id: identityId, + created_by: identityId, + policy: "auto_admit", + }); + }); + it("persists explicit Team and add-device invitation bindings without mutating coordinator memberships", async () => { const device = createIdentity(); const otherDevice = createIdentity(); diff --git a/packages/core/src/better-sqlite-coordinator-store.ts b/packages/core/src/better-sqlite-coordinator-store.ts index af3f996df..59a64d609 100644 --- a/packages/core/src/better-sqlite-coordinator-store.ts +++ b/packages/core/src/better-sqlite-coordinator-store.ts @@ -945,11 +945,16 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore { .map((row) => rowToRecord(row)); } - async getEnrollment(groupId: string, deviceId: string): Promise { + async getEnrollment( + groupId: string, + deviceId: string, + includeDisabled = false, + ): Promise { + const enabledClause = includeDisabled ? "" : "AND enabled = 1"; const row = this.db .prepare(`SELECT ${ENROLLMENT_COLUMNS} FROM enrolled_devices - WHERE group_id = ? AND device_id = ? AND enabled = 1`) + WHERE group_id = ? AND device_id = ? ${enabledClause}`) .get(groupId, deviceId); return row ? rowToRecord(row) : null; } diff --git a/packages/core/src/coordinator-actions.test.ts b/packages/core/src/coordinator-actions.test.ts index 794614152..09b619486 100644 --- a/packages/core/src/coordinator-actions.test.ts +++ b/packages/core/src/coordinator-actions.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BetterSqliteCoordinatorStore } from "./better-sqlite-coordinator-store.js"; import { + coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, @@ -37,6 +38,7 @@ import { type RecipientReviewedIntentV1, recipientReviewedIntentDigest, } from "./recipient-reviewed-intent.js"; +import { verifySignature } from "./sync-auth.js"; import { ensureDeviceIdentity, fingerprintPublicKey, loadPublicKey } from "./sync-identity.js"; type TeamReviewedIntent = Extract; @@ -636,6 +638,125 @@ describe("coordinator local admin actions", () => { expect(result.payload).not.toHaveProperty("reviewed_intent"); }); + it("signs the exact identity-owned add-device invite body without admin or target fields", async () => { + const keysDir = join(tmpDir, "signed-add-device-keys"); + const identityDbPath = join(tmpDir, "signed-add-device.sqlite"); + initDatabase(identityDbPath); + const identityDb = connect(identityDbPath); + let deviceId = ""; + try { + [deviceId] = ensureDeviceIdentity(identityDb, { keysDir }); + } finally { + identityDb.close(); + } + const reviewedIntent = addDeviceReviewedIntent("identity-owner"); + const digest = await recipientReviewedIntentDigest(reviewedIntent); + let requestBody: Record | null = null; + let requestHeaders = new Headers(); + let transmittedBody = Buffer.alloc(0); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + expect(url).toBe("https://coord.example.test/v1/invites/add-device"); + requestHeaders = new Headers(init?.headers); + transmittedBody = + init?.body instanceof Uint8Array ? Buffer.from(init.body) : Buffer.alloc(0); + requestBody = JSON.parse(transmittedBody.toString("utf8")) as Record; + return new Response( + JSON.stringify({ + invite: { + invite_id: "invite-device-1", + invite_kind: "add_device", + target_identity_id: "identity-owner", + reviewed_preview_digest: digest, + }, + payload: { + kind: "add_device", + target_identity_id: "identity-owner", + reviewed_preview_digest: digest, + }, + encoded: "digest-only", + link: "codemem://join?invite=digest-only", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }), + ); + + const result = await coordinatorCreateAddDeviceInviteAction({ + groupId: "team-a", + coordinatorUrl: "https://coord.example.test", + ttlHours: 24, + deviceId, + keysDir, + reviewedPreviewDigest: digest, + reviewedIntent, + }); + + expect(requestBody).toMatchObject({ + group_id: "team-a", + reviewed_preview_digest: digest, + reviewed_intent: reviewedIntent, + }); + expect(requestBody).not.toHaveProperty("target_identity_id"); + expect(requestBody).not.toHaveProperty("invite_kind"); + expect(requestHeaders.get("X-Opencode-Device")).toBe(deviceId); + expect(requestHeaders.get("X-Opencode-Signature")).toMatch(/^v2:/u); + expect(requestHeaders.has("X-Codemem-Coordinator-Admin")).toBe(false); + expect( + verifySignature({ + method: "POST", + pathWithQuery: "/v1/invites/add-device", + timestamp: String(requestHeaders.get("X-Opencode-Timestamp")), + nonce: String(requestHeaders.get("X-Opencode-Nonce")), + signature: String(requestHeaders.get("X-Opencode-Signature")), + publicKey: String(loadPublicKey(keysDir)), + deviceId, + bodyBytes: transmittedBody, + }), + ).toBe(true); + expect(result).toMatchObject({ + invite_id: "invite-device-1", + invite_kind: "add_device", + target_identity_id: "identity-owner", + link: "codemem://join?invite=digest-only", + }); + }); + + it("surfaces signed add-device coordinator authorization failures without admin fallback", async () => { + const keysDir = join(tmpDir, "signed-add-device-error-keys"); + const identityDbPath = join(tmpDir, "signed-add-device-error.sqlite"); + initDatabase(identityDbPath); + const identityDb = connect(identityDbPath); + let deviceId = ""; + try { + [deviceId] = ensureDeviceIdentity(identityDb, { keysDir }); + } finally { + identityDb.close(); + } + const reviewedIntent = addDeviceReviewedIntent("identity-owner"); + const digest = await recipientReviewedIntentDigest(reviewedIntent); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ error: "identity_binding_required" }), { status: 403 }), + ), + ); + + await expect( + coordinatorCreateAddDeviceInviteAction({ + groupId: "team-a", + coordinatorUrl: "https://coord.example.test", + ttlHours: 24, + deviceId, + keysDir, + reviewedPreviewDigest: digest, + reviewedIntent, + }), + ).rejects.toThrow("Remote coordinator request failed (403): identity_binding_required"); + }); + it("imports invites using CODEMEM_DB and CODEMEM_KEYS_DIR when flags are omitted", async () => { const envDbPath = join(tmpDir, "env-mem.sqlite"); const envKeysDir = join(tmpDir, "env-keys"); diff --git a/packages/core/src/coordinator-actions.ts b/packages/core/src/coordinator-actions.ts index 8d52bde9b..b8b4ec4ea 100644 --- a/packages/core/src/coordinator-actions.ts +++ b/packages/core/src/coordinator-actions.ts @@ -50,6 +50,7 @@ import { type RecipientReviewedIntentV1, verifyRecipientReviewedIntent, } from "./recipient-reviewed-intent.js"; +import { buildAuthHeaders } from "./sync-auth.js"; import { updatePeerAddresses } from "./sync-discovery.js"; import { fingerprintPublicKey } from "./sync-fingerprint.js"; import { buildBaseUrl, requestJson } from "./sync-http-client.js"; @@ -1208,6 +1209,71 @@ export async function coordinatorCreateInviteAction(opts: { } } +export async function coordinatorCreateAddDeviceInviteAction(opts: { + groupId: string; + coordinatorUrl?: string | null; + ttlHours: number; + deviceId: string; + keysDir?: string | null; + remoteUrl?: string | null; + reviewedPreviewDigest: string; + reviewedIntent: RecipientReviewedIntentV1; +}): Promise> { + const groupId = String(opts.groupId ?? "").trim(); + const deviceId = String(opts.deviceId ?? "").trim(); + const remote = String( + opts.remoteUrl ?? opts.coordinatorUrl ?? coordinatorRemoteTarget().remoteUrl ?? "", + ).trim(); + if (!groupId) throw new Error("group_id_required"); + if (!deviceId) throw new Error("device_id_required"); + if (!remote) throw new Error("coordinator_not_configured"); + if (!Number.isInteger(opts.ttlHours) || opts.ttlHours < 1) throw new Error("ttl_hours_invalid"); + const reviewedPreviewDigest = String(opts.reviewedPreviewDigest ?? "").trim(); + if (!/^[a-f0-9]{64}$/u.test(reviewedPreviewDigest)) { + throw new Error("reviewed_preview_digest_invalid"); + } + const expiresAt = new Date(Date.now() + opts.ttlHours * 3600 * 1000).toISOString(); + const body = { + group_id: groupId, + expires_at: expiresAt, + reviewed_preview_digest: reviewedPreviewDigest, + reviewed_intent: opts.reviewedIntent, + }; + const url = `${stripTrailingSlashes(remote)}/v1/invites/add-device`; + const bodyBytes = Buffer.from(JSON.stringify(body), "utf8"); + const [status, response] = await requestJson("POST", url, { + headers: buildAuthHeaders({ + deviceId, + method: "POST", + url, + bodyBytes, + keysDir: opts.keysDir ?? undefined, + }), + bodyBytes, + timeoutS: 3, + }); + if (status < 200 || status >= 300) { + const detail = typeof response?.error === "string" ? response.error : "unknown"; + throw new Error(`Remote coordinator request failed (${status}): ${detail}`); + } + const invite = + response?.invite && typeof response.invite === "object" && !Array.isArray(response.invite) + ? (response.invite as Record) + : null; + return { + group_id: groupId, + invite_id: invite?.invite_id, + invite_kind: invite?.invite_kind ?? "add_device", + target_identity_id: invite?.target_identity_id ?? null, + reviewed_preview_digest: invite?.reviewed_preview_digest ?? reviewedPreviewDigest, + encoded: response?.encoded, + link: response?.link, + payload: response?.payload, + warnings: inviteUrlWarnings(opts.coordinatorUrl || remote), + mode: "remote", + }; +} + interface ProjectInviteTrustResult { bootstrapGrantId: string | null; inviterPeer?: { diff --git a/packages/core/src/coordinator-api.test.ts b/packages/core/src/coordinator-api.test.ts index 4c300a9ec..102c7b6f9 100644 --- a/packages/core/src/coordinator-api.test.ts +++ b/packages/core/src/coordinator-api.test.ts @@ -170,6 +170,40 @@ function teamReviewedIntent(teamId = "policy-team-1"): RecipientReviewedIntentV1 }; } +function addDeviceReviewedIntent(identityId = "identity-owner"): RecipientReviewedIntentV1 { + return { + version: 1, + journey: "add_device", + targetIdentity: { identityId, displayName: "Adam" }, + projects: [ + { + canonicalProjectIdentity: "git:https://example.test/codemem", + displayName: "codemem", + existingMemoryCount: 3, + futureMemoriesShared: true, + sources: [{ kind: "direct" }], + }, + ], + excludedProjects: [], + }; +} + +function enrolledDevice( + identityId: string | null = "identity-owner", + enabled = 1, +): CoordinatorEnrollment { + return { + group_id: "g1", + device_id: "device-a", + public_key: "pk-a", + fingerprint: "fp-a", + identity_id: identityId, + display_name: "Device A", + enabled, + created_at: "2026-03-28T00:00:00Z", + }; +} + describe("createCoordinatorApp dependency injection", () => { it("uses injected admin secret and store factory for admin routes", async () => { const store = createMockStore({ @@ -460,6 +494,312 @@ describe("createCoordinatorApp dependency injection", () => { expect(response.link).not.toContain("reviewed_intent"); }); + describe("signed add-device invitation creation", () => { + function activeGroup(): CoordinatorGroup { + return { + group_id: "g1", + display_name: "Coordinator One", + archived_at: null, + created_at: "2026-03-28T00:00:00Z", + }; + } + + it("derives invitation authority from the signed enrollment and signs exact body bytes", async () => { + const reviewedIntent = addDeviceReviewedIntent(); + const digest = await recipientReviewedIntentDigest(reviewedIntent); + const body = JSON.stringify({ + group_id: "g1", + expires_at: "2026-04-04T00:00:00Z", + reviewed_preview_digest: digest, + reviewed_intent: reviewedIntent, + }); + const requestVerifier: CoordinatorRequestVerifier = vi.fn(async (input) => { + expect(new TextDecoder().decode(input.bodyBytes)).toBe(body); + expect(input.pathWithQuery).toBe("/v1/invites/add-device"); + return true; + }); + const createInvite = vi.fn(async (input: CoordinatorCreateInviteInput) => ({ + invite_id: "invite-add-device-1", + group_id: input.groupId, + token: "add-device-token", + policy: input.policy, + expires_at: input.expiresAt, + created_at: "2026-03-28T00:00:00Z", + created_by: input.createdBy ?? null, + team_name_snapshot: "Coordinator One", + revoked_at: null, + invite_kind: "add_device" as const, + target_identity_id: input.targetIdentityId ?? null, + reviewed_preview_digest: input.reviewedPreviewDigest ?? null, + })); + const store = createMockStore({ + getEnrollment: vi.fn(async () => enrolledDevice()), + getGroup: vi.fn(async () => activeGroup()), + createInvite, + }); + const app = createCoordinatorApp({ + storeFactory: () => store, + runtime: { adminSecret: () => null, now: () => "2026-03-28T00:00:00Z" }, + requestVerifier, + }); + + const response = await app.request("https://coordinator.example.test/v1/invites/add-device", { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + payload: { + kind: "add_device", + coordinator_url: "https://coordinator.example.test", + group_id: "g1", + policy: "auto_admit", + target_identity_id: "identity-owner", + reviewed_preview_digest: digest, + }, + }); + expect(createInvite).toHaveBeenCalledWith({ + groupId: "g1", + policy: "auto_admit", + expiresAt: "2026-04-04T00:00:00Z", + createdBy: "identity-owner", + inviteKind: "add_device", + targetIdentityId: "identity-owner", + reviewedPreviewDigest: digest, + reviewedIntent, + }); + expect(requestVerifier).toHaveBeenCalledOnce(); + }); + + it("rejects disabled enrollments centrally before signature or nonce handling", async () => { + const reviewedIntent = addDeviceReviewedIntent(); + const digest = await recipientReviewedIntentDigest(reviewedIntent); + const requestVerifier = vi.fn(async () => true); + const store = createMockStore({ + getEnrollment: vi.fn(async () => enrolledDevice("identity-owner", 0)), + getGroup: vi.fn(async () => activeGroup()), + }); + const app = createCoordinatorApp({ + storeFactory: () => store, + runtime: { adminSecret: () => null, now: () => "2026-03-28T00:00:00Z" }, + requestVerifier, + }); + + const response = await app.request("/v1/presence", { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ group_id: "g1", addresses: [] }), + }); + const inviteResponse = await app.request("/v1/invites/add-device", { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ + group_id: "g1", + expires_at: "2026-04-04T00:00:00Z", + reviewed_preview_digest: digest, + reviewed_intent: reviewedIntent, + }), + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "device_disabled" }); + expect(inviteResponse.status).toBe(403); + expect(await inviteResponse.json()).toEqual({ error: "device_disabled" }); + expect(requestVerifier).not.toHaveBeenCalled(); + expect(store.recordNonce).not.toHaveBeenCalled(); + expect(store.upsertPresence).not.toHaveBeenCalled(); + }); + + it("rejects signed issuance from an enrollment without an Identity binding", async () => { + const reviewedIntent = addDeviceReviewedIntent(); + const digest = await recipientReviewedIntentDigest(reviewedIntent); + const store = createMockStore({ + getEnrollment: vi.fn(async () => enrolledDevice(null)), + getGroup: vi.fn(async () => activeGroup()), + }); + const app = createCoordinatorApp({ + storeFactory: () => store, + runtime: { adminSecret: () => null, now: () => "2026-03-28T00:00:00Z" }, + requestVerifier: allowRequest, + }); + + const response = await app.request("/v1/invites/add-device", { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ + group_id: "g1", + expires_at: "2026-04-04T00:00:00Z", + reviewed_preview_digest: digest, + reviewed_intent: reviewedIntent, + }), + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "identity_binding_required" }); + expect(store.createInvite).not.toHaveBeenCalled(); + }); + + it("rejects signed issuance from an unknown device", async () => { + const reviewedIntent = addDeviceReviewedIntent(); + const digest = await recipientReviewedIntentDigest(reviewedIntent); + const requestVerifier = vi.fn(async () => true); + const store = createMockStore({ getEnrollment: vi.fn(async () => null) }); + const app = createCoordinatorApp({ + storeFactory: () => store, + runtime: { adminSecret: () => null, now: () => "2026-03-28T00:00:00Z" }, + requestVerifier, + }); + + const response = await app.request("/v1/invites/add-device", { + method: "POST", + headers: { ...authHeaders("unknown-device"), "Content-Type": "application/json" }, + body: JSON.stringify({ + group_id: "g1", + expires_at: "2026-04-04T00:00:00Z", + reviewed_preview_digest: digest, + reviewed_intent: reviewedIntent, + }), + }); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unknown_device" }); + expect(requestVerifier).not.toHaveBeenCalled(); + expect(store.recordNonce).not.toHaveBeenCalled(); + expect(store.createInvite).not.toHaveBeenCalled(); + }); + + it("preserves bad-signature and nonce-replay authorization errors", async () => { + const reviewedIntent = addDeviceReviewedIntent(); + const digest = await recipientReviewedIntentDigest(reviewedIntent); + const body = JSON.stringify({ + group_id: "g1", + expires_at: "2026-04-04T00:00:00Z", + reviewed_preview_digest: digest, + reviewed_intent: reviewedIntent, + }); + const baseStore = { + getEnrollment: vi.fn(async () => enrolledDevice()), + getGroup: vi.fn(async () => activeGroup()), + }; + const invalidSignatureStore = createMockStore(baseStore); + const invalidSignatureApp = createCoordinatorApp({ + storeFactory: () => invalidSignatureStore, + runtime: { adminSecret: () => null, now: () => "2026-03-28T00:00:00Z" }, + requestVerifier: async () => false, + }); + const replayStore = createMockStore({ ...baseStore, recordNonce: vi.fn(async () => false) }); + const replayApp = createCoordinatorApp({ + storeFactory: () => replayStore, + runtime: { adminSecret: () => null, now: () => "2026-03-28T00:00:00Z" }, + requestVerifier: allowRequest, + }); + + const invalidSignature = await invalidSignatureApp.request("/v1/invites/add-device", { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body, + }); + const replay = await replayApp.request("/v1/invites/add-device", { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body, + }); + + expect(invalidSignature.status).toBe(401); + expect(await invalidSignature.json()).toEqual({ error: "invalid_signature" }); + expect(replay.status).toBe(401); + expect(await replay.json()).toEqual({ error: "nonce_replay" }); + expect(invalidSignatureStore.createInvite).not.toHaveBeenCalled(); + expect(replayStore.createInvite).not.toHaveBeenCalled(); + }); + + it("rejects every field outside the fixed add-device request contract", async () => { + const reviewedIntent = addDeviceReviewedIntent(); + const digest = await recipientReviewedIntentDigest(reviewedIntent); + const storeFactory = vi.fn(() => createMockStore()); + const app = createCoordinatorApp({ + storeFactory, + runtime: { adminSecret: () => null, now: () => "2026-03-28T00:00:00Z" }, + requestVerifier: allowRequest, + }); + const forbiddenFields = [ + ["invite_kind", "team_member"], + ["target_identity_id", "identity-other"], + ["policy", "approval_required"], + ["created_by", "identity-other"], + ["coordinator_url", "https://attacker.example.test"], + ["assigned_identity_id", "identity-other"], + ] as const; + + for (const [field, value] of forbiddenFields) { + const response = await app.request("/v1/invites/add-device", { + method: "POST", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ + group_id: "g1", + expires_at: "2026-04-04T00:00:00Z", + reviewed_preview_digest: digest, + reviewed_intent: reviewedIntent, + [field]: value, + }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "unexpected_add_device_invite_fields", + }); + } + expect(storeFactory).not.toHaveBeenCalled(); + }); + + it("rejects cross-Identity, malformed, and digest-mismatched reviewed intents", async () => { + const crossIdentityIntent = addDeviceReviewedIntent("identity-other"); + const crossIdentityDigest = await recipientReviewedIntentDigest(crossIdentityIntent); + const validIntent = addDeviceReviewedIntent(); + const store = createMockStore({ + getEnrollment: vi.fn(async () => enrolledDevice()), + getGroup: vi.fn(async () => activeGroup()), + }); + const app = createCoordinatorApp({ + storeFactory: () => store, + runtime: { adminSecret: () => null, now: () => "2026-03-28T00:00:00Z" }, + requestVerifier: allowRequest, + }); + const request = (reviewedIntent: unknown, digest: string, nonce: string) => + app.request("/v1/invites/add-device", { + method: "POST", + headers: { ...authHeaders("device-a", nonce), "Content-Type": "application/json" }, + body: JSON.stringify({ + group_id: "g1", + expires_at: "2026-04-04T00:00:00Z", + reviewed_preview_digest: digest, + reviewed_intent: reviewedIntent, + }), + }); + + const crossIdentity = await request(crossIdentityIntent, crossIdentityDigest, "nonce-cross"); + const malformed = await request( + { version: 1, journey: "add_device" }, + "f".repeat(64), + "nonce-malformed", + ); + const malformedDigest = await request(validIntent, "not-a-digest", "nonce-bad-digest"); + const mismatchedDigest = await request(validIntent, "f".repeat(64), "nonce-wrong-digest"); + + expect(crossIdentity.status).toBe(409); + expect(await crossIdentity.json()).toEqual({ error: "recipient_invite_intent_mismatch" }); + expect(malformed.status).toBe(400); + expect(await malformed.json()).toEqual({ error: "recipient_invite_review_unavailable" }); + expect(malformedDigest.status).toBe(400); + expect(await malformedDigest.json()).toEqual({ error: "reviewed_preview_digest_invalid" }); + expect(mismatchedDigest.status).toBe(409); + expect(await mismatchedDigest.json()).toEqual({ error: "recipient_invite_intent_mismatch" }); + expect(store.createInvite).not.toHaveBeenCalled(); + }); + }); + it("returns safe errors for unavailable or mismatched stored recipient reviews", async () => { const invite: CoordinatorInvite = { invite_id: "invite-team-1", diff --git a/packages/core/src/coordinator-api.ts b/packages/core/src/coordinator-api.ts index af29e28ec..154fc5fa0 100644 --- a/packages/core/src/coordinator-api.ts +++ b/packages/core/src/coordinator-api.ts @@ -145,10 +145,13 @@ async function authorizeRequest( return { ok: false, error: "missing_headers", enrollment: null }; } - const enrollment = await store.getEnrollment(opts.groupId, deviceId); + const enrollment = await store.getEnrollment(opts.groupId, deviceId, true); if (!enrollment) { return { ok: false, error: "unknown_device", enrollment: null }; } + if (enrollment.enabled !== 1) { + return { ok: false, error: "device_disabled", enrollment: null }; + } const group = await store.getGroup(opts.groupId); if (!group) { return { ok: false, error: "group_not_found", enrollment: null }; @@ -199,6 +202,12 @@ async function authorizeRequest( return { ok: true, error: "ok", enrollment }; } +function authErrorStatus(error: string): 401 | 403 | 409 { + if (error === "device_disabled") return 403; + if (error === "group_archived") return 409; + return 401; +} + // --------------------------------------------------------------------------- // App factory // --------------------------------------------------------------------------- @@ -402,7 +411,7 @@ export function createCoordinatorApp( auth, response: rateLimitedResponse(c, c.req.path, false) ?? - c.json({ error: auth.error }, auth.error === "group_archived" ? 409 : 401), + c.json({ error: auth.error }, authErrorStatus(auth.error)), }; } const limited = rateLimitedResponse(c, String(auth.enrollment.device_id), true); @@ -463,7 +472,7 @@ export function createCoordinatorApp( if (!auth.ok || !auth.enrollment) { const limited = rateLimitedResponse(c, c.req.path, false); if (limited) return limited; - return c.json({ error: auth.error }, auth.error === "group_archived" ? 409 : 401); + return c.json({ error: auth.error }, authErrorStatus(auth.error)); } const limited = rateLimitedResponse(c, String(auth.enrollment.device_id), true); if (limited) return limited; @@ -531,7 +540,7 @@ export function createCoordinatorApp( if (!auth.ok || !auth.enrollment) { const limited = rateLimitedResponse(c, c.req.path, false); if (limited) return limited; - return c.json({ error: auth.error }, auth.error === "group_archived" ? 409 : 401); + return c.json({ error: auth.error }, authErrorStatus(auth.error)); } const limited = rateLimitedResponse(c, String(auth.enrollment.device_id), true); if (limited) return limited; @@ -629,7 +638,10 @@ export function createCoordinatorApp( nonce: c.req.header("X-Opencode-Nonce") ?? null, }); if (!auth.ok || !auth.enrollment) { - return rateLimitedResponse(c, c.req.path, false) ?? c.json({ error: auth.error }, 401); + return ( + rateLimitedResponse(c, c.req.path, false) ?? + c.json({ error: auth.error }, authErrorStatus(auth.error)) + ); } const limited = rateLimitedResponse(c, String(auth.enrollment.device_id), true); if (limited) return limited; @@ -679,7 +691,10 @@ export function createCoordinatorApp( nonce: c.req.header("X-Opencode-Nonce") ?? null, }); if (!auth.ok || !auth.enrollment) { - return rateLimitedResponse(c, c.req.path, false) ?? c.json({ error: auth.error }, 401); + return ( + rateLimitedResponse(c, c.req.path, false) ?? + c.json({ error: auth.error }, authErrorStatus(auth.error)) + ); } const limited = rateLimitedResponse(c, String(auth.enrollment.device_id), true); if (limited) return limited; @@ -701,6 +716,125 @@ export function createCoordinatorApp( } }); + // ----------------------------------------------------------------------- + // POST /v1/invites/add-device — create an Identity-owned device invite + // ----------------------------------------------------------------------- + + app.post("/v1/invites/add-device", async (c) => { + const raw = await readRequestBytes(c); + if (raw == null) return c.json({ error: "body_too_large" }, 413); + const data = parseJsonObject(raw); + if (!data) return c.json({ error: "invalid_json" }, 400); + + const allowedFields = new Set([ + "group_id", + "expires_at", + "reviewed_preview_digest", + "reviewed_intent", + ]); + if (Object.keys(data).some((key) => !allowedFields.has(key))) { + return c.json({ error: "unexpected_add_device_invite_fields" }, 400); + } + + const groupId = String(data.group_id ?? "").trim(); + const expiresAt = String(data.expires_at ?? "").trim(); + const reviewedPreviewDigest = String(data.reviewed_preview_digest ?? "").trim(); + if (!groupId || !expiresAt) { + return c.json({ error: "group_id_and_expires_at_required" }, 400); + } + if (Number.isNaN(new Date(expiresAt).getTime())) { + return c.json({ error: "invalid_expires_at" }, 400); + } + if (!/^[a-f0-9]{64}$/u.test(reviewedPreviewDigest)) { + return c.json({ error: "reviewed_preview_digest_invalid" }, 400); + } + if (data.reviewed_intent == null) { + return c.json({ error: "recipient_invite_review_unavailable" }, 400); + } + + const store = createStore(); + try { + const auth = await authorizeRequest(store, runtime, requestVerifier, { + method: c.req.method, + url: c.req.url, + groupId, + body: raw, + deviceId: c.req.header("X-Opencode-Device") ?? null, + signature: c.req.header("X-Opencode-Signature") ?? null, + timestamp: c.req.header("X-Opencode-Timestamp") ?? null, + nonce: c.req.header("X-Opencode-Nonce") ?? null, + }); + if (!auth.ok || !auth.enrollment) { + return ( + rateLimitedResponse(c, c.req.path, false) ?? + c.json({ error: auth.error }, authErrorStatus(auth.error)) + ); + } + const limited = rateLimitedResponse(c, String(auth.enrollment.device_id), true); + if (limited) return limited; + + const targetIdentityId = auth.enrollment.identity_id; + if ( + !targetIdentityId || + targetIdentityId !== targetIdentityId.trim() || + targetIdentityId.length > 256 || + /[\p{Cc}\p{Cf}]/u.test(targetIdentityId) + ) { + return c.json({ error: "identity_binding_required" }, 403); + } + + let reviewedIntent: RecipientReviewedIntentV1; + try { + reviewedIntent = await verifyRecipientReviewedIntent(data.reviewed_intent, { + target: { kind: "add_device", targetIdentityId }, + digest: reviewedPreviewDigest, + }); + } catch (error) { + if ( + error instanceof RecipientReviewedIntentError && + error.code === "recipient_invite_intent_mismatch" + ) { + return c.json({ error: error.code }, 409); + } + return c.json({ error: "recipient_invite_review_unavailable" }, 400); + } + + const invite = await store.createInvite({ + groupId, + policy: "auto_admit", + expiresAt, + createdBy: targetIdentityId, + inviteKind: "add_device", + targetIdentityId, + reviewedPreviewDigest, + reviewedIntent, + }); + const payload: InvitePayload = { + v: 1, + kind: "add_device", + coordinator_url: new URL(c.req.url).origin, + group_id: groupId, + policy: invite.policy, + token: String(invite.token ?? ""), + expires_at: invite.expires_at, + team_name: (invite.team_name_snapshot as string) ?? null, + target_identity_id: invite.target_identity_id ?? undefined, + reviewed_preview_digest: invite.reviewed_preview_digest ?? undefined, + }; + const encoded = encodeInvitePayload(payload); + const { token: _token, ...inviteWithoutToken } = invite; + return c.json({ + ok: true, + invite: inviteWithoutToken, + payload, + encoded, + link: inviteLink(encoded), + }); + } finally { + await store.close(); + } + }); + // ----------------------------------------------------------------------- // Admin routes // ----------------------------------------------------------------------- diff --git a/packages/core/src/coordinator-store-contract.ts b/packages/core/src/coordinator-store-contract.ts index f15d20312..5dcd16165 100644 --- a/packages/core/src/coordinator-store-contract.ts +++ b/packages/core/src/coordinator-store-contract.ts @@ -449,7 +449,11 @@ export interface CoordinatorStore { unarchiveGroup(groupId: string): Promise; enrollDevice(groupId: string, opts: CoordinatorEnrollDeviceInput): Promise; listEnrolledDevices(groupId: string, includeDisabled?: boolean): Promise; - getEnrollment(groupId: string, deviceId: string): Promise; + getEnrollment( + groupId: string, + deviceId: string, + includeDisabled?: boolean, + ): Promise; renameDevice(groupId: string, deviceId: string, displayName: string): Promise; setDeviceEnabled(groupId: string, deviceId: string, enabled: boolean): Promise; removeDevice(groupId: string, deviceId: string): Promise; diff --git a/packages/core/src/coordinator-store-test-harness.ts b/packages/core/src/coordinator-store-test-harness.ts index ddd655c55..2e8f9063b 100644 --- a/packages/core/src/coordinator-store-test-harness.ts +++ b/packages/core/src/coordinator-store-test-harness.ts @@ -769,6 +769,10 @@ export function runCoordinatorStoreContract( await store.setDeviceEnabled("g1", "d1", false); expect(await store.listEnrolledDevices("g1")).toHaveLength(0); expect(await store.listEnrolledDevices("g1", true)).toHaveLength(1); + expect(await store.getEnrollment("g1", "d1", true)).toMatchObject({ + device_id: "d1", + enabled: 0, + }); await store.setDeviceEnabled("g1", "d1", true); expect(await store.listEnrolledDevices("g1")).toHaveLength(1); }); diff --git a/packages/core/src/d1-coordinator-store.ts b/packages/core/src/d1-coordinator-store.ts index 6d2de63dc..e22396f29 100644 --- a/packages/core/src/d1-coordinator-store.ts +++ b/packages/core/src/d1-coordinator-store.ts @@ -759,12 +759,17 @@ export class D1CoordinatorStore implements CoordinatorStore { ).map((row) => rowToRecord(row)); } - async getEnrollment(_groupId: string, _deviceId: string): Promise { + async getEnrollment( + _groupId: string, + _deviceId: string, + _includeDisabled = false, + ): Promise { + const enabledClause = _includeDisabled ? "" : "AND enabled = 1"; const row = await firstRow( this.db .prepare(`SELECT ${ENROLLMENT_COLUMNS} FROM enrolled_devices - WHERE group_id = ? AND device_id = ? AND enabled = 1`) + WHERE group_id = ? AND device_id = ? ${enabledClause}`) .bind(_groupId, _deviceId), ); return row ? rowToRecord(row) : null; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 53bd080ca..535c3ee6e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export { } from "./codex-hooks.js"; export { coordinatorArchiveGroupAction, + coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, diff --git a/packages/viewer-server/src/index.test.ts b/packages/viewer-server/src/index.test.ts index 4b0913d35..99d63a50b 100644 --- a/packages/viewer-server/src/index.test.ts +++ b/packages/viewer-server/src/index.test.ts @@ -10396,9 +10396,11 @@ describe("viewer-server", () => { const keysDir = join(configDir, "keys"); const previousConfig = process.env.CODEMEM_CONFIG; const previousKeysDir = process.env.CODEMEM_KEYS_DIR; + const previousAdminSecret = process.env.CODEMEM_SYNC_COORDINATOR_ADMIN_SECRET; const previousFetch = globalThis.fetch; process.env.CODEMEM_CONFIG = configPath; process.env.CODEMEM_KEYS_DIR = keysDir; + delete process.env.CODEMEM_SYNC_COORDINATOR_ADMIN_SECRET; writeFileSync( configPath, JSON.stringify({ @@ -10408,37 +10410,48 @@ describe("viewer-server", () => { }), ); const coordinatorBodies: Record[] = []; - globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const coordinatorUrls: string[] = []; + const coordinatorHeaders: Headers[] = []; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); const body = JSON.parse( init?.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : String(init?.body ?? "{}"), ) as Record; coordinatorBodies.push(body); + coordinatorUrls.push(url); + coordinatorHeaders.push(new Headers(init?.headers)); + const signedAddDevice = url.endsWith("/v1/invites/add-device"); + const reviewedIntent = body.reviewed_intent as + | Extract + | undefined; + const kind = signedAddDevice ? "add_device" : String(body.invite_kind); + const targetIdentityId = signedAddDevice + ? reviewedIntent?.targetIdentity.identityId + : body.target_identity_id; const payload = { v: 1, - kind: body.invite_kind, + kind, coordinator_url: "https://coord.example.test", group_id: body.group_id, - policy: body.policy, - token: `token-${String(body.invite_kind)}`, + policy: signedAddDevice ? "auto_admit" : body.policy, + token: `token-${kind}`, expires_at: body.expires_at, team_name: null, policy_team_id: body.policy_team_id ?? undefined, - target_identity_id: body.target_identity_id ?? undefined, - assigned_identity_id: - body.invite_kind === "team_member" ? "identity-assigned-team" : undefined, + target_identity_id: targetIdentityId ?? undefined, + assigned_identity_id: kind === "team_member" ? "identity-assigned-team" : undefined, reviewed_preview_digest: body.reviewed_preview_digest, }; return new Response( JSON.stringify({ invite: { - invite_id: `invite-${String(body.invite_kind)}`, - invite_kind: body.invite_kind, + invite_id: `invite-${kind}`, + invite_kind: kind, policy_team_id: body.policy_team_id, - target_identity_id: body.target_identity_id, - assigned_identity_id: - body.invite_kind === "team_member" ? "identity-assigned-team" : null, + target_identity_id: targetIdentityId, + assigned_identity_id: kind === "team_member" ? "identity-assigned-team" : null, reviewed_preview_digest: body.reviewed_preview_digest, }, payload, @@ -10491,6 +10504,10 @@ describe("viewer-server", () => { ); } expect(coordinatorBodies).toHaveLength(2); + expect(coordinatorUrls).toEqual([ + "https://coord.example.test/v1/admin/invites", + "https://coord.example.test/v1/admin/invites", + ]); expect(coordinatorBodies[0]).toMatchObject({ invite_kind: "team_member", policy_team_id: "policy-team-a", @@ -10499,10 +10516,11 @@ describe("viewer-server", () => { invite_kind: "add_device", target_identity_id: store.actorId, }); + expect(coordinatorHeaders[0]?.get("X-Codemem-Coordinator-Admin")).toBeTruthy(); + expect(coordinatorHeaders[1]?.get("X-Codemem-Coordinator-Admin")).toBeTruthy(); for (const body of coordinatorBodies) { expect(body.reviewed_intent).toMatchObject({ version: 1, - journey: body.invite_kind === "team_member" ? "team" : "add_device", }); expect(body.reviewed_preview_digest).toBe( await core.recipientReviewedIntentDigest(body.reviewed_intent), @@ -10521,6 +10539,116 @@ describe("viewer-server", () => { else process.env.CODEMEM_CONFIG = previousConfig; if (previousKeysDir == null) delete process.env.CODEMEM_KEYS_DIR; else process.env.CODEMEM_KEYS_DIR = previousKeysDir; + if (previousAdminSecret == null) delete process.env.CODEMEM_SYNC_COORDINATOR_ADMIN_SECRET; + else process.env.CODEMEM_SYNC_COORDINATOR_ADMIN_SECRET = previousAdminSecret; + rmSync(configDir, { recursive: true, force: true }); + } + }); + + it("creates add-device invites without an admin secret while Team creation stays admin-only", async () => { + const configDir = mkdtempSync(join(tmpdir(), "codemem-signed-add-device-invite-")); + const configPath = join(configDir, "config.json"); + const keysDir = join(configDir, "keys"); + const previousConfig = process.env.CODEMEM_CONFIG; + const previousKeysDir = process.env.CODEMEM_KEYS_DIR; + const previousAdminSecret = process.env.CODEMEM_SYNC_COORDINATOR_ADMIN_SECRET; + const previousFetch = globalThis.fetch; + process.env.CODEMEM_CONFIG = configPath; + process.env.CODEMEM_KEYS_DIR = keysDir; + delete process.env.CODEMEM_SYNC_COORDINATOR_ADMIN_SECRET; + writeFileSync( + configPath, + JSON.stringify({ + sync_coordinator_url: "https://coord.example.test", + sync_coordinator_group: "coordinator-a", + actor_display_name: "Owner", + }), + ); + const { app, ensureStore, cleanup } = createTestApp({ seedDevice: false }); + try { + const store = ensureStore(); + const [deviceId] = ensureDeviceIdentity(store.db, { keysDir }); + store.adoptEnsuredDeviceIdentity(deviceId); + let signedBody: Record | null = null; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("https://coord.example.test/v1/invites/add-device"); + signedBody = JSON.parse( + init?.body instanceof Uint8Array + ? new TextDecoder().decode(init.body) + : String(init?.body ?? "{}"), + ) as Record; + const digest = String(signedBody.reviewed_preview_digest); + return new Response( + JSON.stringify({ + invite: { + invite_id: "invite-add-device", + invite_kind: "add_device", + target_identity_id: store.actorId, + reviewed_preview_digest: digest, + }, + payload: { + kind: "add_device", + target_identity_id: store.actorId, + reviewed_preview_digest: digest, + }, + encoded: "signed-add-device", + link: "codemem://join?invite=signed-add-device", + }), + { status: 200 }, + ); + }) as typeof fetch; + + const preview = await app.request("/api/sync/recipient-policy/v1/invites/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "add_device", target_identity_id: store.actorId }), + }); + expect(preview.status).toBe(200); + const previewBody = (await preview.json()) as { + preview: { reviewedOnboardingDigest: string }; + }; + const created = await app.request("/api/sync/recipient-policy/v1/invites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + kind: "add_device", + target_identity_id: store.actorId, + reviewed_onboarding_digest: previewBody.preview.reviewedOnboardingDigest, + }), + }); + expect(created.status, JSON.stringify(await created.clone().json())).toBe(200); + expect(signedBody).not.toHaveProperty("target_identity_id"); + expect(signedBody).not.toHaveProperty("invite_kind"); + + const teamPreview = await app.request("/api/sync/recipient-policy/v1/invites/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "team_member", policy_team_id: "policy-team-a" }), + }); + expect(teamPreview.status).toBe(400); + expect(await teamPreview.json()).toMatchObject({ + error: "coordinator_admin_secret_missing", + }); + + const crossIdentity = await app.request("/api/sync/recipient-policy/v1/invites/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + kind: "add_device", + target_identity_id: "identity-other", + }), + }); + expect(crossIdentity.status).toBe(400); + expect(await crossIdentity.json()).toEqual({ error: "invite_identity_conflict" }); + } finally { + cleanup(); + globalThis.fetch = previousFetch; + if (previousConfig == null) delete process.env.CODEMEM_CONFIG; + else process.env.CODEMEM_CONFIG = previousConfig; + if (previousKeysDir == null) delete process.env.CODEMEM_KEYS_DIR; + else process.env.CODEMEM_KEYS_DIR = previousKeysDir; + if (previousAdminSecret == null) delete process.env.CODEMEM_SYNC_COORDINATOR_ADMIN_SECRET; + else process.env.CODEMEM_SYNC_COORDINATOR_ADMIN_SECRET = previousAdminSecret; rmSync(configDir, { recursive: true, force: true }); } }); diff --git a/packages/viewer-server/src/routes/sync.ts b/packages/viewer-server/src/routes/sync.ts index cecff935a..417f45ae1 100644 --- a/packages/viewer-server/src/routes/sync.ts +++ b/packages/viewer-server/src/routes/sync.ts @@ -40,6 +40,7 @@ import { cleanupNonces, commitRecipientPolicyEdges, coordinatorArchiveGroupAction, + coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, @@ -430,6 +431,22 @@ function coordinatorAdminUnavailable(status: ReturnType, + kind: RecipientInviteKind, +): { body: Record; httpStatus: 400 } | null { + if (status.readiness === "not_configured") { + return { body: { error: "coordinator_not_configured", status }, httpStatus: 400 }; + } + if (!status.has_groups) { + return { body: { error: "coordinator_group_missing", status }, httpStatus: 400 }; + } + if (kind === "team_member" && !status.has_admin_secret) { + return { body: { error: "coordinator_admin_secret_missing", status }, httpStatus: 400 }; + } + return null; +} + async function parseViewerJsonBody( c: Context, options: { allowEmpty?: boolean } = {}, @@ -4894,17 +4911,20 @@ export function syncRoutes( const store = getStore(); const body = await parseViewerJsonBody(c); if (!body) return c.json({ error: "request_invalid" }, 400); - const config = readCoordinatorSyncConfig(); - const status = coordinatorAdminStatusPayload(config); - const unavailable = coordinatorAdminUnavailable(status); - if (unavailable) return c.json(unavailable.body, unavailable.httpStatus); try { const kind = recipientInviteKind(body.kind ?? body.invite_kind); + const config = readCoordinatorSyncConfig(); + const status = coordinatorAdminStatusPayload(config); + const unavailable = recipientInviteCreationUnavailable(status, kind); + if (unavailable) return c.json(unavailable.body, unavailable.httpStatus); const targetId = kind === "team_member" ? optionalViewerStrictString(body, "policy_team_id") : optionalViewerStrictString(body, "target_identity_id"); if (!targetId) throw new Error("recipient_invite_metadata_invalid"); + if (kind === "add_device" && targetId !== store.actorId) { + throw new Error("invite_identity_conflict"); + } const preview = recipientInviteOnboardingPreview( store, body, @@ -4923,17 +4943,20 @@ export function syncRoutes( const store = getStore(); const body = await parseViewerJsonBody(c); if (!body) return c.json({ error: "request_invalid" }, 400); - const config = readCoordinatorSyncConfig(); - const status = coordinatorAdminStatusPayload(config); - const unavailable = coordinatorAdminUnavailable(status); - if (unavailable) return c.json(unavailable.body, unavailable.httpStatus); try { const kind = recipientInviteKind(body.kind ?? body.invite_kind); + const config = readCoordinatorSyncConfig(); + const status = coordinatorAdminStatusPayload(config); + const unavailable = recipientInviteCreationUnavailable(status, kind); + if (unavailable) return c.json(unavailable.body, unavailable.httpStatus); const targetId = kind === "team_member" ? optionalViewerStrictString(body, "policy_team_id") : optionalViewerStrictString(body, "target_identity_id"); if (!targetId) throw new Error("recipient_invite_metadata_invalid"); + if (kind === "add_device" && targetId !== store.actorId) { + throw new Error("invite_identity_conflict"); + } const preview = recipientInviteOnboardingPreview( store, body, @@ -4950,20 +4973,35 @@ export function syncRoutes( const ttlHours = parseInviteTtlHours(body.ttl_hours); if (!groupId) throw new Error("group_id_required"); if (ttlHours == null) throw new Error("ttl_hours_invalid"); - const result = await coordinatorCreateInviteAction({ - groupId, - coordinatorUrl: config.syncCoordinatorUrl || null, - policy: "auto_admit", - ttlHours, - createdBy: store.actorId, - remoteUrl: config.syncCoordinatorUrl || null, - adminSecret: config.syncCoordinatorAdminSecret || null, - inviteKind: kind, - policyTeamId: kind === "team_member" ? targetId : null, - targetIdentityId: kind === "add_device" ? targetId : null, - reviewedPreviewDigest, - reviewedIntent, - }); + // Initial owner enrollments intentionally remain Identity-unbound, so an explicitly + // admin-configured owner selects admin issuance up front. Non-admin devices use the + // signed Identity-bound endpoint; failures never fall back across this boundary. + const useAdminIssuance = kind === "team_member" || status.has_admin_secret; + const result = useAdminIssuance + ? await coordinatorCreateInviteAction({ + groupId, + coordinatorUrl: config.syncCoordinatorUrl || null, + policy: "auto_admit", + ttlHours, + createdBy: store.actorId, + remoteUrl: config.syncCoordinatorUrl || null, + adminSecret: config.syncCoordinatorAdminSecret || null, + inviteKind: kind, + policyTeamId: kind === "team_member" ? targetId : null, + targetIdentityId: kind === "add_device" ? targetId : null, + reviewedPreviewDigest, + reviewedIntent, + }) + : await coordinatorCreateAddDeviceInviteAction({ + groupId, + coordinatorUrl: config.syncCoordinatorUrl || null, + ttlHours, + deviceId: preview.binding.deviceId, + keysDir: syncKeysDir(), + remoteUrl: config.syncCoordinatorUrl || null, + reviewedPreviewDigest, + reviewedIntent, + }); return c.json({ ok: true, kind, preview, invite: result }); } catch (error) { return c.json(