Gate RFC 001 multi channel notifications
RFC001 — Auth Gate MultiChannel Notifications
*uthor:*Koder Engineering *ate:*20260511 *tatus:*Draft *odules:*
infra/net/jet(producer)services/foundation/id(consumer: push to Koder ID app)products/horizontal/talk(consumer: interactive card)services/foundation/reporter(existing consumer: email — no changes)
*rigin:*adhoc conversation 20260511, anchored on approver modelpolicies/environments.kmd § Multi
1. Summary
Extend the Koder Jet auth gate (per policies/environments.kmd) from *mailonly*notifications to a *ultichannel*model. The admin receives the same approval request via any subset of: email (existing), Koder ID app push (new), Talk DM card (new). Every channel funnels back to the same confirm.koder.dev callback — token is single-use across all channels.
2. Problem
Today the gate only emails the admin. Three pain points:
- *mail reliability*— SMTP delivery delay, spam folder, mailbox
inbox-zero hygiene. Admin literally said "the email didn't arrive."
- *o phone-native UX*— admin has a Koder ID app and a Talk app on
the phone but must context
switch to email to approve a 1second decision. - *ingle point of failure*— if SMTP is down, the gate is dead. Per
the current
Emailerdoc comment, "if SMTP is down the gate surfaces anemail_skippedevent and the visitor sees the same 403."
3. Goals
- Pluggable notifier interface — adding a channel doesn't touch the gate
middleware.
- Per-vhost channel selection via
[sites.auth_gate].notifiers. - Backwards compatible — vhosts without
notifierskeep currentemail-only behaviour.
- Single token across channels — admin can approve from any device.
- Failure isolation — one channel down doesn't block the others.
4. Non-Goals
- Push notification transport choice (FCM/APNS vs self-hosted). That
lives in
services/foundation/id(consumer-side detail). - Talk bot framework design. The bot lives in
products/horizontal/talkand uses whatever interactive-message primitive Talk already exposes.
- Replacing the existing
confirm.koder.devconfirmation flow. Allchannels still resolve through the same Koder ID-authenticated page.
5. Architecture
┌─────────────────────┐
│ Visitor hits │
│ stg.<vhost> │
└──────────┬──────────┘
│ blocked (no whitelist entry)
▼
┌─────────────────────────────────────────────────────────┐
│ Jet auth-gate middleware │
│ │
│ 1. Generate token (once) │
│ 2. Store pending (token → req) │
│ 3. Fan out to notifiers (configured per-vhost): │
│ ├─ EmailNotifier (existing SMTP send) │
│ ├─ KoderIDNotifier (HTTP POST to id.koder.dev) │
│ └─ TalkNotifier (HTTP POST to talk.koder.dev) │
│ 4. Emit `auth_gate.notifier_sent` / `notifier_failed` │
│ per channel │
│ 5. Render 403 access-pending page │
└─────────────────────────────────────────────────────────┘
│ admin clicks/taps approve
▼
┌──────────────────────┐
│ confirm.koder.dev │
│ (Koder ID auth + │
│ whitelist add) │
└──────────────────────┘6. Producer interface (Jet)
6.1 Go interface
Existing ApprovalRequester is renamed and split. The current single Emailer becomes one of N notifiers. A new MultiRequester orchestrates token generation, store enqueue, and fan-out.
package authgate
// Notifier dispatches the approval notification on a single channel.
// The token has already been generated and the pending request stored;
// the notifier just delivers the message.
type Notifier interface {
// Channel returns the channel identifier ("email", "koder_id", "talk").
// Used in observability events.
Channel() string
// Notify sends the approval message. Errors are surfaced as
// `auth_gate.notifier_failed` events; they do NOT prevent other
// notifiers from running.
Notify(token string, req store.PendingRequest, confirmURL string) error
}
// MultiRequester implements ApprovalRequester by fanning out to a list
// of notifiers. The first notifier to succeed sets the token observed
// by the rate limiter; all subsequent notifiers run regardless.
type MultiRequester struct {
Store *store.Store
Sink EventSink
Notifiers []Notifier
TTL time.Duration
}
func (m *MultiRequester) Enqueue(req store.PendingRequest) (string, error) { … }The middleware (gate.go) is unchanged — it still calls deps.Requester.Enqueue(...). The wiring at vhost.go swaps Emailer for MultiRequester{Notifiers: [...]}.
6.2 Webhook payload (Notifier → consumer)
KoderIDNotifier and TalkNotifier are both implementations of the same WebhookNotifier — a thin HTTP POST to a consumer-specific URL.
POST https://id.koder.dev/v1/auth-gate/approvals
Content-Type: application/json
Authorization: Bearer <mTLS service token>
{
"token": "8c7d2a9e1b3f4a0c5e6d8b9a7c1d2e3f",
"host": "stg.kds.koder.dev",
"environment": "stg",
"origin_ip": "189.45.12.30",
"user_agent": "Mozilla/5.0 …",
"geo": "BR",
"timestamp": "2026-05-11T15:19:31Z",
"admin": "rpm@koder.dev",
"confirm_url": "https://confirm.koder.dev/req/8c7d2a9e…",
"ttl_seconds": 600
}Required response: 200 OK within 2s, body ignored. Anything else counts as failure.
6.3 sites.toml schema
[[sites]]
domains = ["stg.kds.koder.dev"]
environment = "stg"
[sites.auth_gate]
enabled = true
admin_email = "rpm32510@gmail.com" # used by email notifier
admin = "rpm@koder.dev" # Koder ID handle for push/Talk
notifiers = ["email", "koder_id", "talk"] # NEW; default ["email"] if absent
whitelist_ttl_sec = 604800
geofence = ["BR"]
confirm_endpoint = "https://confirm.koder.dev/req"
# Channel-specific overrides are optional. Default URLs are baked into
# Jet env config (KODER_JET_KODER_ID_URL, KODER_JET_TALK_URL).
[sites.auth_gate.koder_id]
url = "https://id.koder.dev/v1/auth-gate/approvals"
[sites.auth_gate.talk]
url = "https://talk.koder.dev/api/v1/bots/koder-gate/approvals"Backwards compat: when notifiers is absent, Jet defaults to ["email"] (current behaviour).
7. Consumer: Koder ID app (push)
7.1 Receiver endpoint
POST /v1/auth-gate/approvals- AuthZ: mTLS or service
toservice token (Jet → ID). Not user-facing. - Resolves
adminfield to a Koder ID account. - Stores
(token, host, ip, ua, ts, ttl)inauth_gate_pendingtable. - Sends push notification to all of admin's registered devices.
7.2 Push payload
{
"type": "auth_gate.approval_request",
"title": "Acesso solicitado — stg.kds.koder.dev",
"body": "189.45.12.30 (BR) · Chrome 148 · agora",
"data": {
"token": "8c7d2a9e…",
"confirm_url": "https://confirm.koder.dev/req/8c7d2a9e…",
"host": "stg.kds.koder.dev"
}
}7.3 Koder ID app UI
A new screen AuthGateApprovalDialog (Flutter, in koder_kit as a shared dialog if Talk reuses it; otherwise local to ID app):
┌────────────────────────────────────┐
│ 🔒 Access requested │
│ │
│ stg.kds.koder.dev │
│ │
│ From 189.45.12.30 (BR) │
│ When agora │
│ UA Chrome 148 on Linux │
│ │
│ [ Deny ] [ Approve ] │
│ │
│ Or open: confirm.koder.dev → │
└────────────────────────────────────┘ApproveDeny taps call `POST /v1auth-gateapprovalstoken/decide
on the ID server, which proxies to confirm.koder.dev`. The session is already authenticated (the user is signed into the ID app), so no extra auth step.
7.4 Implementation breakdown
services/foundation/id/engine/internal/authgate/— receiver, store,push dispatcher.
services/foundation/id/app/<surface>/lib/authgate/— UI dialog +push handler.
- Token TTL mirrors Jet's TTL (10min default); expired pushes are
silently dismissed by the app.
8. Consumer: Talk (interactive card)
8.1 Receiver endpoint
POST /api/v1/bots/koder-gate/approvalsOwned by a system bot identity "Koder Gate" (a tenant-level bot, not a user-created bot). The bot's display name in DMs is *Koder Auth Gate"*
8.2 Message format
A Talk interactive card (existing rich-message primitive — see Talk ticket #041) posted to the admin's DM thread with the bot:
🔒 Access requested
stg.kds.koder.dev
From 189.45.12.30 (BR)
UA Chrome 148
When 2026-05-11 15:19 UTC
[ Approve ] [ Deny ] [ Open in browser ]Approve/Deny → bot calls confirm.koder.dev server-side with the admin's already-authenticated identity. "Open in browser" → URL link.
8.3 Implementation breakdown
products/horizontal/talk/engine/bots/koder-gate/— bot receiver +card renderer.
- Card schema reuses Talk's existing interactive-card type (#041
ships rich text; this RFC depends on that landing first if buttons are still TBD).
9. Single-token invariant
The token generated by Jet is the *ame string*delivered on every channel. Implications:
- Admin sees the same
confirm_urlin email, push, and Talk card. - First approve/deny wins; subsequent attempts get a friendly "already
decided" page.
confirm.koder.devdeduplicates by token; no per-channel state needed.
10. Failure semantics
| Failure | Behaviour |
|---|---|
| Email SMTP timeout | Other notifiers still run; emit notifier_failed{channel:email} |
| Koder ID API 5xx | Other notifiers still run; emit notifier_failed{channel:koder_id} |
| Talk bot offline | Other notifiers still run; emit notifier_failed{channel:talk} |
| All notifiers fail | Token still stored; admin can recover by visiting confirm.koder.dev manually if they noticed via Eye/logs; emit auth_gate.all_notifiers_failed |
| Token already exists (rate limit) | No notifiers run; behaviour unchanged |
11. Observability
New events (extend the existing JSON event stream):
{"event":"auth_gate.notifier_sent", "channel":"koder_id", "host":"…", "token":"…"}
{"event":"auth_gate.notifier_failed", "channel":"talk", "host":"…", "error":"…"}
{"event":"auth_gate.all_notifiers_failed", "host":"…", "token":"…"}Existing auth_gate.email_sent / email_skipped events stay for backwards compat with current Eye dashboards but are deprecated. Migration is email_sent → notifier_sent{channel:email}.
12. Security
- *TLS Jet → ID and Jet → Talk.*All three live in the same LXC
network on
s.khost1(10.0.1.0/24). Service certs already exist for Koder ID inter-service traffic. - *oken is bearer-equivalent on confirm.koder.dev*— anyone with the
token can approve. This is the existing model; not changed.
- *ush and Talk messages carry the token in the payload.*That's
acceptable because (a) they're delivered to authenticated channels (admin's device, admin's DM), (b) tokens are single-use, (c) tokens expire in 10min.
- *dmin enumeration risk*— Jet doesn't validate that the configured
adminexists on the consumer side; an attacker who can editsites.tomlcan already do worse than enumerate admins.
13. Rollout
| Phase | Scope | Module owners |
|---|---|---|
| *1*— RFC merged | This document; backlog tickets opened | meta + jet + id + talk |
| *2*— Jet producer | MultiRequester, WebhookNotifier, sites.toml schema, env config; default notifiers=["email"] preserves behaviour |
jet |
| *3*— Koder ID receiver | API endpoint, store, push dispatcher (serverFEATURE_AUTH_GATE_APPROVAL |
id (engine) |
| *4*— Koder ID app UI | Flutter screen + push handler; ship behind feature flag until P5 lands | id (app) |
| *5*— Talk bot | System bot + interactive card | talk |
*6*— Flip stg.kds.koder.dev to notifiers=["email","koder_id","talk"] |
Validate end |
jet config |
P3P4P5 can ship in parallel after P2; the producer config is the gating piece.
14. Open questions
- Q1: Should the
adminfield be a single Koder ID account or a list(multi-approver V2 from
environments.kmd)? Recommend: single for P2; list when Koder ID admin-role expansion lands (out of scope). - Q2: Push transport — self-hosted (Koder Push) or FCM/APNS? Defer to
Koder ID team. RFC is transport-agnostic.
- Q3: Should Talk card buttons be deep links into the ID app instead
of inline approve? Less code in Talk; tradeoff is one more tap. Recommend: inline for P5; revisit after telemetry.
15. References
meta/docs/stack/policies/environments.kmd— auth gate specinfra/net/jet/backlog/done/108-approval-requester.md— originalApprovalRequesterinterfaceservices/foundation/id— receiver implementationproducts/horizontal/talk/engine/backlog/done/041-rich-text-formatting.md—interactive card prerequisite
policies/self-hosted-first.kmd— push notifications should followthe 5 gates when self-hosted Koder Push matures