spec: console invite delivery by Keymail federation, then email
Invites have never delivered: SendApp's grant only reaches its own user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+267
−0
docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md+267 −0
diff --git a/docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md b/docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md| new file mode 100644 |
| index 000000000..225daf3ee |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-28-console-invite-delivery-design.md |
| @@ -0,0 +1,267 @@ |
| +# Console invite delivery: Keymail federation, then email |
| + |
| +Status: design, awaiting review (2026-09-28). |
| +Follow-on: email magic-link + passkey sign-in gets its own spec and reuses |
| +the console email sender built here (§4). |
| + |
| +## 1. The bug |
| + |
| +An account owner invites `bill@keymail.dev` from the Team page and gets: |
| + |
| +> Invited bill@keymail.dev, but delivering the link failed — you can send it |
| +> on yourself: https://…/invite/… |
| + |
| +Tito's console journal (2026-09-28 14:09 UTC) has the cause: |
| + |
| +``` |
| +console: invite send to bill@keymail.dev: keymail: POST /api/app/send: envelope to must be paul@keymail.dev |
| +``` |
| + |
| +`handleInvite` (`internal/console/server.go`) sends the invite through |
| +`Keymail.SendApp` using the **inviter's** OAuth grant. Keymail's app API |
| +lets a grant deliver to exactly one recipient, the user who approved it |
| +(`keymail/appapi.go` `handleAppSend`: "The grant authorizes exactly one |
| +recipient"). So every invite to anyone but yourself has failed since the |
| +invite shipped in 35602776 (2026-08-02), on every deployment. The test fake |
| +in `keymail_test.go` does not enforce that rule, which is why nothing caught |
| +it. |
| + |
| +A second fault in the same handler: an inviter with no stored grant token |
| +sends nothing and still gets the success flash ("the link is in their |
| +Keymail Apps inbox"). |
| + |
| +## 2. What it should do |
| + |
| +Deliver the invite by Keymail if the invitee has a Keymail mailbox, by |
| +email if not, and fall back to the hand-off link only when neither works. |
| +The flash says which one happened. |
| + |
| +Out of scope: the other `SendApp` callers (sign-in confirmation, CLI |
| +sign-in notice, alert watcher). They all send to the grant's own user, |
| +which Keymail allows. |
| + |
| +## 3. Keymail delivery by federation |
| + |
| +Keymail already has a way for a sender it does not know to reach any |
| +mailbox: the federation protocol (`keymail/federation.go`). It is what one |
| +Keymail instance uses to write to another, and it charges an unknown sender |
| +a proof of work instead of requiring a grant. The console uses it as a |
| +sender in its own name. |
| + |
| +### 3.1 Identity |
| + |
| +- **From** `console@<origin host>` (e.g. `console@console.platform.tito.io`), |
| + the same From `SendApp` already pins. |
| +- **Signing key**: the console's existing long-lived envelope key |
| + (`Keymail.signKey`). Keymail registers a sender by (address, signing |
| + key); the same key on every send means the proof of work is paid once |
| + per recipient mailbox, and every later send to that mailbox is free. |
| +- **Box key**: registration also records a key for replies to be sealed |
| + to. Nobody replies to an invite and the console serves no Keymail |
| + endpoints, so the console generates a P-256 ECDH key per process and |
| + never keeps the private half. A later re-registration with a different |
| + box key is harmless: Keymail keys free passage on the signing key. |
| + |
| +### 3.2 Flow: `Keymail.SendFederated(ctx, to, subject, body) (msgID, error)` |
| + |
| +All calls go to the configured Keymail base URL (`k.base`). On |
| +keymail.dev that is the relay, which routes every federation verb to the |
| +recipient's mailbox by address (`keymail/relay.go` `route`). |
| + |
| +1. `GET /api/federation/lookup?addr=<to>&from=<from>` → `box_pub`, |
| + `pow_bits`. A non-200 here returns `ErrNoKeymailMailbox`, wrapping |
| + Keymail's message. The relay answers 400 "could not determine target |
| + mailbox" for an address it cannot resolve and 404 "no such mailbox" |
| + for an unknown local part. |
| +2. Seal `{subject, body}` to `box_pub` and sign the envelope, exactly as |
| + `SendApp` does today. Factor the shared seal-and-sign into one helper. |
| +3. `POST /api/federation/deliver {envelope}`. 200 means delivered. |
| +4. On 403 `{"error":"unknown_sender","pow_bits":N}`: |
| + - If `N > maxFederationPowBits` (24), fail with |
| + `ErrFederationPriceTooHigh`. A mailbox can set its own price, and the |
| + console must not burn an HTTP request's worth of CPU on a hostile one. |
| + - `POST /api/federation/challenge {to, from, sign_pub}` → |
| + `{challenge, bits}`. |
| + - Mine: find `counter` with |
| + `SHA-256(SHA-256(challenge) || sign_pub_raw || counter_be64)` having |
| + ≥ `bits` leading zero bits (`keymail/internal/pow`). A port of |
| + `pow.Mine` of about 30 lines, cancelled by `ctx`. At 20 bits this is |
| + about a million hashes, well under a second. |
| + - `POST /api/federation/register {to, challenge, counter, from, |
| + sign_pub, box_pub, attestation}`, where attestation is the signing |
| + key's signature over `attestationMsg(from, sign_pub, box_pub)`. |
| + - Retry step 3 once. |
| +5. Any other non-200 is an error carrying Keymail's text. A 403 "blocked" |
| + is an error like any other: the recipient has blocked the console. |
| + |
| +The invite lands in the recipient's main inbox, not Apps. That is the |
| +right place for a message from someone who is not an app the recipient |
| +granted. |
| + |
| +The protocol constants and message shapes (`attestationMsg`, the pow hash |
| +layout, `envSigContext`) are copied from the Keymail source with a comment |
| +naming the file, as `keymail.go` already does for the envelope. |
| + |
| +## 4. The console email sender |
| + |
| +### 4.1 Where the From address comes from |
| + |
| +The identity is created through the platform's own mail layer, the same |
| +`mail.EnsureDomain` walk apps use. It is owned by the built-in ops |
| +account `bab` (`internal/release/qualified.go`), with the referrer app |
| +name `console`. The send uses the console's own AWS role. No SMTP |
| +credential exists, so nothing secret is stored. |
| + |
| +Config, read in `cmd/console/main.go` next to the other `CARLOS_MAIL_*` |
| +variables: |
| + |
| +| Variable | Meaning | |
| +|---|---| |
| +| `CARLOS_CONSOLE_MAIL_FROM` | Full From address. Optional. | |
| + |
| +- Set: use it. Its domain goes through `EnsureDomain` as whatever |
| + `DomainKindFor` says. A custom domain comes back with pending records |
| + that the operator publishes, exactly as for an account's custom domain. |
| +- Unset, `CARLOS_MAIL_PLATFORM_SUFFIX` set: `noreply@console.bab.<suffix>`. |
| + This is a platform name owned by `bab`, since `platformNameOwnedBy` |
| + wants the sqid as the label left of the suffix. The DNS writer |
| + publishes its records automatically. |
| +- Unset, no suffix, or `CARLOS_MAIL_REGION` unset (no provisioner): no |
| + email fallback. The console logs this once at startup and invites fall |
| + back to the hand-off link, as today. |
| + |
| +### 4.2 Lifecycle |
| + |
| +- **Startup**: a background goroutine runs `EnsureDomain(bab, "console", |
| + domain)`. It is idempotent: `EnsureIdentity` reads the identity back |
| + rather than minting new DKIM tokens. It logs pending records for a |
| + custom domain. A failure is logged and retried on the next send |
| + attempt, never fatal, matching how `mailBackends` degrades. |
| +- **Send**: `ConsoleMailer.Send(ctx, to, subject, body)`. |
| + 1. Check `IdentityStatus`. Anything but verified returns |
| + `ErrConsoleMailNotReady`. SES would refuse the send anyway; checking |
| + first gives a clear log line. Cache a verified result for the |
| + process lifetime and anything else for 5 minutes. |
| + 2. SESv2 `SendEmail` with plain-text `Simple` content, `FromEmailAddress` |
| + = the From address, `TenantName = mail.TenantName("bab")`, and |
| + `ConfigurationSetName` = the domain record's config set when one |
| + exists, so bounces and complaints are counted like any sender's. |
| +- **Seam**: `ConsoleMailer` is an interface with one `Send` method. The |
| + SES implementation takes a narrow SESv2 client interface, so tests use a |
| + fake. |
| + |
| +### 4.3 Guarding the `console` referrer |
| + |
| +The domain record names `console` as a referring app in `bab`. Any |
| +teardown path that walks `bab`'s app domains must not delete this domain |
| +if an app literally named `console` is deleted from `bab`. The plan's |
| +first task checks `mail.Teardown` and the domain-detach paths and either |
| +reserves the name `console` in `bab` for apps, or records the referrer |
| +under a name `release.ValidApp` accepts but app creation refuses. The |
| +plan picks one after reading the code; the spec only requires that no |
| +user action on `bab` can tear down the console's identity. |
| + |
| +### 4.4 IAM (`infra/modules/region-host`) |
| + |
| +New optional variable `console_mail_identity_arns` (list, default empty). |
| +When non-empty, the host role gets: |
| + |
| +``` |
| +ses:SendEmail |
| + Resource: those identity ARNs (+ the config set ARN pattern for the deployment) |
| + Condition: StringLike ses:FromAddress = the configured From address |
| +``` |
| + |
| +The live deployments' existing SES/IAM rights for the provisioner were |
| +granted outside this repo (nothing in `infra/modules` mentions `ses:`). |
| +The plan records where each deployment's grants live before adding to |
| +them. Flagship is `keymail` (271376211898). Tito is `titogo` |
| +(665106592142), and a tofu apply there needs Paul's go. That apply can |
| +ride with the store-boundary IAM that already blocks Tito's console |
| +upgrade. |
| + |
| +## 5. `handleInvite`, rewritten |
| + |
| +``` |
| +tok := CreateInvite(...) // unchanged |
| +link := origin + "/invite/" + tok |
| + |
| +ctx 20s |
| +err := km.SendFederated(ctx, to, subj, body) // Keymail first |
| +if err == nil → flash ok "…in their Keymail inbox" |
| +log err |
| +if mailer != nil: |
| + err2 := mailer.Send(ctx, to, subj, body) // email second |
| + if err2 == nil → flash ok "…emailed to <to>" |
| + log err2 |
| +flash err "…delivering the link failed… <link>" // unchanged fallback |
| +``` |
| + |
| +- Email is tried after **any** Keymail failure, not only "no mailbox". |
| + The invite link is bound to the invitee's signed-in identity (accepting |
| + it requires being signed in as `to`), so plain email does not weaken |
| + it. Delivery matters more here than sealing. |
| +- The inviter's grant is no longer consulted, which also fixes the false |
| + success for an inviter with no grant. |
| +- The `ValidAddress` gate on the invite form stays as it is. Widening |
| + who can be invited belongs to the sign-in spec. |
| +- Worst case, the request waits for a Keymail timeout followed by an SES |
| + call. The shared 20s context bounds it. |
| + |
| +## 6. Copy (goes through copy review before it is written) |
| + |
| +Drafts only. The copy-review skill runs over these before any lands in a |
| +file: |
| + |
| +1. Flash, Keymail delivered: "Invited {to}. The link is in their Keymail |
| + inbox." |
| +2. Flash, emailed: "Invited {to}. We emailed them the link." |
| +3. Flash, both failed: unchanged ("Invited {to}, but delivering the link |
| + failed — you can send it on yourself: {link}"). It has an em dash, so |
| + review it too. |
| +4. Message subject: unchanged ("You've been invited to an account on the |
| + CARLOS platform"). |
| +5. Message body: unchanged text, reviewed as one string because email now |
| + carries it. |
| + |
| +## 7. Testing |
| + |
| +- **Keymail fake** (`keymail_test.go`): `/api/app/send` enforces |
| + `to == grant user` and answers 403 with Keymail's message otherwise. |
| + This is the regression test for §1. It also gains lookup (resolvable |
| + and unresolvable addresses), challenge, register and deliver, with a |
| + real pow check at 8 bits, a sender registry keyed by (from, sign_pub), |
| + and a configurable price. |
| +- **`SendFederated`**: |
| + - a known sender delivers without pow; |
| + - an unknown sender mines, registers and delivers; |
| + - a price above the cap is refused without mining; |
| + - a blocked sender gets an error; |
| + - an unresolvable address gets `ErrNoKeymailMailbox`; |
| + - the delivered envelope verifies and unseals. |
| +- **`ConsoleMailer`** with a fake SESv2 client: |
| + - unverified → `ErrConsoleMailNotReady` and no send; |
| + - verified → one `SendEmail` with tenant, config set and From set; |
| + - the verified cache holds. |
| +- **`handleInvite`**, table-driven over: |
| + - Keymail ok; |
| + - no mailbox + email ok; |
| + - Keymail error + email ok; |
| + - both fail; |
| + - no mailer + Keymail fails; |
| + - inviter with no grant still delivers. |
| + |
| + Each case asserts the flash kind and text, and that the invite row |
| + exists either way. |
| +- **Config**: the From resolution table in §4.1. |
| +- `GOOS=darwin` and `GOOS=windows` builds stay green (cross-compile gate). |
| + |
| +## 8. Rollout |
| + |
| +1. Merge, then roll consoles by the normal recipe. Keymail federation |
| + works immediately with no infra change, so the §1 bug is fixed as soon |
| + as a console runs the build. |
| +2. Flagship: apply the region-host variable, restart the console, check |
| + the identity verifies, and invite a non-Keymail address. |
| +3. Tito: blocked on the store-boundary IAM (Tito's console is pinned on |
| + cf02a4d5). Bundle the SES grant with that apply, with Paul's go. |