18 Commits
Author SHA1 Message Date
Nirav Patel 918b184b01 Merge pull request 'docs: this describes the prod deployment now, not beta' (#11) from fix/docs-prod-forge-bases into main
Docs-only. NOTE: merged WITHOUT a completed granthi-review. The gate's webhook rejection was fixed today (item 52eea395), but reviews still fail open — diff fetch timeout, and shre-router returning prose instead of JSON (item 6d4e8819). Merged on explicit human approval, not on a green check.
2026-08-30 01:25:54 -04:00
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
Nirav Patel 7eb57acdbe Merge pull request 'fix(link): say "that address is already an account here", not "502"' (#10) from fix/link-duplicate-email-message into main 2026-08-23 16:46:22 -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
Nirav Patel 1b6682eee5 Merge pull request 'fix: three defects found by the codex review of today's merged work' (#9) from fix/codex-review-findings into main 2026-08-23 16:14:56 -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
Nirav Patel 9511093b14 Merge pull request 'fix(client): flush the device code, or nobody ever sees it' (#8) from fix/link-output-unbuffered into main 2026-08-23 15:58:36 -04:00
claude e77c0077e3 fix(client): flush the device code, or nobody ever sees it
Hit for real driving a first sign-in: `granthi-sync link` run through anything
that is not a terminal -- a wrapper, a pipe, `| tee setup.log` -- printed
NOTHING, while the code it was holding silently expired five minutes later.
Python buffers stdout when stdout is not a tty, and the two prints carrying
the verification URL and the user code did not flush.

They flush now. One regression test asserts both are printed with flush=True,
because this fails silently and only under redirection, which is exactly the
condition a test would otherwise never reproduce.

204 tests (was 203).
2026-08-23 15:58:34 -04:00
Nirav Patel 6d62419fc7 Merge pull request 'docs: SETUP.md — setting up a new computer, one command at a time' (#7) from docs/setup-guide into main 2026-08-23 15:26:59 -04:00
claude 2dcf42c780 docs: SETUP.md — setting up a new computer, one command at a time
Written because the walkthrough only existed in a chat log, and a new machine
reads its instructions from the clone.

Leads with the thing that will otherwise look like a bug: a first sign-in can
produce a NEW EMPTY account rather than one you already had, because Granthi
refuses to hand over an existing account just because the login name lines up.
The guide explains why that rule exists and gives both ways forward -- fresh
account (recommended, since the saved token acts as whatever account it is
bound to) or joining an existing one via a verified email match.

Also documents what people actually trip on: an empty `list` is a permissions
answer not a failure; a bare repo name that matches two teams is refused
rather than guessed; sign-out takes effect at the server, immediately; and
restore always writes to a new folder.

Plain language throughout, per the estate's 5th-grade rule for docs.

Verified: all 13 commands and every flag referenced exist in the client.
2026-08-23 15:26:24 -04:00
Nirav Patel f9ee2740c4 Merge pull request 'fix(client): resolve a bare repo name against what the forge grants' (#6) from fix/resolve-repo-by-grant into main 2026-08-23 15:05:49 -04:00
claude ced481e06b fix(client): resolve a bare repo name against what the forge grants
Found by running the real flow on a fresh clone against production, not by a
test: `granthi-sync get Ai-Assistant` failed with "Repository not found".
`get <name>` meant `<your-login>/<name>` and nothing else, but of 172 repos
granted to this estate's own admin, 157 are owned by an ORG -- so the bare
name failed for 91% of what a user can actually see, with an error that reads
like a permissions problem rather than a naming one.

A bare name is now matched against the granted list, which is the forge's own
answer about what this account may have. An owner-qualified name is taken as
given. An ambiguous bare name is REFUSED with its candidates rather than
guessed -- picking one of two repos called `notes` owned by different teams is
not a guess worth making for someone.

Best effort by design: if the listing is unreachable, a fully-qualified name
still clones and a bare name degrades to the caller's own namespace, so a
network blip cannot block a clone. The clone that follows reports the real
problem precisely.

cmd_bootstrap carried the same assumption and is fixed with it.

203 tests (was 198). Verified live afterwards: the failing command now clones
Nirlabinc/Ai-Assistant, and `get shreai` lists both candidates instead of
guessing.
2026-08-23 15:04:57 -04:00
Nirav Patel 8c99fe261e Merge pull request 'feat: share repos with people, and invite people who have no account yet' (#5) from feat/invites-and-grants into main 2026-08-23 14:11:04 -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
Nirav Patel a9ec8909eb Merge pull request 'feat(client): workspace bootstrap — what a new computer pulls first' (#4) from feat/workspace-bootstrap into main 2026-08-23 13:45:43 -04:00
claude 24a7fa6cdb feat(client): workspace bootstrap — what a new computer pulls first
granthi-sync bootstrap <folder> reads a workspace.json and pulls the repos it
names, still bounded by what the forge grants: a manifest naming a repo this
account cannot see prints NOT GRANTED and continues, because that is a
permissions answer, not an error to route around.

It does NOT install applications. Dash, deck, genie and shiva each have their
own repo, deploy path and reviewers; a sync client installing them would
create a second unreviewed deploy path beside the real one. So apps are
REFERENCED -- the command prints each app's repo, model, setup doc and the
vault keys it needs, with the shre-cred line to supply them.

Per-repo mode overrides the default, so a documents folder can be declared
mirror while real projects stay on the safe snapshot default.

184 tests (was 178).
2026-08-23 13:45:05 -04:00
Nirav Patel 4ba1256894 Merge pull request 'feat: sign in from any computer, no private network needed' (#3) from feat/public-link-endpoint into main 2026-08-23 12:33:30 -04:00
claude 49ab2a7408 feat: sign in from any computer, no private network needed
granthi-link is now public at https://granthi-link.shre.ai (cloudflared,
origin still tailnet-only), so the client defaults there instead of a tailnet
IP. Internal machines pass --server or GRANTHI_LINK_SERVER.

Two rollout traps recorded in the README: a third-level hostname
(link.granthi.shre.ai) fails TLS because Cloudflare Universal SSL covers
shre.ai and *.shre.ai only; and exposure REQUIRES trust_forwarded_for with
the tunnel as the sole trusted proxy, or every request looks like the tunnel
and one abuser spends everyone's rate budget.

178 tests.
2026-08-23 12:33:19 -04:00
7 changed files with 1370 additions and 42 deletions
+130 -24
View File
@@ -1,8 +1,10 @@
# granthi-sync v1.2 # granthi-sync v1.2
The signup → download → link-folders → cloud product spine for the Granthi The signup → download → link-folders → cloud product spine for the Granthi
forge, tested against the BETA forge (granthi-beta.shre.ai). Python 3 stdlib + forge. Developed and live-E2E-tested against the BETA forge
git CLI only — same portability heritage as the estate's `gitea_sync.py` mesh. (granthi-beta.shre.ai); **the deployed service now runs against the PRODUCTION
forge** — see "Where this actually runs" below. Python 3 stdlib + git CLI only
— same portability heritage as the estate's `gitea_sync.py` mesh.
``` ```
┌──────────────┐ device flow ┌─────────────────┐ ┌──────────────┐ device flow ┌─────────────────┐
@@ -13,7 +15,8 @@ git CLI only — same portability heritage as the estate's `gitea_sync.py` mesh.
│ │ POST /v1/link {zitadel_access_token, device_name} │ │ POST /v1/link {zitadel_access_token, device_name}
│ │ ───────────────▶ ┌───────────────────────────────┐ │ │ ───────────────▶ ┌───────────────────────────────┐
│ │ ◀─────────────── │ granthi-link :3042 │ │ │ ◀─────────────── │ granthi-link :3042 │
│ │ {login, token} │ (granthi VPS, tailnet-only) │ │ {login, token} │ (granthi VPS; public via
│ │ │ https://granthi-link.shre.ai)│
│ │ │ · userinfo validation │ │ │ │ · userinfo validation │
│ │ POST /v1/repos │ · ensure Gitea user (admin) │ │ │ POST /v1/repos │ · ensure Gitea user (admin) │
│ │ ───────────────▶ │ · mint scoped user token │ │ │ ───────────────▶ │ · mint scoped user token │
@@ -21,16 +24,35 @@ git CLI only — same portability heritage as the estate's `gitea_sync.py` mesh.
│ │ git push/fetch (user token │ admin API │ │ git push/fetch (user token │ admin API
│ │ via credential helper) ▼ │ │ via credential helper) ▼
│ │ ───────────────▶ ┌───────────────────────────────┐ │ │ ───────────────▶ ┌───────────────────────────────┐
└──────────────┘ │ BETA forge :3041 └──────────────┘ │ PROD forge :3040
│ granthi-beta.shre.ai │ │ granthi.shre.ai
└───────────────────────────────┘ └───────────────────────────────┘
``` ```
## Where this actually runs
Verified on the granthi VPS (`[email protected]`) on 2026-08-30, because
this file previously described the beta tier long after the deployment moved:
| | value |
|---|---|
| service | `/opt/granthi-link/`, `granthi-link.service`, `systemctl is-active``active` |
| public entry | `https://granthi-link.shre.ai``/health` → 200. `/` → 404 is **no root route**, not an outage |
| `gitea_base` | `http://127.0.0.1:3040` (container `gitea-central-gitea-1`) |
| `public_gitea_base` | `https://granthi.shre.ai` |
| rate limiting | `trust_forwarded_for: true`, `trusted_proxies: ["100.107.37.98/32"]`, no `rules` key → falls back to `DEFAULT_RATE_RULES` |
| deployed version | `/health` reports **1.1.0** while this repo is **v1.2** — the running service lags `main` |
So a new computer that follows Quickstart lands on the **production** forge.
Beta remains where changes are proven before they reach it.
## Quickstart (invited user) ## Quickstart (invited user)
You need a shre-id account — an operator creates it; there is no open signup You need a shre-id account — an operator creates it; there is no open signup
(see "Invite-only story"). You also need to be on the tailnet: the (see "Invite-only story"). You do **not** need to be on any private network:
provisioning service is not publicly exposed yet. the provisioning service answers at `https://granthi-link.shre.ai`, which is
the client's default server. Internal machines can still pass
`--server http://100.111.127.127:3042` to reach it over the tailnet.
```sh ```sh
git clone https://granthi.shre.ai/nirpa/granthi-sync.git git clone https://granthi.shre.ai/nirpa/granthi-sync.git
@@ -166,11 +188,18 @@ So:
* **Our own machines** may join the tailnet — that is an operator action with * **Our own machines** may join the tailnet — that is an operator action with
an operator's judgement behind it. an operator's judgement behind it.
* **Customer devices never do.** Their transport is public HTTPS to * **Customer devices never do.** Their transport is public HTTPS to
granthi-link and the forge through cloudflared. That is the same exposure granthi-link and the forge through cloudflared. **Done 2026-08-23:**
step already in the promotion window below, and it is what makes a genuinely `https://granthi-link.shre.ai` fronts `:3042` on the `pulse-granthi-edge`
new computer able to onboard itself at all — today `link` only works from tunnel, so a new computer signs itself in with one command and never
inside the tailnet, which means "you can't set up a new computer without an touches the private network.
operator first" is the honest status.
Two things that bit during that rollout, worth not rediscovering:
`link.granthi.shre.ai` fails TLS — Cloudflare's Universal SSL covers
`shre.ai` and `*.shre.ai`, **not** a third-level `*.granthi.shre.ai`, so
the hostname has to be second-level. And exposure REQUIRES
`trust_forwarded_for: true` with `trusted_proxies: ["100.107.37.98/32"]`
in the same change: behind the tunnel every request otherwise looks like
the tunnel itself, and one abuser would spend everybody's rate budget.
## Components ## Components
@@ -185,13 +214,16 @@ So:
**64 KB** (413 beyond; missing `Content-Length` → 411, invalid → 400). **64 KB** (413 beyond; missing `Content-Length` → 411, invalid → 400).
* `POST /v1/repos {token, name, private}` → creates the user repo with the * `POST /v1/repos {token, name, private}` → creates the user repo with the
USER token; clone/html URLs are rebased onto `public_gitea_base` because the USER token; clone/html URLs are rebased onto `public_gitea_base` because the
container `ROOT_URL` (https://granthi-beta.shre.ai) does not resolve for container `ROOT_URL` does not necessarily resolve for the client that asked
tailnet-only clients. (it was a tailnet-only address on beta; on prod the rebase keeps clone URLs
on `https://granthi.shre.ai` rather than the container's own view).
Deployment: `/opt/granthi-link/{granthi_link.py,config.json,state.json}` + Deployment: `/opt/granthi-link/{granthi_link.py,config.json,state.json}` +
systemd unit `granthi-link.service`; binds `127.0.0.1:3042` **and** systemd unit `granthi-link.service`; binds `127.0.0.1:3042` **and**
`100.111.127.127:3042` (tailnet). **Not publicly exposed** — see promotion `100.111.127.127:3042` (tailnet), and is **publicly reachable** at
window. The service **refuses to start** (exit 2) unless `config.json` is `https://granthi-link.shre.ai` through the `pulse-granthi-edge` cloudflared
tunnel (done 2026-08-23; re-verified 2026-08-30). The service **refuses to
start** (exit 2) unless `config.json` is
mode 0600/0400 and owned by the user it runs as — the config carries the mode 0600/0400 and owned by the user it runs as — the config carries the
forge admin password, so permissive perms fail closed, not open. forge admin password, so permissive perms fail closed, not open.
@@ -403,7 +435,7 @@ deleted it again, `DELETE …/tokens/{id}` returning 204 under basic auth):
## Tests ## Tests
* `python3 -m unittest discover -s tests` — 172 tests. The v1.2 additions * `python3 -m unittest discover -s tests` — 198 tests. The v1.2 additions
cover: a snapshot capturing uncommitted work while HEAD, the index and the cover: a snapshot capturing uncommitted work while HEAD, the index and the
working tree stay byte-identical; snapshots landing outside `refs/heads`; working tree stay byte-identical; snapshots landing outside `refs/heads`;
an unchanged tree not being re-pushed; a diverged folder still being backed an unchanged tree not being re-pushed; a diverged folder still being backed
@@ -503,7 +535,77 @@ holding a working forge token, and **the forge decides** whose it is
(`GET /api/v1/user`). No login is ever read from the request body, so a body (`GET /api/v1/user`). No login is ever read from the request body, so a body
claiming another account changes nothing. claiming another account changes nothing.
## Next phase — invites and per-repo access (designed, not built) ## Workspace bootstrap — what a new computer should pull first
granthi-sync bootstrap ~/granthi/acme --into ~/work
A workspace is a repo holding a `workspace.json`:
```json
{"name": "acme",
"repos": ["notes", {"name": "reports", "mode": "mirror"}],
"apps": [{"id": "dash", "repo": "nirpa/hermes-agent", "model": "aum/70b",
"setup": "docs/SETUP.md", "needs_keys": ["anthropic"]}]}
```
`bootstrap` pulls the repos it names — **still only the ones the forge grants
this account**; a manifest asking for a repo you cannot see prints
`NOT GRANTED` and moves on, because that is a permissions answer and not an
error to route around.
**It does not install applications, and that is the design, not a shortcut.**
Dash, deck, genie and shiva each have their own repo, deploy path and
reviewers. A sync client that installed them would create a second,
unreviewed deploy path beside the real one — the same copy-instead-of-refer
mistake the estate rulebook exists to end. So `apps` is *referenced*: the
command prints each app's repo, the model it uses, its setup doc, and which
vault keys must exist, with the `shre-cred request` line to supply them.
Per-repo `mode` in the manifest overrides the default, so a documents folder
can be declared `mirror` while everything else stays on the safe `snapshot`.
## Sharing: grants and invites
Two commands, because there are two situations.
**They already have an account** — share directly:
granthi-sync share bob --repo notes # write by default
granthi-sync share bob --repo notes --permission read
granthi-sync share bob --repo notes --revoke
granthi-sync shared --repo notes # who can see it
**They do not have an account yet** — invite them:
granthi-sync invite carol@example.com --repo notes --repo reports
An invite creates **nothing**: no account, no token, no collaborator row. The
grant is held against their email and applied the first time they run
`granthi-sync link`. An invite that is never accepted leaves nothing behind.
Three properties worth keeping:
* **The forge decides who may share.** Before recording anything, the service
asks Gitea whether the caller can administer that repo (listing
collaborators requires repo admin, so a 200 is Gitea's own answer). Deciding
it here from the repo name would be a second opinion about someone else's
authorisation — and the wrong one the first time a repo is transferred.
* **An unverified email collects nothing.** The address is the only thing
tying a promise to a person, so an invite is applied only when the IdP says
the address is verified. The invite stays pending rather than being consumed.
* **A failed grant never blocks a sign-in.** If a promised repo has since been
deleted, the person still links successfully and the failure is audited.
Someone must not be locked out of their own account by somebody else's
stale invite.
Applying an invite uses the admin credential deliberately: the inviter
authorised it when they issued it, and their session is long gone by the time
it is redeemed. Everything else — sharing, unsharing, listing — runs on the
caller's own token, which is why granting needs no elevated rights at all
(verified against the live forge: a repo owner's scoped token adds and removes
collaborators, HTTP 204).
## Superseded design note — invites and per-repo access
Today `/v1/link` creates an account and every folder becomes a private repo Today `/v1/link` creates an account and every folder becomes a private repo
under it. What is missing is the multi-person case: an existing account under it. What is missing is the multi-person case: an existing account
@@ -537,12 +639,16 @@ Shape this should take, so the next session does not re-litigate it:
## Promotion window (beta → prod) ## Promotion window (beta → prod)
1. **Expose :3042** behind cloudflared (granthi.shre.ai vhost or 1. ~~**Expose :3042** behind cloudflared~~ **DONE 2026-08-23**
link.granthi.shre.ai) — today it is tailnet-only by design. `https://granthi-link.shre.ai` (second-level, see above), origin stays
2. **Swap forge base URLs** in `/opt/granthi-link/config.json`: tailnet-only, `trust_forwarded_for` on with the tunnel as the only
`gitea_base` → prod forge, `public_gitea_base` trusted proxy.
`https://granthi.shre.ai`; the client default server URL moves to the 2. ~~**Swap forge base URLs** in `/opt/granthi-link/config.json`~~
public endpoint. **DONE — verified live 2026-08-30**: the deployed config reads
`gitea_base: http://127.0.0.1:3040` and
`public_gitea_base: https://granthi.shre.ai`, and the client default
server is already the public endpoint. Linking a new device therefore
creates the account on **prod**.
3. The `granthi-web` OIDC app already lists the prod callback; the device 3. The `granthi-web` OIDC app already lists the prod callback; the device
app is host-independent. Rotate the beta admin token/password out of the app is host-independent. Rotate the beta admin token/password out of the
config when pointing at prod (prod forge is READ-ONLY to this estate — config when pointing at prod (prod forge is READ-ONLY to this estate —
+163
View File
@@ -0,0 +1,163 @@
# Setting up Granthi on a new computer
Everything here is one command at a time. You need `git` and `python3`
(3.9 or newer). There is nothing to install — no package, no build.
## Read this first: what you will see when you sign in
Signing in does **not** hand you an account you already had somewhere else.
Granthi looks up your shre-id identity, and:
* if it has seen you before, you get the same forge account as last time;
* if it has not, and the login it would use is free, it makes you a **new,
empty account**.
So the first time you sign in on a new computer, `granthi-sync list` may show
**nothing**. That is not a failure. It means you are a new account and nobody
has given you access to anything yet.
Granthi refuses to hand you an existing account just because the name lines
up. It will only join you to one if that account's email is the same as your
verified sign-in email. That rule is what stops somebody else claiming your
files, so it is worth the small surprise on day one.
**Two ways to end up with your files, and it is worth choosing on purpose:**
| | what happens | when to pick it |
|---|---|---|
| **Fresh account** (recommended) | You sign in, get a new empty account, and somebody shares the repos you need with you | Your everyday laptop. The token stored on the machine can only do what that account can do. |
| **Join an existing account** | An operator sets that account's email to your verified sign-in email first; then signing in joins you to it and you see everything it owns straight away | You want one identity everywhere and accept that the laptop then carries whatever that account can do |
The fresh account is recommended for a plain reason: the token `link` saves on
the machine acts as that account. If you join an admin account, every computer
you sync from is carrying admin.
## 1. Get the program
git clone https://granthi.shre.ai/nirpa/granthi-sync.git
cd granthi-sync
No account is needed for this step.
## 2. Sign in
./bin/granthi-sync link
It prints a web address and a short code. Open the address, type the code, and
approve — you have five minutes. When it finishes, your account exists and a
key is saved at `~/.granthi-sync/config.json`, readable only by you. You never
see or type a forge password.
If the code runs out, **nothing was created** — no account, no key, no
half-finished state. Run `link` again.
You do not need to be on any private network. The client talks to
`https://granthi-link.shre.ai`.
## 3. See what you have
./bin/granthi-sync list
./bin/granthi-sync list work- # only names containing "work-"
This list comes from the forge, so it is exactly what you are allowed to see —
not a guess made on this computer.
If it is empty, see the note at the top: you are a new account. Ask whoever
owns the repos to run `granthi-sync share <your-login> --repo <name>`, or to
add you to the right organisation.
## 4. Bring folders down
./bin/granthi-sync get notes # by name
./bin/granthi-sync get Nirlabinc/notes # when two teams both have a "notes"
./bin/granthi-sync get --all --into ~/work # everything you are allowed to see
If a plain name matches two repos, Granthi stops and shows you both rather
than guessing which one you meant.
## 5. Or send a folder up
./bin/granthi-sync add ~/Documents/notes
It becomes a private repo. Two things happen for your protection:
* a starter `.gitignore` is written if the folder has none, covering `.env`,
`*.key`, `*.pem`, `id_rsa` and similar — those will **not** be synced;
* a folder holding more than 20,000 files or 512 MB is refused unless you add
`--force`, so you cannot upload your whole disk by accident.
## 6. Keep it syncing
Run it in a terminal:
./bin/granthi-sync watch
Or leave it running in the background on a Mac:
cp client/launchd/ai.granthi.sync.plist ~/Library/LaunchAgents/
Open that copied file and replace `/PATH/TO/granthi-sync` with the real path
to this folder. Then:
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ai.granthi.sync.plist
Its log is `/tmp/granthi-sync.log`.
### What syncing does to your folder
It depends on what the folder was:
* **A folder that was already a git project** — Granthi **never commits for
you**. Your history stays yours. Work you have not committed is copied to
the cloud with a timestamp so it is not only on this laptop.
* **A plain folder** — Granthi saves changes for you as it goes, and each save
is a point you can go back to.
Either way, if your copy and the cloud copy have both moved on, Granthi
**stops and tells you**. It will never merge or overwrite your work to make
the conflict go away.
## 7. Going back to an earlier version
./bin/granthi-sync snapshots ~/Documents/notes
./bin/granthi-sync restore ~/Documents/notes --at 20260823T142530Z
Restore writes to a **new folder**. It never overwrites what you have now —
if you are restoring, you have enough problems already.
You can restore from a computer that never had the folder, including one
replacing a laptop that is gone.
## 8. Sharing
./bin/granthi-sync share bob --repo notes # they have an account
./bin/granthi-sync share bob --repo notes --revoke
./bin/granthi-sync shared --repo notes # who can see it
./bin/granthi-sync invite carol@example.com --repo notes # they do not yet
An invite creates nothing until it is accepted. If the person never signs in,
nothing was ever made for them.
## 9. Keeping track of your computers
./bin/granthi-sync devices # every computer signed in to your account
./bin/granthi-sync logout # sign this computer out
./bin/granthi-sync logout --device <id> # sign out a lost one
./bin/granthi-sync activity # sign-ins and sign-outs, with times
Signing a computer out takes effect **at the server**, immediately. The key on
that machine stops working on its very next attempt — it is not a setting that
machine could ignore. That is what makes it useful for a laptop you have lost.
Your folders are left alone when you sign out; only the key is removed.
## If something looks wrong
* **`list` is empty** — you are a new account, nobody has shared anything yet.
* **"Repository not found"** — you may not have access to that repo; run
`list` to see what you do have.
* **"this device's access has been revoked"** — someone signed this computer
out. Run `link` again.
* **A folder shows DIVERGED** — your copy and the cloud copy both changed.
Granthi is waiting for you on purpose. Nothing is lost; sort it out in git
or on the forge web page.
+310 -9
View File
@@ -87,7 +87,12 @@ ZITADEL_BASE = "https://id.shre.ai"
DEVICE_CLIENT_ID = "386909715541590022" DEVICE_CLIENT_ID = "386909715541590022"
# ^ Zitadel native app "granthi-sync-device" (appId 386909715541524486) in # ^ Zitadel native app "granthi-sync-device" (appId 386909715541524486) in
# project granthi-forge (386906525790109702); device-code + refresh grants. # project granthi-forge (386906525790109702); device-code + refresh grants.
DEFAULT_SERVER = "http://100.111.127.127:3042" # Public by default: a genuinely new computer cannot be expected to join a
# private network before it can sign in. The tailnet address still works and
# is what internal machines should pass to --server.
DEFAULT_SERVER = os.environ.get("GRANTHI_LINK_SERVER",
"https://granthi-link.shre.ai")
TAILNET_SERVER = "http://100.111.127.127:3042"
DEVICE_SCOPE = "openid profile email" DEVICE_SCOPE = "openid profile email"
FORGE_PAGE_LIMIT = 50 FORGE_PAGE_LIMIT = 50
@@ -99,6 +104,10 @@ FORGE_MAX_PAGES = 40 # 2000 repos; a guard against an unbounded paging loop
# Under refs/heads a machine taking a snapshot every 30s would bury the # Under refs/heads a machine taking a snapshot every 30s would bury the
# user's real branches. # user's real branches.
BACKUP_NS = "refs/granthi-backup" BACKUP_NS = "refs/granthi-backup"
_SNAPSHOT_IDENT = {"GIT_AUTHOR_NAME": "granthi-sync",
"GIT_AUTHOR_EMAIL": "[email protected]",
"GIT_COMMITTER_NAME": "granthi-sync",
"GIT_COMMITTER_EMAIL": "[email protected]"}
SNAPSHOT_TS_FMT = "%Y%m%dT%H%M%SZ" SNAPSHOT_TS_FMT = "%Y%m%dT%H%M%SZ"
# Retention. Unbounded snapshots are a disk leak with no way to use them, so # Retention. Unbounded snapshots are a disk leak with no way to use them, so
@@ -296,15 +305,33 @@ def build_snapshot(folder):
args = ["commit-tree", tree, "-m", f"granthi snapshot: {ts}"] args = ["commit-tree", tree, "-m", f"granthi snapshot: {ts}"]
if head: if head:
args += ["-p", head] args += ["-p", head]
# The worktree tree alone loses STAGED-ONLY work. Stage a hunk, edit the
# file further, lose the laptop, and the snapshot holds only the later
# worktree version -- the carefully staged one is gone. git itself keeps
# index and worktree as separate states, so the backup must too.
# The user's real index is read WITHOUT touching it, and when it differs
# from both HEAD and the worktree it rides along as a second parent, so
# it is reachable from the snapshot. (codex review, P2.)
rc_idx, index_tree = git(folder, "write-tree", check=False)
index_tree = index_tree.strip()
if rc_idx == 0 and index_tree and index_tree != tree:
head_tree_now = ""
if head:
head_tree_now = git(folder, "rev-parse", f"{head}^{{tree}}")[1].strip()
if index_tree != head_tree_now:
icommit_args = ["commit-tree", index_tree, "-m",
f"granthi snapshot (staged): {ts}"]
if head:
icommit_args += ["-p", head]
rc_ic, icommit = git(folder, *icommit_args, check=False,
env=_SNAPSHOT_IDENT)
if rc_ic == 0 and icommit.strip():
args += ["-p", icommit.strip()]
# Snapshots are parented on HEAD and nothing else -- deliberately NOT # Snapshots are parented on HEAD and nothing else -- deliberately NOT
# chained to the previous snapshot. Chaining would keep every old # chained to the previous snapshot. Chaining would keep every old
# snapshot reachable from the newest one, so pruning a ref would free # snapshot reachable from the newest one, so pruning a ref would free
# nothing and retention would be decorative. # nothing and retention would be decorative.
_, commit = git(folder, *args, _, commit = git(folder, *args, env=_SNAPSHOT_IDENT)
env={"GIT_AUTHOR_NAME": "granthi-sync",
"GIT_AUTHOR_EMAIL": "[email protected]",
"GIT_COMMITTER_NAME": "granthi-sync",
"GIT_COMMITTER_EMAIL": "[email protected]"})
return commit.strip(), tree return commit.strip(), tree
finally: finally:
if os.path.exists(tmp_index): if os.path.exists(tmp_index):
@@ -594,8 +621,13 @@ def device_flow():
form={"client_id": DEVICE_CLIENT_ID, "scope": DEVICE_SCOPE}) form={"client_id": DEVICE_CLIENT_ID, "scope": DEVICE_SCOPE})
if status != 200: if status != 200:
raise SystemExit(f"device authorization failed (HTTP {status}): {resp}") raise SystemExit(f"device authorization failed (HTTP {status}): {resp}")
print(f"\nTo link this device, open:\n\n {resp.get('verification_uri_complete') or resp.get('verification_uri')}\n") # flush=True is not cosmetic. Python buffers stdout when it is not a
print(f"and enter code: {resp['user_code']}\n") # terminal, so `granthi-sync link | tee setup.log`, a wrapper script, or
# anything capturing output shows NOTHING while the code silently expires
# five minutes later. Hit for real on 2026-08-23 driving a first sign-in.
print(f"\nTo link this device, open:\n\n {resp.get('verification_uri_complete') or resp.get('verification_uri')}\n",
flush=True)
print(f"and enter code: {resp['user_code']}\n", flush=True)
interval = int(resp.get("interval", 5)) interval = int(resp.get("interval", 5))
deadline = time.time() + int(resp.get("expires_in", 300)) deadline = time.time() + int(resp.get("expires_in", 300))
while time.time() < deadline: while time.time() < deadline:
@@ -828,6 +860,59 @@ def clone_one(cfg, full_name, dest, mode=None):
return meta return meta
def resolve_granted(cfg, want, repos=None, truncated=False):
"""Turn what the user typed into the repo the forge actually grants them.
`get notes` used to mean `<your-login>/notes` and nothing else. On a real
account that is wrong for almost everything: of 172 repos granted to this
estate's own admin, 157 are owned by an ORG, so the bare name failed for
91% of what the user could see and the error said "Repository not found"
-- which reads like a permissions problem rather than a naming one.
So a bare name is resolved against the granted list, which is the forge's
own answer about what this account may have. An owner-qualified name is
taken as given. An ambiguous bare name is REFUSED with the candidates
rather than guessed: picking one of two repos called `notes` owned by
different teams is not a guess worth making for someone.
"""
if "/" in want:
return parse_repo_arg(want, cfg["login"])
if repos is None:
try:
repos, truncated = list_repos(cfg)
except (SystemExit, ValueError, OSError) as e:
# Best effort. If the listing is unreachable, a name the user
# typed in full must still clone, and a bare name should degrade
# to their own namespace rather than refusing outright -- the
# clone that follows reports the real problem precisely.
log(f"could not read your repo list ({e}); assuming "
f"{cfg['login']}/{want}")
return parse_repo_arg(want, cfg["login"])
matches = [r.get("full_name") for r in repos
if (r.get("full_name") or "").rsplit("/", 1)[-1] == want]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
listed = "\n".join(f" {m}" for m in sorted(matches))
raise SystemExit(
f"{want!r} is ambiguous — {len(matches)} repos you can see have "
f"that name:\n{listed}\nRe-run with the owner, e.g. "
f"granthi-sync get {sorted(matches)[0]}")
# Nothing granted by that name. If the listing was TRUNCATED the answer is
# unknown rather than absent -- falling back to <login>/<name> could clone a
# different repo that happens to exist under your own account. Refuse and
# ask for the owner. (codex review, P3.)
if truncated:
raise SystemExit(
f"could not confirm {want!r}: your repo list was truncated at "
f"{FORGE_MAX_PAGES * FORGE_PAGE_LIMIT} repos, so a match may exist "
f"beyond it. Re-run with the owner, e.g. "
f"granthi-sync get <owner>/{want}")
# Otherwise the listing was complete and simply has no such repo; name a
# concrete one so the clone error is precise.
return parse_repo_arg(want, cfg["login"])
def cmd_get(args): def cmd_get(args):
cfg = require_linked(load_config()) cfg = require_linked(load_config())
device_id(cfg) device_id(cfg)
@@ -835,7 +920,7 @@ def cmd_get(args):
return _get_all(cfg, args) return _get_all(cfg, args)
if not args.repo: if not args.repo:
raise SystemExit("give a repo name, or --all") raise SystemExit("give a repo name, or --all")
full_name = parse_repo_arg(args.repo, cfg["login"]) full_name = resolve_granted(cfg, args.repo)
name = full_name.rsplit("/", 1)[-1] name = full_name.rsplit("/", 1)[-1]
clone_one(cfg, full_name, os.path.abspath(args.into or name), args.mode) clone_one(cfg, full_name, os.path.abspath(args.into or name), args.mode)
return 0 return 0
@@ -1223,6 +1308,193 @@ def cmd_activity(args):
return 0 return 0
WORKSPACE_FILE = "workspace.json"
def read_workspace(folder):
"""Parse a workspace manifest, or raise SystemExit with a usable message.
Shape (everything except `repos` is optional):
{"name": "acme",
"repos": [{"name": "notes", "mode": "mirror"}, "reports"],
"apps": [{"id": "dash", "repo": "nirpa/hermes-agent",
"model": "aum/70b", "setup": "docs/SETUP.md",
"needs_keys": ["anthropic"]}]}
`apps` REFERENCES applications; it never vendors them. Dash, deck, genie
and shiva each have their own repo, deploy path and reviewers, and copying
them into a workspace would fork them silently -- the same
copy-without-a-generator failure the estate rulebook exists to end.
"""
path = os.path.join(folder, WORKSPACE_FILE)
try:
with open(path) as f:
data = json.load(f)
except FileNotFoundError:
raise SystemExit(f"no {WORKSPACE_FILE} in {folder}")
except ValueError as e:
raise SystemExit(f"{path} is not valid JSON: {e}")
if not isinstance(data, dict):
raise SystemExit(f"{path} must be a JSON object")
repos = data.get("repos") or []
if not isinstance(repos, list):
raise SystemExit(f"{path}: 'repos' must be a list")
norm = []
for entry in repos:
if isinstance(entry, str):
norm.append({"name": entry})
elif isinstance(entry, dict) and entry.get("name"):
norm.append(entry)
else:
raise SystemExit(
f"{path}: every repo must be a name or an object with 'name'")
data["repos"] = norm
apps = data.get("apps") or []
if not isinstance(apps, list) or any(not isinstance(a, dict) for a in apps):
raise SystemExit(f"{path}: 'apps' must be a list of objects")
return data
def cmd_bootstrap(args):
"""Set a computer up from a workspace manifest.
Deliberately does NOT install applications. It pulls the repos the
workspace names -- still only the ones the forge grants this account --
and then TELLS the user what each app needs: its repo, its model, its
setup doc, and which vault keys must exist. Installing someone's dash or
shiva from a sync client would put a second, unreviewed deploy path next
to the real one.
"""
cfg = require_linked(load_config())
folder = os.path.abspath(args.folder)
ws = read_workspace(folder)
name = ws.get("name") or os.path.basename(folder)
into = os.path.abspath(args.into or ".")
print(f"workspace: {name}\n")
granted_repos, granted_truncated = list_repos(cfg)
granted = {r.get("full_name") for r in granted_repos}
pulled = skipped = denied = 0
for repo in ws["repos"]:
want = repo["name"]
try:
full = resolve_granted(cfg, want, repos=granted_repos,
truncated=granted_truncated)
except SystemExit as e:
log(f"AMBIGUOUS {want}: {e}")
denied += 1
continue
if full not in granted:
# The forge decides. A manifest asking for a repo this account was
# not granted is a permissions answer, not an error to route round.
log(f"NOT GRANTED {full}: your account cannot see it — ask the "
f"workspace owner for access")
denied += 1
continue
dest = os.path.join(into, full.rsplit("/", 1)[-1])
if os.path.exists(dest) and os.listdir(dest):
log(f"skip {full}: already at {dest}")
skipped += 1
continue
if args.dry_run:
log(f"WOULD PULL {full} -> {dest}")
continue
try:
clone_one(cfg, full, dest, repo.get("mode"))
pulled += 1
except (SystemExit, RuntimeError) as e:
log(f"FAILED {full}: {e}")
print(f"\nrepos: {pulled} pulled, {skipped} already here, "
f"{denied} not granted")
apps = ws.get("apps") or []
if apps:
print("\napps this workspace expects (NOT installed by granthi-sync —"
"\neach has its own repo and deploy path):")
for app in apps:
print(f" {app.get('id', '?')}")
for label, key in (("repo", "repo"), ("model", "model"),
("setup", "setup")):
if app.get(key):
print(f" {label:6} {app[key]}")
keys = app.get("needs_keys") or []
if keys:
print(f" keys {', '.join(keys)} "
f"(add with: shre-cred request --name <key> ...)")
return 0
def _post(cfg, path, body):
status, resp = http_json("POST", f"{cfg['server']}{path}", body=body)
if status == 401:
raise SystemExit("this device's access has been revoked -- "
"run: granthi-sync link")
return status, resp
def cmd_share(args):
"""Give someone who already has an account access to one of your repos."""
cfg = require_linked(load_config())
status, resp = _post(cfg, "/v1/grants",
{"token": cfg["token"],
"action": "remove" if args.revoke else "add",
"repo": args.repo, "login": args.who,
"permission": args.permission})
if status == 403:
raise SystemExit(f"you cannot administer {args.repo} -- only the "
f"owner can share it")
if status != 200:
raise SystemExit(f"share failed (HTTP {status}): {resp}")
verb = "no longer shared with" if args.revoke else \
f"shared with ({resp.get('permission')})"
log(f"{resp.get('repo')} {verb} {resp.get('login')}")
return 0
def cmd_shared(args):
"""Who can see one of your repos."""
cfg = require_linked(load_config())
status, resp = _post(cfg, "/v1/grants",
{"token": cfg["token"], "action": "list",
"repo": args.repo})
if status == 403:
raise SystemExit(f"you cannot administer {args.repo}")
if status != 200:
raise SystemExit(f"could not list access (HTTP {status}): {resp}")
people = resp.get("collaborators") or []
if not people:
print(f"{resp.get('repo')}: nobody else has access")
return 0
print(f"{resp.get('repo')} is shared with:")
for p in people:
print(f" {p}")
return 0
def cmd_invite(args):
"""Promise access to someone who has no account yet.
Nothing is created for them now. The grant is held against their email and
applied the first time they sign in -- so an invite that is never accepted
leaves nothing behind.
"""
cfg = require_linked(load_config())
repos = [{"name": r, "permission": args.permission} for r in args.repo]
status, resp = _post(cfg, "/v1/invite",
{"token": cfg["token"], "email": args.email,
"repos": repos})
if status == 403:
raise SystemExit(str(resp.get("error") or "you cannot share that repo"))
if status != 200:
raise SystemExit(f"invite failed (HTTP {status}): {resp}")
log(f"invited {resp['invited']} to "
f"{', '.join(g['repo'] for g in resp['repos'])}")
log("they get access the first time they run `granthi-sync link` with "
"that email -- until then nothing exists for them")
return 0
def _folder_meta(cfg, folder): def _folder_meta(cfg, folder):
path = os.path.abspath(folder) path = os.path.abspath(folder)
meta = cfg.get("folders", {}).get(path) meta = cfg.get("folders", {}).get(path)
@@ -1429,6 +1701,35 @@ def main(argv=None):
sp.add_argument("--limit", type=int, default=50) sp.add_argument("--limit", type=int, default=50)
sp.set_defaults(fn=cmd_activity) sp.set_defaults(fn=cmd_activity)
sp = sub.add_parser("bootstrap",
help="set this computer up from a workspace.json")
sp.add_argument("folder", help="folder holding workspace.json")
sp.add_argument("--into", help="where to clone the repos (default: .)")
sp.add_argument("--dry-run", action="store_true")
sp.set_defaults(fn=cmd_bootstrap)
sp = sub.add_parser("share", help="share one of your repos with someone")
sp.add_argument("who", help="their forge login")
sp.add_argument("--repo", required=True)
sp.add_argument("--permission", default="write",
choices=("read", "write", "admin"))
sp.add_argument("--revoke", action="store_true",
help="take the access away again")
sp.set_defaults(fn=cmd_share)
sp = sub.add_parser("shared", help="who can see one of your repos")
sp.add_argument("--repo", required=True)
sp.set_defaults(fn=cmd_shared)
sp = sub.add_parser("invite",
help="invite someone who has no account yet")
sp.add_argument("email")
sp.add_argument("--repo", action="append", required=True,
help="repeatable")
sp.add_argument("--permission", default="write",
choices=("read", "write", "admin"))
sp.set_defaults(fn=cmd_invite)
sp = sub.add_parser("status", help="show linked folders") sp = sub.add_parser("status", help="show linked folders")
sp.set_defaults(fn=cmd_status) sp.set_defaults(fn=cmd_status)
+7 -5
View File
@@ -1,15 +1,17 @@
{ {
"gitea_base": "http://127.0.0.1:3041", "_comment_forge": "Values below mirror the LIVE prod deployment (verified 2026-08-30). For the beta tier use gitea_base http://127.0.0.1:3041, public_gitea_base https://granthi-beta.shre.ai, container gitea-beta-gitea-1, creds /opt/gitea-beta/.admin-creds.",
"public_gitea_base": "http://100.111.127.127:3041", "gitea_base": "http://127.0.0.1:3040",
"public_gitea_base": "https://granthi.shre.ai",
"zitadel_userinfo": "https://id.shre.ai/oidc/v1/userinfo", "zitadel_userinfo": "https://id.shre.ai/oidc/v1/userinfo",
"admin_token": "MINT-VIA: docker exec -u git gitea-beta-gitea-1 gitea admin user generate-access-token --username nirpa --scopes write:admin,write:user,write:repository --raw", "admin_token": "MINT-VIA: docker exec -u git gitea-central-gitea-1 gitea admin user generate-access-token --username nirpa --scopes write:admin,write:user,write:repository --raw",
"admin_login": "nirpa", "admin_login": "nirpa",
"admin_password": "FROM /opt/gitea-beta/.admin-creds (required: Gitea 1.27 token minting only works via basic auth + Sudo header)", "admin_password": "FROM shre-cred: superadmin/granthi-link/granthi-prod-forge-admin (required: Gitea 1.27 token minting only works via basic auth + Sudo header). NEVER paste this into chat or a shell history.",
"binds": [["127.0.0.1", 3042], ["100.111.127.127", 3042]], "binds": [["127.0.0.1", 3042], ["100.111.127.127", 3042]],
"test_mode": false, "test_mode": false,
"rate_limit": { "rate_limit": {
"enabled": true, "enabled": true,
"trust_forwarded_for": false, "trust_forwarded_for": true,
"trusted_proxies": ["100.107.37.98/32"],
"rules": {"/v1/link": [5, 3600], "/v1/repos": [60, 3600]} "rules": {"/v1/link": [5, 3600], "/v1/repos": [60, 3600]}
} }
} }
+254 -3
View File
@@ -80,6 +80,9 @@ TEST_MODE_ENV = "GRANTHI_LINK_ALLOW_TEST_MODE"
# Sentinel: create_user hit a 409 (someone else created the login first). # Sentinel: create_user hit a 409 (someone else created the login first).
USER_CREATE_CONFLICT = object() USER_CREATE_CONFLICT = object()
# Sentinel: the address already belongs to another forge account, which is a
# 409 the caller can act on -- not a 502 that reads like the service is down.
EMAIL_IN_USE = object()
LOGIN_SAFE = re.compile(r"[^a-zA-Z0-9._-]+") LOGIN_SAFE = re.compile(r"[^a-zA-Z0-9._-]+")
@@ -90,6 +93,10 @@ DEFAULT_RATE_RULES = {"/v1/link": (5, 3600), "/v1/repos": (60, 3600),
# Reads are cheap but still authenticated work. # Reads are cheap but still authenticated work.
"/v1/devices": (120, 3600), "/v1/devices": (120, 3600),
"/v1/audit": (120, 3600), "/v1/audit": (120, 3600),
# Sharing is an ordinary act; inviting reaches a person
# who does not exist yet, so it is the tighter of the two.
"/v1/grants": (120, 3600),
"/v1/invite": (60, 3600),
# Revocation is a safety action, so its limit is set # Revocation is a safety action, so its limit is set
# high rather than tight. It is NOT 0: in this limiter # high rather than tight. It is NOT 0: in this limiter
# a limit of 0 DISABLES the endpoint outright (see # a limit of 0 DISABLES the endpoint outright (see
@@ -288,6 +295,14 @@ def check_config_perms(path, euid=None):
# Identity map: zitadel sub -> gitea login (JSON, 0600, atomic writes) # Identity map: zitadel sub -> gitea login (JSON, 0600, atomic writes)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def _email_in_use_message(login, userinfo):
email = userinfo.get("email") or "your address"
return (f"cannot create the account '{login}': {email} already belongs to "
f"a different account on this forge. If that other account is "
f"yours, an operator must bind your identity to it; if it is not, "
f"use a different address.")
class IdentityStore: class IdentityStore:
"""Persistent map of Zitadel `sub` -> Gitea login binding records. """Persistent map of Zitadel `sub` -> Gitea login binding records.
@@ -368,6 +383,59 @@ class IdentityStore:
return sub, dict(ident.get("devices") or {}) return sub, dict(ident.get("devices") or {})
return None, {} return None, {}
# -- pending invites ---------------------------------------------------
# Keyed by VERIFIED email. An invite is a promise of access made before the
# person has a forge account; it is applied the first time they link. The
# key must be an identity the IdP vouches for, or anyone could claim
# someone else's invite by asserting their address.
def add_invite(self, email, grants, invited_by):
data = self._load()
book = data.setdefault("invites", {})
entry = book.setdefault(email.strip().lower(), [])
for g in grants:
# Re-inviting the same repo updates the permission rather than
# stacking duplicates that would be applied twice.
entry[:] = [e for e in entry if e["repo"] != g["repo"]]
entry.append({"repo": g["repo"], "permission": g["permission"],
"invited_by": invited_by,
"invited_at": datetime.now(timezone.utc).isoformat(
timespec="seconds")})
self._write(data)
def take_invites(self, email):
"""Read AND clear the invites for an email, atomically under the
caller's lock. Returns [] when there are none."""
if not email:
return []
data = self._load()
book = data.get("invites") or {}
pending = book.pop(email.strip().lower(), [])
if pending:
self._write(data)
return pending
def consume_invite(self, email, repo):
"""Drop ONE applied grant, leaving any that failed still pending.
The whole-list `take_invites` is what made a transient forge error
permanent; this removes only what actually landed.
"""
data = self._load()
book = data.get("invites") or {}
key = (email or "").strip().lower()
entry = [e for e in book.get(key, []) if e.get("repo") != repo]
if entry:
book[key] = entry
else:
book.pop(key, None)
data["invites"] = book
self._write(data)
def peek_invites(self, email):
book = self._load().get("invites") or {}
return list(book.get((email or "").strip().lower(), []))
def mark_revoked(self, sub, device_id, when): def mark_revoked(self, sub, device_id, when):
data = self._load() data = self._load()
ident = data["identities"].get(str(sub)) or {} ident = data["identities"].get(str(sub)) or {}
@@ -620,6 +688,14 @@ class LinkService:
headers=self._admin_hdr(), body=body) headers=self._admin_hdr(), body=body)
if status == 409: if status == 409:
return USER_CREATE_CONFLICT return USER_CREATE_CONFLICT
if status == 422 and "e-mail already in use" in str(resp).lower():
# The address belongs to a DIFFERENT forge account. Reported as its
# own case because the generic path turns it into a bare 502, and
# "502" sends the person looking for an outage when the real answer
# is "that address is already somebody's account here". Hit live on
# 2026-08-23: an operator moved an email onto another account and
# the next sign-in failed with nothing but the number.
return EMAIL_IN_USE
if status != 201: if status != 201:
return f"gitea admin user create failed (HTTP {status}): {resp}" return f"gitea admin user create failed (HTTP {status}): {resp}"
return None return None
@@ -676,6 +752,8 @@ class LinkService:
"was not created by this service; refusing " "was not created by this service; refusing "
"to re-create"}, None "to re-create"}, None
err = self.create_user(login, userinfo) err = self.create_user(login, userinfo)
if err is EMAIL_IN_USE:
return 409, {"error": _email_in_use_message(login, userinfo)}, None
if err and err is not USER_CREATE_CONFLICT: if err and err is not USER_CREATE_CONFLICT:
return 502, {"error": err}, None return 502, {"error": err}, None
LOG.info("re-created service-managed gitea user %s", login) LOG.info("re-created service-managed gitea user %s", login)
@@ -701,6 +779,8 @@ class LinkService:
return 409, {"error": "login exists and is not linked " return 409, {"error": "login exists and is not linked "
"to this identity"}, None "to this identity"}, None
LOG.info("user %s created concurrently; continuing", login) LOG.info("user %s created concurrently; continuing", login)
elif err is EMAIL_IN_USE:
return 409, {"error": _email_in_use_message(login, userinfo)}, None
elif err: elif err:
return 502, {"error": err}, None return 502, {"error": err}, None
else: else:
@@ -767,12 +847,16 @@ class LinkService:
"linked_at": datetime.now(timezone.utc).isoformat( "linked_at": datetime.now(timezone.utc).isoformat(
timespec="seconds"), timespec="seconds"),
}) })
granted = self.apply_invites(login, userinfo, client_ip)
self.audit.write("device.link", login=login, device_id=device_id, self.audit.write("device.link", login=login, device_id=device_id,
device_name=device_name, token_name=token_name, device_name=device_name, token_name=token_name,
client_ip=client_ip) client_ip=client_ip)
return 200, {"gitea_base": self.public_gitea, "login": login, resp = {"gitea_base": self.public_gitea, "login": login,
"token": gitea_token, "token_name": token_name, "token": gitea_token, "token_name": token_name,
"device_id": device_id} "device_id": device_id}
if granted:
resp["granted_repos"] = granted
return 200, resp
# -- device registry endpoints ----------------------------------------- # -- device registry endpoints -----------------------------------------
@@ -862,6 +946,169 @@ class LinkService:
return None return None
return f"forge refused token deletion (HTTP {status}): {resp}" return f"forge refused token deletion (HTTP {status}): {resp}"
# -- sharing -----------------------------------------------------------
PERMISSIONS = ("read", "write", "admin")
def _repo_admin_check(self, token, full_name):
"""Can this caller administer that repo? Ask the FORGE.
Listing collaborators requires repo admin, so a 200 here is Gitea's own
answer to 'may you share this?'. Deciding it ourselves from the repo
name would be a second opinion about someone else's authorisation --
and the wrong one the first time a repo is transferred.
"""
status, _ = http_json(
"GET", f"{self.gitea}/api/v1/repos/{full_name}/collaborators",
headers={"Authorization": f"token {token}"})
return status == 200
def grants(self, body, client_ip=None):
"""Share a repo with someone who already has a forge account."""
login = self.whoami(body.get("token"))
if not login:
return 401, {"error": "invalid or revoked token"}
token = body.get("token")
action = (body.get("action") or "add").lower()
if action not in ("add", "remove", "list"):
return 400, {"error": "action must be add, remove or list"}
repo = str(body.get("repo") or "").strip()
if not repo:
return 400, {"error": "repo required"}
full = repo if "/" in repo else f"{login}/{repo}"
if not self._repo_admin_check(token, full):
self.audit.write("grant.denied", login=login, repo=full,
client_ip=client_ip,
reason="caller cannot administer this repo")
return 403, {"error": f"you cannot administer {full}"}
if action == "list":
status, resp = http_json(
"GET", f"{self.gitea}/api/v1/repos/{full}/collaborators",
headers={"Authorization": f"token {token}"})
people = [u.get("login") for u in resp] if isinstance(resp, list) else []
return 200, {"repo": full, "collaborators": people}
who = str(body.get("login") or "").strip()
if not who:
return 400, {"error": "login required"}
permission = (body.get("permission") or "write").lower()
if permission not in self.PERMISSIONS:
return 400, {"error": f"permission must be one of "
f"{', '.join(self.PERMISSIONS)}"}
if action == "add":
status, resp = http_json(
"PUT",
f"{self.gitea}/api/v1/repos/{full}/collaborators/"
f"{urllib.parse.quote(who, safe='')}",
headers={"Authorization": f"token {token}"},
body={"permission": permission})
ok = status in (200, 204)
else:
status, resp = http_json(
"DELETE",
f"{self.gitea}/api/v1/repos/{full}/collaborators/"
f"{urllib.parse.quote(who, safe='')}",
headers={"Authorization": f"token {token}"})
ok = status in (200, 204, 404) # already gone is the wanted state
if not ok:
self.audit.write("grant.failed", login=login, repo=full,
grantee=who, action=action, client_ip=client_ip,
reason=f"HTTP {status}")
return 502, {"error": f"forge refused (HTTP {status}): {resp}"}
self.audit.write(f"grant.{action}", login=login, repo=full,
grantee=who, permission=permission,
client_ip=client_ip)
return 200, {"repo": full, "login": who, "action": action,
"permission": permission}
def invite(self, body, client_ip=None):
"""Promise access to someone who has no forge account yet.
Nothing is created for them here -- no account, no token. The grant is
recorded against their VERIFIED email and applied the first time they
link. If they never link, nothing ever existed.
"""
login = self.whoami(body.get("token"))
if not login:
return 401, {"error": "invalid or revoked token"}
email = str(body.get("email") or "").strip().lower()
if "@" not in email:
return 400, {"error": "a valid email is required"}
repos = body.get("repos")
if not isinstance(repos, list) or not repos:
return 400, {"error": "repos must be a non-empty list"}
wanted = []
for entry in repos:
if isinstance(entry, str):
entry = {"name": entry}
if not isinstance(entry, dict) or not entry.get("name"):
return 400, {"error": "each repo needs a name"}
name = str(entry["name"]).strip()
full = name if "/" in name else f"{login}/{name}"
permission = (entry.get("permission") or "write").lower()
if permission not in self.PERMISSIONS:
return 400, {"error": f"permission must be one of "
f"{', '.join(self.PERMISSIONS)}"}
if not self._repo_admin_check(body.get("token"), full):
self.audit.write("invite.denied", login=login, repo=full,
invitee=email, client_ip=client_ip,
reason="caller cannot administer this repo")
return 403, {"error": f"you cannot administer {full}"}
wanted.append({"repo": full, "permission": permission})
with self.state.lock:
self.state.add_invite(email, wanted, login)
self.audit.write("invite", login=login, invitee=email,
repos=[w["repo"] for w in wanted], client_ip=client_ip)
return 200, {"invited": email,
"repos": wanted,
"note": "applied the first time they sign in with a "
"verified email that matches"}
def apply_invites(self, login, userinfo, client_ip=None):
"""Turn recorded invites into real collaborator rows at link time.
Uses the ADMIN credential deliberately: the inviter authorised this
when they issued the invite, and their session is long gone by now.
Nothing here can fail the link -- someone signing in must not be
blocked because a repo they were promised has since been deleted.
"""
email = (userinfo.get("email") or "").strip().lower()
if not email or not userinfo.get("email_verified"):
# Unverified email must never collect an invite: the address is
# the only thing tying the promise to this person.
return []
with self.state.lock:
pending = self.state.peek_invites(email)
applied = []
# PEEK, not take. Consuming the invite first means a transient 502 from
# the forge destroys it: the person links successfully, gets no access,
# and re-linking never retries because the promise is gone. Only the
# grants that actually landed are removed, so a failure is retried on
# the next link instead of being silently lost. (codex review, P2.)
for grant in pending:
status, resp = http_json(
"PUT",
f"{self.gitea}/api/v1/repos/{grant['repo']}/collaborators/"
f"{urllib.parse.quote(login, safe='')}",
headers={"Authorization": _basic(self.cfg["admin_login"],
self.cfg["admin_password"])},
body={"permission": grant.get("permission", "write")})
if status in (200, 204):
applied.append(grant["repo"])
with self.state.lock:
self.state.consume_invite(email, grant["repo"])
self.audit.write("invite.applied", login=login,
repo=grant["repo"],
permission=grant.get("permission"),
client_ip=client_ip)
else:
self.audit.write("invite.apply.failed", login=login,
repo=grant["repo"], client_ip=client_ip,
reason=f"HTTP {status}: {resp}")
LOG.warning("invite for %s on %s could not be applied "
"(HTTP %s)", login, grant["repo"], status)
return applied
def audit_read(self, body, client_ip=None): def audit_read(self, body, client_ip=None):
login = self.whoami(body.get("token")) login = self.whoami(body.get("token"))
if not login: if not login:
@@ -982,6 +1229,10 @@ class Handler(BaseHTTPRequestHandler):
status, resp = self.service.devices(body, peer) status, resp = self.service.devices(body, peer)
elif self.path == "/v1/devices/revoke": elif self.path == "/v1/devices/revoke":
status, resp = self.service.revoke_device(body, peer) status, resp = self.service.revoke_device(body, peer)
elif self.path == "/v1/grants":
status, resp = self.service.grants(body, peer)
elif self.path == "/v1/invite":
status, resp = self.service.invite(body, peer)
elif self.path == "/v1/audit": elif self.path == "/v1/audit":
status, resp = self.service.audit_read(body, peer) status, resp = self.service.audit_read(body, peer)
else: else:
+256
View File
@@ -1102,5 +1102,261 @@ class TestPruneClockIsPersisted(GitScenarioBase):
self.assertEqual(pruner.call_count, 0) # no snapshot folders linked self.assertEqual(pruner.call_count, 0) # no snapshot folders linked
class TestDefaultServer(unittest.TestCase):
"""A new computer must be able to sign in without joining a private
network first -- that is the whole point of exposing the endpoint."""
def test_default_server_is_public_https(self):
self.assertTrue(client.DEFAULT_SERVER.startswith("https://"),
client.DEFAULT_SERVER)
self.assertNotIn("100.111.", client.DEFAULT_SERVER)
def test_env_override_wins_for_internal_machines(self):
import importlib
with mock.patch.dict(os.environ,
{"GRANTHI_LINK_SERVER": "http://10.0.0.5:3042"}):
reloaded = importlib.reload(client)
self.assertEqual(reloaded.DEFAULT_SERVER, "http://10.0.0.5:3042")
importlib.reload(client) # restore for the rest of the suite
class TestWorkspaceBootstrap(GitScenarioBase):
"""A new computer set up from a manifest -- pulling only what the forge
grants, and never installing an application behind someone's back."""
def setUp(self):
super().setUp()
self.forge = os.path.join(self.tmp, "forge")
for full in ("alice/notes", "alice/reports"):
path = os.path.join(self.forge, full + ".git")
os.makedirs(os.path.dirname(path), exist_ok=True)
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", path],
check=True, capture_output=True, env=GIT_ENV)
seed = os.path.join(self.tmp, "seed-" + full.replace("/", "-"))
subprocess.run(["git", "clone", "-q", path, seed], check=True,
capture_output=True, env=GIT_ENV)
run_git(seed, "config", "user.name", "s")
run_git(seed, "config", "user.email", "s@s")
self.write(seed, "f.txt", full)
run_git(seed, "add", "-A"); run_git(seed, "commit", "-m", "seed")
run_git(seed, "push", "-q", "origin", "main")
client.save_config({"gitea_base": self.forge, "login": "alice",
"token": "t", "folders": {}})
self.ws = os.path.join(self.tmp, "ws")
os.makedirs(self.ws)
self.repos = [{"name": "notes", "full_name": "alice/notes"},
{"name": "reports", "full_name": "alice/reports"}]
def manifest(self, obj):
with open(os.path.join(self.ws, "workspace.json"), "w") as f:
json.dump(obj, f)
def ns(self, **kw):
kw.setdefault("folder", self.ws)
kw.setdefault("into", os.path.join(self.tmp, "out"))
kw.setdefault("dry_run", False)
return argparse.Namespace(**kw)
def test_pulls_the_repos_the_manifest_names(self):
self.manifest({"name": "acme",
"repos": ["notes", {"name": "reports", "mode": "mirror"}]})
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
mock.patch("sys.stdout", new_callable=io.StringIO):
client.cmd_bootstrap(self.ns())
out = os.path.join(self.tmp, "out")
self.assertTrue(os.path.exists(os.path.join(out, "notes", "f.txt")))
self.assertTrue(os.path.exists(os.path.join(out, "reports", "f.txt")))
modes = {m["name"]: m["mode"]
for m in client.load_config()["folders"].values()}
self.assertEqual(modes["reports"], "mirror") # manifest override
self.assertEqual(modes["notes"], "snapshot") # safe default
def test_a_repo_the_account_cannot_see_is_reported_not_attempted(self):
"""The forge decides. A manifest naming someone else's repo is a
permissions answer, not an error to route around."""
self.manifest({"repos": ["notes", "someone-elses-secrets"]})
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
client.cmd_bootstrap(self.ns())
self.assertIn("NOT GRANTED", out.getvalue())
self.assertIn("1 not granted", out.getvalue())
self.assertFalse(os.path.exists(
os.path.join(self.tmp, "out", "someone-elses-secrets")))
def test_apps_are_described_never_installed(self):
self.manifest({"repos": [], "apps": [
{"id": "dash", "repo": "nirpa/hermes-agent", "model": "aum/70b",
"setup": "docs/SETUP.md", "needs_keys": ["anthropic"]}]})
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
client.cmd_bootstrap(self.ns())
text = out.getvalue()
self.assertIn("NOT installed by granthi-sync", text)
self.assertIn("nirpa/hermes-agent", text)
self.assertIn("aum/70b", text)
self.assertIn("shre-cred request", text) # how to supply the key
# nothing was cloned or written for the app
self.assertFalse(os.path.exists(os.path.join(self.tmp, "out", "dash")))
def test_dry_run_changes_nothing(self):
self.manifest({"repos": ["notes"]})
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
client.cmd_bootstrap(self.ns(dry_run=True))
self.assertIn("WOULD PULL", out.getvalue())
self.assertFalse(os.path.exists(os.path.join(self.tmp, "out", "notes")))
self.assertEqual(client.load_config()["folders"], {})
def test_rerun_is_idempotent(self):
self.manifest({"repos": ["notes"]})
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
mock.patch("sys.stdout", new_callable=io.StringIO):
client.cmd_bootstrap(self.ns())
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
client.cmd_bootstrap(self.ns())
self.assertIn("already here", out.getvalue())
def test_a_broken_manifest_says_what_is_wrong(self):
with open(os.path.join(self.ws, "workspace.json"), "w") as f:
f.write("{not json")
with self.assertRaises(SystemExit) as e:
client.cmd_bootstrap(self.ns())
self.assertIn("not valid JSON", str(e.exception))
os.remove(os.path.join(self.ws, "workspace.json"))
with self.assertRaises(SystemExit) as e:
client.cmd_bootstrap(self.ns())
self.assertIn("no workspace.json", str(e.exception))
class TestResolveGranted(unittest.TestCase):
"""`get <name>` has to find the repo the forge actually grants.
Found by running the real flow against production: of 172 repos granted
to this estate's own admin, 157 are owned by an ORG, so a bare name failed
for 91% of what the user could see -- and the error read "Repository not
found", which sounds like a permissions problem rather than a naming one.
"""
def setUp(self):
self.cfg = {"login": "alice", "gitea_base": "http://forge.example",
"token": "t"}
self.repos = [{"full_name": "Nirlabinc/Ai-Assistant"},
{"full_name": "alice/notes"},
{"full_name": "Shreai/notes"}]
def test_a_bare_name_finds_an_org_owned_repo(self):
self.assertEqual(
client.resolve_granted(self.cfg, "Ai-Assistant", self.repos),
"Nirlabinc/Ai-Assistant")
def test_an_owner_qualified_name_is_taken_as_given(self):
self.assertEqual(
client.resolve_granted(self.cfg, "Nirlabinc/Ai-Assistant",
self.repos),
"Nirlabinc/Ai-Assistant")
def test_an_ambiguous_bare_name_is_refused_with_the_candidates(self):
"""Two teams with a repo called `notes` is not a guess worth making
on someone's behalf."""
with self.assertRaises(SystemExit) as e:
client.resolve_granted(self.cfg, "notes", self.repos)
msg = str(e.exception)
self.assertIn("ambiguous", msg)
self.assertIn("Shreai/notes", msg)
self.assertIn("alice/notes", msg)
def test_an_unknown_name_falls_back_to_your_own_namespace(self):
self.assertEqual(
client.resolve_granted(self.cfg, "brand-new", self.repos),
"alice/brand-new")
def test_an_unreachable_listing_does_not_block_a_clone(self):
"""A network blip must not stop someone cloning a repo they named."""
def boom(cfg):
raise SystemExit("listing repos failed (HTTP 502)")
with mock.patch.object(client, "list_repos", boom), \
mock.patch("sys.stdout", new_callable=io.StringIO):
self.assertEqual(client.resolve_granted(self.cfg, "notes"),
"alice/notes")
self.assertEqual(
client.resolve_granted(self.cfg, "Nirlabinc/Ai-Assistant"),
"Nirlabinc/Ai-Assistant")
class TestDeviceFlowOutputIsVisible(unittest.TestCase):
"""The code has a five-minute life. If it is sitting in a buffer, the user
never sees it and it expires -- which is what happened on 2026-08-23 while
driving a first real sign-in through a wrapper."""
def test_the_url_and_code_are_flushed_immediately(self):
seen = []
real_print = print
def spy(*a, **kw):
seen.append(kw.get("flush", False))
return real_print(*a, **{k: v for k, v in kw.items() if k != "flush"})
resp = {"verification_uri_complete": "https://id.example/device?user_code=AB-CD",
"user_code": "AB-CD", "device_code": "dc", "interval": 0,
"expires_in": 0}
with mock.patch.object(client, "http_json", lambda *a, **k: (200, resp)), \
mock.patch("builtins.print", spy), \
mock.patch.object(client.time, "sleep", lambda *_: None):
with self.assertRaises(SystemExit): # expires_in 0 -> times out
client.device_flow()
self.assertTrue(seen, "device_flow printed nothing")
self.assertTrue(all(seen[:2]),
"the URL and code must be printed with flush=True")
class TestCodexReviewFindings(GitScenarioBase):
"""Regressions for the three issues codex found in today's merged work."""
def test_staged_only_work_survives_a_snapshot(self):
"""[P2] Stage a hunk, edit further, lose the laptop: the staged version
must still be recoverable, not just the later worktree one."""
self.write(self.local, "a.txt", "committed")
run_git(self.local, "add", "-A")
run_git(self.local, "commit", "-m", "base")
self.write(self.local, "a.txt", "THE CAREFULLY STAGED VERSION")
run_git(self.local, "add", "a.txt") # staged
self.write(self.local, "a.txt", "later scratch edit") # worktree moved on
commit, tree = client.build_snapshot(self.local)
# the worktree state is the snapshot's own tree
self.assertEqual(run_git(self.local, "show", f"{commit}:a.txt"),
"later scratch edit")
# ...and the staged state is reachable through the extra parent
parents = run_git(self.local, "log", "-1", "--format=%P", commit).split()
staged = [p for p in parents
if run_git(self.local, "show", f"{p}:a.txt")
== "THE CAREFULLY STAGED VERSION"]
self.assertTrue(staged, f"staged version unreachable from {parents}")
def test_a_snapshot_does_not_disturb_the_index(self):
self.write(self.local, "a.txt", "one")
run_git(self.local, "add", "-A")
run_git(self.local, "commit", "-m", "base")
self.write(self.local, "a.txt", "staged")
run_git(self.local, "add", "a.txt")
before = run_git(self.local, "status", "--porcelain")
client.build_snapshot(self.local)
self.assertEqual(run_git(self.local, "status", "--porcelain"), before)
def test_a_truncated_listing_refuses_instead_of_guessing(self):
"""[P3] A name that is merely beyond the page cap must not resolve to a
different repo that happens to exist under your own account."""
cfg = {"login": "alice", "gitea_base": "http://forge.example", "token": "t"}
with self.assertRaises(SystemExit) as e:
client.resolve_granted(cfg, "notes", repos=[], truncated=True)
self.assertIn("truncated", str(e.exception))
# a complete listing still falls back, because absence is then real
self.assertEqual(
client.resolve_granted(cfg, "notes", repos=[], truncated=False),
"alice/notes")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+250 -1
View File
@@ -51,6 +51,14 @@ class StubUpstream(BaseHTTPRequestHandler):
if not login: if not login:
return self._json(401, {"message": "unauthorized"}) return self._json(401, {"message": "unauthorized"})
return self._json(200, {"login": login}) return self._json(200, {"login": login})
if self.path.endswith("/collaborators") and self.path.startswith("/api/v1/repos/"):
full = self.path.split("/api/v1/repos/")[1].rsplit("/collaborators", 1)[0]
tok = self.headers.get("Authorization", "").replace("token ", "")
caller = st["tokens_by_sha"].get(tok)
if st["repo_owner"].get(full) != caller:
return self._json(403, {"message": "must have admin rights"})
return self._json(200, [{"login": w}
for w in st["collab"].get(full, {})])
if self.path.startswith("/api/v1/users/") and not self.path.endswith("/tokens"): if self.path.startswith("/api/v1/users/") and not self.path.endswith("/tokens"):
login = self.path.rsplit("/", 1)[1] login = self.path.rsplit("/", 1)[1]
if login in st["hide_once"]: if login in st["hide_once"]:
@@ -69,6 +77,10 @@ class StubUpstream(BaseHTTPRequestHandler):
if self.path == "/api/v1/admin/users": if self.path == "/api/v1/admin/users":
if body["username"] in st["users"]: if body["username"] in st["users"]:
return self._json(409, {"message": "user already exists"}) return self._json(409, {"message": "user already exists"})
if body.get("email") in st["users"].values():
# real Gitea: emails are unique across accounts
return self._json(422, {"message":
f"e-mail already in use [email: {body['email']}]"})
st["users"][body["username"]] = body["email"] st["users"][body["username"]] = body["email"]
st["created"].append(body) st["created"].append(body)
return self._json(201, {"login": body["username"]}) return self._json(201, {"login": body["username"]})
@@ -96,8 +108,36 @@ class StubUpstream(BaseHTTPRequestHandler):
f"alice/{body['name']}"}) f"alice/{body['name']}"})
self._json(404, {}) self._json(404, {})
def do_PUT(self):
"""Gitea adds a collaborator with PUT, and the admin path uses basic
auth because the inviter's session is long gone by then."""
st = self.state
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
if "/collaborators/" in self.path and self.path.startswith("/api/v1/repos/"):
full = self.path.split("/api/v1/repos/")[1].split("/collaborators/")[0]
who = self.path.rsplit("/", 1)[1]
if full not in st["repo_owner"]:
return self._json(404, {"message": "no such repo"})
tok = self.headers.get("Authorization", "")
caller = st["tokens_by_sha"].get(tok.replace("token ", ""))
if not tok.startswith("Basic ") and st["repo_owner"].get(full) != caller:
return self._json(403, {"message": "forbidden"})
st["collab"].setdefault(full, {})[who] = body.get("permission", "write")
return self._json(204, {})
self._json(404, {})
def do_DELETE(self): def do_DELETE(self):
st = self.state st = self.state
if "/collaborators/" in self.path and self.path.startswith("/api/v1/repos/"):
full = self.path.split("/api/v1/repos/")[1].split("/collaborators/")[0]
who = self.path.rsplit("/", 1)[1]
tok = self.headers.get("Authorization", "").replace("token ", "")
caller = st["tokens_by_sha"].get(tok)
if st["repo_owner"].get(full) != caller:
return self._json(403, {"message": "forbidden"})
st["collab"].get(full, {}).pop(who, None)
return self._json(204, {})
if self.path.startswith("/api/v1/users/") and "/tokens/" in self.path: if self.path.startswith("/api/v1/users/") and "/tokens/" in self.path:
if not self.headers.get("Authorization", "").startswith("Basic "): if not self.headers.get("Authorization", "").startswith("Basic "):
# matches real Gitea: token auth cannot delete tokens # matches real Gitea: token auth cannot delete tokens
@@ -125,7 +165,8 @@ class ServiceTestBase(unittest.TestCase):
StubUpstream.state = {"users": {}, "created": [], "repos": set(), StubUpstream.state = {"users": {}, "created": [], "repos": set(),
"token_reqs": [], "hide_once": set(), "token_reqs": [], "hide_once": set(),
"tokens": {}, "tokens_by_sha": {}, "tokens": {}, "tokens_by_sha": {},
"revoke_fails": False} "revoke_fails": False,
"repo_owner": {}, "collab": {}}
self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), StubUpstream) self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), StubUpstream)
threading.Thread(target=self.upstream.serve_forever, daemon=True).start() threading.Thread(target=self.upstream.serve_forever, daemon=True).start()
self.addCleanup(self.upstream.shutdown) self.addCleanup(self.upstream.shutdown)
@@ -784,6 +825,214 @@ class TestDeviceEndpointsOverHttp(HandlerTestBase):
for route, (limit, _window) in granthi_link.DEFAULT_RATE_RULES.items(): for route, (limit, _window) in granthi_link.DEFAULT_RATE_RULES.items():
self.assertGreater(limit, 0, f"{route} is disabled by its rule") self.assertGreater(limit, 0, f"{route} is disabled by its rule")
class TestSharing(ServiceTestBase):
"""Give a person access to one repo -- and to nothing else."""
def setUp(self):
super().setUp()
self.enable_test_mode()
self.alice = self.stub_link("s1", "alice", email="[email protected]",
verified=True, device_id="dev-a")[1]
self.bob = self.stub_link("s2", "bob", email="[email protected]",
verified=True, device_id="dev-b")[1]
StubUpstream.state["repo_owner"]["alice/notes"] = "alice"
StubUpstream.state["repo_owner"]["bob/private"] = "bob"
def test_owner_can_share_and_unshare(self):
status, resp = self.svc.grants({"token": self.alice["token"],
"repo": "notes", "login": "bob"})
self.assertEqual(status, 200, resp)
self.assertIn("bob", StubUpstream.state["collab"]["alice/notes"])
status, _ = self.svc.grants({"token": self.alice["token"],
"action": "remove",
"repo": "notes", "login": "bob"})
self.assertEqual(status, 200)
self.assertNotIn("bob", StubUpstream.state["collab"]["alice/notes"])
def test_a_non_owner_cannot_share_someone_elses_repo(self):
"""The forge decides who may share, not this service."""
status, resp = self.svc.grants({"token": self.bob["token"],
"repo": "alice/notes",
"login": "bob"})
self.assertEqual(status, 403)
self.assertEqual(StubUpstream.state["collab"].get("alice/notes", {}), {})
def test_permission_is_validated(self):
status, _ = self.svc.grants({"token": self.alice["token"],
"repo": "notes", "login": "bob",
"permission": "owner"})
self.assertEqual(status, 400)
def test_listing_shows_who_has_access(self):
self.svc.grants({"token": self.alice["token"], "repo": "notes",
"login": "bob"})
status, resp = self.svc.grants({"token": self.alice["token"],
"action": "list", "repo": "notes"})
self.assertEqual((status, resp["collaborators"]), (200, ["bob"]))
def test_removing_someone_who_never_had_access_is_not_an_error(self):
status, _ = self.svc.grants({"token": self.alice["token"],
"action": "remove", "repo": "notes",
"login": "nobody"})
self.assertEqual(status, 200)
class TestInvites(ServiceTestBase):
"""A promise of access for someone with no account yet."""
def setUp(self):
super().setUp()
self.enable_test_mode()
self.alice = self.stub_link("s1", "alice", email="[email protected]",
verified=True, device_id="dev-a")[1]
StubUpstream.state["repo_owner"]["alice/notes"] = "alice"
def invite(self, email, repos=("notes",), token=None):
return self.svc.invite({"token": token or self.alice["token"],
"email": email, "repos": list(repos)})
def test_invite_creates_nothing_until_they_sign_in(self):
status, resp = self.invite("[email protected]")
self.assertEqual(status, 200, resp)
# no account, no collaborator row yet
self.assertNotIn("carol", StubUpstream.state["users"])
self.assertEqual(StubUpstream.state["collab"].get("alice/notes", {}), {})
def test_access_lands_on_first_link_with_a_verified_email(self):
self.invite("[email protected]")
status, resp = self.stub_link("s9", "carol", email="[email protected]",
verified=True, device_id="dev-c")
self.assertEqual(status, 200, resp)
self.assertEqual(resp.get("granted_repos"), ["alice/notes"])
self.assertIn("carol", StubUpstream.state["collab"]["alice/notes"])
def test_an_unverified_email_collects_nothing(self):
"""The address is the only thing tying the promise to this person."""
self.invite("[email protected]")
status, resp = self.stub_link("s10", "dave", email="[email protected]",
verified=False, device_id="dev-d")
self.assertEqual(status, 200)
self.assertIsNone(resp.get("granted_repos"))
self.assertEqual(StubUpstream.state["collab"].get("alice/notes", {}), {})
# and the invite is still waiting, not consumed
self.assertTrue(self.svc.state.peek_invites("[email protected]"))
def test_an_invite_is_applied_once(self):
self.invite("[email protected]")
self.stub_link("s11", "erin", email="[email protected]", verified=True,
device_id="dev-e")
status, resp = self.stub_link("s11", "erin", email="[email protected]",
verified=True, device_id="dev-e2")
self.assertIsNone(resp.get("granted_repos"))
def test_cannot_invite_to_a_repo_you_do_not_administer(self):
StubUpstream.state["repo_owner"]["bob/secret"] = "bob"
status, _ = self.invite("[email protected]", repos=("bob/secret",))
self.assertEqual(status, 403)
self.assertEqual(self.svc.state.peek_invites("[email protected]"), [])
def test_reinviting_updates_the_permission_instead_of_stacking(self):
self.svc.invite({"token": self.alice["token"], "email": "[email protected]",
"repos": [{"name": "notes", "permission": "read"}]})
self.svc.invite({"token": self.alice["token"], "email": "[email protected]",
"repos": [{"name": "notes", "permission": "write"}]})
pending = self.svc.state.peek_invites("[email protected]")
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0]["permission"], "write")
def test_a_bad_email_is_refused(self):
status, _ = self.svc.invite({"token": self.alice["token"],
"email": "not-an-email",
"repos": ["notes"]})
self.assertEqual(status, 400)
def test_a_failed_grant_does_not_block_the_person_signing_in(self):
"""Someone must not be locked out because a repo they were promised
has since been deleted."""
self.invite("[email protected]", repos=("notes",))
StubUpstream.state["repo_owner"].pop("alice/notes")
status, resp = self.stub_link("s12", "gina", email="[email protected]",
verified=True, device_id="dev-g")
self.assertEqual(status, 200)
self.assertIn("token", resp)
def test_invite_and_application_are_both_audited(self):
self.invite("[email protected]")
self.stub_link("s13", "hana", email="[email protected]", verified=True,
device_id="dev-h")
_, mine = self.svc.audit_read({"token": self.alice["token"]})
self.assertIn("invite", [e["event"] for e in mine["events"]])
class TestInviteSurvivesAFailedGrant(ServiceTestBase):
"""[P2, codex] A transient forge error must not destroy the promise."""
def setUp(self):
super().setUp()
self.enable_test_mode()
self.alice = self.stub_link("s1", "alice", email="[email protected]",
verified=True, device_id="dev-a")[1]
StubUpstream.state["repo_owner"]["alice/notes"] = "alice"
StubUpstream.state["repo_owner"]["alice/reports"] = "alice"
def test_a_failed_grant_leaves_the_invite_pending_for_next_time(self):
self.svc.invite({"token": self.alice["token"], "email": "[email protected]",
"repos": ["notes"]})
# the forge loses the repo mid-flight -> the PUT 404s
StubUpstream.state["repo_owner"].pop("alice/notes")
status, resp = self.stub_link("s9", "carol", email="[email protected]",
verified=True, device_id="dev-c")
self.assertEqual(status, 200) # sign-in still succeeds
self.assertIsNone(resp.get("granted_repos"))
# the promise is STILL THERE rather than silently consumed
self.assertEqual([g["repo"] for g in self.svc.state.peek_invites("[email protected]")],
["alice/notes"])
# and it lands on the next link, once the repo is back
StubUpstream.state["repo_owner"]["alice/notes"] = "alice"
status, resp = self.stub_link("s9", "carol", email="[email protected]",
verified=True, device_id="dev-c2")
self.assertEqual(resp.get("granted_repos"), ["alice/notes"])
self.assertEqual(self.svc.state.peek_invites("[email protected]"), [])
def test_a_partial_failure_only_consumes_what_landed(self):
self.svc.invite({"token": self.alice["token"], "email": "[email protected]",
"repos": ["notes", "reports"]})
StubUpstream.state["repo_owner"].pop("alice/reports") # one of two fails
_, resp = self.stub_link("s10", "dan", email="[email protected]",
verified=True, device_id="dev-d")
self.assertEqual(resp.get("granted_repos"), ["alice/notes"])
self.assertEqual([g["repo"] for g in self.svc.state.peek_invites("[email protected]")],
["alice/reports"])
class TestDuplicateEmailIsExplained(ServiceTestBase):
"""A 502 sends someone looking for an outage. The real answer is that the
address already belongs to another account here -- say so. (Hit live on
2026-08-23 when an operator moved an email onto a different account.)"""
def setUp(self):
super().setUp()
self.enable_test_mode()
# an existing account already holds the address
StubUpstream.state["users"]["existing"] = "[email protected]"
def test_it_is_a_409_that_names_the_problem(self):
status, resp = self.stub_link("s-new", "brandnew", email="[email protected]",
verified=True, device_id="dev-x")
self.assertEqual(status, 409, resp)
msg = resp["error"]
self.assertIn("[email protected]", msg)
self.assertIn("already belongs to a different account", msg)
self.assertIn("brandnew", msg) # names the login it tried
self.assertNotIn("502", msg)
def test_a_normal_create_is_unaffected(self):
status, resp = self.stub_link("s-ok", "fresh", email="[email protected]",
verified=True, device_id="dev-y")
self.assertEqual(status, 200, resp)
self.assertIn("token", resp)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()