carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 7cf6526d71166027bd3166eaafceffc93159421a parent 1c43c2c
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.