fix: isolate anonymous rollout rate limits
This commit is contained in:
@@ -77,7 +77,7 @@ docker compose --profile admin up -d dashboard
|
||||
|
||||
Invoking the `tools` worker explicitly imports **production archives** into `/app/data/reference`, the shared web/worker volume. `--check` fetches metadata only. Fixture imports require an explicit `--fixture` override and a separate demo output directory/volume; never use a fixture worker against the live volume.
|
||||
|
||||
For remote exposure use HTTPS reverse proxies for the app and Convex browser WebSocket/API endpoints. `PUBLIC_CONVEX_URL` must be browser-reachable, while Compose's `CONVEX_URL=http://backend:3210` is internal. Configure exact origins/callbacks. Keep backend/admin ports on loopback/management networks. The backend defaults to the immutable digest exercised by this repository’s smoke test. The optional dashboard’s `latest` default is a development convenience: pin a tested digest before production. See [security gates](docs/security.md).
|
||||
For remote exposure use HTTPS reverse proxies for the app and Convex browser WebSocket/API endpoints. `PUBLIC_CONVEX_URL` must be browser-reachable, while Compose's `CONVEX_URL=http://backend:3210` is internal. Configure exact origins/callbacks. Keep backend/admin ports on loopback/management networks. The backend and optional dashboard defaults are immutable digest-pinned images in `compose.yaml`; use only reviewed digest overrides for production. See [security gates](docs/security.md).
|
||||
|
||||
## Structure
|
||||
|
||||
|
||||
@@ -132,17 +132,11 @@ allocated loopback ports. Do not use a production callback or provider key.
|
||||
configuration against the restored instance using this same in-memory key
|
||||
is the credential continuity check.
|
||||
|
||||
3. Run the existing synthetic write smoke against this backend, with
|
||||
`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:
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
3. The legacy `scripts/smoke-convex.ts` exercises useful synthetic writes,
|
||||
but currently accepts an arbitrary `CONVEX_SELF_HOSTED_URL` when write is
|
||||
enabled. Do **not** execute it as part of this runbook; it remains
|
||||
unexecuted evidence until it is wrapped with the same loopback target,
|
||||
explicit disposable confirmation, and key scoping as the marker tool.
|
||||
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
|
||||
random-only smoke result as proof of record survival:
|
||||
@@ -217,8 +211,9 @@ allocated loopback ports. Do not use a production callback or provider key.
|
||||
bun "$repo/scripts/configure-convex.ts" >/dev/null
|
||||
```
|
||||
|
||||
3. Verify the deterministic marker before running any fresh smoke, then run
|
||||
the synthetic Convex smoke with fresh IDs:
|
||||
3. Verify the deterministic marker before any other restore evidence. The
|
||||
legacy synthetic Convex smoke remains unexecuted for the safety reason
|
||||
above; do not substitute its arbitrary-target behavior for restore proof:
|
||||
|
||||
```sh
|
||||
RESTORE_MARKER="$RESTORE_MARKER" \
|
||||
@@ -226,8 +221,6 @@ allocated loopback ports. Do not use a production callback or provider key.
|
||||
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:
|
||||
|
||||
@@ -51,11 +51,34 @@ try {
|
||||
});
|
||||
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() }) });
|
||||
const chatRequest = () => fetch(`${base}/api/chat`, { method: 'POST', headers, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) });
|
||||
const chat = await chatRequest();
|
||||
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 } });
|
||||
// Chat retains its existing 30-request window for anonymous IPs.
|
||||
for (let attempt = 2; attempt <= 30; attempt += 1) {
|
||||
const response = await chatRequest();
|
||||
await assertStatus(response, 503, `anonymous chat denial ${attempt}`);
|
||||
await response.text();
|
||||
}
|
||||
const chatLimited = await chatRequest();
|
||||
await assertStatus(chatLimited, 429, 'anonymous chat rate limit');
|
||||
await chatLimited.text();
|
||||
const syncRequest = () => fetch(`${base}/api/sync`, { method: 'POST', headers: { origin: base } });
|
||||
const sync = await syncRequest();
|
||||
await assertStatus(sync, 503, 'asset sync');
|
||||
await sync.text();
|
||||
// The pre-session anonymous sync bucket allows exactly six denials; the
|
||||
// seventh request is rate-limited. This is intentionally isolated from the
|
||||
// authenticated trap subprocess below.
|
||||
for (let attempt = 2; attempt <= 6; attempt += 1) {
|
||||
const response = await syncRequest();
|
||||
await assertStatus(response, 503, `anonymous sync denial ${attempt}`);
|
||||
await response.text();
|
||||
}
|
||||
const syncLimited = await syncRequest();
|
||||
await assertStatus(syncLimited, 429, 'anonymous sync rate limit');
|
||||
await syncLimited.text();
|
||||
const token = await fetch(`${base}/api/session/token`);
|
||||
await assertStatus(token, 503, 'session token');
|
||||
const characters = await fetch(`${base}/characters`);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
|
||||
}
|
||||
const current = await session(event);
|
||||
protectMutation(event, `chat:${current?.userId ?? event.getClientAddress()}`);
|
||||
if (current) protectMutation(event, `chat:${current.userId}`);
|
||||
const parsed = schema.safeParse(await boundedJson(event));
|
||||
if (!parsed.success) return json({ error: 'Provide a message (1–2000 characters) and a UUID requestId.' }, { status: 400 });
|
||||
const { message, requestId } = parsed.data;
|
||||
|
||||
@@ -13,7 +13,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
|
||||
}
|
||||
const current = await session(event);
|
||||
protectMutation(event, `sync:${current?.userId ?? event.getClientAddress()}`, 6);
|
||||
if (current) protectMutation(event, `sync:${current.userId}`, 6);
|
||||
if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
|
||||
try {
|
||||
const result = await synchronize(current.userId);
|
||||
|
||||
@@ -24,8 +24,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).toContain('chat:${current.userId}');
|
||||
expect(syncRoute).toContain('sync:${current.userId}');
|
||||
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)'));
|
||||
|
||||
Reference in New Issue
Block a user