Invite backend: schema, CLI, redeem endpoints #4

Closed
opened 2026-05-09 23:24:31 +02:00 by arne · 1 comment
Owner

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 invites table 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 Gone
  • POST /api/v1/invite/redeem body {invite, deviceName} — mint mst_… 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=15m flag on invite 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

  • Migration v3 adds invites table with (id, token_hash, device_hint, created_at, expires_at, consumed_at) and a unique index on token_hash
  • posta-server invite create --slug=X [--device-hint=...] [--ttl=24h] generates token, prints https://<url>/setup#invite=pinv_…
  • posta-server invite list --slug=X lists active invites with TTL remaining
  • posta-server invite revoke --slug=X --id=... deletes the row
  • GET /api/v1/invite/info?invite=... returns identity + device hint without consuming; 410 if expired/consumed
  • POST /api/v1/invite/redeem mints mst_… token, marks consumed_at, returns plaintext once
  • Auth middleware skips /api/v1/invite/*
  • Per-IP rate limit on /api/v1/invite/*
  • Tests: full curl-based round-trip (mint invite, info, redeem, second-redeem-fails, expired-fails)

Blocked by

## 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 `invites` table 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 Gone - `POST /api/v1/invite/redeem` body `{invite, deviceName}` — mint `mst_…` 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=15m` flag on `invite 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 - [ ] Migration v3 adds `invites` table with `(id, token_hash, device_hint, created_at, expires_at, consumed_at)` and a unique index on `token_hash` - [ ] `posta-server invite create --slug=X [--device-hint=...] [--ttl=24h]` generates token, prints `https://<url>/setup#invite=pinv_…` - [ ] `posta-server invite list --slug=X` lists active invites with TTL remaining - [ ] `posta-server invite revoke --slug=X --id=...` deletes the row - [ ] `GET /api/v1/invite/info?invite=...` returns identity + device hint without consuming; 410 if expired/consumed - [ ] `POST /api/v1/invite/redeem` mints `mst_…` token, marks `consumed_at`, returns plaintext once - [ ] Auth middleware skips `/api/v1/invite/*` - [ ] Per-IP rate limit on `/api/v1/invite/*` - [ ] Tests: full curl-based round-trip (mint invite, info, redeem, second-redeem-fails, expired-fails) ## Blocked by - #1
Author
Owner

This was generated by AI during triage.

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 no invites table.

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 normal mst_… bearer token — there is no second-class "invite-only" token.

Schema (new migration, appended to the existing ordered migration list):
A new invites table per identity SQLite database with columns:

  • id — integer primary key, autoincrement
  • token_hash — sha256 hex of the plaintext invite, unique
  • device_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_at is nullable

Token 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.Server auth carve-out — auth middleware does not run on these paths):

  • GET /api/v1/invite/info?invite=<plaintext>
    • 200 with body {identity: <url>, deviceName: <hint or null>, expiresAt: <RFC3339>} if the invite is valid and unconsumed.
    • 410 Gone if the invite is unknown, expired, or already consumed. Does not distinguish — same response for all three.
    • Does not consume the invite.
  • POST /api/v1/invite/redeem with body {invite: <plaintext>, deviceName: <string>}
    • 200 with body {token: <plaintext mst_…>, identity: <url>, deviceName: <stored>}. The token plaintext is returned exactly once.
    • 410 Gone for unknown, expired, or already-consumed invites — symmetric with /info.
    • Marks 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 existing golang.org/x/time/rate infrastructure 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]
    • Generates a fresh pinv_… token, stores its sha256 hash in the named identity's invites table, prints exactly one line: https://<identity-url>/setup#invite=pinv_…. Default TTL is 24h; the --ttl flag accepts any Go time.ParseDuration string (e.g. 15m, 7d168h).
  • posta-server invite list --slug=X
    • Lists active (unconsumed and unexpired) invites with their integer id, device hint, and TTL remaining. Does not show plaintext or hash.
  • posta-server invite revoke --slug=X --id=N
    • Deletes the row whose primary key is N. The integer id is the user-facing handle — operators see ids via invite list. Plaintext invites are not accepted as a revoke key (the plaintext is gone the moment the URL is handed out).
  • The auth middleware applied to other /api/v1/* routes must not run on /api/v1/invite/*.
  • Per-IP rate limit middleware on /api/v1/invite/* only.

Acceptance criteria:

  • A new migration appends an invites table with columns (id, token_hash, device_hint, created_at, expires_at, consumed_at) and a unique index on token_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=X lists active invites with id, device hint, and TTL remaining.
  • invite revoke --slug=X --id=N deletes 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/redeem mints mst_…, marks consumed_at, returns the token plaintext exactly once on success; returns 410 for unknown / expired / consumed (symmetric with /info).
  • The mint-and-mark-consumed step in /redeem is atomic — a partial failure cannot produce a usable token without consuming the invite, and cannot consume the invite without producing a token.
  • Auth middleware does not run on /api/v1/invite/*.
  • Per-IP rate limit applies to /api/v1/invite/* (reusing the existing x/time/rate building block, keyed by remote IP).
  • An end-to-end test exercises a curl-driven round-trip: create invite → GET /info succeeds → POST /redeem succeeds → second POST /redeem returns 410 → GET /info for an expired invite returns 410.

Out of scope:

  • The /setup HTML page that consumes these endpoints — that is #5.
  • Multi-use invites, group invites, or any non-bearer redemption (OAuth, OIDC).
  • Migrating existing tokens or backfilling invite rows for them.
  • An admin UI surfacing invites — the CLI is the only management surface for now.
  • Email/notification integration. The operator hands out URLs themselves.
  • Per-identity rate-limit configuration — global rate-limit settings continue to apply.
> *This was generated by AI during triage.* ## 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 no `invites` table. **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 normal `mst_…` bearer token — there is no second-class "invite-only" token. **Schema (new migration, appended to the existing ordered migration list):** A new `invites` table per identity SQLite database with columns: - `id` — integer primary key, autoincrement - `token_hash` — sha256 hex of the plaintext invite, **unique** - `device_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_at` is nullable **Token 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.Server` auth carve-out — auth middleware does not run on these paths):** - `GET /api/v1/invite/info?invite=<plaintext>` - **200** with body `{identity: <url>, deviceName: <hint or null>, expiresAt: <RFC3339>}` if the invite is valid and unconsumed. - **410 Gone** if the invite is unknown, expired, or already consumed. Does not distinguish — same response for all three. - Does not consume the invite. - `POST /api/v1/invite/redeem` with body `{invite: <plaintext>, deviceName: <string>}` - **200** with body `{token: <plaintext mst_…>, identity: <url>, deviceName: <stored>}`. The token plaintext is returned exactly once. - **410 Gone** for unknown, expired, or already-consumed invites — symmetric with `/info`. - Marks `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 existing `golang.org/x/time/rate` infrastructure 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]` - Generates a fresh `pinv_…` token, stores its sha256 hash in the named identity's `invites` table, prints exactly one line: `https://<identity-url>/setup#invite=pinv_…`. Default TTL is 24h; the `--ttl` flag accepts any Go `time.ParseDuration` string (e.g. `15m`, `7d` → `168h`). - `posta-server invite list --slug=X` - Lists active (unconsumed and unexpired) invites with their integer id, device hint, and TTL remaining. Does not show plaintext or hash. - `posta-server invite revoke --slug=X --id=N` - Deletes the row whose primary key is `N`. The integer id is the user-facing handle — operators see ids via `invite list`. Plaintext invites are not accepted as a revoke key (the plaintext is gone the moment the URL is handed out). - The auth middleware applied to other `/api/v1/*` routes must not run on `/api/v1/invite/*`. - Per-IP rate limit middleware on `/api/v1/invite/*` only. **Acceptance criteria:** - [ ] A new migration appends an `invites` table with columns `(id, token_hash, device_hint, created_at, expires_at, consumed_at)` and a unique index on `token_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=X` lists active invites with id, device hint, and TTL remaining. - [ ] `invite revoke --slug=X --id=N` deletes 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/redeem` mints `mst_…`, marks `consumed_at`, returns the token plaintext exactly once on success; returns 410 for unknown / expired / consumed (symmetric with `/info`). - [ ] The mint-and-mark-consumed step in `/redeem` is atomic — a partial failure cannot produce a usable token without consuming the invite, and cannot consume the invite without producing a token. - [ ] Auth middleware does not run on `/api/v1/invite/*`. - [ ] Per-IP rate limit applies to `/api/v1/invite/*` (reusing the existing `x/time/rate` building block, keyed by remote IP). - [ ] An end-to-end test exercises a curl-driven round-trip: create invite → `GET /info` succeeds → `POST /redeem` succeeds → second `POST /redeem` returns 410 → `GET /info` for an expired invite returns 410. **Out of scope:** - The `/setup` HTML page that consumes these endpoints — that is #5. - Multi-use invites, group invites, or any non-bearer redemption (OAuth, OIDC). - Migrating existing tokens or backfilling invite rows for them. - An admin UI surfacing invites — the CLI is the only management surface for now. - Email/notification integration. The operator hands out URLs themselves. - Per-identity rate-limit configuration — global rate-limit settings continue to apply.
arne closed this issue 2026-05-10 01:19:49 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/server#4
No description provided.