Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/plans/2026-07-25-signed-add-device-invites-design.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions docs/plans/2026-07-25-signed-add-device-invites-implementation.md
Original file line number Diff line number Diff line change
@@ -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.
149 changes: 149 additions & 0 deletions packages/cloudflare-coordinator-worker/test/worker.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/better-sqlite-coordinator-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,11 +945,16 @@ export class BetterSqliteCoordinatorStore implements CoordinatorStore {
.map((row) => rowToRecord<CoordinatorEnrollment>(row));
}

async getEnrollment(groupId: string, deviceId: string): Promise<CoordinatorEnrollment | null> {
async getEnrollment(
groupId: string,
deviceId: string,
includeDisabled = false,
): Promise<CoordinatorEnrollment | null> {
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<CoordinatorEnrollment>(row) : null;
}
Expand Down
Loading