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 policies/environments.kmd § Multiapprover model


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:

  1. *mail reliability*— SMTP delivery delay, spam folder, mailbox

    inbox-zero hygiene. Admin literally said "the email didn't arrive."

  2. *o phone-native UX*— admin has a Koder ID app and a Talk app on

    the phone but must contextswitch to email to approve a 1second decision.

  3. *ingle point of failure*— if SMTP is down, the gate is dead. Per

    the current Emailer doc comment, "if SMTP is down the gate surfaces an email_skipped event 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 notifiers keep current

    email-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/talk

    and uses whatever interactive-message primitive Talk already exposes.

  • Replacing the existing confirm.koder.dev confirmation flow. All

    channels 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 servicetoservice token (Jet → ID). Not user-facing.
  • Resolves admin field to a Koder ID account.
  • Stores (token, host, ip, ua, ts, ttl) in auth_gate_pending table.
  • 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/approvals

Owned 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_url in email, push, and Talk card.
  • First approve/deny wins; subsequent attempts get a friendly "already

    decided" page.

  • confirm.koder.dev deduplicates 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

    admin exists on the consumer side; an attacker who can edit sites.toml can 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 (serverside); flaggated app UI behind FEATURE_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 endtoend jet config

P3P4P5 can ship in parallel after P2; the producer config is the gating piece.

14. Open questions

  • Q1: Should the admin field 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 spec
  • infra/net/jet/backlog/done/108-approval-requester.md — original

    ApprovalRequester interface

  • services/foundation/id — receiver implementation
  • products/horizontal/talk/engine/backlog/done/041-rich-text-formatting.md

    interactive card prerequisite

  • policies/self-hosted-first.kmd — push notifications should follow

    the 5 gates when self-hosted Koder Push matures

Source: ../home/koder/dev/koder/meta/docs/stack/rfcs/gate-RFC-001-multi-channel-notifications.kmd