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
granthi-sync v1.2
The signup → download → link-folders → cloud product spine for the Granthi
forge. Developed and live-E2E-tested against the BETA forge
(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 ┌─────────────────┐
│ 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; public via │
│ │ │ https://granthi-link.shre.ai)│
│ │ │ · userinfo validation │
│ │ POST /v1/repos │ · ensure Gitea user (admin) │
│ │ ───────────────▶ │ · mint scoped user token │
│ │ └──────────────┬────────────────┘
│ │ git push/fetch (user token │ admin API
│ │ via credential helper) ▼
│ │ ───────────────▶ ┌───────────────────────────────┐
└──────────────┘ │ PROD forge :3040 │
│ 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)
You need a shre-id account — an operator creates it; there is no open signup
(see "Invite-only story"). You do not need to be on any private network:
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.
git clone https://granthi.shre.ai/nirpa/granthi-sync.git
cd granthi-sync
# 1. Link this device. Prints a URL + code; approve it in a browser within
# 5 minutes. Creates your forge account and stores a scoped token in
# ~/.granthi-sync/config.json (0600). You never see a forge password.
./bin/granthi-sync link
# 2. See what is already yours on the forge.
./bin/granthi-sync list
# 3. Either pull an existing repo down...
./bin/granthi-sync get <repo> # or <owner>/<repo>, --into DIR
# 3b. ...or push a local folder up. It becomes a private repo.
./bin/granthi-sync add ~/work/notes
# 3c. ...or pull down everything this account is allowed to see.
./bin/granthi-sync get --all --into ~/granthi
# 4. Keep everything synced. Autocommits, ff-pulls, pushes; skips anything
# that has diverged rather than merging or forcing.
./bin/granthi-sync watch # --once for a single pass
./bin/granthi-sync status # what is linked, mode, last sync
# 5. Go back to how a folder looked at some point in time.
./bin/granthi-sync snapshots ~/work/notes
./bin/granthi-sync restore ~/work/notes --at 20260823T142530Z
Run watch as a background daemon on macOS with
client/launchd/ai.granthi.sync.plist (edit the script path, then
launchctl bootstrap gui/$UID <plist>).
If the device code expires (5 minutes, unapproved), nothing is created —
no account, no token, no partial state. Just run link again.
Merging is a forge action, not a client one. watch deliberately refuses
to merge; when a folder shows DIVERGED in status, resolve it in git or on
the forge web UI. The client will never force or auto-merge your work.
Two modes, because two very different folders ask for this
A folder people sync is either their documents or their git project, and
the correct behaviour is opposite in each case. Each linked folder therefore
carries a mode.
mirror |
snapshot |
|
|---|---|---|
| chosen for | a plain folder add turned into a repo |
a folder that was already a git repo, and anything get clones |
| commits on your behalf | yes, sync: <ISO ts> |
never |
| where work lands | the branch | refs/granthi-backup/<device>/<ts> |
| a restore point is | every commit | every snapshot |
snapshot mode is what "the work may not be committed, but it is still
backed up" means in git terms. Each pass loads a scratch index from HEAD,
stages the working tree into that index, writes a tree, and commits it with
commit-tree. HEAD, your index, your stash and every file on disk are
untouched — you can be mid-rebase with a dirty tree and the backup still
records exactly what is on the disk right now. The user's history stays the
user's.
Why a custom ref namespace: verified on the beta forge (Gitea 1.27.2) that
refs/granthi-backup/... is accepted, is readable through ls-remote, and
does not appear in the branch list. Under refs/heads a machine taking a
backup every 30 seconds would bury the branches a person actually made.
Snapshots are parented on HEAD and deliberately not chained to the previous snapshot: chaining would keep every old snapshot reachable from the newest, so pruning a ref would free nothing and retention would be decorative.
Retention (or 30-second backups become a disk leak nobody can navigate): everything is kept for 24 h, then thinned to hourly for 7 days, then daily. Pruning runs at most hourly, per device, and only over that device's own refs. A ref whose timestamp this version cannot parse is kept — deleting the unrecognised is how a backup system loses the one thing someone needed.
Restore never writes over the working tree. restore materialises a
restore point into a new directory and refuses a non-empty destination.
Someone restoring a backup is already having a bad day; overwriting the files
they still have would make the recovery tool the second disaster.
The credential helper must be the ONLY helper (found by live QA)
credential.helper is a list that accumulates across system, global and repo
config, and git asks every helper in it. A stock mac already has two —
osxkeychain from Xcode's gitconfig, and store from many people's
~/.gitconfig — and they lose in both directions:
- reading: a stale entry for the forge host answers before our helper, so
pushes fail
remote: Failed to authenticate userlong after the token was rotated, and nothing in this tool's config explains why. This is exactly how the first live-QA run failed; - writing: git calls
approveon every helper after a successful auth, sostorecopies the forge token into~/.git-credentialsin plaintext. Keeping the token in a 0600 file and out of remote URLs buys nothing if git then hands it to a plaintext store.
So install_credential_helper (and the git clone in get) sets an empty
credential.helper first, which resets the inherited list, then adds ours.
Exactly one helper serves this repo.
Corollary worth remembering: a token embedded in a remote URL gets saved by
store on first use. During QA a verification clone with a URL-embedded
token re-created the very entry that had just been cleaned out. That is the
whole reason this client passes tokens through a helper and never a URL.
Device identity
link mints a uuid on first run and persists it in ~/.granthi-sync/config.json
as device_id, and sends it to /v1/link. Hostnames are neither stable
(people rename laptops) nor unique (every new mac is "Mac mini"), so a
hostname cannot key a backup ref or a device registry — two machines would
overwrite each other's snapshots. The service-side device registry is the
next phase; the client leads so the id already exists when it lands.
What "add the computer to the network" means — and does not
The onboarding shape is: download → login → the device is federated to the account → the device can reach its repos.
The middle step is a device registration, not a network membership. Those sound like one step and must not be built as one: this estate's tailnet is a single flat private network carrying the granthi VPS, aros-vps, the Shadow box and the Mac. Putting a customer's laptop on it to let them sync a folder would hand that laptop L3 reach to every piece of infrastructure we run.
So:
-
Our own machines may join the tailnet — that is an operator action with an operator's judgement behind it.
-
Customer devices never do. Their transport is public HTTPS to granthi-link and the forge through cloudflared. Done 2026-08-23:
https://granthi-link.shre.aifronts:3042on thepulse-granthi-edgetunnel, so a new computer signs itself in with one command and never touches the private network.Two things that bit during that rollout, worth not rediscovering:
link.granthi.shre.aifails TLS — Cloudflare's Universal SSL coversshre.aiand*.shre.ai, not a third-level*.granthi.shre.ai, so the hostname has to be second-level. And exposure REQUIREStrust_forwarded_for: truewithtrusted_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
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, applies the identity binding rules (below), mints a token scopedwrite:repository,write:user, returns{gitea_base, login, token, token_name}. POST bodies are capped at 64 KB (413 beyond; missingContent-Length→ 411, invalid → 400).POST /v1/repos {token, name, private}→ creates the user repo with the USER token; clone/html URLs are rebased ontopublic_gitea_basebecause the containerROOT_URLdoes not necessarily resolve for the client that asked (it was a tailnet-only address on beta; on prod the rebase keeps clone URLs onhttps://granthi.shre.airather than the container's own view).
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), and is publicly reachable at
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
forge admin password, so permissive perms fail closed, not open.
Rate limiting
Sliding-window, per (route, client), on by default — /v1/link creates
accounts and mints tokens, so unlimited has to be a deliberate config act,
never an omission. Defaults: /v1/link 5/hour, /v1/repos 60/hour. GET /health is never limited. Over the limit → 429 with a Retry-After
header, decided before the body is read so an abusive caller costs nothing.
"rate_limit": {
"enabled": true,
"trust_forwarded_for": false,
"trusted_proxies": [],
"rules": {"/v1/link": [5, 3600], "/v1/repos": [60, 3600]}
}
- Anything malformed refuses startup rather than silently meaning
unlimited: a bad rule, a non-boolean
enabled/trust_forwarded_for(JSONnull,0, or the string"false"— every non-empty string is truthy), or arate_limitthat is not an object.[0, N]disables an endpoint outright. - Denied requests are not recorded, so a client that keeps hammering cannot push its own window forward and lock itself out permanently.
- State is an in-process dict behind a lock — granthi-link is one
ThreadingHTTPServer, so that is the entire store. If this ever runs multi-process or multi-host, the limiter must move with it. The clock is read inside the lock; taken outside, racing threads append out of order and bothRetry-Afterand window reclamation silently go wrong. - The key store is capped (
MAX_RATE_KEYS) per route, not globally. At capacity it reclaims expired windows, and if every window is still live it refuses the new key — fail closed. Evicting a live window would let an attacker who can mint many distinct keys clear their own limit on demand. The per-route budget matters just as much: with one shared table, a flood of cheap/v1/reposkeys would exhaust it and lock brand-new/v1/linkclients out, turning the fail-closed guard into a cross-route DoS. - Rule values must be real integers.
boolsubclassesintin Python, so[5, true]would otherwise pass as a 1-second window — an hourly limit quietly becoming ~5/sec. trust_forwarded_foris off by default, and turning it on requires a non-emptytrusted_proxies— the header is honored only when the socket peer is in that list. Without it, anyone reaching the origin directly (it also listens on the tailnet) could pick and rotate their own rate-limit key just by sending a header. Turn it on when exposing behind cloudflared, where every request otherwise arrives from the tunnel and one abuser would starve everyone. A caller can prepend anything toX-Forwarded-For; a trusted proxy appends the peer it actually saw, so the service reads the last entry, never the first, and requires it to parse as a real IP.trusted_proxiesis validated at startup: it must be a list (a bare string would be iterated character by character), every entry a valid network, and wildcards (0.0.0.0/0,::/0) are refused outright — they would restore exactly the "trust anyone" hole the setting exists to close.
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:
- 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).
- 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. - Unmapped sub, login taken → bind ONLY when the Gitea user's primary
email equals the Zitadel userinfo
emailandemail_verifiedis true (recorded withcreated_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
{"<sub>": {"login": "<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, re-probed and still
holding on 1.27.2 (beta forge, 2026-08-22 — all three results below
matched; the probe minted a token on the granthi-sync-e2e machine user and
deleted it again, DELETE …/tokens/{id} returning 204 under basic auth):
- 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. It is honored only when the service environment also setsGRANTHI_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 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). -
list [pattern]— every repo the linked token can see, with the local folder each is already synced to.patternnarrows the table by name (substring, or a glob likework-*), case-insensitively, against bothowner/nameand the bare name. Filtering is display-only: the set already came from the forge under this account's token. ReadsGET /api/v1/user/reposon the forge directly with the scoped user token — no granthi-link round-trip, so the read path needs no service change. Pagination is followed to a short page; if theFORGE_MAX_PAGESguard trips, the output says the list is incomplete rather than letting a bounded page read as the whole set. -
get --all [--into DIR] [--mode M]— clone every repo this account can see, skipping the ones already linked here. "Only the repos they are granted" needs no client-side permission logic:/api/v1/user/reposis evaluated by the forge against this account's own scoped token, so the list is the grant. A client-side filter would be a second opinion about someone else's authorisation. One repo failing does not abandon the rest, and a truncated listing is reported loudly —--allmust never quietly mean "the first 2000".alice/notesandbob/notesboth want<base>/notes; the second is cloned to<base>/bob-notesand the clash is logged, because reporting it as "already present" would leave the user believing they had pulled both. -
get <repo|owner/repo> [--into DIR] [--mode M]— the download half ofadd. Defaults tosnapshotmode unless the repo carries a.granthi-sync.jsonmarker saying otherwise, so a plain synced folder behaves the same on the second machine while someone's real project is never autocommitted onto. Clones with--origin granthi(the remote namewatchlooks for) and-c credential.helper=…(the repo does not exist yet, so the helper cannot be installed first; git also persists it into the new config), then registers the folder inconfig.jsonwith the same shapeaddwrites — so a cloned repo is picked up bywatchimmediately. Refuses a non-empty destination. Branch is read withsymbolic-ref(an empty repo has an unborn HEAD) and falls back tomain. -
add <folder> [--name N] [--private|--public] [--mode M] [--force]—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). Pushes the folder's current branch, not a hardcodedmain: an existing repo may sit onmasteror a feature branch, and publishing that work under the wrong name is not a cosmetic error. Two guards, becauseadd -Atakes whatever it is given: a starter.gitignoreis seeded when the folder has none (an existing one is never touched — it is the user's), and a folder over 20 000 files / 512 MB is refused unless--force. The seeded ignore file covers.env,*.key,*.pem,id_rsaand friends, and it governs snapshots too — the scratch index honours.gitignoreexactly as a normal commit does. The size guard measures what git would sync, ignore rules included (including the machine's global excludes), because its own advice is "add a .gitignore for what should not sync" and advice that changes nothing is worse than none. It asks git through a throwaway git dir outside the folder, so a refusedaddleaves no.gitbehind in a directory the user never agreed to turn into a repo. -
watch [--interval 30] [--once]— per folder, by mode.mirror: autocommit (sync: <ISO ts>) → fetch → ff-pull if remote strictly ahead → push if local strictly ahead.snapshot: fetch → push a snapshot of the working tree to this device's backup ref → ff-pull only when the tree is clean (local edits are already safe on the backup ref, so it reports and leaves the tree alone rather than failing) → push the user's own commits when they are strictly ahead. DIVERGED → log + record + SKIP. Never force, never merge — the same policy as the mesh — but the backup still happens, because divergence is when work is most at risk. Retention pruning runs at most hourly. SIGTERM-clean. -
snapshots <folder> [--limit 20]— restore points, newest first, across every device, with the device that took each one. Read from the remote, not a local cache: the feature exists for the case where this machine is gone.The three scopes differ deliberately. Writing is device-scoped, so two machines never overwrite each other. Pruning is device-scoped, so machine A never applies its clock to machine B's refs. Reading is not scoped — a replacement laptop has a new id, and scoping the read to it would print "no restore points yet" while the backups sit on the forge. That defect was live in the first draft and is now pinned by a test that restores a dead machine's work from a fresh clone.
-
restore <folder> --at <ts|sha> [--into DIR]— materialise one restore point into a new directory; refuses a non-empty destination. Accepts whatsnapshotsprinted in either mode, including a mirror-mode%cItimestamp. Two commits inside the same second share that timestamp, so an ambiguous--atis refused with the candidate ids rather than resolved by guessing. -
status— table of linked folders, mode, 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— 198 tests. The v1.2 additions cover: a snapshot capturing uncommitted work while HEAD, the index and the working tree stay byte-identical; snapshots landing outsiderefs/heads; an unchanged tree not being re-pushed; a diverged folder still being backed up; a dirty tree blocking the ff-pull but not the backup; retention keeping everything recent, thinning to hourly then daily, and keeping unparseable timestamps; prune deleting only the thinned refs;.gitignoreseeding never overwriting an existing one and keeping.envout of snapshots; the folder-size guard being bounded rather than walking the disk; mode detection;listfiltering;get --allskipping what is already present, defaulting to snapshot mode, and shouting about truncation;restorewriting a new folder, refusing a non-empty destination, and leaving the working tree alone; and the credential helper being the only one the repo consults, proven by drivinggit credential fillagainst a deliberately poisoned outer helper.- Earlier suite: 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/reposservice 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. Thelist/getset covers pagination-to-a-short-page, truncation being reported rather than hidden, HTTP errors being fatal instead of a silent empty list, non-empty-destination refusal, owner-qualified names, unborn-HEAD branch fallback, no token in the remote URL, and — the one that matters — that agetfolder is actually picked up by a subsequentsync_folderpass. - 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.
Devices, sign-out, and the audit trail
granthi-sync devices lists every computer signed in to the account —
name, id, when it linked, and whether it is still active. The registry hangs
off the identity that owns it in state.json, so "which computers can reach
my files" has one answer that cannot drift from the identity map.
granthi-sync logout [--device ID] signs a computer out. Revocation
happens at the forge: granthi-link deletes that device's Gitea token
(admin basic auth + Sudo, the only mechanism Gitea 1.27 accepts — verified
204, after which the token returns 401 immediately). It is not a flag a
client could ignore, which is the only kind of sign-out worth having for a
laptop somebody lost. Signing out the current computer also deletes the local
token; linked folders are left on disk untouched.
Two properties worth keeping:
- The device id is part of the token name, because revocation deletes by name. Without it, one user linking two machines called "macbook" in the same second would collide, and signing one out would kill the other.
- A failed forge deletion is not recorded as revoked. A registry that says "revoked" while the token still works is worse than an honest error.
granthi-sync activity shows the security events for the account:
device.link, device.revoke, device.revoke.denied, with timestamp,
device, and client IP. The log is append-only JSONL (0600, rotated at 64 MB),
kept separate from state.json on purpose — state is rewritten
atomically on every change, and an audit trail the audited thing can rewrite
is not an audit trail. A failed audit write is logged loudly and never breaks
the request it was auditing.
What the audit log cannot see, and this matters. Every event in it is one granthi-link handled. Git pushes and pulls do not pass through this service — they go straight to the forge — so:
| you want to know | where it actually lives |
|---|---|
| who linked/revoked a computer, from what IP | granthi-sync activity (this log) |
| who pushed what, and when | Gitea: the repo's activity feed and commit history |
| who pulled or cloned | nowhere by default — Gitea does not record fetches unless its router access log is enabled |
| last time a device used its token | Gitea access_token.updated_unix |
Reading the audit log and believing it lists file activity would be a real
mistake, so /v1/audit returns that caveat in its own response.
Endpoints added
POST /v1/devices {token}→ the caller's devices.POST /v1/devices/revoke {token, device_id}→ kills that device's forge token. Never rate-limited — nobody should be throttled out of signing a lost laptop out.POST /v1/audit {token, limit}→ the caller's own events.
Authorisation on all three is the same: the caller proves who they are by
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
claiming another account changes nothing.
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:
{"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 [email protected] --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
under it. What is missing is the multi-person case: an existing account
inviting somebody, and that person's device waking up with access to some
repos and not others.
Shape this should take, so the next session does not re-litigate it:
- Where grants live: granthi-link's own store, not Zitadel orgs. The
estate's house pattern is app-side tenancy tables with the IdP only
providing identity (see the AROS
tenants/tenant_memberssplit). Grants therefore sit besidestate.json, and Gitea is the enforcement point — a grant is materialised as a repo collaborator or an org team membership, so the forge itself refuses unauthorised reads. Nothing in the client decides access, which is whyget --allneeds no permission logic. - Token scope does not change.
write:repository,write:userstays; per repo permission is collaborator/team state, not a token property. POST /v1/invite(account admin → new member): creates the shre-id user (Zitadel admin PAT, on aros-vps at/opt/shre-id/deploy/secrets/shre_id_zitadel_pat), records the intended grants, and returns an invite the person redeems by runninglink. Until they redeem it, nothing exists on the forge.POST /v1/grants(account admin): add/remove repo access for a member; applies the change to Gitea and records it. Removing a grant must also remove the collaborator — a grant store that drifts from the forge is worse than no store.- Both endpoints are account-admin-only and rate-limited like
/v1/link. - The grant store inherits the same fragility already noted for the rate
limiter: a flat JSON file behind an in-process lock, fine for one
ThreadingHTTPServerand not fine the day this runs multi-process.
Promotion window (beta → prod)
Expose :3042 behind cloudflaredDONE 2026-08-23 —https://granthi-link.shre.ai(second-level, see above), origin stays tailnet-only,trust_forwarded_foron with the tunnel as the only trusted proxy.Swap forge base URLs inDONE — verified live 2026-08-30: the deployed config reads/opt/granthi-link/config.jsongitea_base: http://127.0.0.1:3040andpublic_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.- 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.DONE — see "Rate limiting" above (5 links/hour per client by default). When exposing behind cloudflared, settrust_forwarded_for: truein the same change, or every request will look like the tunnel and one abuser will throttle everybody.- Hardening checklist (must all hold before exposing):
config.jsonis 0600 (or 0400) and owned by the service user — the service refuses to start otherwise; verify withsystemctl status granthi-linkafter any config edit.test_modeis absent from the production config andGRANTHI_LINK_ALLOW_TEST_MODEis not set in the unit environment./opt/granthi-link/state.jsonexists, is 0600, and is included in VPS backups — losing it orphans sub→login bindings (existing users would need verified-email re-adoption).GET /healthreturns 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.