Homework — Lesson 14
Three tasks · daily quota, session timeout, per-IP rate limit at Caddy · ~75–100 min
Lesson 14 turned PKB-TS from a single-tenant playground into a multi-tenant
server with three orthogonal guarantees: identity (bearer
middleware), isolation (per-tenant note store keyed off
the authenticated session), and quota (token-bucket rate
limit at CallTool). The homework extends each of those three
axes by exactly one production-grade increment: (1) a
per-tenant daily quota on top of the per-minute rate limit; (2)
a session TTL so abandoned SSE connections do not pile up forever; (3)
a per-IP rate limit at Caddy so anonymous floods never reach the SDK in
the first place. None of these requires rewriting what L14 shipped —
each is an additive layer.
Before you start
Lesson 14's lab must be done end-to-end: auth.ts,
store.ts, rateLimit.ts exist; server.ts
mounts bearerAuth on /sse; two Inspector tabs
with tok_alice / tok_bob see isolated notes;
pkb.search from a single tenant trips the 10-burst /
60-per-minute rate limit. You also need the L13 Caddy stack running on
your hostname — Task 3 edits the Caddyfile. No new
npm dependencies are required (daily-quota and session-TTL use the
standard library; Caddy's rate_limit directive needs Caddy
2.7+ — pin caddy:2.7 or newer in
docker-compose.yml if you have not already).
Task 1 — Add a per-tenant daily quota
The L14 token-bucket caps throughput (60 calls/min sustained, 10-burst). It does not cap volume. A misbehaving client that paces itself exactly at 60/min still burns 86 400 calls/day. Layer a second check on top: a daily counter per tenant that resets at UTC midnight.
Requirements:
- Extend
src/rateLimit.ts(or add a siblingsrc/quota.tsif you prefer the separation) with adailyCheck(tenant)function that returns{ allowed: boolean; remaining: number; resetAt: string }—resetAtis an ISO-8601 UTC timestamp of the next midnight. - The default quota is 1000 calls/tenant/day. Make it configurable
via
PKB_DAILY_QUOTA. - Anchor the reset on calendar midnight UTC, not on
"24h after first call". A tenant who first calls at 23:55 must roll
over 5 minutes later, not 24h later. Use
new Date(now); d.setUTCHours(24, 0, 0, 0)to get the next boundary. - Wire it into
CallToolinserver.tsafter the per-minutecheck(tenant)call, so the cheap check runs first. On!allowed, return{ isError: true, content: [{ type: "text", text: "daily quota exceeded; resets at <ISO>" }] }— do not throw.
✓ Self-check: set
PKB_DAILY_QUOTA=5 and restart the server; call
pkb.search 5 times as Alice — all succeed; the 6th
returns isError: true with
daily quota exceeded; resets at 2026-05-27T00:00:00.000Z;
a parallel call as Bob still succeeds (counters are per-tenant);
restart the process — the counter resets in-memory (production
would persist this to SQLite or Redis); log a denial line with
phase=denied reason=daily_quota tenant=alice.
Task 2 — Add a session timeout
The sessions Map in server.ts is cleared on
res.on("close", ...), which handles the happy case. It
does not handle: clients that drop their TCP stream without closing
cleanly, NAT timeouts that abandon a stream halfway, or clients that
just walk away. Over hours those leak into RAM. Cap the session
lifetime explicitly.
Requirements:
- In the
/ssehandler inserver.ts, attach asetTimeoutthat prunes the session from the map afterPKB_SESSION_TTL_MSmilliseconds (default30 * 60_000= 30 minutes). - Store the timer handle on the session entry
(
{ transport, tenant, timer }) sores.on("close", ...)canclearTimeoutit on clean disconnect — otherwise the timer fires later and tries to delete a key that no longer exists (harmless but noisy in logs). - When the timer fires:
sessions.delete(sessionId),transport.close()(so the SSE stream'sresis ended cleanly), and logphase=session_expired tenant=<t> session_id=<sid>. - Subsequent
POST /messages?sessionId=<expired>must return404 unknown session— the existing code path already does this; just verify it after the TTL expires.
✓ Self-check: set
PKB_SESSION_TTL_MS=10000 and restart; open Inspector with
Bearer tok_alice, confirm a pkb.search
succeeds; wait 11 s; attempt another pkb.search in the
same Inspector tab — the POST /messages returns
404 unknown session; logs contain a
phase=session_expired line; reconnecting (Inspector
re-opens /sse) gets a fresh session id and resumes
working.
Task 3 — Per-IP rate limit at Caddy
The L14 rate limit is per tenant. It cannot help against
anonymous traffic — a flood with no
Authorization header still costs us the 401 round-trip
and the per-request log line. Push that defence one layer out to
Caddy, where it costs near-zero per request and never reaches Node.
Requirements:
- Extend the
Caddyfilewith therate_limitdirective (Caddy 2.7+; thecaddy:2.7image includes it as a standard module — if you are on an older tag, pincaddy:2.7or newer indocker-compose.ymlfirst). - Limit: 30 requests per minute per source IP,
applied to the
/ssepath only (do not throttle/healthz— Docker's healthcheck would mark the service unhealthy under load). Usezone sse_ip { key {remote_host} window 1m events 30 }and match it inside a path matcher. - Excess requests must return
429 Too Many Requestswith aRetry-Afterheader derived from the zone window — this is Caddy's default; do not override. - Keep the existing
reverse_proxyfor/sseand/messagesintact; the rate limit is an additional directive, not a replacement.
✓ Self-check:
for i in {1..50}; do curl -sk -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer tok_alice" https://<your-hostname>/sse; done
produces a mix of 200 and 429 after the
first ~30 requests within one minute; healthcheck on
/healthz stays 200 throughout (proves the
limit is path-scoped); Caddy's access log shows the 429 responses
with the same remote_ip; tenant=alice never
enters the SDK for those rejected requests (the per-tenant counter
in rateLimit.ts is untouched).