8 Commits
Author SHA1 Message Date
Nirav Patel 918b184b01 Merge pull request 'docs: this describes the prod deployment now, not beta' (#11) from fix/docs-prod-forge-bases into main
Docs-only. NOTE: merged WITHOUT a completed granthi-review. The gate's webhook rejection was fixed today (item 52eea395), but reviews still fail open — diff fetch timeout, and shre-router returning prose instead of JSON (item 6d4e8819). Merged on explicit human approval, not on a green check.
2026-08-30 01:25:54 -04:00
ClaudeandClaude Opus 5 ba107c1b53 docs: this describes the prod deployment now, not beta
granthi-link has run against the PROD forge since the promotion, but every
document in this repo still described the beta tier. Someone following the
README would expect their device to land on granthi-beta.shre.ai; it lands on
granthi.shre.ai. Two of the statements were not merely stale but false:
"tailnet-only" and "Not publicly exposed" — the service has been public at
https://granthi-link.shre.ai since 2026-08-23, and that 404 at / (no root
route) has twice been misread as an outage.

Verified on [email protected], 2026-08-30:
  gitea_base         = http://127.0.0.1:3040   (gitea-central-gitea-1)
  public_gitea_base  = https://granthi.shre.ai
  systemctl is-active granthi-link -> active
  https://granthi-link.shre.ai/health -> 200 {"status":"ok","version":"1.1.0"}
  rate_limit: trust_forwarded_for true, trusted_proxies ["100.107.37.98/32"],
              no "rules" key -> falls back to DEFAULT_RATE_RULES

Docs only. No server, client or test code is touched, and the deployed
service is NOT redeployed by this change — it still reports 1.1.0 against a
v1.2 repo, which is recorded separately.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01L1b6BN9TZVxHkmignRq4p8
2026-08-29 23:20:30 -04:00
Nirav Patel 7eb57acdbe Merge pull request 'fix(link): say "that address is already an account here", not "502"' (#10) from fix/link-duplicate-email-message into main 2026-08-23 16:46:22 -04:00
claude ea14038355 fix(link): say "that address is already an account here", not "502"
Hit live today. An operator moved an email onto a different forge account; the
next sign-in tried to create a user with that address, Gitea answered
422 "e-mail already in use", and the generic path turned it into a bare 502.
The person approved a device code and got a number that reads like an outage,
when the real answer was "that address already belongs to somebody here".

create_user now distinguishes that case, and the binding rules translate it to
409 with a message naming the address, the login it tried to create, and the
two ways out: have an operator bind your identity to the existing account, or
use a different address.

211 tests (was 209): the stub now enforces unique emails like real Gitea, one
test asserts the 409 names the address and login and never says 502, and one
asserts an ordinary create is unaffected.
2026-08-23 16:44:49 -04:00
Nirav Patel 1b6682eee5 Merge pull request 'fix: three defects found by the codex review of today's merged work' (#9) from fix/codex-review-findings into main 2026-08-23 16:14:56 -04:00
claude 062f8d1863 fix: three defects found by codex review of today's merged work
No P1s. All three confirmed in the code before fixing.

[P2] Invites were destroyed by a transient forge error. apply_invites() popped
the whole pending list BEFORE attempting the collaborator PUT, so a 502 or a
timeout while someone first signed in meant they got no access and re-linking
never retried -- the promise was gone. Now it peeks, and consumes each grant
only after that grant actually lands. A partial failure keeps exactly the
grants that failed.

[P2] Snapshots lost staged-only work. build_snapshot() read HEAD into a scratch
index and staged the WORKTREE, so a hunk you staged and then edited further
survived only in its later worktree form. git keeps index and worktree as
separate states and the backup now does too: the real index is read without
being touched, and when it differs from both HEAD and the worktree it rides
along as a second parent.

[P3] A truncated repo listing could resolve a bare name to the WRONG repo.
resolve_granted() discarded the truncation flag, so a name whose only match sat
beyond the 2000-repo cap fell back to <login>/<name> and would clone that
instead. Truncation now means "unknown", not "absent": it refuses and asks for
the owner. A complete listing still falls back, because absence is then real.

209 tests (was 204).
2026-08-23 16:14:21 -04:00
Nirav Patel 9511093b14 Merge pull request 'fix(client): flush the device code, or nobody ever sees it' (#8) from fix/link-output-unbuffered into main 2026-08-23 15:58:36 -04:00
claude e77c0077e3 fix(client): flush the device code, or nobody ever sees it
Hit for real driving a first sign-in: `granthi-sync link` run through anything
that is not a terminal -- a wrapper, a pipe, `| tee setup.log` -- printed
NOTHING, while the code it was holding silently expired five minutes later.
Python buffers stdout when stdout is not a tty, and the two prints carrying
the verification URL and the user code did not flush.

They flush now. One regression test asserts both are printed with flush=True,
because this fails silently and only under redirection, which is exactly the
condition a test would otherwise never reproduce.

204 tests (was 203).
2026-08-23 15:58:34 -04:00
6 changed files with 291 additions and 32 deletions
+38 -13
View File
@@ -1,8 +1,10 @@
# granthi-sync v1.2 # granthi-sync v1.2
The signup → download → link-folders → cloud product spine for the Granthi The signup → download → link-folders → cloud product spine for the Granthi
forge, tested against the BETA forge (granthi-beta.shre.ai). Python 3 stdlib + forge. Developed and live-E2E-tested against the BETA forge
git CLI only — same portability heritage as the estate's `gitea_sync.py` mesh. (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 ┌─────────────────┐ ┌──────────────┐ 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} │ │ POST /v1/link {zitadel_access_token, device_name}
│ │ ───────────────▶ ┌───────────────────────────────┐ │ │ ───────────────▶ ┌───────────────────────────────┐
│ │ ◀─────────────── │ granthi-link :3042 │ │ │ ◀─────────────── │ granthi-link :3042 │
│ │ {login, token} │ (granthi VPS, tailnet-only) │ │ {login, token} │ (granthi VPS; public via
│ │ │ https://granthi-link.shre.ai)│
│ │ │ · userinfo validation │ │ │ │ · userinfo validation │
│ │ POST /v1/repos │ · ensure Gitea user (admin) │ │ │ POST /v1/repos │ · ensure Gitea user (admin) │
│ │ ───────────────▶ │ · mint scoped user token │ │ │ ───────────────▶ │ · 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 │ │ git push/fetch (user token │ admin API
│ │ via credential helper) ▼ │ │ via credential helper) ▼
│ │ ───────────────▶ ┌───────────────────────────────┐ │ │ ───────────────▶ ┌───────────────────────────────┐
└──────────────┘ │ BETA forge :3041 └──────────────┘ │ PROD forge :3040
│ granthi-beta.shre.ai │ │ 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) ## Quickstart (invited user)
You need a shre-id account — an operator creates it; there is no open signup 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). **64 KB** (413 beyond; missing `Content-Length` → 411, invalid → 400).
* `POST /v1/repos {token, name, private}` → creates the user repo with the * `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 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 container `ROOT_URL` does not necessarily resolve for the client that asked
tailnet-only clients. (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}` + Deployment: `/opt/granthi-link/{granthi_link.py,config.json,state.json}` +
systemd unit `granthi-link.service`; binds `127.0.0.1:3042` **and** systemd unit `granthi-link.service`; binds `127.0.0.1:3042` **and**
`100.111.127.127:3042` (tailnet). **Not publicly exposed** — see promotion `100.111.127.127:3042` (tailnet), and is **publicly reachable** at
window. The service **refuses to start** (exit 2) unless `config.json` is `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 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. 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 `https://granthi-link.shre.ai` (second-level, see above), origin stays
tailnet-only, `trust_forwarded_for` on with the tunnel as the only tailnet-only, `trust_forwarded_for` on with the tunnel as the only
trusted proxy. trusted proxy.
2. **Swap forge base URLs** in `/opt/granthi-link/config.json`: 2. ~~**Swap forge base URLs** in `/opt/granthi-link/config.json`~~
`gitea_base` → prod forge, `public_gitea_base` **DONE — verified live 2026-08-30**: the deployed config reads
`https://granthi.shre.ai`; the client default server URL moves to the `gitea_base: http://127.0.0.1:3040` and
public endpoint. `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 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 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 — config when pointing at prod (prod forge is READ-ONLY to this estate —
+51 -13
View File
@@ -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 # Under refs/heads a machine taking a snapshot every 30s would bury the
# user's real branches. # user's real branches.
BACKUP_NS = "refs/granthi-backup" 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" SNAPSHOT_TS_FMT = "%Y%m%dT%H%M%SZ"
# Retention. Unbounded snapshots are a disk leak with no way to use them, so # 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}"] args = ["commit-tree", tree, "-m", f"granthi snapshot: {ts}"]
if head: if head:
args += ["-p", 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 # Snapshots are parented on HEAD and nothing else -- deliberately NOT
# chained to the previous snapshot. Chaining would keep every old # chained to the previous snapshot. Chaining would keep every old
# snapshot reachable from the newest one, so pruning a ref would free # snapshot reachable from the newest one, so pruning a ref would free
# nothing and retention would be decorative. # nothing and retention would be decorative.
_, commit = git(folder, *args, _, commit = git(folder, *args, env=_SNAPSHOT_IDENT)
env={"GIT_AUTHOR_NAME": "granthi-sync",
"GIT_AUTHOR_EMAIL": "[email protected]",
"GIT_COMMITTER_NAME": "granthi-sync",
"GIT_COMMITTER_EMAIL": "[email protected]"})
return commit.strip(), tree return commit.strip(), tree
finally: finally:
if os.path.exists(tmp_index): if os.path.exists(tmp_index):
@@ -599,8 +621,13 @@ def device_flow():
form={"client_id": DEVICE_CLIENT_ID, "scope": DEVICE_SCOPE}) form={"client_id": DEVICE_CLIENT_ID, "scope": DEVICE_SCOPE})
if status != 200: if status != 200:
raise SystemExit(f"device authorization failed (HTTP {status}): {resp}") 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") # flush=True is not cosmetic. Python buffers stdout when it is not a
print(f"and enter code: {resp['user_code']}\n") # 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)) interval = int(resp.get("interval", 5))
deadline = time.time() + int(resp.get("expires_in", 300)) deadline = time.time() + int(resp.get("expires_in", 300))
while time.time() < deadline: while time.time() < deadline:
@@ -833,7 +860,7 @@ def clone_one(cfg, full_name, dest, mode=None):
return meta 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. """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 `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"]) return parse_repo_arg(want, cfg["login"])
if repos is None: if repos is None:
try: try:
repos, _truncated = list_repos(cfg) repos, truncated = list_repos(cfg)
except (SystemExit, ValueError, OSError) as e: except (SystemExit, ValueError, OSError) as e:
# Best effort. If the listing is unreachable, a name the user # Best effort. If the listing is unreachable, a name the user
# typed in full must still clone, and a bare name should degrade # 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"{want!r} is ambiguous — {len(matches)} repos you can see have "
f"that name:\n{listed}\nRe-run with the owner, e.g. " f"that name:\n{listed}\nRe-run with the owner, e.g. "
f"granthi-sync get {sorted(matches)[0]}") f"granthi-sync get {sorted(matches)[0]}")
# Nothing granted by that name. Fall back to the caller's own namespace so # Nothing granted by that name. If the listing was TRUNCATED the answer is
# the message names a concrete repo instead of a guess. # 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"]) return parse_repo_arg(want, cfg["login"])
@@ -1336,13 +1373,14 @@ def cmd_bootstrap(args):
into = os.path.abspath(args.into or ".") into = os.path.abspath(args.into or ".")
print(f"workspace: {name}\n") 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} granted = {r.get("full_name") for r in granted_repos}
pulled = skipped = denied = 0 pulled = skipped = denied = 0
for repo in ws["repos"]: for repo in ws["repos"]:
want = repo["name"] want = repo["name"]
try: try:
full = resolve_granted(cfg, want, repos=granted_repos) full = resolve_granted(cfg, want, repos=granted_repos,
truncated=granted_truncated)
except SystemExit as e: except SystemExit as e:
log(f"AMBIGUOUS {want}: {e}") log(f"AMBIGUOUS {want}: {e}")
denied += 1 denied += 1
+7 -5
View File
@@ -1,15 +1,17 @@
{ {
"gitea_base": "http://127.0.0.1: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.",
"public_gitea_base": "http://100.111.127.127:3041", "gitea_base": "http://127.0.0.1:3040",
"public_gitea_base": "https://granthi.shre.ai",
"zitadel_userinfo": "https://id.shre.ai/oidc/v1/userinfo", "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_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]], "binds": [["127.0.0.1", 3042], ["100.111.127.127", 3042]],
"test_mode": false, "test_mode": false,
"rate_limit": { "rate_limit": {
"enabled": true, "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]} "rules": {"/v1/link": [5, 3600], "/v1/repos": [60, 3600]}
} }
} }
+48 -1
View File
@@ -80,6 +80,9 @@ TEST_MODE_ENV = "GRANTHI_LINK_ALLOW_TEST_MODE"
# Sentinel: create_user hit a 409 (someone else created the login first). # Sentinel: create_user hit a 409 (someone else created the login first).
USER_CREATE_CONFLICT = object() 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._-]+") 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) # 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: class IdentityStore:
"""Persistent map of Zitadel `sub` -> Gitea login binding records. """Persistent map of Zitadel `sub` -> Gitea login binding records.
@@ -404,6 +415,23 @@ class IdentityStore:
self._write(data) self._write(data)
return pending 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): def peek_invites(self, email):
book = self._load().get("invites") or {} book = self._load().get("invites") or {}
return list(book.get((email or "").strip().lower(), [])) return list(book.get((email or "").strip().lower(), []))
@@ -660,6 +688,14 @@ class LinkService:
headers=self._admin_hdr(), body=body) headers=self._admin_hdr(), body=body)
if status == 409: if status == 409:
return USER_CREATE_CONFLICT 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: if status != 201:
return f"gitea admin user create failed (HTTP {status}): {resp}" return f"gitea admin user create failed (HTTP {status}): {resp}"
return None return None
@@ -716,6 +752,8 @@ class LinkService:
"was not created by this service; refusing " "was not created by this service; refusing "
"to re-create"}, None "to re-create"}, None
err = self.create_user(login, userinfo) 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: if err and err is not USER_CREATE_CONFLICT:
return 502, {"error": err}, None return 502, {"error": err}, None
LOG.info("re-created service-managed gitea user %s", login) 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 " return 409, {"error": "login exists and is not linked "
"to this identity"}, None "to this identity"}, None
LOG.info("user %s created concurrently; continuing", login) 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: elif err:
return 502, {"error": err}, None return 502, {"error": err}, None
else: else:
@@ -1038,8 +1078,13 @@ class LinkService:
# the only thing tying the promise to this person. # the only thing tying the promise to this person.
return [] return []
with self.state.lock: with self.state.lock:
pending = self.state.take_invites(email) pending = self.state.peek_invites(email)
applied = [] 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: for grant in pending:
status, resp = http_json( status, resp = http_json(
"PUT", "PUT",
@@ -1050,6 +1095,8 @@ class LinkService:
body={"permission": grant.get("permission", "write")}) body={"permission": grant.get("permission", "write")})
if status in (200, 204): if status in (200, 204):
applied.append(grant["repo"]) applied.append(grant["repo"])
with self.state.lock:
self.state.consume_invite(email, grant["repo"])
self.audit.write("invite.applied", login=login, self.audit.write("invite.applied", login=login,
repo=grant["repo"], repo=grant["repo"],
permission=grant.get("permission"), permission=grant.get("permission"),
+74
View File
@@ -1284,5 +1284,79 @@ class TestResolveGranted(unittest.TestCase):
client.resolve_granted(self.cfg, "Nirlabinc/Ai-Assistant"), client.resolve_granted(self.cfg, "Nirlabinc/Ai-Assistant"),
"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__": if __name__ == "__main__":
unittest.main() unittest.main()
+73
View File
@@ -77,6 +77,10 @@ class StubUpstream(BaseHTTPRequestHandler):
if self.path == "/api/v1/admin/users": if self.path == "/api/v1/admin/users":
if body["username"] in st["users"]: if body["username"] in st["users"]:
return self._json(409, {"message": "user already exists"}) 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["users"][body["username"]] = body["email"]
st["created"].append(body) st["created"].append(body)
return self._json(201, {"login": body["username"]}) return self._json(201, {"login": body["username"]})
@@ -960,6 +964,75 @@ class TestInvites(ServiceTestBase):
_, mine = self.svc.audit_read({"token": self.alice["token"]}) _, mine = self.svc.audit_read({"token": self.alice["token"]})
self.assertIn("invite", [e["event"] for e in mine["events"]]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()