carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Console invite delivery: Keymail federation, then email
2
3Status: design, awaiting review (2026-09-28).
4Follow-on: email magic-link + passkey sign-in gets its own spec and reuses
5the console email sender built here (§4).
6
7## 1. The bug
8
9An account owner invites `bill@keymail.dev` from the Team page and gets:
10
11> Invited bill@keymail.dev, but delivering the link failed — you can send it
12> on yourself: https://…/invite/…
13
14Tito's console journal (2026-09-28 14:09 UTC) has the cause:
15
16```
17console: invite send to bill@keymail.dev: keymail: POST /api/app/send: envelope to must be paul@keymail.dev
18```
19
20`handleInvite` (`internal/console/server.go`) sends the invite through
21`Keymail.SendApp` using the **inviter's** OAuth grant. Keymail's app API
22lets a grant deliver to exactly one recipient, the user who approved it
23(`keymail/appapi.go` `handleAppSend`: "The grant authorizes exactly one
24recipient"). So every invite to anyone but yourself has failed since the
25invite shipped in 35602776 (2026-08-02), on every deployment. The test fake
26in `keymail_test.go` does not enforce that rule, which is why nothing caught
27it.
28
29A second fault in the same handler: an inviter with no stored grant token
30sends nothing and still gets the success flash ("the link is in their
31Keymail Apps inbox").
32
33## 2. What it should do
34
35Deliver the invite by Keymail if the invitee has a Keymail mailbox, by
36email if not, and fall back to the hand-off link only when neither works.
37The flash says which one happened.
38
39Out of scope: the other `SendApp` callers (sign-in confirmation, CLI
40sign-in notice, alert watcher). They all send to the grant's own user,
41which Keymail allows.
42
43## 3. Keymail delivery by federation
44
45Keymail already has a way for a sender it does not know to reach any
46mailbox: the federation protocol (`keymail/federation.go`). It is what one
47Keymail instance uses to write to another, and it charges an unknown sender
48a proof of work instead of requiring a grant. The console uses it as a
49sender in its own name.
50
51### 3.1 Identity
52
53- **From** `console@<origin host>` (e.g. `console@console.platform.tito.io`),
54 the same From `SendApp` already pins.
55- **Signing key**: the console's existing long-lived envelope key
56 (`Keymail.signKey`). Keymail registers a sender by (address, signing
57 key); the same key on every send means the proof of work is paid once
58 per recipient mailbox, and every later send to that mailbox is free.
59- **Box key**: registration also records a key for replies to be sealed
60 to. Nobody replies to an invite and the console serves no Keymail
61 endpoints, so the console generates a P-256 ECDH key per process and
62 never keeps the private half. A later re-registration with a different
63 box key is harmless: Keymail keys free passage on the signing key.
64
65### 3.2 Flow: `Keymail.SendFederated(ctx, to, subject, body) (msgID, error)`
66
67All calls go to the configured Keymail base URL (`k.base`). On
68keymail.dev that is the relay, which routes every federation verb to the
69recipient's mailbox by address (`keymail/relay.go` `route`).
70
711. `GET /api/federation/lookup?addr=<to>&from=<from>` → `box_pub`,
72 `pow_bits`. A non-200 here returns `ErrNoKeymailMailbox`, wrapping
73 Keymail's message. The relay answers 400 "could not determine target
74 mailbox" for an address it cannot resolve and 404 "no such mailbox"
75 for an unknown local part.
762. Seal `{subject, body}` to `box_pub` and sign the envelope, exactly as
77 `SendApp` does today. Factor the shared seal-and-sign into one helper.
783. `POST /api/federation/deliver {envelope}`. 200 means delivered.
794. On 403 `{"error":"unknown_sender","pow_bits":N}`:
80 - If `N > maxFederationPowBits` (24), fail with
81 `ErrFederationPriceTooHigh`. A mailbox can set its own price, and the
82 console must not burn an HTTP request's worth of CPU on a hostile one.
83 - `POST /api/federation/challenge {to, from, sign_pub}` →
84 `{challenge, bits}`.
85 - Mine: find `counter` with
86 `SHA-256(SHA-256(challenge) || sign_pub_raw || counter_be64)` having
87 ≥ `bits` leading zero bits (`keymail/internal/pow`). A port of
88 `pow.Mine` of about 30 lines, cancelled by `ctx`. At 20 bits this is
89 about a million hashes, well under a second.
90 - `POST /api/federation/register {to, challenge, counter, from,
91 sign_pub, box_pub, attestation}`, where attestation is the signing
92 key's signature over `attestationMsg(from, sign_pub, box_pub)`.
93 - Retry step 3 once.
945. Any other non-200 is an error carrying Keymail's text. A 403 "blocked"
95 is an error like any other: the recipient has blocked the console.
96
97The invite lands in the recipient's main inbox, not Apps. That is the
98right place for a message from someone who is not an app the recipient
99granted.
100
101The protocol constants and message shapes (`attestationMsg`, the pow hash
102layout, `envSigContext`) are copied from the Keymail source with a comment
103naming the file, as `keymail.go` already does for the envelope.
104
105## 4. The console email sender
106
107### 4.1 Where the From address comes from
108
109The identity is created through the platform's own mail layer, the same
110`mail.EnsureDomain` walk apps use. It is owned by the built-in ops
111account `bab` (`internal/release/qualified.go`), with the referrer app
112name `console`. The send uses the console's own AWS role. No SMTP
113credential exists, so nothing secret is stored.
114
115Config, read in `cmd/console/main.go` next to the other `CARLOS_MAIL_*`
116variables:
117
118| Variable | Meaning |
119|---|---|
120| `CARLOS_CONSOLE_MAIL_FROM` | Full From address. Optional. |
121
122- Set: use it. Its domain goes through `EnsureDomain` as whatever
123 `DomainKindFor` says. A custom domain comes back with pending records
124 that the operator publishes, exactly as for an account's custom domain.
125- Unset, `CARLOS_MAIL_PLATFORM_SUFFIX` set: `noreply@console.bab.<suffix>`.
126 This is a platform name owned by `bab`, since `platformNameOwnedBy`
127 wants the sqid as the label left of the suffix. The DNS writer
128 publishes its records automatically.
129- Unset, no suffix, or `CARLOS_MAIL_REGION` unset (no provisioner): no
130 email fallback. The console logs this once at startup and invites fall
131 back to the hand-off link, as today.
132
133### 4.2 Lifecycle
134
135- **Startup**: a background goroutine runs `EnsureDomain(bab, "console",
136 domain)`. It is idempotent: `EnsureIdentity` reads the identity back
137 rather than minting new DKIM tokens. It logs pending records for a
138 custom domain. A failure is logged and retried on the next send
139 attempt, never fatal, matching how `mailBackends` degrades.
140- **Send**: `ConsoleMailer.Send(ctx, to, subject, body)`.
141 1. Check `IdentityStatus`. Anything but verified returns
142 `ErrConsoleMailNotReady`. SES would refuse the send anyway; checking
143 first gives a clear log line. Cache a verified result for the
144 process lifetime and anything else for 5 minutes.
145 2. SESv2 `SendEmail` with plain-text `Simple` content, `FromEmailAddress`
146 = the From address, `TenantName = mail.TenantName("bab")`, and
147 `ConfigurationSetName` = the domain record's config set when one
148 exists, so bounces and complaints are counted like any sender's.
149- **Seam**: `ConsoleMailer` is an interface with one `Send` method. The
150 SES implementation takes a narrow SESv2 client interface, so tests use a
151 fake.
152
153### 4.3 Guarding the `console` referrer
154
155The domain record names `console` as a referring app in `bab`. Any
156teardown path that walks `bab`'s app domains must not delete this domain
157if an app literally named `console` is deleted from `bab`. The plan's
158first task checks `mail.Teardown` and the domain-detach paths and either
159reserves the name `console` in `bab` for apps, or records the referrer
160under a name `release.ValidApp` accepts but app creation refuses. The
161plan picks one after reading the code; the spec only requires that no
162user action on `bab` can tear down the console's identity.
163
164### 4.4 IAM (`infra/modules/region-host`)
165
166New optional variable `console_mail_identity_arns` (list, default empty).
167When non-empty, the host role gets:
168
169```
170ses:SendEmail
171 Resource: those identity ARNs (+ the config set ARN pattern for the deployment)
172 Condition: StringLike ses:FromAddress = the configured From address
173```
174
175The live deployments' existing SES/IAM rights for the provisioner were
176granted outside this repo (nothing in `infra/modules` mentions `ses:`).
177The plan records where each deployment's grants live before adding to
178them. Flagship is `keymail` (271376211898). Tito is `titogo`
179(665106592142), and a tofu apply there needs Paul's go. That apply can
180ride with the store-boundary IAM that already blocks Tito's console
181upgrade.
182
183## 5. `handleInvite`, rewritten
184
185```
186tok := CreateInvite(...) // unchanged
187link := origin + "/invite/" + tok
188
189ctx 20s
190err := km.SendFederated(ctx, to, subj, body) // Keymail first
191if err == nil → flash ok "…in their Keymail inbox"
192log err
193if mailer != nil:
194 err2 := mailer.Send(ctx, to, subj, body) // email second
195 if err2 == nil → flash ok "…emailed to <to>"
196 log err2
197flash err "…delivering the link failed… <link>" // unchanged fallback
198```
199
200- Email is tried after **any** Keymail failure, not only "no mailbox".
201 The invite link is bound to the invitee's signed-in identity (accepting
202 it requires being signed in as `to`), so plain email does not weaken
203 it. Delivery matters more here than sealing.
204- The inviter's grant is no longer consulted, which also fixes the false
205 success for an inviter with no grant.
206- The `ValidAddress` gate on the invite form stays as it is. Widening
207 who can be invited belongs to the sign-in spec.
208- Worst case, the request waits for a Keymail timeout followed by an SES
209 call. The shared 20s context bounds it.
210
211## 6. Copy (goes through copy review before it is written)
212
213Drafts only. The copy-review skill runs over these before any lands in a
214file:
215
2161. Flash, Keymail delivered: "Invited {to}. The link is in their Keymail
217 inbox."
2182. Flash, emailed: "Invited {to}. We emailed them the link."
2193. Flash, both failed: unchanged ("Invited {to}, but delivering the link
220 failed — you can send it on yourself: {link}"). It has an em dash, so
221 review it too.
2224. Message subject: unchanged ("You've been invited to an account on the
223 CARLOS platform").
2245. Message body: unchanged text, reviewed as one string because email now
225 carries it.
226
227## 7. Testing
228
229- **Keymail fake** (`keymail_test.go`): `/api/app/send` enforces
230 `to == grant user` and answers 403 with Keymail's message otherwise.
231 This is the regression test for §1. It also gains lookup (resolvable
232 and unresolvable addresses), challenge, register and deliver, with a
233 real pow check at 8 bits, a sender registry keyed by (from, sign_pub),
234 and a configurable price.
235- **`SendFederated`**:
236 - a known sender delivers without pow;
237 - an unknown sender mines, registers and delivers;
238 - a price above the cap is refused without mining;
239 - a blocked sender gets an error;
240 - an unresolvable address gets `ErrNoKeymailMailbox`;
241 - the delivered envelope verifies and unseals.
242- **`ConsoleMailer`** with a fake SESv2 client:
243 - unverified → `ErrConsoleMailNotReady` and no send;
244 - verified → one `SendEmail` with tenant, config set and From set;
245 - the verified cache holds.
246- **`handleInvite`**, table-driven over:
247 - Keymail ok;
248 - no mailbox + email ok;
249 - Keymail error + email ok;
250 - both fail;
251 - no mailer + Keymail fails;
252 - inviter with no grant still delivers.
253
254 Each case asserts the flash kind and text, and that the invite row
255 exists either way.
256- **Config**: the From resolution table in §4.1.
257- `GOOS=darwin` and `GOOS=windows` builds stay green (cross-compile gate).
258
259## 8. Rollout
260
2611. Merge, then roll consoles by the normal recipe. Keymail federation
262 works immediately with no infra change, so the §1 bug is fixed as soon
263 as a console runs the build.
2642. Flagship: apply the region-host variable, restart the console, check
265 the identity verifies, and invite a non-Keymail address.
2663. Tito: blocked on the store-boundary IAM (Tito's console is pinned on
267 cf02a4d5). Bundle the SES grant with that apply, with Paul's go.
268