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.
|
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
|
## 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
|
configuration against the restored instance using this same in-memory key
|
||||||
is the credential continuity check.
|
is the credential continuity check.
|
||||||
|
|
||||||
3. Run the existing synthetic write smoke against this backend, with
|
3. The legacy `scripts/smoke-convex.ts` exercises useful synthetic writes,
|
||||||
`SMOKE_ALLOW_WRITE=1`, and record its output without recording secrets. It
|
but currently accepts an arbitrary `CONVEX_SELF_HOSTED_URL` when write is
|
||||||
exercises login/session, staged publication, failed publication preserving
|
enabled. Do **not** execute it as part of this runbook; it remains
|
||||||
the previous generation, tenant isolation, JWT audience checks, and local
|
unexecuted evidence until it is wrapped with the same loopback target,
|
||||||
revocation:
|
explicit disposable confirmation, and key scoping as the marker tool.
|
||||||
|
|
||||||
```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
|
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
|
a fresh marker and synthetic character ID for every rehearsal; do not use a
|
||||||
random-only smoke result as proof of record survival:
|
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
|
bun "$repo/scripts/configure-convex.ts" >/dev/null
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Verify the deterministic marker before running any fresh smoke, then run
|
3. Verify the deterministic marker before any other restore evidence. The
|
||||||
the synthetic Convex smoke with fresh IDs:
|
legacy synthetic Convex smoke remains unexecuted for the safety reason
|
||||||
|
above; do not substitute its arbitrary-target behavior for restore proof:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
RESTORE_MARKER="$RESTORE_MARKER" \
|
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" \
|
RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \
|
||||||
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
|
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
|
||||||
RESTORE_MARKER_PHASE=verify bun "$repo/scripts/smoke-restore-marker.ts"
|
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:
|
Verify:
|
||||||
|
|||||||
@@ -51,11 +51,34 @@ try {
|
|||||||
});
|
});
|
||||||
await waitForServer(base, releaseId);
|
await waitForServer(base, releaseId);
|
||||||
const headers = { origin: base, 'content-type': 'application/json' };
|
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');
|
await assertStatus(chat, 503, 'assistant dispatch');
|
||||||
if (!(await chat.text()).includes('assistant_dispatch')) throw new Error('assistant gate reason missing');
|
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 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`);
|
const token = await fetch(`${base}/api/session/token`);
|
||||||
await assertStatus(token, 503, 'session token');
|
await assertStatus(token, 503, 'session token');
|
||||||
const characters = await fetch(`${base}/characters`);
|
const characters = await fetch(`${base}/characters`);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
|
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
|
||||||
}
|
}
|
||||||
const current = await session(event);
|
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));
|
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 });
|
if (!parsed.success) return json({ error: 'Provide a message (1–2000 characters) and a UUID requestId.' }, { status: 400 });
|
||||||
const { message, requestId } = parsed.data;
|
const { message, requestId } = parsed.data;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
|
return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
|
||||||
}
|
}
|
||||||
const current = await session(event);
|
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 });
|
if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
|
||||||
try {
|
try {
|
||||||
const result = await synchronize(current.userId);
|
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', () => {
|
test('Authenticated route limits remain user-keyed after rollout gates', () => {
|
||||||
expect(chatRoute).toContain('chat:${current?.userId ?? event.getClientAddress()}');
|
expect(chatRoute).toContain('chat:${current.userId}');
|
||||||
expect(syncRoute).toContain('sync:${current?.userId ?? event.getClientAddress()}');
|
expect(syncRoute).toContain('sync:${current.userId}');
|
||||||
expect(chatRoute.indexOf('protectMutation')).toBeLessThan(chatRoute.indexOf('const current = await session(event)'));
|
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(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(chatRoute.indexOf("capabilityEnabled('assistant_dispatch'")).toBeLessThan(chatRoute.indexOf('const current = await session(event)'));
|
||||||
|
|||||||
Reference in New Issue
Block a user