# granthi-sync v1 The signup → download → link-folders → cloud product spine for the Granthi forge, tested against the BETA forge (granthi-beta.shre.ai). Python 3 stdlib + git CLI only — same portability heritage as the estate's `gitea_sync.py` mesh. ``` ┌──────────────┐ device flow ┌─────────────────┐ │ granthi-sync │ ───────────────▶ │ shre-id Zitadel │ │ (client, │ ◀─────────────── │ id.shre.ai │ │ Mac/laptop) │ access token └─────────────────┘ │ │ │ │ POST /v1/link {zitadel_access_token, device_name} │ │ ───────────────▶ ┌───────────────────────────────┐ │ │ ◀─────────────── │ granthi-link :3042 │ │ │ {login, token} │ (granthi VPS, tailnet-only) │ │ │ │ · userinfo validation │ │ │ POST /v1/repos │ · ensure Gitea user (admin) │ │ │ ───────────────▶ │ · mint scoped user token │ │ │ └──────────────┬────────────────┘ │ │ git push/fetch (user token │ admin API │ │ via credential helper) ▼ │ │ ───────────────▶ ┌───────────────────────────────┐ └──────────────┘ │ BETA forge :3041 │ │ granthi-beta.shre.ai │ └───────────────────────────────┘ ``` ## Components ### `server/granthi_link.py` — provisioning service (granthi VPS) * `GET /health` * `POST /v1/link {zitadel_access_token, device_name}` → validates the token against `https://id.shre.ai/oidc/v1/userinfo`, applies the **identity binding rules** (below), mints a token scoped `write:repository,write:user`, returns `{gitea_base, login, token, token_name}`. POST bodies are capped at **64 KB** (413 beyond; missing `Content-Length` → 411, invalid → 400). * `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 container `ROOT_URL` (https://granthi-beta.shre.ai) does not resolve for tailnet-only clients. Deployment: `/opt/granthi-link/{granthi_link.py,config.json,state.json}` + systemd unit `granthi-link.service`; binds `127.0.0.1:3042` **and** `100.111.127.127:3042` (tailnet). **Not publicly exposed** — see promotion window. 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 forge admin password, so permissive perms fail closed, not open. #### Identity binding (`state.json`) `/v1/link` originally bound purely by `preferred_username` / email local-part — any Zitadel identity whose derived login collided with an existing account got a token for that account (account takeover). The service now persists a map of Zitadel `sub` → Gitea login in `/opt/granthi-link/state.json` (0600, atomic tmp+rename writes) and applies: 1. **Mapped sub** → always the mapped login, regardless of what the current userinfo claims. If the mapped login was deleted from the forge it is re-created only when the service created it originally; adopted accounts are refused (409). 2. **Unmapped sub, login free** → create the user, record the mapping (`created_by_service: true`). A concurrent-create 409 from Gitea is handled idempotently: the user is re-fetched and accepted only if its primary email is exactly the one this request would have set. 3. **Unmapped sub, login taken** → bind ONLY when the Gitea user's primary email equals the Zitadel userinfo `email` **and** `email_verified` is true (recorded with `created_by_service: false`); anything else → `409 login exists and is not linked to this identity`. A Gitea token is **never minted before the binding rule passes**, and a corrupt/unreadable `state.json` fails closed (500) instead of falling back to an empty map. **Migration (pre-1.1.0 accounts):** identities whose userinfo carries no verified `email` (e.g. Zitadel *machine* users) cannot self-adopt an existing forge login under rule (c) — for them the first link after the upgrade would 409 forever. Any forge account the v1 service created before this change must be seeded into `state.json` once, as `{"": {"login": "", "created_by_service": true, ...}}`, written 0600 atomically. As of the beta rollout the only such account is the E2E machine user `granthi-sync-e2e` (seeded); the forge's human admin `nirpa` is never provisioned through `/v1/link`, so nothing else needed seeding. Empirically verified mechanics on Gitea **1.27.1** (beta forge): * Token minting: `POST /api/v1/users/{login}/tokens` returns **401 for token-authenticated sudo** (both `Sudo:` header and `?sudo=`); it only works with **admin basic auth + `Sudo: ` header** (201). The config therefore carries `admin_login`/`admin_password` (root-only, 0600) in addition to `admin_token` (minted via `gitea admin user generate-access-token`, used for all other admin calls). * User creation: `source_id` is omitted → **local user** with a random 30-char password, `must_change_password=false`, `visibility=private`. Rationale: `/api/v1/admin/identity-auth-sources` 404s on 1.27.1; the shre-id OAuth2 source is ID 1 (via `gitea admin auth list`), but users attached to an OAuth2 source cannot basic-auth and admin-created users get no `external_login_user` row anyway — first OIDC web login links by email regardless of this choice. * `test_mode` (config flag, **never in production**): allows `/v1/link` to accept `test_userinfo` in the body instead of a Zitadel round-trip, so E2E can exercise the ensure-user + mint path headlessly. It is honored **only when the service environment also sets `GRANTHI_LINK_ALLOW_TEST_MODE=1`**; a config flag without the env gate is logged as an ERROR and ignored. ### `client/granthi_sync_client.py` (+ `bin/granthi-sync`) — client daemon * `link [--server URL] [--token TOK]` — Zitadel **device flow** (native app `granthi-sync-device`, client_id `386909715541590022`, project granthi-forge `386906525790109702`; grants: device_code + refresh_token): prints the verification URL + user code, polls the token endpoint (`authorization_pending`/`slow_down` handled), then calls `/v1/link`. `--token` skips the device flow with a ready Zitadel token (headless/dev). Result stored in `~/.granthi-sync/config.json` (0600). * `add [--name N] [--private|--public]` — `git init -b main` if needed, creates the cloud repo via `/v1/repos`, adds remote `granthi`, initial commit + push. The token is delivered by a **git credential helper** (the client's hidden `git-credential` subcommand reading the 0600 config) — never embedded in the remote URL (estate rule). * `watch [--interval 30] [--once]` — per folder: autocommit (`sync: `) → fetch → ff-pull if remote strictly ahead → push if local strictly ahead. **DIVERGED → log + record + SKIP. Never force, never merge** — the same policy as the mesh. SIGTERM-clean. * `status` — table of linked folders, last sync, divergence flags. * Run as a daemon on macOS with `client/launchd/ai.granthi.sync.plist` (edit the script path, then `launchctl bootstrap gui/$UID `). ## Tests * `python3 -m unittest discover -s tests` — 53 tests: autocommit/ff/diverged logic against real temp git repos (including "diverged never touches the remote"), config 0600 handling (including umask-proof creation and a no-chmod guard), credential-helper quoting/injection, mocked device-flow polling, the full `/v1/link` + `/v1/repos` service flows against an in-process stub playing Zitadel + Gitea, all identity-binding rules (collision 409, verified-email adoption, deleted-login re-create/refuse, concurrent-create race, corrupt-state fail-closed), the test_mode env gate, config-permission refusal, and the 64 KB body cap. * Live E2E against the beta forge is recorded in the delivery notes (link → add → watch ff/push → forced divergence → DIVERGED skip verified via API, remote sha untouched). ## Invite-only story (v1) There is no open signup. An operator invites a user by creating them in shre-id (Zitadel org). The user downloads the client, runs `granthi-sync link`, signs in at id.shre.ai with the printed device code, and the provisioning service creates their forge account + scoped token on the fly — the forge never sees a password and the user never sees the forge admin. Every linked folder becomes a private repo under their account. ## Promotion window (beta → prod) 1. **Expose :3042** behind cloudflared (granthi.shre.ai vhost or link.granthi.shre.ai) — today it is tailnet-only by design. 2. **Swap forge base URLs** in `/opt/granthi-link/config.json`: `gitea_base` → prod forge, `public_gitea_base` → `https://granthi.shre.ai`; the client default server URL moves to the public endpoint. 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 config when pointing at prod (prod forge is READ-ONLY to this estate — promotion is an operator action, not an agent action). 4. Add rate limiting / abuse controls before public exposure (one token mint per link call today). 5. **Hardening checklist (must all hold before exposing):** - [ ] `config.json` is 0600 (or 0400) and owned by the service user — the service refuses to start otherwise; verify with `systemctl status granthi-link` after any config edit. - [ ] `test_mode` is absent from the production config **and** `GRANTHI_LINK_ALLOW_TEST_MODE` is not set in the unit environment. - [ ] `/opt/granthi-link/state.json` exists, is 0600, and is included in VPS backups — losing it orphans sub→login bindings (existing users would need verified-email re-adoption). - [ ] `GET /health` returns 200 on both binds after restart. - [ ] Spot-check the identity map: a repeat link for a known sub returns the same login; a colliding username with a different sub gets 409.