Compare commits
10
Commits
f9ee2740c4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918b184b01 | ||
|
|
ba107c1b53 | ||
|
|
7eb57acdbe | ||
|
|
ea14038355 | ||
|
|
1b6682eee5 | ||
|
|
062f8d1863 | ||
|
|
9511093b14 | ||
|
|
e77c0077e3 | ||
|
|
6d62419fc7 | ||
|
|
2dcf42c780 |
@@ -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,11 +24,28 @@ 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
|
||||
@@ -194,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.
|
||||
|
||||
@@ -620,10 +643,12 @@ Shape this should take, so the next session does not re-litigate it:
|
||||
`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`:
|
||||
`gitea_base` → prod forge, `public_gitea_base` →
|
||||
`https://granthi.shre.ai`; the client default server URL moves to the
|
||||
public endpoint.
|
||||
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.
|
||||
@@ -104,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
|
||||
@@ -301,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):
|
||||
@@ -599,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:
|
||||
@@ -833,7 +860,7 @@ def clone_one(cfg, full_name, dest, mode=None):
|
||||
return meta
|
||||
|
||||
|
||||
def resolve_granted(cfg, want, repos=None):
|
||||
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
|
||||
@@ -852,7 +879,7 @@ def resolve_granted(cfg, want, repos=None):
|
||||
return parse_repo_arg(want, cfg["login"])
|
||||
if repos is None:
|
||||
try:
|
||||
repos, _truncated = list_repos(cfg)
|
||||
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
|
||||
@@ -871,8 +898,18 @@ def resolve_granted(cfg, want, repos=None):
|
||||
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. Fall back to the caller's own namespace so
|
||||
# the message names a concrete repo instead of a guess.
|
||||
# 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"])
|
||||
|
||||
|
||||
@@ -1336,13 +1373,14 @@ def cmd_bootstrap(args):
|
||||
into = os.path.abspath(args.into or ".")
|
||||
print(f"workspace: {name}\n")
|
||||
|
||||
granted_repos, _trunc = list_repos(cfg)
|
||||
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)
|
||||
full = resolve_granted(cfg, want, repos=granted_repos,
|
||||
truncated=granted_truncated)
|
||||
except SystemExit as e:
|
||||
log(f"AMBIGUOUS {want}: {e}")
|
||||
denied += 1
|
||||
|
||||
@@ -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]}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -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._-]+")
|
||||
|
||||
@@ -292,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.
|
||||
|
||||
@@ -404,6 +415,23 @@ class IdentityStore:
|
||||
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(), []))
|
||||
@@ -660,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
|
||||
@@ -716,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)
|
||||
@@ -741,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:
|
||||
@@ -1038,8 +1078,13 @@ class LinkService:
|
||||
# the only thing tying the promise to this person.
|
||||
return []
|
||||
with self.state.lock:
|
||||
pending = self.state.take_invites(email)
|
||||
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",
|
||||
@@ -1050,6 +1095,8 @@ class LinkService:
|
||||
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"),
|
||||
|
||||
@@ -1284,5 +1284,79 @@ class TestResolveGranted(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -77,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"]})
|
||||
@@ -960,6 +964,75 @@ class TestInvites(ServiceTestBase):
|
||||
_, 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