Skip to content
Closed
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
3 changes: 2 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
12 changes: 10 additions & 2 deletions convex/missionControlApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -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);
Expand All @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions docs/mission-control/api-key-rotation-zero-downtime.md
Original file line number Diff line number Diff line change
@@ -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://<deployment>" \
MISSION_CONTROL_BEARER_TOKEN="<jwt>" \
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
4 changes: 4 additions & 0 deletions docs/mission-control/mission-runs-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
84 changes: 76 additions & 8 deletions e2e/mission-control-phase1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ interface PerfFixture {
activityOpenRuns?: number;
activityOpenP95Ms?: number;
itemsPerList?: number;
seedListCount?: number;
seedViaApi?: boolean;
}

function loadPerfFixture(): PerfFixture {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand All @@ -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);
});
});
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 98 additions & 0 deletions scripts/validate-api-key-rotation.mjs
Original file line number Diff line number Diff line change
@@ -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)));
Loading