diff --git a/docs/restore-rehearsal.md b/docs/restore-rehearsal.md index 86a304d..bb14d62 100644 --- a/docs/restore-rehearsal.md +++ b/docs/restore-rehearsal.md @@ -136,7 +136,12 @@ allocated loopback ports. Do not use a production callback or provider key. `SMOKE_ALLOW_WRITE=1`, and record its output without recording secrets. It exercises login/session, staged publication, failed publication preserving the previous generation, tenant isolation, JWT audience checks, and local - revocation. + revocation: + + ```sh + SMOKE_ALLOW_WRITE=1 CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \ + bun "$repo/scripts/smoke-convex.ts" | tee "$work/convex-smoke.log" + ``` Then write a deterministic marker that can be looked up after restore. Use a fresh marker and synthetic character ID for every rehearsal; do not use a @@ -144,10 +149,12 @@ allocated loopback ports. Do not use a production callback or provider key. ```sh export RESTORE_MARKER="$(date -u +%Y%m%dT%H%M%SZ)-synthetic" - export RESTORE_MARKER_CHARACTER_ID=998000001 + export RESTORE_MARKER_CHARACTER_ID=4000000001 # reserved synthetic range + export RESTORE_MARKER_SESSION_EXPIRES_AT=$(( $(date +%s) * 1000 + 7 * 24 * 60 * 60 * 1000 )) SMOKE_ALLOW_WRITE=1 RESTORE_MARKER_CONFIRMATION=DISPOSABLE_LOOPBACK_ONLY \ RESTORE_MARKER_PHASE=write RESTORE_MARKER="$RESTORE_MARKER" \ RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \ + RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \ CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \ bun "$repo/scripts/smoke-restore-marker.ts" | tee "$work/marker-write.log" ``` @@ -216,8 +223,11 @@ allocated loopback ports. Do not use a production callback or provider key. ```sh RESTORE_MARKER="$RESTORE_MARKER" \ RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \ + RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \ CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \ RESTORE_MARKER_PHASE=verify bun "$repo/scripts/smoke-restore-marker.ts" + SMOKE_ALLOW_WRITE=1 CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \ + bun "$repo/scripts/smoke-convex.ts" | tee "$work/convex-restore-smoke.log" ``` Verify: @@ -241,6 +251,16 @@ allocated loopback ports. Do not use a production callback or provider key. revokes all sessions and quarantines token records, a deployment-specific credential-version fence, or an equivalent bounded mechanism. Record the maximum stale-authority window and the decision before reopening traffic. + If implemented for the rehearsal, run the marker check before its explicit + expiry window: + + ```sh + RESTORE_MARKER="$RESTORE_MARKER" RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \ + RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \ + CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \ + RESTORE_MARKER_PHASE=expect-invalidated bun "$repo/scripts/smoke-restore-marker.ts" + ``` + Grant rotation/quarantine behavior is unexecuted until that mechanism exists; no synthetic provider result can substitute for it. diff --git a/scripts/smoke-restore-marker.ts b/scripts/smoke-restore-marker.ts index a2dfd8e..bb8392d 100644 --- a/scripts/smoke-restore-marker.ts +++ b/scripts/smoke-restore-marker.ts @@ -4,6 +4,7 @@ import { hashSecret } from '../src/lib/server/security'; import { parseRestoreMarkerPhase, validateRestoreMarkerTarget, + SYNTHETIC_CHARACTER_ID_MIN, validateRestoreMarkerWrite, } from '../src/lib/server/restore-marker'; @@ -15,9 +16,13 @@ validateRestoreMarkerWrite({ allowWrite: process.env.SMOKE_ALLOW_WRITE, confirmation: process.env.RESTORE_MARKER_CONFIRMATION, }); -const characterId = Number(process.env.RESTORE_MARKER_CHARACTER_ID ?? '998000001'); +const characterId = Number(process.env.RESTORE_MARKER_CHARACTER_ID ?? String(SYNTHETIC_CHARACTER_ID_MIN + 1)); +const expiryRaw = process.env.RESTORE_MARKER_SESSION_EXPIRES_AT; +const sessionExpiresAt = expiryRaw === undefined ? Date.now() + 7 * 24 * 60 * 60_000 : Number(expiryRaw); 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'); +if (!Number.isSafeInteger(characterId) || characterId < SYNTHETIC_CHARACTER_ID_MIN) throw new Error(`RESTORE_MARKER_CHARACTER_ID must be a reserved synthetic ID >= ${SYNTHETIC_CHARACTER_ID_MIN}`); +if (!Number.isSafeInteger(sessionExpiresAt) || sessionExpiresAt <= Date.now()) throw new Error('RESTORE_MARKER_SESSION_EXPIRES_AT must be a future millisecond timestamp'); +if (phase !== 'write' && expiryRaw === undefined) throw new Error('RESTORE_MARKER_SESSION_EXPIRES_AT is required when verifying or invalidating a marker'); const store = new ConvexStore(target.toString(), process.env.CONVEX_BRIDGE_SECRET!); const sessionHash = hashSecret(`restore-marker-session:${marker}`); const ownerHash = hashSecret(`restore-marker-owner:${marker}`); @@ -30,20 +35,20 @@ if (phase === 'write') { ownerHash, scopes: ['esi-assets.read_assets.v1'], encryptedTokens: `restore-marker-placeholder:${marker}`, - tokenExpiresAt: now + 60_000, + tokenExpiresAt: sessionExpiresAt, sessionHash, - sessionExpiresAt: now + 60 * 60_000, + sessionExpiresAt, }); - console.log(`Restore marker written: character=${characterId}, user=${user.userId}`); + console.log(`Restore marker written: character=${characterId}, user=${user.userId}, expiresAt=${sessionExpiresAt}`); } 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}`); + console.log(`Restore marker verified: character=${characterId}, user=${session.userId}, expiresAt=${sessionExpiresAt}`); } 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}`); + console.log(`Restore marker invalidation verified: character=${characterId}, beforeExpiresAt=${sessionExpiresAt}`); } else { throw new Error('RESTORE_MARKER_PHASE must be write, verify, or expect-invalidated'); } diff --git a/src/lib/server/restore-marker.ts b/src/lib/server/restore-marker.ts index 6ff151f..7d423b4 100644 --- a/src/lib/server/restore-marker.ts +++ b/src/lib/server/restore-marker.ts @@ -1,4 +1,5 @@ export const RESTORE_MARKER_CONFIRMATION = 'DISPOSABLE_LOOPBACK_ONLY'; +export const SYNTHETIC_CHARACTER_ID_MIN = 4_000_000_000; const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']); export type RestoreMarkerPhase = 'verify' | 'write' | 'expect-invalidated'; diff --git a/src/routes/api/chat/+server.ts b/src/routes/api/chat/+server.ts index d45fff5..f9d6496 100644 --- a/src/routes/api/chat/+server.ts +++ b/src/routes/api/chat/+server.ts @@ -10,8 +10,9 @@ import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release const schema = z.object({ message: z.string().trim().min(1).max(2000), requestId: z.uuid() }); export const POST: RequestHandler = async (event) => { const config = configuration(); - // Keep disabled routes from touching Convex/session/provider state. The - // normal user-keyed limiter is restored after session resolution below. + // Bound origin/session work by the existing anonymous IP bucket first. The + // normal authenticated user-keyed limiter is restored after resolution. + protectMutation(event, `chat:${event.getClientAddress()}`); if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) { return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 }); } diff --git a/src/routes/api/sync/+server.ts b/src/routes/api/sync/+server.ts index 43215bd..4ceb8db 100644 --- a/src/routes/api/sync/+server.ts +++ b/src/routes/api/sync/+server.ts @@ -6,6 +6,9 @@ import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release import { configuration } from '$lib/server/config'; export const POST: RequestHandler = async (event) => { const config = configuration(); + // Bound origin/session work by the existing anonymous IP bucket first. The + // normal authenticated user-keyed limiter is restored after resolution. + protectMutation(event, `sync:${event.getClientAddress()}`, 6); if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) { return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 }); } diff --git a/tests/restore-marker.test.ts b/tests/restore-marker.test.ts index bd5bac0..d88116c 100644 --- a/tests/restore-marker.test.ts +++ b/tests/restore-marker.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'bun:test'; import { parseRestoreMarkerPhase, RESTORE_MARKER_CONFIRMATION, + SYNTHETIC_CHARACTER_ID_MIN, validateRestoreMarkerTarget, validateRestoreMarkerWrite, } from '../src/lib/server/restore-marker'; @@ -16,6 +17,7 @@ test('restore marker rejects non-loopback targets before any backend call', () = expect(() => validateRestoreMarkerTarget('https://production.example.test')).toThrow('loopback'); expect(() => validateRestoreMarkerTarget('http://192.0.2.10:3210')).toThrow('loopback'); expect(validateRestoreMarkerTarget('http://127.0.0.1:3210').hostname).toBe('127.0.0.1'); + expect(SYNTHETIC_CHARACTER_ID_MIN).toBeGreaterThan(2_000_000_000); }); test('restore marker requires explicit disposable confirmation for writes', () => { diff --git a/tests/workflow-config.test.ts b/tests/workflow-config.test.ts index 5f5c4c4..5658831 100644 --- a/tests/workflow-config.test.ts +++ b/tests/workflow-config.test.ts @@ -26,6 +26,8 @@ test('Gitea workflow keeps source checks separate and holds unverified container test('Authenticated route limits remain user-keyed after rollout gates', () => { expect(chatRoute).toContain('chat:${current?.userId ?? event.getClientAddress()}'); expect(syncRoute).toContain('sync:${current?.userId ?? event.getClientAddress()}'); + expect(chatRoute.indexOf('protectMutation')).toBeLessThan(chatRoute.indexOf('const current = await session(event)')); + expect(syncRoute.indexOf('protectMutation')).toBeLessThan(syncRoute.indexOf('const current = await session(event)')); expect(chatRoute.indexOf("capabilityEnabled('assistant_dispatch'")).toBeLessThan(chatRoute.indexOf('const current = await session(event)')); expect(syncRoute.indexOf("capabilityEnabled('asset_sync'")).toBeLessThan(syncRoute.indexOf('const current = await session(event)')); expect(layoutRoute).toContain('assistant_dispatch');