Co-Authored-By: Claude Fable 5 <[email protected]>
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 /healthPOST /v1/link {zitadel_access_token, device_name}→ validates the token againsthttps://id.shre.ai/oidc/v1/userinfo, derives a login frompreferred_username(email local-part fallback), ensures the Gitea user exists, mints a token scopedwrite:repository,write:user, returns{gitea_base, login, token, token_name}.POST /v1/repos {token, name, private}→ creates the user repo with the USER token; clone/html URLs are rebased ontopublic_gitea_basebecause the containerROOT_URL(https://granthi-beta.shre.ai) does not resolve for tailnet-only clients.
Deployment: /opt/granthi-link/{granthi_link.py,config.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.
Empirically verified mechanics on Gitea 1.27.1 (beta forge):
- Token minting:
POST /api/v1/users/{login}/tokensreturns 401 for token-authenticated sudo (bothSudo:header and?sudo=); it only works with admin basic auth +Sudo: <login>header (201). The config therefore carriesadmin_login/admin_password(root-only, 0600) in addition toadmin_token(minted viagitea admin user generate-access-token, used for all other admin calls). - User creation:
source_idis omitted → local user with a random 30-char password,must_change_password=false,visibility=private. Rationale:/api/v1/admin/identity-auth-sources404s on 1.27.1; the shre-id OAuth2 source is ID 1 (viagitea admin auth list), but users attached to an OAuth2 source cannot basic-auth and admin-created users get noexternal_login_userrow anyway — first OIDC web login links by email regardless of this choice. test_mode(config flag, never in production): allows/v1/linkto accepttest_userinfoin the body instead of a Zitadel round-trip, so E2E can exercise the ensure-user + mint path headlessly.
client/granthi_sync_client.py (+ bin/granthi-sync) — client daemon
link [--server URL] [--token TOK]— Zitadel device flow (native appgranthi-sync-device, client_id386909715541590022, project granthi-forge386906525790109702; grants: device_code + refresh_token): prints the verification URL + user code, polls the token endpoint (authorization_pending/slow_downhandled), then calls/v1/link.--tokenskips the device flow with a ready Zitadel token (headless/dev). Result stored in~/.granthi-sync/config.json(0600).add <folder> [--name N] [--private|--public]—git init -b mainif needed, creates the cloud repo via/v1/repos, adds remotegranthi, initial commit + push. The token is delivered by a git credential helper (the client's hiddengit-credentialsubcommand reading the 0600 config) — never embedded in the remote URL (estate rule).watch [--interval 30] [--once]— per folder: autocommit (sync: <ISO ts>) → 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, thenlaunchctl bootstrap gui/$UID <plist>).
Tests
python3 -m unittest discover -s tests— 24 tests: autocommit/ff/diverged logic against real temp git repos (including "diverged never touches the remote"), config 0600 handling, mocked device-flow polling, credential helper protocol, and the full/v1/link+/v1/reposservice flows against an in-process stub playing Zitadel + Gitea.- 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)
- Expose :3042 behind cloudflared (granthi.shre.ai vhost or link.granthi.shre.ai) — today it is tailnet-only by design.
- 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. - The
granthi-webOIDC 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). - Add rate limiting / abuse controls before public exposure (one token mint per link call today).