Compare commits
18
Commits
dbd6bb6cd0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918b184b01 | ||
|
|
ba107c1b53 | ||
|
|
7eb57acdbe | ||
|
|
ea14038355 | ||
|
|
1b6682eee5 | ||
|
|
062f8d1863 | ||
|
|
9511093b14 | ||
|
|
e77c0077e3 | ||
|
|
6d62419fc7 | ||
|
|
2dcf42c780 | ||
|
|
f9ee2740c4 | ||
|
|
ced481e06b | ||
|
|
8c99fe261e | ||
|
|
1a151a22e2 | ||
|
|
a9ec8909eb | ||
|
|
24a7fa6cdb | ||
|
|
4ba1256894 | ||
|
|
49ab2a7408 |
@@ -1,8 +1,10 @@
|
||||
# granthi-sync v1.2
|
||||
|
||||
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.
|
||||
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 ┌─────────────────┐
|
||||
@@ -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}
|
||||
│ │ ───────────────▶ ┌───────────────────────────────┐
|
||||
│ │ ◀─────────────── │ granthi-link :3042 │
|
||||
│ │ {login, token} │ (granthi VPS, tailnet-only) │
|
||||
│ │ {login, token} │ (granthi VPS; public via │
|
||||
│ │ │ https://granthi-link.shre.ai)│
|
||||
│ │ │ · userinfo validation │
|
||||
│ │ POST /v1/repos │ · ensure Gitea user (admin) │
|
||||
│ │ ───────────────▶ │ · 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
|
||||
│ │ via credential helper) ▼
|
||||
│ │ ───────────────▶ ┌───────────────────────────────┐
|
||||
└──────────────┘ │ BETA forge :3041 │
|
||||
│ granthi-beta.shre.ai │
|
||||
└──────────────┘ │ 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 also need to be on the tailnet: the
|
||||
provisioning service is not publicly exposed yet.
|
||||
(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.
|
||||
|
||||
```sh
|
||||
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
|
||||
an operator's judgement behind it.
|
||||
* **Customer devices never do.** Their transport is public HTTPS to
|
||||
granthi-link and the forge through cloudflared. That is the same exposure
|
||||
step already in the promotion window below, and it is what makes a genuinely
|
||||
new computer able to onboard itself at all — today `link` only works from
|
||||
inside the tailnet, which means "you can't set up a new computer without an
|
||||
operator first" is the honest status.
|
||||
granthi-link and the forge through cloudflared. **Done 2026-08-23:**
|
||||
`https://granthi-link.shre.ai` fronts `:3042` on the `pulse-granthi-edge`
|
||||
tunnel, 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.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
|
||||
|
||||
@@ -185,13 +214,16 @@ So:
|
||||
**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.
|
||||
container `ROOT_URL` does not necessarily resolve for the client that asked
|
||||
(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}` +
|
||||
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
|
||||
`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.
|
||||
|
||||
@@ -403,7 +435,7 @@ deleted it again, `DELETE …/tokens/{id}` returning 204 under basic auth):
|
||||
|
||||
## 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
|
||||
working tree stay byte-identical; snapshots landing outside `refs/heads`;
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
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.
|
||||
1. ~~**Expose :3042** behind cloudflared~~ **DONE 2026-08-23** —
|
||||
`https://granthi-link.shre.ai` (second-level, see above), origin stays
|
||||
tailnet-only, `trust_forwarded_for` on with the tunnel as the only
|
||||
trusted proxy.
|
||||
2. ~~**Swap forge base URLs** in `/opt/granthi-link/config.json`~~
|
||||
**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
|
||||
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 —
|
||||
|
||||
@@ -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.
|
||||
@@ -87,7 +87,12 @@ ZITADEL_BASE = "https://id.shre.ai"
|
||||
DEVICE_CLIENT_ID = "386909715541590022"
|
||||
# ^ Zitadel native app "granthi-sync-device" (appId 386909715541524486) in
|
||||
# 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"
|
||||
|
||||
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
|
||||
# user's real branches.
|
||||
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"
|
||||
|
||||
# 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}"]
|
||||
if 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
|
||||
# chained to the previous snapshot. Chaining would keep every old
|
||||
# snapshot reachable from the newest one, so pruning a ref would free
|
||||
# nothing and retention would be decorative.
|
||||
_, commit = git(folder, *args,
|
||||
env={"GIT_AUTHOR_NAME": "granthi-sync",
|
||||
"GIT_AUTHOR_EMAIL": "[email protected]",
|
||||
"GIT_COMMITTER_NAME": "granthi-sync",
|
||||
"GIT_COMMITTER_EMAIL": "[email protected]"})
|
||||
_, commit = git(folder, *args, env=_SNAPSHOT_IDENT)
|
||||
return commit.strip(), tree
|
||||
finally:
|
||||
if os.path.exists(tmp_index):
|
||||
@@ -594,8 +621,13 @@ def device_flow():
|
||||
form={"client_id": DEVICE_CLIENT_ID, "scope": DEVICE_SCOPE})
|
||||
if status != 200:
|
||||
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")
|
||||
print(f"and enter code: {resp['user_code']}\n")
|
||||
# flush=True is not cosmetic. Python buffers stdout when it is not a
|
||||
# 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))
|
||||
deadline = time.time() + int(resp.get("expires_in", 300))
|
||||
while time.time() < deadline:
|
||||
@@ -828,6 +860,59 @@ def clone_one(cfg, full_name, dest, mode=None):
|
||||
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):
|
||||
cfg = require_linked(load_config())
|
||||
device_id(cfg)
|
||||
@@ -835,7 +920,7 @@ def cmd_get(args):
|
||||
return _get_all(cfg, args)
|
||||
if not args.repo:
|
||||
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]
|
||||
clone_one(cfg, full_name, os.path.abspath(args.into or name), args.mode)
|
||||
return 0
|
||||
@@ -1223,6 +1308,193 @@ def cmd_activity(args):
|
||||
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):
|
||||
path = os.path.abspath(folder)
|
||||
meta = cfg.get("folders", {}).get(path)
|
||||
@@ -1429,6 +1701,35 @@ def main(argv=None):
|
||||
sp.add_argument("--limit", type=int, default=50)
|
||||
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.set_defaults(fn=cmd_status)
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"gitea_base": "http://127.0.0.1:3041",
|
||||
"public_gitea_base": "http://100.111.127.127: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.",
|
||||
"gitea_base": "http://127.0.0.1:3040",
|
||||
"public_gitea_base": "https://granthi.shre.ai",
|
||||
"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_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]],
|
||||
"test_mode": false,
|
||||
"rate_limit": {
|
||||
"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]}
|
||||
}
|
||||
}
|
||||
|
||||
+254
-3
@@ -80,6 +80,9 @@ TEST_MODE_ENV = "GRANTHI_LINK_ALLOW_TEST_MODE"
|
||||
|
||||
# Sentinel: create_user hit a 409 (someone else created the login first).
|
||||
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._-]+")
|
||||
|
||||
@@ -90,6 +93,10 @@ DEFAULT_RATE_RULES = {"/v1/link": (5, 3600), "/v1/repos": (60, 3600),
|
||||
# Reads are cheap but still authenticated work.
|
||||
"/v1/devices": (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
|
||||
# high rather than tight. It is NOT 0: in this limiter
|
||||
# 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)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
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:
|
||||
"""Persistent map of Zitadel `sub` -> Gitea login binding records.
|
||||
|
||||
@@ -368,6 +383,59 @@ class IdentityStore:
|
||||
return sub, dict(ident.get("devices") or {})
|
||||
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):
|
||||
data = self._load()
|
||||
ident = data["identities"].get(str(sub)) or {}
|
||||
@@ -620,6 +688,14 @@ class LinkService:
|
||||
headers=self._admin_hdr(), body=body)
|
||||
if status == 409:
|
||||
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:
|
||||
return f"gitea admin user create failed (HTTP {status}): {resp}"
|
||||
return None
|
||||
@@ -676,6 +752,8 @@ class LinkService:
|
||||
"was not created by this service; refusing "
|
||||
"to re-create"}, None
|
||||
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:
|
||||
return 502, {"error": err}, None
|
||||
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 "
|
||||
"to this identity"}, None
|
||||
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:
|
||||
return 502, {"error": err}, None
|
||||
else:
|
||||
@@ -767,12 +847,16 @@ class LinkService:
|
||||
"linked_at": datetime.now(timezone.utc).isoformat(
|
||||
timespec="seconds"),
|
||||
})
|
||||
granted = self.apply_invites(login, userinfo, client_ip)
|
||||
self.audit.write("device.link", login=login, device_id=device_id,
|
||||
device_name=device_name, token_name=token_name,
|
||||
client_ip=client_ip)
|
||||
return 200, {"gitea_base": self.public_gitea, "login": login,
|
||||
"token": gitea_token, "token_name": token_name,
|
||||
"device_id": device_id}
|
||||
resp = {"gitea_base": self.public_gitea, "login": login,
|
||||
"token": gitea_token, "token_name": token_name,
|
||||
"device_id": device_id}
|
||||
if granted:
|
||||
resp["granted_repos"] = granted
|
||||
return 200, resp
|
||||
|
||||
# -- device registry endpoints -----------------------------------------
|
||||
|
||||
@@ -862,6 +946,169 @@ class LinkService:
|
||||
return None
|
||||
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):
|
||||
login = self.whoami(body.get("token"))
|
||||
if not login:
|
||||
@@ -982,6 +1229,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
status, resp = self.service.devices(body, peer)
|
||||
elif self.path == "/v1/devices/revoke":
|
||||
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":
|
||||
status, resp = self.service.audit_read(body, peer)
|
||||
else:
|
||||
|
||||
@@ -1102,5 +1102,261 @@ class TestPruneClockIsPersisted(GitScenarioBase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
+250
-1
@@ -51,6 +51,14 @@ class StubUpstream(BaseHTTPRequestHandler):
|
||||
if not login:
|
||||
return self._json(401, {"message": "unauthorized"})
|
||||
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"):
|
||||
login = self.path.rsplit("/", 1)[1]
|
||||
if login in st["hide_once"]:
|
||||
@@ -69,6 +77,10 @@ class StubUpstream(BaseHTTPRequestHandler):
|
||||
if self.path == "/api/v1/admin/users":
|
||||
if body["username"] in st["users"]:
|
||||
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["created"].append(body)
|
||||
return self._json(201, {"login": body["username"]})
|
||||
@@ -96,8 +108,36 @@ class StubUpstream(BaseHTTPRequestHandler):
|
||||
f"alice/{body['name']}"})
|
||||
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):
|
||||
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 not self.headers.get("Authorization", "").startswith("Basic "):
|
||||
# matches real Gitea: token auth cannot delete tokens
|
||||
@@ -125,7 +165,8 @@ class ServiceTestBase(unittest.TestCase):
|
||||
StubUpstream.state = {"users": {}, "created": [], "repos": set(),
|
||||
"token_reqs": [], "hide_once": set(),
|
||||
"tokens": {}, "tokens_by_sha": {},
|
||||
"revoke_fails": False}
|
||||
"revoke_fails": False,
|
||||
"repo_owner": {}, "collab": {}}
|
||||
self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), StubUpstream)
|
||||
threading.Thread(target=self.upstream.serve_forever, daemon=True).start()
|
||||
self.addCleanup(self.upstream.shutdown)
|
||||
@@ -784,6 +825,214 @@ class TestDeviceEndpointsOverHttp(HandlerTestBase):
|
||||
for route, (limit, _window) in granthi_link.DEFAULT_RATE_RULES.items():
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user