feat: share repos with people, and invite people who have no account yet

Closes the last piece of the product picture: give one person access to some
of your repos and not others.

- POST /v1/grants add|remove|list -- runs on the CALLER'S OWN token. Verified
  against the live forge that a repo owner's scoped token adds and removes
  collaborators (204), so sharing needs no elevated rights anywhere.
- POST /v1/invite -- records a promise against a VERIFIED email and creates
  nothing until it is redeemed. Applied on first link.
- client: share / shared / invite.

Three properties the tests pin:
  * the FORGE decides who may share (listing collaborators requires repo
    admin, so its 200 is the authorisation answer, not ours);
  * an unverified email collects nothing, and its invite stays pending rather
    than being consumed;
  * a failed grant never blocks a sign-in -- nobody is locked out of their own
    account because a repo they were promised has since been deleted.

Applying an invite uses the admin credential deliberately: the inviter
authorised it at invite time and their session is long gone by redemption.

198 tests (was 184).
This commit is contained in:
claude
2026-08-23 14:10:18 -04:00
parent 24a7fa6cdb
commit 1a151a22e2
4 changed files with 519 additions and 6 deletions
+92
View File
@@ -1339,6 +1339,76 @@ def cmd_bootstrap(args):
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)
@@ -1552,6 +1622,28 @@ def main(argv=None):
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)