From ebf0231da91c046b1870bd92b9fcbd7128b1b84e Mon Sep 17 00:00:00 2001 From: Krusty Date: Tue, 3 Mar 2026 00:21:58 -0800 Subject: [PATCH 1/2] test(mission-control): add API-seeded perf fixture path for AC5 assertions --- e2e/mission-control-phase1.spec.ts | 84 +++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 8 deletions(-) diff --git a/e2e/mission-control-phase1.spec.ts b/e2e/mission-control-phase1.spec.ts index ed47166..1630e92 100644 --- a/e2e/mission-control-phase1.spec.ts +++ b/e2e/mission-control-phase1.spec.ts @@ -9,6 +9,8 @@ interface PerfFixture { activityOpenRuns?: number; activityOpenP95Ms?: number; itemsPerList?: number; + seedListCount?: number; + seedViaApi?: boolean; } function loadPerfFixture(): PerfFixture { @@ -53,6 +55,47 @@ async function createItem(page: Page, itemName: string) { await expect(page.getByText(itemName)).toBeVisible({ timeout: 5000 }); } +function convexSiteUrl() { + const convexUrl = process.env.VITE_CONVEX_URL; + if (!convexUrl) return null; + if (convexUrl.includes("127.0.0.1") || convexUrl.includes("localhost")) { + return convexUrl.replace(":3210", ":3211"); + } + return convexUrl.replace(".convex.cloud", ".convex.site"); +} + +async function seedPerfListsViaApi(page: Page, listCount: number, itemsPerList: number) { + const token = process.env.E2E_AUTH_TOKEN; + const apiBase = convexSiteUrl(); + if (!token || !apiBase) return { seeded: false as const, listNames: [] as string[] }; + + const listNames: string[] = []; + for (let i = 0; i < listCount; i += 1) { + const listName = `Seeded Perf List ${i + 1}`; + const listResp = await page.request.post(`${apiBase}/api/lists/create`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + assetDid: `did:webvh:e2e.poo.app:lists:${Date.now()}-${i}-${Math.random().toString(36).slice(2, 8)}`, + name: listName, + }, + }); + if (!listResp.ok()) return { seeded: false as const, listNames: [] as string[] }; + + const { listId } = (await listResp.json()) as { listId: string }; + listNames.push(listName); + + for (let j = 0; j < itemsPerList; j += 1) { + const itemResp = await page.request.post(`${apiBase}/api/items/add`, { + headers: { Authorization: `Bearer ${token}` }, + data: { listId, name: `Perf Item ${i + 1}.${j + 1}` }, + }); + if (!itemResp.ok()) return { seeded: false as const, listNames: [] as string[] }; + } + } + + return { seeded: true as const, listNames }; +} + function p95(values: number[]) { const sorted = [...values].sort((a, b) => a - b); const idx = Math.ceil(sorted.length * 0.95) - 1; @@ -164,13 +207,23 @@ test.describe("Mission Control Phase 1 acceptance", () => { const runs = perfFixture.listOpenRuns ?? 6; const thresholdMs = perfFixture.listOpenP95Ms ?? 500; const itemsPerList = perfFixture.itemsPerList ?? 1; + const seedListCount = perfFixture.seedListCount ?? runs; + const shouldSeedViaApi = perfFixture.seedViaApi ?? Boolean(process.env.MISSION_CONTROL_FIXTURE_PATH); + + let seededListNames: string[] = []; + if (shouldSeedViaApi) { + const seeded = await seedPerfListsViaApi(page, seedListCount, itemsPerList); + if (seeded.seeded) seededListNames = seeded.listNames; + } for (let i = 0; i < runs; i += 1) { - const listName = `Perf List ${i + 1}`; - await createList(page, listName); + const listName = seededListNames[i] ?? `Perf List ${i + 1}`; + if (!seededListNames[i]) { + await createList(page, listName); - for (let j = 0; j < itemsPerList; j += 1) { - await createItem(page, `Perf Item ${i + 1}.${j + 1}`); + for (let j = 0; j < itemsPerList; j += 1) { + await createItem(page, `Perf Item ${i + 1}.${j + 1}`); + } } await page.getByRole("link", { name: "Back to lists" }).click(); @@ -185,20 +238,35 @@ test.describe("Mission Control Phase 1 acceptance", () => { } const listOpenP95 = p95(samples); - test.info().annotations.push({ type: "metric", description: `list_open_p95_ms=${listOpenP95};samples=${samples.join(",")};fixturePath=${process.env.MISSION_CONTROL_FIXTURE_PATH ?? "none"}` }); + test.info().annotations.push({ type: "metric", description: `list_open_p95_ms=${listOpenP95};samples=${samples.join(",")};fixturePath=${process.env.MISSION_CONTROL_FIXTURE_PATH ?? "none"};seededLists=${seededListNames.length}` }); expect(listOpenP95).toBeLessThan(thresholdMs); }); test("AC5b perf floor harness: activity panel load P95 <700ms", async ({ page }) => { const setup = await openAuthenticatedApp(page, "MC Perf Activity User"); test.skip(!setup.ready, !setup.ready ? setup.reason : ""); - await createList(page, "MC Perf Activity List"); + + const runs = perfFixture.activityOpenRuns ?? 6; + const itemsPerList = perfFixture.itemsPerList ?? 1; + const shouldSeedViaApi = perfFixture.seedViaApi ?? Boolean(process.env.MISSION_CONTROL_FIXTURE_PATH); + + if (shouldSeedViaApi) { + const seeded = await seedPerfListsViaApi(page, 1, Math.max(itemsPerList, runs)); + if (seeded.seeded) { + await page.getByRole("link", { name: "Back to lists" }).click(); + await expect(page.getByRole("heading", { name: "Your Lists" })).toBeVisible({ timeout: 10000 }); + await page.getByRole("heading", { name: /seeded perf list/i }).first().click(); + } else { + await createList(page, "MC Perf Activity List"); + } + } else { + await createList(page, "MC Perf Activity List"); + } const hasActivityPanel = (await page.getByRole("button", { name: /activity/i }).count()) > 0; test.skip(!hasActivityPanel, "Activity panel UI is not in current build; harness reserved for Phase 1 completion."); const samples: number[] = []; - const runs = perfFixture.activityOpenRuns ?? 6; const thresholdMs = perfFixture.activityOpenP95Ms ?? 700; for (let i = 0; i < runs; i += 1) { @@ -210,7 +278,7 @@ test.describe("Mission Control Phase 1 acceptance", () => { } const activityOpenP95 = p95(samples); - test.info().annotations.push({ type: "metric", description: `activity_open_p95_ms=${activityOpenP95};samples=${samples.join(",")};fixturePath=${process.env.MISSION_CONTROL_FIXTURE_PATH ?? "none"}` }); + test.info().annotations.push({ type: "metric", description: `activity_open_p95_ms=${activityOpenP95};samples=${samples.join(",")};fixturePath=${process.env.MISSION_CONTROL_FIXTURE_PATH ?? "none"};seedMode=${shouldSeedViaApi ? "api" : "ui"}` }); expect(activityOpenP95).toBeLessThan(thresholdMs); }); }); From 8bdaa54389786f3057e7d750761d91170fb78752 Mon Sep 17 00:00:00 2001 From: Krusty Date: Tue, 3 Mar 2026 00:22:58 -0800 Subject: [PATCH 2/2] feat(mission-control): add zero-downtime API key rotation validation slice --- API.md | 3 +- convex/missionControlApi.ts | 12 ++- .../api-key-rotation-zero-downtime.md | 40 ++++++++ docs/mission-control/mission-runs-api.md | 4 + package.json | 4 +- scripts/validate-api-key-rotation.mjs | 98 +++++++++++++++++++ 6 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 docs/mission-control/api-key-rotation-zero-downtime.md create mode 100644 scripts/validate-api-key-rotation.mjs diff --git a/API.md b/API.md index 51517a4..bf52233 100644 --- a/API.md +++ b/API.md @@ -202,7 +202,8 @@ New endpoints for Agent Mission Control with scoped API keys. - body: `{ "label": "CI Agent", "scopes": ["tasks:read","memory:write"] }` - `POST /api/v1/auth/keys/:keyId/rotate` — zero-downtime rotation (JWT only) - creates a new key, keeps old key active for grace period - - body: `{ "gracePeriodHours": 24, "label": "CI Agent v2" }` + - body: `{ "gracePeriodHours": 24, "label": "CI Agent v2" }` or `{ "gracePeriodMinutes": 30, "label": "CI Agent v2" }` + - provide exactly one grace field: `gracePeriodHours` (1..168) or `gracePeriodMinutes` (1..10080) - `POST /api/v1/auth/keys/:keyId/finalize-rotation` — revoke old key after cutover (JWT only) - `DELETE /api/v1/auth/keys/:keyId` — revoke key immediately (JWT only) diff --git a/convex/missionControlApi.ts b/convex/missionControlApi.ts index 5a70257..9e3e881 100644 --- a/convex/missionControlApi.ts +++ b/convex/missionControlApi.ts @@ -228,6 +228,7 @@ export const apiKeyByIdHandler = httpAction(async (ctx, request) => { const body = await request.json().catch(() => ({})) as { label?: string; gracePeriodHours?: number; + gracePeriodMinutes?: number; expiresAt?: number; }; @@ -236,8 +237,14 @@ export const apiKeyByIdHandler = httpAction(async (ctx, request) => { if (!oldKey) return errorResponse(request, "API key not found", 404); if (oldKey.revokedAt) return errorResponse(request, "Cannot rotate revoked API key", 400); - const gracePeriodHours = Math.min(Math.max(Math.floor(body.gracePeriodHours ?? 24), 1), 168); - const graceEndsAt = Date.now() + gracePeriodHours * 60 * 60 * 1000; + if (body.gracePeriodHours !== undefined && body.gracePeriodMinutes !== undefined) { + return errorResponse(request, "Provide either gracePeriodHours or gracePeriodMinutes, not both", 400); + } + + const gracePeriodMs = body.gracePeriodMinutes !== undefined + ? Math.min(Math.max(Math.floor(body.gracePeriodMinutes), 1), 7 * 24 * 60) * 60 * 1000 + : Math.min(Math.max(Math.floor(body.gracePeriodHours ?? 24), 1), 168) * 60 * 60 * 1000; + const graceEndsAt = Date.now() + gracePeriodMs; const rawKey = `pa_${randomToken(8)}_${randomToken(24)}`; const keyPrefix = rawKey.slice(0, 12); const keyHash = await sha256Hex(rawKey); @@ -260,6 +267,7 @@ export const apiKeyByIdHandler = httpAction(async (ctx, request) => { rotationEventId: result.rotationEventId, oldKeyId: keyId, oldKeyGraceEndsAt: graceEndsAt, + gracePeriodMs, newKeyId: result.newKeyId, apiKey: rawKey, keyPrefix, diff --git a/docs/mission-control/api-key-rotation-zero-downtime.md b/docs/mission-control/api-key-rotation-zero-downtime.md new file mode 100644 index 0000000..30543b8 --- /dev/null +++ b/docs/mission-control/api-key-rotation-zero-downtime.md @@ -0,0 +1,40 @@ +# API key rotation flow with zero downtime + +Launch-gate runbook for rotating Mission Control API keys without interrupting agent traffic. + +## Flow + +1. Create or select existing key. +2. Rotate old key (`POST /api/v1/auth/keys/:id/rotate`) to mint a new key. +3. During grace period, both old + new keys are valid. +4. Roll out the new key to all clients. +5. Finalize (`POST /api/v1/auth/keys/:id/finalize-rotation`) to revoke old key. + +## Rotate payload + +```json +{ + "label": "mission-control-prod-v2", + "gracePeriodMinutes": 30 +} +``` + +Supported grace controls: +- `gracePeriodMinutes` (1..10080) +- `gracePeriodHours` (1..168) + +Provide exactly one grace field. + +## Validation command + +```bash +MISSION_CONTROL_BASE_URL="https://" \ +MISSION_CONTROL_BEARER_TOKEN="" \ +MISSION_CONTROL_ROTATION_GRACE_MINUTES=10 \ +npm run mission-control:validate-key-rotation +``` + +Validator asserts: +- old key works before rotate +- old + new keys both work during grace window +- after finalize: old key fails, new key works diff --git a/docs/mission-control/mission-runs-api.md b/docs/mission-control/mission-runs-api.md index 2b211d8..00fa1fd 100644 --- a/docs/mission-control/mission-runs-api.md +++ b/docs/mission-control/mission-runs-api.md @@ -65,3 +65,7 @@ Requires scope: `dashboard:read`. `DELETE /api/v1/runs/:id` Requires scope: `runs:control`. + +## API key rotation launch-gate + +See `docs/mission-control/api-key-rotation-zero-downtime.md`. diff --git a/package.json b/package.json index 7c7c1c1..3661c3a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "env:turnkey:prod": "bash ./scripts/sync-convex-turnkey-env.sh .env.local prod", "cap:sync": "npx cap sync", "cap:build": "npm run build && npx cap sync", - "mission-control:readiness-drill": "node scripts/mission-control-readiness-drill.mjs" + "mission-control:readiness-drill": "node scripts/mission-control-readiness-drill.mjs", + "mission-control:validate-retention": "node scripts/validate-mission-control-retention.mjs", + "mission-control:validate-key-rotation": "node scripts/validate-api-key-rotation.mjs" }, "dependencies": { "@capacitor/android": "^8.0.2", diff --git a/scripts/validate-api-key-rotation.mjs b/scripts/validate-api-key-rotation.mjs new file mode 100644 index 0000000..68d31fc --- /dev/null +++ b/scripts/validate-api-key-rotation.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +const baseUrl = process.env.MISSION_CONTROL_BASE_URL; +const bearerToken = process.env.MISSION_CONTROL_BEARER_TOKEN; +const probePath = process.env.MISSION_CONTROL_ROTATION_PROBE_PATH || "/api/v1/dashboard/runs"; +const gracePeriodMinutes = Number(process.env.MISSION_CONTROL_ROTATION_GRACE_MINUTES || 10); +const label = process.env.MISSION_CONTROL_ROTATION_LABEL || `rotation-drill-${Date.now()}`; + +function fail(message, code = 1) { + console.error(`❌ ${message}`); + process.exit(code); +} + +function ok(message) { + console.log(`✅ ${message}`); +} + +async function callWithJwt(path, { method = "GET", body } = {}) { + const res = await fetch(`${baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + let data; + try { data = await res.json(); } catch { data = { raw: await res.text() }; } + return { ok: res.ok, status: res.status, data }; +} + +async function callWithApiKey(apiKey, path, { method = "GET", body } = {}) { + const res = await fetch(`${baseUrl}${path}`, { + method, + headers: { + "X-API-Key": apiKey, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + let data; + try { data = await res.json(); } catch { data = { raw: await res.text() }; } + return { ok: res.ok, status: res.status, data }; +} + +async function main() { + console.log("Mission Control API key zero-downtime rotation validator"); + + if (!baseUrl || !bearerToken) { + console.log("⚠️ Skipping remote validation: set MISSION_CONTROL_BASE_URL + MISSION_CONTROL_BEARER_TOKEN"); + ok("Validator wiring looks good (env-less mode)"); + return; + } + + const created = await callWithJwt("/api/v1/auth/keys", { + method: "POST", + body: { label, scopes: ["dashboard:read"] }, + }); + if (!created.ok) fail(`create key failed (${created.status}) ${JSON.stringify(created.data)}`); + + const oldKeyId = created.data?.keyId; + const oldApiKey = created.data?.apiKey; + if (!oldKeyId || !oldApiKey) fail("create key response missing keyId/apiKey"); + ok(`created baseline key ${oldKeyId}`); + + if (!(await callWithApiKey(oldApiKey, probePath)).ok) fail("old key probe failed before rotation"); + ok("old key accepted before rotation"); + + const rotated = await callWithJwt(`/api/v1/auth/keys/${oldKeyId}/rotate`, { + method: "POST", + body: { label: `${label}-next`, gracePeriodMinutes }, + }); + if (!rotated.ok) fail(`rotate failed (${rotated.status}) ${JSON.stringify(rotated.data)}`); + + const newKeyId = rotated.data?.newKeyId; + const newApiKey = rotated.data?.apiKey; + if (!newKeyId || !newApiKey) fail("rotate response missing newKeyId/apiKey"); + ok(`rotated to new key ${newKeyId}`); + + if (!(await callWithApiKey(oldApiKey, probePath)).ok) fail("old key should work during grace"); + if (!(await callWithApiKey(newApiKey, probePath)).ok) fail("new key should work during grace"); + ok("zero-downtime overlap confirmed (old + new key accepted during grace)"); + + const finalized = await callWithJwt(`/api/v1/auth/keys/${oldKeyId}/finalize-rotation`, { method: "POST" }); + if (!finalized.ok) fail(`finalize failed (${finalized.status}) ${JSON.stringify(finalized.data)}`); + ok("finalized rotation"); + + if ((await callWithApiKey(oldApiKey, probePath)).ok) fail("old key should fail after finalize"); + if (!(await callWithApiKey(newApiKey, probePath)).ok) fail("new key should still work after finalize"); + ok("post-finalize behavior confirmed (old denied, new accepted)"); + + const cleanup = await callWithJwt(`/api/v1/auth/keys/${newKeyId}`, { method: "DELETE" }); + if (cleanup.ok) ok("cleanup: rotated key revoked"); + + console.log("🎯 Rotation validation complete"); +} + +main().catch((error) => fail(error instanceof Error ? error.message : String(error)));