Commit Graph
12 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5 ba107c1b53 docs: this describes the prod deployment now, not beta
granthi-link has run against the PROD forge since the promotion, but every
document in this repo still described the beta tier. Someone following the
README would expect their device to land on granthi-beta.shre.ai; it lands on
granthi.shre.ai. Two of the statements were not merely stale but false:
"tailnet-only" and "Not publicly exposed" — the service has been public at
https://granthi-link.shre.ai since 2026-08-23, and that 404 at / (no root
route) has twice been misread as an outage.

Verified on [email protected], 2026-08-30:
  gitea_base         = http://127.0.0.1:3040   (gitea-central-gitea-1)
  public_gitea_base  = https://granthi.shre.ai
  systemctl is-active granthi-link -> active
  https://granthi-link.shre.ai/health -> 200 {"status":"ok","version":"1.1.0"}
  rate_limit: trust_forwarded_for true, trusted_proxies ["100.107.37.98/32"],
              no "rules" key -> falls back to DEFAULT_RATE_RULES

Docs only. No server, client or test code is touched, and the deployed
service is NOT redeployed by this change — it still reports 1.1.0 against a
v1.2 repo, which is recorded separately.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01L1b6BN9TZVxHkmignRq4p8
2026-08-29 23:20:30 -04:00
claude ea14038355 fix(link): say "that address is already an account here", not "502"
Hit live today. An operator moved an email onto a different forge account; the
next sign-in tried to create a user with that address, Gitea answered
422 "e-mail already in use", and the generic path turned it into a bare 502.
The person approved a device code and got a number that reads like an outage,
when the real answer was "that address already belongs to somebody here".

create_user now distinguishes that case, and the binding rules translate it to
409 with a message naming the address, the login it tried to create, and the
two ways out: have an operator bind your identity to the existing account, or
use a different address.

211 tests (was 209): the stub now enforces unique emails like real Gitea, one
test asserts the 409 names the address and login and never says 502, and one
asserts an ordinary create is unaffected.
2026-08-23 16:44:49 -04:00
claude 062f8d1863 fix: three defects found by codex review of today's merged work
No P1s. All three confirmed in the code before fixing.

[P2] Invites were destroyed by a transient forge error. apply_invites() popped
the whole pending list BEFORE attempting the collaborator PUT, so a 502 or a
timeout while someone first signed in meant they got no access and re-linking
never retried -- the promise was gone. Now it peeks, and consumes each grant
only after that grant actually lands. A partial failure keeps exactly the
grants that failed.

[P2] Snapshots lost staged-only work. build_snapshot() read HEAD into a scratch
index and staged the WORKTREE, so a hunk you staged and then edited further
survived only in its later worktree form. git keeps index and worktree as
separate states and the backup now does too: the real index is read without
being touched, and when it differs from both HEAD and the worktree it rides
along as a second parent.

[P3] A truncated repo listing could resolve a bare name to the WRONG repo.
resolve_granted() discarded the truncation flag, so a name whose only match sat
beyond the 2000-repo cap fell back to <login>/<name> and would clone that
instead. Truncation now means "unknown", not "absent": it refuses and asks for
the owner. A complete listing still falls back, because absence is then real.

209 tests (was 204).
2026-08-23 16:14:21 -04:00
claude 1a151a22e2 feat: share repos with people, and invite people who have no account yet
Closes the last piece of the product picture: give one person access to some
of your repos and not others.

- POST /v1/grants add|remove|list -- runs on the CALLER'S OWN token. Verified
  against the live forge that a repo owner's scoped token adds and removes
  collaborators (204), so sharing needs no elevated rights anywhere.
- POST /v1/invite -- records a promise against a VERIFIED email and creates
  nothing until it is redeemed. Applied on first link.
- client: share / shared / invite.

Three properties the tests pin:
  * the FORGE decides who may share (listing collaborators requires repo
    admin, so its 200 is the authorisation answer, not ours);
  * an unverified email collects nothing, and its invite stays pending rather
    than being consumed;
  * a failed grant never blocks a sign-in -- nobody is locked out of their own
    account because a repo they were promised has since been deleted.

Applying an invite uses the admin credential deliberately: the inviter
authorised it at invite time and their session is long gone by redemption.

198 tests (was 184).
2026-08-23 14:10:18 -04:00
claude dbd6bb6cd0 fix(link): revoke endpoint was disabled by its own rate-limit rule
Live QA: every call to /v1/devices/revoke returned 429 retry_after=3600.
The rule was written (0, 3600) with a comment saying 'never throttle someone
out of signing a lost laptop out' -- but in this limiter a limit of 0
DISABLES the endpoint outright. The comment said unlimited; the code said
never. Set to 600/hour instead.

Every unit test passed while the endpoint was 100% dead over HTTP, because
they called svc.revoke_device() directly and never went through the handler.
Added 4 tests that speak HTTP, including one that fails if ANY route in
DEFAULT_RATE_RULES is configured to 0.

176 tests (was 172).
2026-08-23 12:21:43 -04:00
claude 6b2d1a64c0 feat(link): device registry, immediate sign-out, and an audit trail
Answers three questions that had no answer: which computers are connected,
how do I cut one off, and what is recorded.

- state.json gains a device registry hanging off the identity that owns it,
  so 'which computers can reach my files' cannot drift from the identity map.
  Clients older than v1.2 send no device_id and fall back to the token name,
  so they still register.
- POST /v1/devices lists them; POST /v1/devices/revoke deletes that device's
  forge token via admin basic auth + Sudo (verified 204 on 1.27.2, after
  which the token is 401 immediately). Revocation is deliberately NOT rate
  limited -- nobody should be throttled out of signing out a lost laptop.
- The device id is now part of the token NAME. Revocation deletes by name,
  so two machines called 'macbook' linked in the same second would otherwise
  collide and signing one out would kill the other.
- A failed forge deletion is not recorded as revoked: a registry claiming
  'revoked' while the token still works is worse than an honest error.
- Append-only JSONL audit log (0600, rotates at 64MB), separate from
  state.json because state is rewritten atomically on every change and an
  audit trail the audited thing can rewrite is not one. A failed audit write
  is logged loudly and never breaks the request.
- Authorisation everywhere: the forge decides who a token belongs to
  (GET /api/v1/user). No login is ever read from the request body.
- Client: devices / logout / activity.

The audit log records granthi-link events only -- git pushes and pulls never
pass through this service. /v1/audit returns that caveat in its own response
rather than letting the log read as file activity.

172 tests (was 153).
2026-08-23 12:16:37 -04:00
Nirav PatelandClaude Opus 5 c742ca4798 fix(link): 3 more codex [P2] findings — per-route caps, bool rules, proxy validation
Round 2 of review on the same branch:

- The fail-closed capacity guard was itself a DoS lever. MAX_RATE_KEYS was
  global, and the limiter runs before auth, so a flood of cheap /v1/repos
  keys could exhaust the table and 429 never-seen /v1/link clients until
  live windows expired. Budgets are now per route.
- Rule values accepted booleans: bool subclasses int, so isinstance let
  [5, true] through as a 1-SECOND window (5/hour -> ~5/sec) and false in the
  limit slot disabled the endpoint. Now `type(x) is int`.
- trusted_proxies was unvalidated: a bare string would be iterated character
  by character, malformed entries only surfaced as a per-request log line,
  and 0.0.0.0/0 or ::/0 restored "trust XFF from any peer" — the exact hole
  the setting closes. Now parsed and validated once at startup, wildcards
  refused, and _ip_in_any takes pre-parsed networks so nothing can degrade
  to a silent per-request skip.

Tests 99 -> 108: cross-route flood isolation, per-route reclamation windows,
every bool-in-rule position, bare-string and wildcard proxies, and a v4/v6
mismatch case.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
2026-08-22 23:29:07 -04:00
Nirav PatelandClaude Opus 5 c442721aff fix(link): address 4 codex [P2] findings on the rate limiter
All four were real bypass or fail-open paths on an endpoint about to be
publicly exposed:

- X-Forwarded-For was trusted from ANY peer. The origin also listens on the
  tailnet, so anyone reaching it directly could pick -- and rotate -- their
  own rate-limit key by sending a header. Now honored only when the socket
  peer is in a configured trusted_proxies list, and the last hop must parse
  as a real IP. trust_forwarded_for without trusted_proxies REFUSES startup.
- Capacity eviction was fail-open and exploitable: an attacker able to mint
  many distinct keys could evict their own live window and start fresh. Now
  reclaims only EXPIRED windows and refuses the new key when all are live.
  Fail closed -- /v1/link is invite-only, so hitting the cap is an attack.
- The clock was read outside the lock, so racing threads could append out of
  order; both retry_after (hits[0]) and reclamation (v[-1]) assume the list
  is chronological. Moved inside.
- Config types were unvalidated: `"enabled": null` or `0` silently disabled
  limiting, and the string "false" enabled XFF trust (non-empty strings are
  truthy). Booleans must now be real JSON booleans; rate_limit must be an
  object.

Codex confirmed no path-variant bypass (dispatch is exact-match) and no
keep-alive/pipelining bypass (rejects set close_connection).

Tests 87 -> 99: capacity fail-closed with the victim's window proven
untouched through a 40-key flood, 200-thread chronological-order check,
untrusted-peer spoof, junk XFF, and every config-type trap.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
2026-08-22 23:22:44 -04:00
Nirav PatelandClaude Opus 5 e2fed5886f feat(link): rate-limit /v1/link and /v1/repos
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
2026-08-22 23:14:54 -04:00
Nirav PatelandClaude Fable 5 c674db4746 security: harden granthi-link + client against 7 codex findings
1. CRITICAL account-takeover by login collision: persist zitadel_sub ->
   gitea_login identity map (state.json, 0600, atomic); mapping wins,
   deleted logins re-created only if service-created, existing unmapped
   logins bind only on verified email match, else 409; token never
   minted before binding passes
2. test_mode now gated behind GRANTHI_LINK_ALLOW_TEST_MODE=1 env
3. refuse startup unless config.json is 0600/0400 and owned by service
4. client config created O_CREAT 0600 (no write-then-chmod window)
5. credential-helper command paths shlex-quoted
6. POST bodies capped at 64KB (413); missing/invalid Content-Length rejected
7. Gitea 409 on user create handled idempotently (re-fetch + verify email)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-19 09:17:26 -04:00
Nirav PatelandClaude Fable 5 16d6a30c04 fix: send explicit User-Agent (Cloudflare 403s default Python-urllib UA at id.shre.ai)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-19 00:11:38 -04:00
Nirav PatelandClaude Fable 5 2077591755 granthi-sync v1: granthi-link provisioning service + client daemon + tests
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-19 00:09:19 -04:00