Public: anyone can read this; the crew can change it.

Persistent spawn caps (M2 of #2922) #3021

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3014-persistent-spawn-caps-m2-of-2922 → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/3014-persistent-spawn-caps-m2-of-2922 was merged into main as 71086d9.

Review

Sign in to review this change.
4 files changed +55 −33
src/core/migrations/20261020010000-anon-spawns.ts added +8
@@ -0,0 +1,8 @@
1 // Anonymous spawns counted on disk (#3014): the daily caps survive a restart.
2 export default `
3 CREATE TABLE anon_spawn (
4 bucket TEXT NOT NULL,
5 at INTEGER NOT NULL
6 );
7 CREATE INDEX anon_spawn_at ON anon_spawn (at);
8 `;
src/spawn/door.ts +2 −2
@@ -275,7 +275,7 @@ export async function spawnDoor(
275275 }
276276 // A stranger may start only a few children a day; the plan above is free.
277277 if (anon) {
278 const limit = checkAnonSpawn(ctx.request);
278 const limit = checkAnonSpawn(ctx.app.db, ctx.request);
279279 if (!limit.allowed)
280280 return {
281281 status: 429,
@@ -294,7 +294,7 @@ export async function spawnDoor(
294294 ...(modelEndpoint === undefined ? {} : { modelEndpoint }),
295295 });
296296 if (!made.ok) return spawnWords(made.error);
297 if (anon && !made.value.existed) noteAnonSpawn(ctx.request);
297 if (anon && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request);

The note happens after the awaited spawn, so parallel requests all pass the check. Reserve the slot before spawning and release on failure.

298298 // Its apps come up now, so the first visit after a claim finds them awake.
299299 if (!made.value.existed) {
300300 void bootHostedChild(ctx.app, made.value.name);
src/spawn/rate.ts +24 −28
@@ -1,27 +1,20 @@
11 /**
22 * The anonymous spawn limit: a stranger may start at most this many child
3 * forges in any 24 hours, keyed by their client bucket (`http/peer.ts`).
4 * A light guard against abuse, not a durable ledger — it lives in memory,
5 * so a restart clears it, which is fine for a rate limit. Signed-in
6 * callers are not counted here.
3 * forges in any 24 hours, keyed by their client bucket (`http/peer.ts`), and
4 * all strangers together at most a global number. The count is a table in the
5 * forge's database under FORGE_APP_DATA, so a restart does not clear it.
6 * Signed-in callers are not counted here.
77 */
88
9 import type { Database } from "bun:sqlite";
910 import { clientBucket } from "../http/peer.ts";
1011
1112 /** Anonymous spawns allowed per client in any 24 hours. */
1213 export const ANON_SPAWNS_PER_DAY = 3;
14 /** Anonymous spawns allowed from everyone together in any 24 hours. */
15 export const ANON_SPAWNS_GLOBAL_PER_DAY = 30;
1316 const DAY_MS = 86_400_000;
1417
15 /** When each counted spawn happened, oldest first, per client bucket. */
16 const seen = new Map<string, number[]>();
17
18 const prune = (bucket: string, now: number): number[] => {
19 const kept = (seen.get(bucket) ?? []).filter((t) => now - t < DAY_MS);
20 if (kept.length === 0) seen.delete(bucket);
21 else seen.set(bucket, kept);
22 return kept;
23 };
24
2518 export interface RateLimit {
2619 /** True when another anonymous spawn may be made now. */
2720 readonly allowed: boolean;
@@ -30,25 +23,28 @@ export interface RateLimit {
3023 }
3124
3225 /** Whether this request's client may anonymously spawn now. */
33 export function checkAnonSpawn(request: Request, now = Date.now()): RateLimit {
34 const bucket = clientBucket(request);
35 const kept = prune(bucket, now);
36 const oldest = kept[0] ?? now;
37 return {
38 allowed: kept.length < ANON_SPAWNS_PER_DAY,
39 resetsAt: new Date(oldest + DAY_MS).toISOString(),
26 export function checkAnonSpawn(db: Database, request: Request, now = Date.now()): RateLimit {
27 const since = now - DAY_MS;
28 db.query("DELETE FROM anon_spawn WHERE at <= ?").run(since);
29 const mine = db
30 .query("SELECT COUNT(*) AS n, MIN(at) AS oldest FROM anon_spawn WHERE bucket = ?")
31 .get(clientBucket(request)) as { n: number; oldest: number | null };
32 const all = db.query("SELECT COUNT(*) AS n, MIN(at) AS oldest FROM anon_spawn").get() as {
33 n: number;
34 oldest: number | null;
4035 };
36 const clientFull = mine.n >= ANON_SPAWNS_PER_DAY;
37 const globalFull = all.n >= ANON_SPAWNS_GLOBAL_PER_DAY;
38 const oldest = (clientFull ? mine.oldest : globalFull ? all.oldest : null) ?? now;

Global-full and client-full return the same rate_limited shape; a person cannot tell if they did something or the forge is simply busy today. Return which cap tripped and word it in door.ts.

39 return { allowed: !clientFull && !globalFull, resetsAt: new Date(oldest + DAY_MS).toISOString() };
4140 }
4241
4342 /** Count an anonymous spawn against this request's client. */
44 export function noteAnonSpawn(request: Request, now = Date.now()): void {
45 const bucket = clientBucket(request);
46 const kept = prune(bucket, now);
47 kept.push(now);
48 seen.set(bucket, kept);
43 export function noteAnonSpawn(db: Database, request: Request, now = Date.now()): void {
44 db.query("INSERT INTO anon_spawn (bucket, at) VALUES (?, ?)").run(clientBucket(request), now);
4945 }
5046
5147 /** Forget every counted spawn (tests). */
52 export function resetAnonSpawns(): void {
53 seen.clear();
48 export function resetAnonSpawns(db: Database): void {
49 db.exec("DELETE FROM anon_spawn");
5450 }
test/spawn-anon.test.ts +21 −3
@@ -11,7 +11,8 @@ import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1313 import { usePlacement, useProbe } from "../src/spawn/door.ts";
14 import { resetAnonSpawns } from "../src/spawn/rate.ts";
14 import { Database } from "bun:sqlite";
15 import { ANON_SPAWNS_GLOBAL_PER_DAY, ANON_SPAWNS_PER_DAY, checkAnonSpawn, noteAnonSpawn, resetAnonSpawns } from "../src/spawn/rate.ts";
1516 import { createToken } from "../src/core/tokens.ts";
1617 import { createUser } from "../src/core/users.ts";
1718 import { serve, type Servers } from "../src/http/server.ts";
@@ -39,8 +40,8 @@ describe("anonymous spawn", () => {
3940
4041 beforeAll(async () => {
4142 dataDir = await mkdtemp(join(tmpdir(), "forge-spawn-anon-"));
42 resetAnonSpawns();
4343 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge-code" });
44 resetAnonSpawns(app.db);
4445 servers = await serve(app);
4546 base = servers.url;
4647 const a = await createUser(app.db, {
@@ -164,7 +165,24 @@ describe("anonymous spawn", () => {
164165 // A dry run is free: the plan still comes back.
165166 const dry = await post({ name: "kid5" });
166167 expect(dry.status).toBe(200);
167 resetAnonSpawns();
168 resetAnonSpawns(app.db);
169 });
170
171 test("the counts survive a restart and a global cap holds", async () => {
172 const req = new Request("http://x/", { headers: { "x-forwarded-for": "203.0.113.9" } });
173 for (let i = 0; i < ANON_SPAWNS_PER_DAY; i++) noteAnonSpawn(app.db, req);
174 expect(checkAnonSpawn(app.db, req).allowed).toBe(false);
175 // A new connection to the same file still sees the counted spawns.
176 const again = new Database(join(dataDir, "forge.db"));
177 expect(checkAnonSpawn(again, req).allowed).toBe(false);
178 again.close();
179 // They age out after a day.
180 expect(checkAnonSpawn(app.db, req, Date.now() + 86_400_001).allowed).toBe(true);
181 resetAnonSpawns(app.db);
182 for (let i = 0; i < ANON_SPAWNS_GLOBAL_PER_DAY; i++)
183 noteAnonSpawn(app.db, new Request(`http://x/${i}`, { headers: { "x-forwarded-for": `198.51.100.${i}` } }));
184 expect(checkAnonSpawn(app.db, req).allowed).toBe(false);
185 resetAnonSpawns(app.db);
168186 });
169187
170188 test("a signed-in spawn still works (regression)", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs