Invite backend: schema, CLI, redeem endpoints #4
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
posta/server#4
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What to build
Server-side machinery for the invite-link onboarding flow. The operator generates an invite via CLI, hands a
https://<id-url>/setup#invite=pinv_…URL to the user (out of band: chat, email). The user redeems via the browser in #5; this slice covers everything except the HTML page.New schema migration v3 adds an
invitestable per identity DB (one per*store.SQLite).Two endpoints, both in the auth-middleware carve-out:
GET /api/v1/invite/info?invite=<token>— validate without consuming, return{identity, deviceName, expiresAt}or 410 GonePOST /api/v1/invite/redeembody{invite, deviceName}— mintmst_…token, mark invite consumed, return{token, identity, deviceName}Token format: 32 random bytes, base64url,
pinv_prefix. Stored as sha256 hash, never plaintext. TTL default 24h, configurable via--ttl=15mflag oninvite create.Per-IP rate limit on
/api/v1/invite/*to deter brute-force on the 32-byte token search-space (defence in depth — random tokens are already unguessable).Acceptance criteria
invitestable with(id, token_hash, device_hint, created_at, expires_at, consumed_at)and a unique index ontoken_hashposta-server invite create --slug=X [--device-hint=...] [--ttl=24h]generates token, printshttps://<url>/setup#invite=pinv_…posta-server invite list --slug=Xlists active invites with TTL remainingposta-server invite revoke --slug=X --id=...deletes the rowGET /api/v1/invite/info?invite=...returns identity + device hint without consuming; 410 if expired/consumedPOST /api/v1/invite/redeemmintsmst_…token, marksconsumed_at, returns plaintext once/api/v1/invite/*/api/v1/invite/*Blocked by
Agent Brief
Category: enhancement
Summary: Add the server-side machinery (DB schema, CLI commands, two HTTP endpoints) for the invite-link onboarding flow. The HTML setup page is a separate issue (#5).
Blocked by: #1 (multi-tenant daemon) and #2 (the
--slug-scoped CLI surface this builds on).Current behavior:
There is no invite mechanism. New devices obtain a bearer token via
posta-server token create, which the operator runs locally and copies out of band. There is no way to delegate this — every new device requires shell access on the host. There is noinvitestable.Desired behavior:
The operator generates an invite token via CLI, hands the user a
https://<id-url>/setup#invite=pinv_…URL out of band (chat, email), and the user redeems it through the browser (UI is #5). Invites are single-use, time-limited, per-identity, and revocable. The redeem flow mints a normalmst_…bearer token — there is no second-class "invite-only" token.Schema (new migration, appended to the existing ordered migration list):
A new
invitestable per identity SQLite database with columns:id— integer primary key, autoincrementtoken_hash— sha256 hex of the plaintext invite, uniquedevice_hint— optional human label provided at create time (used by the setup UI to pre-fill the device name)created_at,expires_at,consumed_at— timestamps;consumed_atis nullableToken format:
32 cryptographically random bytes, base64url-encoded, prefixed
pinv_. The plaintext is shown to the operator exactly once at create time (printed inside the redeem URL). Only the sha256 hash is stored. Default TTL 24h, configurable.HTTP endpoints (both per-identity, both inside the
api.Serverauth carve-out — auth middleware does not run on these paths):GET /api/v1/invite/info?invite=<plaintext>{identity: <url>, deviceName: <hint or null>, expiresAt: <RFC3339>}if the invite is valid and unconsumed.POST /api/v1/invite/redeemwith body{invite: <plaintext>, deviceName: <string>}{token: <plaintext mst_…>, identity: <url>, deviceName: <stored>}. The token plaintext is returned exactly once./info.consumed_at = now()atomically with the token mint. The pair must succeed or fail together.Per-IP rate limit:
A separate per-IP rate limiter wraps
/api/v1/invite/*. Reuse the existinggolang.org/x/time/rateinfrastructure used by the inbound wire-receiver, but key by remote IP rather than sender URL. This is defence-in-depth — the 32-byte token search-space is already unguessable, but rate-limiting prevents log noise from automated probing.Key interfaces:
posta-server invite create --slug=X [--device-hint=...] [--ttl=24h]pinv_…token, stores its sha256 hash in the named identity'sinvitestable, prints exactly one line:https://<identity-url>/setup#invite=pinv_…. Default TTL is 24h; the--ttlflag accepts any Gotime.ParseDurationstring (e.g.15m,7d→168h).posta-server invite list --slug=Xposta-server invite revoke --slug=X --id=NN. The integer id is the user-facing handle — operators see ids viainvite list. Plaintext invites are not accepted as a revoke key (the plaintext is gone the moment the URL is handed out)./api/v1/*routes must not run on/api/v1/invite/*./api/v1/invite/*only.Acceptance criteria:
invitestable with columns(id, token_hash, device_hint, created_at, expires_at, consumed_at)and a unique index ontoken_hash. Idempotent — re-running migrations is a no-op.invite create --slug=X [--device-hint=...] [--ttl=24h]generates a token, hashes it, inserts a row, and prints the redeem URL on stdout.invite list --slug=Xlists active invites with id, device hint, and TTL remaining.invite revoke --slug=X --id=Ndeletes the named row.GET /api/v1/invite/info?invite=...returns identity + device hint without consuming on success; returns 410 for unknown / expired / consumed.POST /api/v1/invite/redeemmintsmst_…, marksconsumed_at, returns the token plaintext exactly once on success; returns 410 for unknown / expired / consumed (symmetric with/info)./redeemis atomic — a partial failure cannot produce a usable token without consuming the invite, and cannot consume the invite without producing a token./api/v1/invite/*./api/v1/invite/*(reusing the existingx/time/ratebuilding block, keyed by remote IP).GET /infosucceeds →POST /redeemsucceeds → secondPOST /redeemreturns 410 →GET /infofor an expired invite returns 410.Out of scope:
/setupHTML page that consumes these endpoints — that is #5.