Public: anyone can read this; the crew can change it.
Persistent spawn caps (M2 of #2922) #3021
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3014-persistent-spawn-caps-m2-of-2922 → main 1 commit fork 2 threads · 2 unresolved
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( | ||
| 275 | 275 | } |
| 276 | 276 | // A stranger may start only a few children a day; the plan above is free. |
| 277 | 277 | if (anon) { |
| 278 | const limit = checkAnonSpawn(ctx.request); | |
| 278 | const limit = checkAnonSpawn(ctx.app.db, ctx.request); | |
| 279 | 279 | if (!limit.allowed) |
| 280 | 280 | return { |
| 281 | 281 | status: 429, |
| @@ -294,7 +294,7 @@ export async function spawnDoor( | ||
| 294 | 294 | ...(modelEndpoint === undefined ? {} : { modelEndpoint }), |
| 295 | 295 | }); |
| 296 | 296 | 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); | |
| 298 | 298 | // Its apps come up now, so the first visit after a claim finds them awake. |
| 299 | 299 | if (!made.value.existed) { |
| 300 | 300 | void bootHostedChild(ctx.app, made.value.name); |
src/spawn/rate.ts +24 −28
| @@ -1,27 +1,20 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import type { Database } from "bun:sqlite"; | |
| 9 | 10 | import { clientBucket } from "../http/peer.ts"; |
| 10 | 11 | |
| 11 | 12 | /** Anonymous spawns allowed per client in any 24 hours. */ |
| 12 | 13 | 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; | |
| 13 | 16 | const DAY_MS = 86_400_000; |
| 14 | 17 | |
| 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 | ||
| 25 | 18 | export interface RateLimit { |
| 26 | 19 | /** True when another anonymous spawn may be made now. */ |
| 27 | 20 | readonly allowed: boolean; |
| @@ -30,25 +23,28 @@ export interface RateLimit { | ||
| 30 | 23 | } |
| 31 | 24 | |
| 32 | 25 | /** 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; | |
| 40 | 35 | }; |
| 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 |
||
| 39 | return { allowed: !clientFull && !globalFull, resetsAt: new Date(oldest + DAY_MS).toISOString() }; | |
| 41 | 40 | } |
| 42 | 41 | |
| 43 | 42 | /** 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); | |
| 49 | 45 | } |
| 50 | 46 | |
| 51 | 47 | /** 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"); | |
| 54 | 50 | } |
test/spawn-anon.test.ts +21 −3
| @@ -11,7 +11,8 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 13 | 13 | 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"; | |
| 15 | 16 | import { createToken } from "../src/core/tokens.ts"; |
| 16 | 17 | import { createUser } from "../src/core/users.ts"; |
| 17 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -39,8 +40,8 @@ describe("anonymous spawn", () => { | ||
| 39 | 40 | |
| 40 | 41 | beforeAll(async () => { |
| 41 | 42 | dataDir = await mkdtemp(join(tmpdir(), "forge-spawn-anon-")); |
| 42 | resetAnonSpawns(); | |
| 43 | 43 | app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge-code" }); |
| 44 | resetAnonSpawns(app.db); | |
| 44 | 45 | servers = await serve(app); |
| 45 | 46 | base = servers.url; |
| 46 | 47 | const a = await createUser(app.db, { |
| @@ -164,7 +165,24 @@ describe("anonymous spawn", () => { | ||
| 164 | 165 | // A dry run is free: the plan still comes back. |
| 165 | 166 | const dry = await post({ name: "kid5" }); |
| 166 | 167 | 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); | |
| 168 | 186 | }); |
| 169 | 187 | |
| 170 | 188 | test("a signed-in spawn still works (regression)", async () => { |
new above
offline
The note happens after the awaited spawn, so parallel requests all pass the check. Reserve the slot before spawning and release on failure.