123 lines
7.3 KiB
Markdown
123 lines
7.3 KiB
Markdown
# 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`, derives a login from
|
|
`preferred_username` (email local-part fallback), ensures the Gitea user
|
|
exists, mints a token scoped `write: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 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}` + 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}/tokens` returns **401 for
|
|
token-authenticated sudo** (both `Sudo:` header and `?sudo=`); it only works
|
|
with **admin basic auth + `Sudo: <login>` 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.
|
|
|
|
### `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 <folder> [--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: <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, then `launchctl 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/repos` service 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)
|
|
|
|
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).
|