fix: fail closed backend capabilities and isolate gate smoke

This commit is contained in:
jb
2026-09-19 10:01:23 -04:00
parent 1371995cbe
commit 5b0076cb20
17 changed files with 344 additions and 81 deletions
+45 -13
View File
@@ -21,23 +21,24 @@ const base = `http://127.0.0.1:${port}`;
const releaseId = `gate-${crypto.randomUUID()}`;
const referenceDir = await mkdtemp(join(tmpdir(), 'eden-gate-reference-'));
let server: Bun.Subprocess | undefined;
let trap: ReturnType<typeof Bun.serve> | undefined;
async function run(args: string[], env: Record<string, string>): Promise<void> {
const child = Bun.spawn(args, { env: { ...safeEnv, ...env }, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
if (await child.exited !== 0) throw new Error(`Command failed: ${args.join(' ')}`);
}
async function waitForServer(): Promise<void> {
async function waitForServer(baseUrl: string, expectedReleaseId: string): Promise<void> {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const response = await fetch(base);
if (response.status === 200 && response.headers.get('x-application-release') === releaseId) return;
const response = await fetch(baseUrl);
if (response.status === 200 && response.headers.get('x-application-release') === expectedReleaseId) return;
} catch { /* startup race */ }
await Bun.sleep(250);
}
throw new Error('Rollout-gate smoke server did not become ready');
}
function assertStatus(response: Response, expected: number, label: string): void {
if (response.status !== expected) throw new Error(`${label}: expected ${expected}, got ${response.status}`);
async function assertStatus(response: Response, expected: number, label: string): Promise<void> {
if (response.status !== expected) throw new Error(`${label}: expected ${expected}, got ${response.status}; ${await response.clone().text()}`);
}
try {
await run(['bun', 'workers/reference.ts', '--fixture', '--directory', referenceDir], { APP_MODE: 'demo', REFERENCE_DIR: referenceDir });
@@ -48,25 +49,56 @@ try {
TYPESAFE_API_KEY: 'sentinel-not-used-when-assistant-is-disabled',
}, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
});
await waitForServer();
await waitForServer(base, releaseId);
const headers = { origin: base, 'content-type': 'application/json' };
const chat = await fetch(`${base}/api/chat`, { method: 'POST', headers, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) });
assertStatus(chat, 503, 'assistant dispatch');
await assertStatus(chat, 503, 'assistant dispatch');
if (!(await chat.text()).includes('assistant_dispatch')) throw new Error('assistant gate reason missing');
const sync = await fetch(`${base}/api/sync`, { method: 'POST', headers: { origin: base } });
assertStatus(sync, 503, 'asset sync');
await assertStatus(sync, 503, 'asset sync');
const token = await fetch(`${base}/api/session/token`);
assertStatus(token, 503, 'session token');
await assertStatus(token, 503, 'session token');
const characters = await fetch(`${base}/characters`);
assertStatus(characters, 200, 'character page');
await assertStatus(characters, 200, 'character page');
if (!(await characters.text()).includes('identity')) throw new Error('identity gate reason missing');
const oauth = await fetch(`${base}/auth/eve`, { method: 'POST', headers: { origin: base, 'content-type': 'application/x-www-form-urlencoded' }, redirect: 'manual' });
assertStatus(oauth, 503, 'OAuth initiation');
await assertStatus(oauth, 503, 'OAuth initiation');
const callback = await fetch(`${base}/auth/eve/callback?state=synthetic`, { redirect: 'manual' });
assertStatus(callback, 303, 'OAuth callback');
await assertStatus(callback, 303, 'OAuth callback');
if (!callback.headers.get('location')?.includes('auth=disabled')) throw new Error('OAuth callback gate reason missing');
console.log(`Rollout-gate HTTP smoke passed: disabled capabilities returned fail-closed responses; ${base}`);
server.kill('SIGTERM');
await server.exited;
server = undefined;
// Live-mode trap: a synthetic session cookie makes any accidental session()
// call observable without contacting Convex or a provider.
let trapCalls = 0;
trap = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => { trapCalls++; return new Response('null', { headers: { 'content-type': 'application/json' } }); } });
const livePort = await freePort();
const liveBase = `http://127.0.0.1:${livePort}`;
const liveRelease = `gate-live-${crypto.randomUUID()}`;
server = Bun.spawn(['bun', 'build/index.js'], {
env: {
...safeEnv, APP_MODE: 'live', APP_ORIGIN: liveBase, ORIGIN: liveBase, HOST: '127.0.0.1', PORT: String(livePort),
REFERENCE_DIR: referenceDir, APP_RELEASE_ID: liveRelease, APP_CAPABILITY_ALLOWLIST: '',
CONVEX_URL: `http://127.0.0.1:${trap.port}`, PUBLIC_CONVEX_URL: `http://127.0.0.1:${trap.port}`,
CONVEX_BRIDGE_SECRET: 'synthetic-bridge-secret', TYPESAFE_API_KEY: 'sentinel-not-used',
}, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
});
await waitForServer(liveBase, liveRelease);
const cookie = { cookie: 'eve_session=synthetic-session' };
const liveChat = await fetch(`${liveBase}/api/chat`, { method: 'POST', headers: { ...headers, origin: liveBase, ...cookie }, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) });
await assertStatus(liveChat, 503, 'authenticated chat gate');
const liveSync = await fetch(`${liveBase}/api/sync`, { method: 'POST', headers: { origin: liveBase, ...cookie } });
await assertStatus(liveSync, 503, 'authenticated sync gate');
const liveCharacters = await fetch(`${liveBase}/characters`, { headers: cookie });
await assertStatus(liveCharacters, 200, 'authenticated character gate');
const liveAssets = await fetch(`${liveBase}/assets`, { headers: cookie });
await assertStatus(liveAssets, 200, 'authenticated asset gate');
if (trapCalls !== 0) throw new Error(`disabled route performed ${trapCalls} Convex session calls`);
console.log(`Rollout-gate HTTP smoke passed: anonymous and authenticated disabled paths avoided provider/session calls`);
} finally {
if (server) { server.kill('SIGTERM'); await server.exited; }
trap?.stop();
await rm(referenceDir, { recursive: true, force: true });
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { ConvexStore } from '../src/lib/server/store';
import { hashSecret } from '../src/lib/server/security';
if (process.env.SMOKE_ALLOW_WRITE !== '1') throw new Error('Set SMOKE_ALLOW_WRITE=1 only for a disposable backend');
const marker = process.env.RESTORE_MARKER;
const phase = process.env.RESTORE_MARKER_PHASE ?? 'write';
const characterId = Number(process.env.RESTORE_MARKER_CHARACTER_ID ?? '998000001');
if (!marker || !/^[A-Za-z0-9._-]{8,100}$/.test(marker)) throw new Error('RESTORE_MARKER must be a synthetic 8–100 character identifier');
if (!Number.isSafeInteger(characterId) || characterId < 90_000_000) throw new Error('RESTORE_MARKER_CHARACTER_ID must be a synthetic safe integer');
const store = new ConvexStore(process.env.CONVEX_SELF_HOSTED_URL!, process.env.CONVEX_BRIDGE_SECRET!);
const sessionHash = hashSecret(`restore-marker-session:${marker}`);
const ownerHash = hashSecret(`restore-marker-owner:${marker}`);
if (phase === 'write') {
const now = Date.now();
const user = await store.completeLogin({
characterId,
name: `Restore marker ${marker}`,
ownerHash,
scopes: ['esi-assets.read_assets.v1'],
encryptedTokens: `restore-marker-placeholder:${marker}`,
tokenExpiresAt: now + 60_000,
sessionHash,
sessionExpiresAt: now + 60 * 60_000,
});
console.log(`Restore marker written: character=${characterId}, user=${user.userId}`);
} else if (phase === 'verify') {
const session = await store.session(sessionHash);
assert(session, 'restore marker session is missing');
const characters = await store.characters(session.userId);
assert(characters.some((character) => character.id === characterId && character.name === `Restore marker ${marker}`), 'restore marker character is missing or changed');
console.log(`Restore marker verified: character=${characterId}, user=${session.userId}`);
} else if (phase === 'expect-invalidated') {
assert.equal(await store.session(sessionHash), null, 'restore marker session remains usable; invalidation is incomplete');
console.log(`Restore marker invalidation verified: character=${characterId}`);
} else {
throw new Error('RESTORE_MARKER_PHASE must be write, verify, or expect-invalidated');
}