Last unbuilt item on the promotion-window hardening checklist. /v1/link
round-trips Zitadel, can CREATE a forge account and always mints a token, so
it is the endpoint that must not be free to hammer.
Sliding window per (route, client), ON by default -- unlimited has to be a
deliberate config act, not an omission. Defaults 5/hour and 60/hour; /health
never limited. 429 + Retry-After, decided BEFORE the body is read so an
abusive caller costs nothing.
Decisions worth naming:
- State is an in-process dict behind a lock. granthi-link is ONE
ThreadingHTTPServer, so that IS the store -- no redis. Kept behind a class
so a future multi-process move has one thing to change.
- Denied requests are NOT recorded. Recording them lets a hammering client
push its own window forward and lock itself out forever.
- Key store is capped; at capacity it drops least-recent windows and logs
loudly. Fail-open under key pressure, chosen over an unbounded dict that
is a memory DoS.
- trust_forwarded_for OFF by default. Behind cloudflared every request comes
from the tunnel, so limiting on the socket peer starves everyone; but XFF
is client-controlled. A caller can PREPEND, a trusted proxy APPENDS what it
actually saw -- so we read the LAST entry, never the first.
- A malformed rule refuses startup instead of silently meaning unlimited.
Tests 69 -> 87, including a 40-thread race proving the lock holds, the
self-lockout case, XFF spoof-resistance, and a real 429 on the wire.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
The client is downloadable from central now, but no document took an invited
user from clone to working state. Quickstart covers link -> list -> get/add
-> watch, plus the two things people get wrong: an expired device code
creates nothing (just re-run link), and merging is deliberately a forge
action because watch refuses to merge or force.
Also re-probed the token-minting mechanics the README pinned to 1.27.1,
since both forges now run 1.27.2. All three results still hold:
token-auth + Sudo header -> 401
token-auth + ?sudo= -> 401
admin basic auth + Sudo -> 201
Probe minted a token on the granthi-sync-e2e machine user and deleted it
(204 under basic auth). /v1/link's mint path is unaffected by the bump.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
`add` pushed a local folder up; nothing pulled a cloud repo down, so the
"show me my repos -> download -> start working" half of onboarding had no
implementation. Both new commands read the forge directly with the scoped
user token the link already handed us, so neither needs a granthi-link
endpoint, a service restart, or a VPS config edit.
- list: GET /api/v1/user/repos, pagination followed to a short page, with a
FORGE_MAX_PAGES guard whose trip is REPORTED — a bounded page must never
read as "that is all of them". Shows which repos are already local.
- get: clones with --origin granthi (the remote name watch looks for) and
-c credential.helper (the repo does not exist yet, so the helper cannot be
installed first), then registers the folder in the same shape `add` writes
— without that, watch silently ignores everything cloned.
- require_linked(): one failure mode for every forge-touching command.
- VERSION 1.0.0 -> 1.1.0, matching the README and the 1.1.0 hardening.
Tests 55 -> 65.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
- main() exits 2 on permissive config (not just the predicate)
- 413 proven with an actual over-limit wire body, not header-only
- README: machine-user accounts need a seeded state.json mapping; only
granthi-sync-e2e required it in beta
Co-Authored-By: Claude Fable 5 <[email protected]>