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
+43 -2
View File
@@ -412,7 +412,7 @@ deleted it again, `DELETE …/tokens/{id}` returning 204 under basic auth):
## Tests
* `python3 -m unittest discover -s tests` — 184 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
@@ -541,7 +541,48 @@ 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`.
## Next phase — invites and per-repo access (designed, not built)
## 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