diff --git a/package.json b/package.json index f038869..f728cdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openparachute/cloud", - "version": "0.0.8-rc.126", + "version": "0.0.8-rc.127", "private": true, "description": "Open Parachute PBC's Vault Cloud \u2014 one Durable Object per vault on Cloudflare, OAuth issuer + self-serve console (accounts + vault ownership).", "license": "AGPL-3.0", diff --git a/scripts/smoke-staging.ts b/scripts/smoke-staging.ts index 4e83b05..d8bdc9b 100644 --- a/scripts/smoke-staging.ts +++ b/scripts/smoke-staging.ts @@ -1182,9 +1182,29 @@ async function main() { // the console MUST surface it: the card's "Using X of Y" line + the // plan line's across-vaults total. Re-triggering proves the same-day // upsert (rows refresh, never duplicate — the summary stays recorded>0). - { + // + // cloud#224: the trigger is SCOPED to this run's own vault (?vault=), so + // the rollup is O(1), not O(fleet). It used to enumerate EVERY staging + // vault; as the fleet grew with smoke debris past ~260 vaults it crossed + // the runtime's fetch timeout — and because §14 had NEITHER an explicit + // AbortSignal NOR a liveCatch, the throw escaped to main().catch() as a + // bare, section-less DOMException (empty stack), turning the deploy gate + // red with no clue which section died. Now the trigger carries an + // explicit, generous timeout and is classified via liveCatch (a genuine + // stall is ADVISORY, not an anonymous crash), and — applying cloud#221 — + // the live trigger and the console-render assertions live in SEPARATE + // blocks, so a slow rollup can't silently blind the surface checks: an + // unverified rollup skips them with a named advisory instead of failing + // them for a non-bug reason. The nightly cron still rolls up the whole + // fleet, tested unit-side (workers/identity/test/usage.test.ts). + const USAGE_ROLLUP_TIMEOUT_MS = 120_000; + let usageRecorded = false; + try { const run = async (): Promise<{ day: string; vaults: number; recorded: number; failed: number; capped: boolean } | null> => { - const r = await fetch(`${IDENTITY}/__test/usage-run`, { method: "POST" }); + const r = await fetch(`${IDENTITY}/__test/usage-run?vault=${encodeURIComponent(arrivalVault)}`, { + method: "POST", + signal: AbortSignal.timeout(USAGE_ROLLUP_TIMEOUT_MS), + }); return r.status === 200 ? ((await r.json()) as { day: string; vaults: number; recorded: number; failed: number; capped: boolean }) : null; @@ -1197,6 +1217,9 @@ async function main() { "usage: the rollup recorded rows (this run's fresh vault included)", `day=${first.day} vaults=${first.vaults} recorded=${first.recorded} failed=${first.failed}`, ); + // The row is upserted before the summary returns, so the console below + // can render it even if the re-run trigger later stalls. + usageRecorded = first.recorded >= 1; const again = await run(); assert( !!again && again.recorded >= 1 && again.day === first.day, @@ -1204,18 +1227,40 @@ async function main() { again ? `recorded=${again.recorded}` : "non-200", ); } - const conHtml = await (await fetch(`${IDENTITY}/console`, { headers: { cookie: arrivalCookie } })).text(); - assert( - // The arrival user is on the no-card trial (mirrors Plus): the card cap - // renders "of 8.5 GiB" (500 MB notes + 8 GiB attachments, summed). - conHtml.includes('data-testid="vault-usage"') && /Using \d+(\.\d+)? MB of 8\.5 GiB/.test(conHtml), - "usage: the vault card shows 'Using X of Y' from the rollup row", - arrivalVault, - ); - assert( - conHtml.includes('data-testid="usage-total"'), - "usage: the plan line carries the across-vaults total", - ); + } catch (err) { + liveCatch("usage: live rollup trigger", err); + } + + // Console render — a SEPARATE concern (does the console SURFACE the usage?), + // deliberately NOT sharing the trigger's try (cloud#221). If the rollup above + // stalled it recorded its OWN named advisory and left usageRecorded false, so + // we SKIP these dependent checks with an advisory rather than fail them + // fatally for a non-bug reason. When the rollup DID record, the surface MUST + // render it — these asserts stay fatal. + if (usageRecorded) { + try { + const conHtml = await ( + await fetch(`${IDENTITY}/console`, { + headers: { cookie: arrivalCookie }, + signal: AbortSignal.timeout(USAGE_ROLLUP_TIMEOUT_MS), + }) + ).text(); + assert( + // The arrival user is on the no-card trial (mirrors Plus): the card cap + // renders "of 8.5 GiB" (500 MB notes + 8 GiB attachments, summed). + conHtml.includes('data-testid="vault-usage"') && /Using \d+(\.\d+)? MB of 8\.5 GiB/.test(conHtml), + "usage: the vault card shows 'Using X of Y' from the rollup row", + arrivalVault, + ); + assert( + conHtml.includes('data-testid="usage-total"'), + "usage: the plan line carries the across-vaults total", + ); + } catch (err) { + liveCatch("usage: console usage render", err); + } + } else { + advisory("usage: console-render assertions SKIPPED — the rollup trigger above was unverified (see its advisory)"); } // 15. Operator admin console (Wave 4c). The dev user IS the operator diff --git a/workers/identity/src/index.ts b/workers/identity/src/index.ts index 46e7df2..7d2f94d 100644 --- a/workers/identity/src/index.ts +++ b/workers/identity/src/index.ts @@ -363,7 +363,13 @@ app.post("/__test/drip-run", async (c) => { app.post("/__test/usage-run", async (c) => { const deps = depsFor(c.env); if (!deps.exposeDevLinks) return c.notFound(); - return c.json(await runUsageRollup(c.env, deps)); + // Optional single-vault scope: smoke-staging §14 passes ?vault= so its rollup assertion stays O(1) instead of enumerating the whole + // staging fleet (which outgrew the smoke's client timeout — cloud#224, the + // same cliff as the snapshot sweep in cloud#166/#218). No param → the full + // fleet rollup, unchanged (the nightly USAGE_CRON path). + const onlyVault = c.req.query("vault") || undefined; + return c.json(await runUsageRollup(c.env, deps, onlyVault ? { onlyVault } : {})); }); // Staging/dev-only snapshot-sweep trigger — same gate + rationale (404 in diff --git a/workers/identity/src/usage.ts b/workers/identity/src/usage.ts index eb831da..66f8c01 100644 --- a/workers/identity/src/usage.ts +++ b/workers/identity/src/usage.ts @@ -53,20 +53,32 @@ export interface UsageRunSummary { * right transport per environment; `deps.now` is the injectable clock. * `opts.runCap` exists ONLY so tests can exercise the cap path without * seeding 500 vaults; the cron always runs the default. + * + * `opts.onlyVault` scopes the run to a SINGLE vault instead of the whole fleet. + * The USAGE_CRON never sets it; the staging-only /__test/usage-run trigger + * passes it so the live smoke's rollup assertion (smoke-staging.ts §14) stays + * O(1) rather than O(fleet) — a fleet-wide rollup outgrew the smoke's client + * timeout as staging debris accumulated (cloud#224, the same cliff the snapshot + * sweep hit in cloud#166/#218). */ export async function runUsageRollup( env: Env, deps: OAuthDeps, - opts: { runCap?: number } = {}, + opts: { runCap?: number; onlyVault?: string } = {}, ): Promise { const runCap = opts.runCap ?? USAGE_RUN_CAP; const now = deps.now?.() ?? new Date(); const day = now.toISOString().slice(0, 10); // +1 over the cap so "stopped at the cap" is distinguishable from "drained". - const res = await env.DB.prepare("SELECT name, owner_user_id FROM vaults ORDER BY name LIMIT ?") - .bind(runCap + 1) - .all<{ name: string; owner_user_id: string }>(); + // `onlyVault` narrows to a single row (the run is inherently uncapped then). + const res = opts.onlyVault + ? await env.DB.prepare("SELECT name, owner_user_id FROM vaults WHERE name = ? LIMIT 1") + .bind(opts.onlyVault) + .all<{ name: string; owner_user_id: string }>() + : await env.DB.prepare("SELECT name, owner_user_id FROM vaults ORDER BY name LIMIT ?") + .bind(runCap + 1) + .all<{ name: string; owner_user_id: string }>(); const rows = res.results ?? []; const capped = rows.length > runCap; const vaults = capped ? rows.slice(0, runCap) : rows; diff --git a/workers/identity/test/usage.test.ts b/workers/identity/test/usage.test.ts index 93817b2..d6da350 100644 --- a/workers/identity/test/usage.test.ts +++ b/workers/identity/test/usage.test.ts @@ -235,6 +235,27 @@ describe("runUsageRollup", () => { expect((await usageRows()).map((r) => r.vault_name)).toEqual(["cap-a", "cap-b"]); expect(USAGE_RUN_CAP).toBe(500); // the real bound the cron runs with }); + + test("onlyVault scopes the run to a SINGLE vault — the rest of the fleet is untouched (O(1), not O(fleet))", async () => { + const { id: owner } = await seedUser("usage-scope@example.com"); + await seedVault("uscope-mine", owner); + await seedVault("uscope-other", owner); + // ONLY the scoped vault's config read is intercepted; uscope-other must not + // be read at all — an unexpected call trips disableNetConnect, and + // assertNoPendingInterceptors (afterEach) proves the scoped interceptor was + // consumed. This is what keeps smoke §14's rollup O(1) as the fleet grows + // (cloud#224, mirroring the snapshot-sweep scope in cloud#166/#218). + interceptConfigGet("uscope-mine", splitBody(3333, 111)); + const summary = await quietly(() => runUsageRollup(env, rollupDeps(), { onlyVault: "uscope-mine" })); + expect(summary).toEqual({ day: TODAY, vaults: 1, recorded: 1, failed: 0, capped: false }); + expect((await usageRows()).map((r) => r.vault_name)).toEqual(["uscope-mine"]); + }); + + test("onlyVault for an unknown name → an empty, clean run (no vault reads)", async () => { + const summary = await quietly(() => runUsageRollup(env, rollupDeps(), { onlyVault: "does-not-exist" })); + expect(summary).toEqual({ day: TODAY, vaults: 0, recorded: 0, failed: 0, capped: false }); + expect(await usageRows()).toEqual([]); + }); }); // --- latestUsageForVaults -------------------------------------------------------- @@ -348,4 +369,21 @@ describe("staging usage trigger", () => { const prod = await worker.fetch(req(), { ...env, ENVIRONMENT: "production" }); expect(prod.status).toBe(404); }); + + test("?vault= scopes the trigger to that one vault (smoke §14's O(1) rollup setup)", async () => { + const { id: owner } = await seedUser("trigger-scope-usage@example.com"); + await seedVault("utrig-a", owner); + await seedVault("utrig-b", owner); + // Only utrig-a is intercepted; utrig-b must never be read — no interceptor + // + assertNoPendingInterceptors proves the trigger scoped to the one vault. + interceptConfigGet("utrig-a", splitBody(555, 5)); + const res = await quietly(async () => + worker.fetch(new Request(`${ISSUER}/__test/usage-run?vault=utrig-a`, { method: "POST" }), env), + ); + expect(res.status).toBe(200); + const summary = (await res.json()) as { vaults: number; recorded: number }; + expect(summary.vaults).toBe(1); // only the scoped vault, not the fleet + expect(summary.recorded).toBe(1); + expect((await usageRows()).map((r) => r.vault_name)).toEqual(["utrig-a"]); + }); });