From 6fc03fa4c35b71ecce080e8c96a64a8ee58a8a27 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 9 Jul 2026 19:45:11 +0530 Subject: [PATCH 01/30] =?UTF-8?q?docs(iios):=20notifications=20=E2=80=94?= =?UTF-8?q?=20API=20guide=20=C2=A75.11=20(endpoints=20+=20engine=20diagram?= =?UTF-8?q?),=20env,=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API guide §5.11 Notifications: vapid-public-key / subscribe / unsubscribe / thread mute endpoints, the focus_thread presence signal, the three gates, and the engine flow diagram; SDK reference (registerPush/muteThread/focus); VAPID env; tests 205. - DEPLOYMENT: VAPID env + the in-memory-presence → Redis (multi-replica) caveat. - CEO overview: counts (205 tests, 54 tables / 20 migrations) + notifications in the "P9 has begun" note. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/DEPLOYMENT.md | 4 +++ docs/IIOS_API_AND_SDK_GUIDE.md | 52 +++++++++++++++++++++++++++++++++- docs/IIOS_OVERVIEW_FOR_CEO.md | 6 ++-- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b71255c..22b2a37 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -54,6 +54,10 @@ for the full, commented list. Highlights: app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests. - **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change. +- **Notifications (Web Push):** set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` + to enable push (unset → the engine no-ops). ⚠️ Presence (the "are you viewing this thread?" + gate) is **in-memory / single-instance** today — with N>1 replicas, back `PresenceService` + with Redis so the projector on one replica sees focus from another. - **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*` (unauthenticated token minting, webhook injection, chaos, retention sweep). This is the single most important prod-hardening flag. diff --git a/docs/IIOS_API_AND_SDK_GUIDE.md b/docs/IIOS_API_AND_SDK_GUIDE.md index 59a47ad..1691791 100644 --- a/docs/IIOS_API_AND_SDK_GUIDE.md +++ b/docs/IIOS_API_AND_SDK_GUIDE.md @@ -243,6 +243,54 @@ sequenceDiagram --- +### 5.11 Notifications (push, presence-gated) + +The engine reacts to `message.sent` and pushes to **absent** recipients through a swappable +**NotificationPort** (Web Push/VAPID now; email/FCM later). The DB holds only push +subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays `IiosInboxItem`. + +| Method | Path | Body | Returns | +|---|---|---|---| +| GET | `/v1/notifications/vapid-public-key` | — | `{key}` — the public VAPID key the client needs to subscribe | +| POST | `/v1/notifications/subscribe` | `{kind?, endpoint, keys:{p256dh, auth}, userAgent?}` | `{ok:true}` — store/refresh the caller's push subscription | +| DELETE | `/v1/notifications/subscribe` | `{endpoint}` | `{ok:true}` | +| POST | `/v1/threads/:id/mute` · `/unmute` | — | `{threadId, muted}` — per-thread notification mute for the caller | + +**Presence (socket):** the app emits `focus_thread` `{threadId | null}` on the `/message` +namespace whenever the foreground conversation changes (or the tab blurs). This is how the +engine knows you're *viewing* a thread — **room membership ≠ viewing**, because the sidebar +joins every thread room for live updates. `GET /v1/threads` returns each thread's `muted`. + +**The three gates (fail-closed, in the notification policy — not the kernel):** +1. **Policy** — DM always; a group message only if you were `@`-mentioned **or** it's a reply to you. +2. **Presence** — skip if you're currently focused on that thread (you saw it live). +3. **Mute** — skip if you muted the thread. +A dead subscription (Web Push `404/410`) is pruned. DM-vs-group is read from the opaque +`membership` thread attribute here in the *notification policy*; the kernel never branches on it. + +```mermaid +flowchart TB + SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"] + PROJ --> LOOP{{"for each recipient (never the sender)"}} + LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"} + G1 -->|no| X1["skip"] + G1 -->|yes| G2{"PRESENCE: focused on this thread?"} + G2 -->|yes| X2["skip — seen live"] + G2 -->|no| G3{"MUTE: thread muted?"} + G3 -->|yes| X3["skip"] + G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"] + PORT --> WP["Web Push (VAPID)"] + WP -->|sent| OUT["→ browser push service → service worker → OS notification"] + WP -->|"gone (404/410)"| PRUNE["prune dead subscription"] +``` + +**Client (SDK layer, `lib/notifications.ts` → belongs in `@insignia/iios-kernel-client`):** +`registerPush()` (permission → register service worker → `PushManager.subscribe` with the +VAPID key → POST the subscription), `muteThread(threadId, muted)`, and `MessageSocket.focus(threadId)`. +A service worker renders the OS notification on `push` and deep-links to the thread on click. + +--- + ## 6. SDK reference Two layers: a low-level `RestClient` (+ socket) and per-domain React hook packages. @@ -256,6 +304,7 @@ Methods (all return typed promises): - **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})` - **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates. - **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.* +- **Notifications:** `registerPush()` (service worker + `PushManager.subscribe` + POST subscription), `muteThread(threadId, muted)`, `MessageSocket.focus(threadId)` (presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering. - **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})` - **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)` - **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)` @@ -333,7 +382,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/`) | `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) | | `smoke-tenant.mjs` | cross-tenant 403 + list isolation | -Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check: `pnpm boundary`. +Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`. --- @@ -358,6 +407,7 @@ Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check | `MEDIA_DIR` | `/iios-media` | local media storage dir (dev `StoragePort`) | | `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs | | `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs | +| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | — | Web Push (notifications). Unset → push disabled (engine no-ops). Generate once: `node -e "console.log(require('web-push').generateVAPIDKeys())"` | | `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` | | `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) | | `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit | diff --git a/docs/IIOS_OVERVIEW_FOR_CEO.md b/docs/IIOS_OVERVIEW_FOR_CEO.md index a114ac1..1fad5f2 100644 --- a/docs/IIOS_OVERVIEW_FOR_CEO.md +++ b/docs/IIOS_OVERVIEW_FOR_CEO.md @@ -159,9 +159,9 @@ Each product SDK is a handful of React hooks — a front-end dev wires the UI, t | Calendar/Zoom providers | Simulated sync | P9 | | Multi-tenant scale, retention, SLOs | Not yet | P9 | -**Proof it works:** 192 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture. +**Proof it works:** 205 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture. -**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, and media sharing** (images/video/audio/docs on a swappable storage port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off. +**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, media sharing** (images/video/audio/docs on a swappable storage port), and **presence-gated push notifications** (Web Push via a swappable notification port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off. --- @@ -177,4 +177,4 @@ Narrative arc for the CEO: **one engine → chat → inbox → support → chann --- -*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 53 Postgres tables across 19 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media). Boundary-enforced dependency law; 192 passing tests; 8+ end-to-end smoke scripts. First real-provider swap live: Supabase auth (JWKS-verified) + a media storage port.* +*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 54 Postgres tables across 20 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media → notifications). Boundary-enforced dependency law; 205 passing tests; 8+ end-to-end smoke scripts. First real-provider swaps live: Supabase auth (JWKS-verified), a media storage port, and Web Push notifications.* -- 2.52.0 From 58ebaf1982786ffd308a1f40526036234162e3ff Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 9 Jul 2026 19:48:10 +0530 Subject: [PATCH 02/30] =?UTF-8?q?docs:=20add=20CLAUDE.md=20=E2=80=94=20pro?= =?UTF-8?q?ject=20rules=20for=20future=20Claude=20Code=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures what IIOS is, the #1 generic-safety guardrail (no chat vocabulary in the kernel; meaning lives in OPA policy + opaque attributes + the app), the architecture (kernel + platform ports + fail-closed + outbox/projectors), tech stack, run/test commands (isolated iios_test DB, the replay.spec flake note), conventions (commit email, IIOS_DEV_TOKENS off in prod), and current build state. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..02559dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# IIOS — Claude Code Rules + +## What IIOS is + +**IIOS (Insignia Interaction OS)** is a **generic, multi-tenant "interaction OS"** — one +NestJS service (`@insignia/iios-service`) + a family of SDKs. Every interaction (a chat +message, a support ticket, a routed post, an AI suggestion, a meeting) is the **same kernel +object** inside a **tenant scope**, behind the **same fail-closed gates** (policy/consent), +emitting the **same audit trail**. Products (messaging, support, community, AI, meetings) are +thin **specializations** on top of the kernel — never the other way around. + +`chat-web` (separate repo) is the reference consumer app. + +## THE #1 LOCKED RULE — generic-safety (read this before touching the kernel) + +**The kernel must never hardcode chat/domain vocabulary.** No `'dm'` / `'group'` / +`'reaction'` / `'emoji'` / `'mention'` literals in kernel or messaging *logic*. If a reviewer +asked *"is this a chat backend now?"* the answer must stay **no**. + +Domain meaning lives in **three** places, never the kernel: +1. **OPA policy** (the policy plane — `DevOpaPort` now, real OPA later). The DM-cap, + group-admin, governed-join, media-limit, and notification-trigger rules live here. +2. **Opaque thread/interaction attributes** the kernel stores but never interprets: + `thread.metadata.membership` (`'dm'|'group'`), interaction annotations (opaque + `annotationType` + `value` → the app writes `reaction`/`pin`/`save`), `mentions[]` (an + opaque userId notify-list the kernel fans out; it never parses `@`). +3. **The app** (chat-web) — rendering + product semantics. + +Reading an opaque attribute inside a **policy/notification gate** (e.g. `membership === 'dm'` +in `DevOpaPort` or `notification.projector.ts`) is allowed — that file *is* the policy plane, +not the kernel. Everywhere else, keep it generic. Verify with a grep before committing: +`grep -rniE "'dm'|'group'|reaction|emoji" packages/iios-service/src | grep -v spec` — hits +should only be in the policy/notification/app-facing layers or comments. + +`pnpm boundary` enforces the layer dependency law (specializations import the kernel, never +the reverse). Run it; don't break it. + +## Architecture + +- **Kernel primitives** (generic): `IiosScope` (six-vector: org/app/tenant/bu/…), `IiosSourceHandle` + (externalId = userId; stays `UNVERIFIED` until MDM resolves → `canonicalEntityId`), `IiosActorRef`, + `IiosThread` (subject/metadata), `IiosThreadParticipant`, `IiosInteraction` (+ `parentInteractionId` + reply link), `IiosMessagePart` (media as `contentRef`), `IiosInteractionAnnotation` (generic). +- **Platform ports** (`IiosPlatformPorts`, DI token `PLATFORM_PORTS`; dev = `LocalDevPorts`): + session, opa, cmp (consent), mdm, sas, capability — plus a `StoragePort` (media) and + `NotificationPort` (push). **Dev stubs → real adapters with zero consumer changes.** Every + op passes `decideOrThrow(ports, {action,…})` **fail-closed**. +- **Session (auth):** `SessionVerifier` verifies (a) real OIDC tokens (Supabase/`AUTH_ISSUERS`) + against the issuer JWKS (ES256, no secret), routed by `iss` → per-issuer `appId` scope; or + (b) legacy dev HS256 app tokens (`APP_SECRETS`, keyed by `appId`). `userId = email` for OIDC. +- **Events:** transactional outbox → `OutboxBus` → **projectors** (inbox, notifications). + Projectors are **idempotent** (`claim()` on `IiosProcessedEvent` + projection cursor). + Delivery is at-least-once → clients dedupe by message `id`. +- **Scope isolation:** every row is tagged by `scopeId` (org+app+tenant). By-id ops call + `assertOwns` (tenant fence → 403). Always `select`/scope Prisma queries. + +## Tech stack + +NestJS 11 · Prisma 6 / PostgreSQL 16 (docker `iios-db` on **:5434**, db `iios`) · Redis +(socket.io adapter, multi-replica) · socket.io (`/message` namespace) · Vitest · pnpm +monorepo (`packages/*`). `iios-service` is a **modular monolith** (HTTP + WS + relay + +projectors in one process). + +## Running & testing + +```bash +docker start iios-db # Postgres :5434 (OrbStack; `open -a OrbStack` if down) +pnpm --filter @insignia/iios-service exec nest build +# run (dev auth via Supabase; media + notifications enabled): +SUPABASE_URL=https://.supabase.co REDIS_URL=redis://localhost:6379 PORT=3200 \ + APP_SECRETS='{"portal-demo":"dev-secret"}' MEDIA_DIR=/tmp/iios-media \ + VAPID_PUBLIC_KEY=… VAPID_PRIVATE_KEY=… VAPID_SUBJECT=mailto:dev@insignia \ + node packages/iios-service/dist/main.js # → :3200 ; GET /health +``` + +- **`pnpm test`** — Vitest. Runs against an **isolated `iios_test` DB** (globalSetup creates + + migrates it; `DATABASE_URL` overridden). **It never wipes the dev `iios` DB.** ~205 tests. +- **TDD**: write the failing spec first (see `*.spec.ts` next to the code). DB specs use + `resetDb()` + real Postgres. +- **Flaky `outbox/replay.spec`**: it's clock/ordering-sensitive and pre-existing. If it fails + in a full run, `docker exec iios-db psql -U iios -d postgres -c "DROP DATABASE IF EXISTS iios_test WITH (FORCE)"` + then re-run — it's stale test-DB state, not a regression. +- **`pnpm boundary`** — import-boundary check (must stay OK). +- Smokes: `packages/iios-service/scripts/smoke-*.mjs` (run against a live service). + +## Conventions + +- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email + **`maaz@insigniaconsultancy.com`**, and co-author every commit with Claude. Branch off `main` + before committing if asked; otherwise the session has committed directly to `main`. +- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token + minting). Single most important prod flag. +- New kernel capability = a **generic primitive** only (see the #1 rule). Add domain meaning in + policy + app. +- Prisma: always `select` to avoid leaking `passwordHash`/PII; org/tenant scope every `where`. + +## What's built (state) + +P0–P8: kernel → messaging → inbox → support → adapters → routing → AI → calendar/meetings. +Recent (the "P9 real-providers" era, mostly driven by the chat app): +- **Real Supabase auth** — multi-issuer JWKS verification (`SessionVerifier`); first stubbed + port turned real. +- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive + (opaque type/value), `annotate` socket event, `GET /v1/threads/my-annotations`. +- **@mentions → Inbox** — `mentions[]` on send → `MENTION` inbox item (projector). +- **Media** — `StoragePort` (dev local disk → prod S3/Supabase), presigned upload/download, + attachment on `MessageDto`. +- **Notifications** — presence-gated Web Push: `NotificationProjector` (policy/presence/mute + gates) + swappable `NotificationPort`, `focus_thread` presence signal, per-thread mute. +- `senderId` (stable externalId) on messages for reliable "is this mine?". + +## Docs (as-built) + +- `docs/IIOS_API_AND_SDK_GUIDE.md` — the **as-built REST/socket/SDK reference** (endpoints, + shapes, env, vocab). Keep it current when adding endpoints. +- `docs/DEPLOYMENT.md` — deploy/topology/env/scaling. +- `docs/IIOS_OVERVIEW_FOR_CEO.md` — plain-language capability tour. -- 2.52.0 From 5abee1b5b7d1f52d62ca7056d4060b41d51c99b4 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 9 Jul 2026 19:55:38 +0530 Subject: [PATCH 03/30] docs: add repo-local project memory under .claude/memory/ Versioned, per-topic project memory (index + entries): IIOS overview, the generic-safety rule, run/test + the replay.spec flake workaround, recent features (Supabase auth, reactions/pins/saves, mentions, media, notifications), the chat-web consumer, and workflow conventions. CLAUDE.md points to it as the accumulating-notes companion to the canonical rules. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/memory/MEMORY.md | 12 +++++++ .claude/memory/feedback_generic_safety.md | 28 +++++++++++++++++ .claude/memory/feedback_workflow.md | 19 ++++++++++++ .claude/memory/project_iios_overview.md | 23 ++++++++++++++ .claude/memory/project_recent_features.md | 31 +++++++++++++++++++ .claude/memory/reference_chat_web_consumer.md | 20 ++++++++++++ .claude/memory/reference_run_and_test.md | 30 ++++++++++++++++++ CLAUDE.md | 5 +++ 8 files changed, 168 insertions(+) create mode 100644 .claude/memory/MEMORY.md create mode 100644 .claude/memory/feedback_generic_safety.md create mode 100644 .claude/memory/feedback_workflow.md create mode 100644 .claude/memory/project_iios_overview.md create mode 100644 .claude/memory/project_recent_features.md create mode 100644 .claude/memory/reference_chat_web_consumer.md create mode 100644 .claude/memory/reference_run_and_test.md diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..ae82af3 --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -0,0 +1,12 @@ +# IIOS Project Memory — Index + +Repo-local, versioned project memory. The full canonical rules are in the repo root +**`CLAUDE.md`**; these files are granular, per-topic memories that accumulate across sessions. +Read the relevant ones before working in that area; add/update files as the project evolves. + +- [project_iios_overview.md](project_iios_overview.md) — what IIOS is (generic interaction OS; kernel + specializations + platform ports) +- [feedback_generic_safety.md](feedback_generic_safety.md) — THE #1 rule: no chat/domain vocabulary in the kernel +- [reference_run_and_test.md](reference_run_and_test.md) — run command, isolated `iios_test` DB, and the `replay.spec` flake workaround +- [project_recent_features.md](project_recent_features.md) — the "real-providers" era: Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications +- [reference_chat_web_consumer.md](reference_chat_web_consumer.md) — chat-web is the reference app driving IIOS feature work +- [feedback_workflow.md](feedback_workflow.md) — commits (email + co-author), IIOS_DEV_TOKENS off in prod, TDD, build-before-restart diff --git a/.claude/memory/feedback_generic_safety.md b/.claude/memory/feedback_generic_safety.md new file mode 100644 index 0000000..e0f0662 --- /dev/null +++ b/.claude/memory/feedback_generic_safety.md @@ -0,0 +1,28 @@ +--- +name: feedback_generic_safety +description: THE #1 locked rule — the kernel must never hardcode chat/domain vocabulary +metadata: + type: feedback +--- + +**The kernel must stay generic.** No `'dm'` / `'group'` / `'reaction'` / `'emoji'` / `'mention'` +literals in kernel or messaging *logic*. If a reviewer asked "is this a chat backend now?" the +answer must stay **no**. + +**Why:** IIOS is a generic interaction OS; chat is one consumer. Baking chat meaning into the +kernel destroys reuse (support/community/meetings share the same core). + +**How to apply:** domain meaning lives in exactly three places, never the kernel — +1. **OPA policy** (`DevOpaPort` → real OPA): DM-cap, group-admin, governed-join, media limits, + notification triggers. +2. **Opaque attributes** the kernel stores but never interprets: `thread.metadata.membership` + (`dm`/`group`), interaction annotations (opaque `annotationType`+`value` → app writes + `reaction`/`pin`/`save`), `mentions[]` (opaque userId notify-list; kernel never parses `@`). +3. **The app** (chat-web): rendering + product semantics. + +Reading an opaque attr inside a **policy/notification gate** (`membership === 'dm'` in +`DevOpaPort` or `notification.projector.ts`) is OK — that file IS the policy plane. Everywhere +else, keep generic. Verify before committing: +`grep -rniE "'dm'|'group'|reaction|emoji" packages/iios-service/src | grep -v spec` — hits only +in policy/notification/app-facing layers or comments. Also run `pnpm boundary`. +Related: [[project_iios_overview]]. diff --git a/.claude/memory/feedback_workflow.md b/.claude/memory/feedback_workflow.md new file mode 100644 index 0000000..603c938 --- /dev/null +++ b/.claude/memory/feedback_workflow.md @@ -0,0 +1,19 @@ +--- +name: feedback_workflow +description: Working conventions — commits, prod flags, TDD, rebuild-before-restart +metadata: + type: feedback +--- + +- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email + **`maaz@insigniaconsultancy.com`**, co-author every commit with Claude. The user commits + directly to `main` in this project (fine); branch only if asked. +- **⚠ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token + minting, chaos, retention sweep). The single most important prod-hardening flag. +- **TDD** — write the failing spec first; verify it fails; implement; verify it passes. +- **Rebuild before restart** — after backend changes, `nest build` then restart from `dist`; + `lsof -ti :3200 | xargs kill -9` first or the old build keeps serving (stale-dist bites). +- **New kernel capability = a generic primitive only** — add domain meaning in OPA policy + the + app, never the kernel. See [[feedback_generic_safety]]. +- The user prefers **direct, fast iteration** (implement → verify end-to-end → commit), not + heavyweight multi-agent/spec ceremony. Include "how to test" in summaries; report failures honestly. diff --git a/.claude/memory/project_iios_overview.md b/.claude/memory/project_iios_overview.md new file mode 100644 index 0000000..1ff1020 --- /dev/null +++ b/.claude/memory/project_iios_overview.md @@ -0,0 +1,23 @@ +--- +name: project_iios_overview +description: What IIOS is — a generic multi-tenant interaction OS; kernel + specializations + swappable platform ports +metadata: + type: project +--- + +IIOS (Insignia Interaction OS) = one NestJS service (`@insignia/iios-service`) + SDKs, in a +pnpm monorepo (`packages/*`). Every interaction (chat message, ticket, routed post, AI +suggestion, meeting) is the **same kernel object** inside a **tenant scope** (`IiosScope`: +org/app/tenant/…), behind the **same fail-closed gates**, emitting the **same audit trail**. + +Products (messaging, inbox, support, routing, AI, calendar, media, notifications) are thin +**specializations** on top of a tiny kernel — never the reverse (`pnpm boundary` enforces it). + +Platform seams are **ports** (`IiosPlatformPorts`, DI token `PLATFORM_PORTS`; dev = permissive +`LocalDevPorts`): session, opa, cmp (consent), mdm, sas, capability, plus `StoragePort` (media) +and `NotificationPort` (push). **Dev stubs swap to real adapters with zero consumer changes.** +Every op passes `decideOrThrow(ports, {action,…})` fail-closed. Events go through a +transactional outbox → `OutboxBus` → idempotent projectors (inbox, notifications). + +See the repo `CLAUDE.md` and `docs/IIOS_API_AND_SDK_GUIDE.md` (as-built reference) for detail. +Related: [[feedback_generic_safety]]. diff --git a/.claude/memory/project_recent_features.md b/.claude/memory/project_recent_features.md new file mode 100644 index 0000000..bb04db5 --- /dev/null +++ b/.claude/memory/project_recent_features.md @@ -0,0 +1,31 @@ +--- +name: project_recent_features +description: The "real-providers" era — Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications +metadata: + type: project +--- + +Beyond P0–P8 (kernel → messaging → inbox → support → adapters → routing → AI → +calendar/meetings), recent work (mostly driven by the chat app, the start of P9 "real +providers"): + +- **Real Supabase auth** — `SessionVerifier` verifies real OIDC tokens against issuer JWKS + (ES256, no secret), a **multi-issuer registry** routed by the `iss` claim → per-issuer `appId` + scope (`AUTH_ISSUERS` JSON, or `SUPABASE_URL` single-issuer shorthand). `userId = email`. + Legacy HS256 app-token path (`APP_SECRETS`) stays for dev/tests. +- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive (opaque + `annotationType`+`value`); socket `annotate` event → `annotation` broadcast; + `GET /v1/threads/my-annotations?type=save`. +- **@mentions → Inbox** — `send(... mentions[])` (opaque userId list) → `InboxProjector` fans out + a `MENTION` inbox item to mentioned participants; reading resolves it. +- **Media** — `StoragePort` (dev = local disk `MEDIA_DIR`; prod swap to S3/Supabase), presigned + upload/download (signed HS256 tokens, OPA-gated size/type, tenant-fenced), `attachment` on + `MessageDto`; parts use generic `MEDIA_REF/VOICE_REF/FILE_REF`. +- **Notifications** — presence-gated Web Push: `NotificationProjector` runs 3 gates + (policy=DM/mention/reply-to-you · presence=`focus_thread` signal · per-thread `muted`) → + swappable `NotificationPort` (Web Push/VAPID); dead sub (410) pruned. Presence is in-memory + (single-instance) → Redis for multi-replica. +- `senderId` (stable externalId) on `MessageDto` for reliable "is this mine?". + +~205 tests. Keep `docs/IIOS_API_AND_SDK_GUIDE.md` current when adding endpoints. +Related: [[reference_chat_web_consumer]], [[feedback_generic_safety]]. diff --git a/.claude/memory/reference_chat_web_consumer.md b/.claude/memory/reference_chat_web_consumer.md new file mode 100644 index 0000000..687c55b --- /dev/null +++ b/.claude/memory/reference_chat_web_consumer.md @@ -0,0 +1,20 @@ +--- +name: reference_chat_web_consumer +description: chat-web is the reference consumer app that drives IIOS feature work +metadata: + type: reference +--- + +**chat-web** (separate repo, `~/Documents/insignia-work/chat-web`) is the reference app on IIOS — +a 1:1 + group chat UI (Vite + React + TanStack Router/Query + socket.io-client + supabase-js). +It's frontend-only; IIOS provides identity, threads, messages, realtime, reactions, mentions, +media, notifications. + +Feature work usually spans **both repos**: a generic primitive/port in iios + the app UI in +chat-web. The layer split we follow: **service** owns storage/auth/governance, the **SDK layer** +(`chat-web/src/lib/*`, mirrors `@insignia/iios-kernel-client`) owns client plumbing +(e.g. `uploadMedia`/`mediaUrl`, `registerPush`), the **app** owns rendering. + +chat-web uses real Supabase login (`VITE_SUPABASE_URL` + anon key in its `.env`); identity = +email. Run it with `pnpm dev`. Commit both repos with git email `maaz@insigniaconsultancy.com`. +Related: [[project_recent_features]]. diff --git a/.claude/memory/reference_run_and_test.md b/.claude/memory/reference_run_and_test.md new file mode 100644 index 0000000..3285aee --- /dev/null +++ b/.claude/memory/reference_run_and_test.md @@ -0,0 +1,30 @@ +--- +name: reference_run_and_test +description: How to run the service + the test-DB isolation and the replay.spec flake workaround +metadata: + type: reference +--- + +**Infra:** Postgres in docker `iios-db` on **:5434** (db `iios`), Redis on :6379. If Docker is +down: `open -a OrbStack` then `docker start iios-db`. + +**Run** (dev auth via Supabase; media + push enabled): +``` +pnpm --filter @insignia/iios-service exec nest build # rebuild after backend changes +SUPABASE_URL=https://.supabase.co REDIS_URL=redis://localhost:6379 PORT=3200 \ + APP_SECRETS='{"portal-demo":"dev-secret"}' MEDIA_DIR=/tmp/iios-media \ + VAPID_PUBLIC_KEY=… VAPID_PRIVATE_KEY=… VAPID_SUBJECT=mailto:dev@insignia \ + node packages/iios-service/dist/main.js # :3200 ; GET /health +``` +Restarting: `lsof -ti :3200 | xargs kill -9` first (stale instance → EADDRINUSE / old build served). + +**Tests:** `pnpm test` (Vitest) runs against an **isolated `iios_test` DB** (globalSetup creates ++ migrates it; `DATABASE_URL` overridden) — it **never wipes the dev `iios` DB**. TDD: spec next +to code; DB specs use `resetDb()`. + +**⚠ Known flake — `outbox/replay.spec`:** clock/ordering-sensitive, pre-existing. If it fails in a +full run, it's stale `iios_test` state, NOT a regression. Fix: +`docker exec iios-db psql -U iios -d postgres -c "DROP DATABASE IF EXISTS iios_test WITH (FORCE)"` +then re-run. Prove it's not yours by stashing changes and re-running. + +`pnpm boundary` = import-boundary check (must stay OK). Smokes: `packages/iios-service/scripts/smoke-*.mjs`. diff --git a/CLAUDE.md b/CLAUDE.md index 02559dc..b1f0e5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,10 @@ # IIOS — Claude Code Rules +> **Project memory:** granular, versioned per-topic notes live in **`.claude/memory/`** — read +> `.claude/memory/MEMORY.md` (the index) and the relevant entries before working in an area, and +> add/update memories as the project evolves. This file is the canonical rules; those are the +> accumulating notes. + ## What IIOS is **IIOS (Insignia Interaction OS)** is a **generic, multi-tenant "interaction OS"** — one -- 2.52.0 From 64c498a97a428558acd57f0dfb107f9fa1e78ea3 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Fri, 10 Jul 2026 17:13:55 +0530 Subject: [PATCH 04/30] build: publish @insignia/iios-* SDKs to the Gitea package registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .npmrc routes the @insignia scope to https://git.lynkedup.cloud/api/packages/insignia/npm/ (auth via ${GITEA_TOKEN} env — no secret committed). - The 9 frontend SDK packages (contracts, kernel-client, adapter-sdk, *-web) are now publishable: private dropped, version 0.1.0, publishConfig pinned to Gitea. iios-service and iios-testkit stay private (pnpm publish skips them). - Root `release` / `release:dry` scripts; a Gitea Actions workflow publishes on a v* tag. - PUBLISHING.md documents publish + consumer (.npmrc) setup. Verified: dry-run packs cleanly and workspace:* deps resolve to 0.1.0 in the tarball. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/publish-sdks.yml | 27 ++++++ .npmrc | 6 ++ PUBLISHING.md | 53 +++++++++++ docs/insignia-platform-live-state.md | 113 +++++++++++++++++++++++ package.json | 4 +- packages/iios-adapter-sdk/package.json | 10 +- packages/iios-ai-web/package.json | 17 +++- packages/iios-community-web/package.json | 17 +++- packages/iios-contracts/package.json | 10 +- packages/iios-inbox-web/package.json | 17 +++- packages/iios-kernel-client/package.json | 17 +++- packages/iios-meeting-web/package.json | 17 +++- packages/iios-message-web/package.json | 17 +++- packages/iios-support-web/package.json | 17 +++- 14 files changed, 307 insertions(+), 35 deletions(-) create mode 100644 .gitea/workflows/publish-sdks.yml create mode 100644 .npmrc create mode 100644 PUBLISHING.md create mode 100644 docs/insignia-platform-live-state.md diff --git a/.gitea/workflows/publish-sdks.yml b/.gitea/workflows/publish-sdks.yml new file mode 100644 index 0000000..8956bd4 --- /dev/null +++ b/.gitea/workflows/publish-sdks.yml @@ -0,0 +1,27 @@ +name: publish-sdks + +# Publish the @insignia/iios-* SDK packages to the Gitea npm registry on a version tag. +# Requires: Gitea Actions enabled + a runner, and a repo secret GITEA_PUBLISH_TOKEN +# (a token with `write:package` scope for the `insignia` org). +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: pnpm install --frozen-lockfile + - run: pnpm -r build + - run: pnpm -r publish --no-git-checks + env: + # maps to ${GITEA_TOKEN} in .npmrc; private packages (service, testkit) are skipped + GITEA_TOKEN: ${{ secrets.GITEA_PUBLISH_TOKEN }} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..83c8b52 --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +# @insignia SDK packages publish to / install from the Gitea package registry. +# Auth comes from the GITEA_TOKEN env var (never commit the token itself). +# publish → token needs the `write:package` scope +# install → token needs `read:package` +@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/ +//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN} diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..17ab5a1 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,53 @@ +# Publishing the IIOS SDKs (Gitea package registry) + +The `@insignia/iios-*` **frontend SDKs** publish to our self-hosted Gitea npm registry: + +``` +https://git.lynkedup.cloud/api/packages/insignia/npm/ +``` + +**Published (public in the registry):** `iios-contracts`, `iios-kernel-client`, `iios-adapter-sdk`, +and the React hook packages `iios-message-web`, `iios-inbox-web`, `iios-support-web`, +`iios-ai-web`, `iios-community-web`, `iios-meeting-web`. +**Kept private (never published):** `iios-service` (the deployed backend) and `iios-testkit` (dev fakes). + +> be-crm does **not** consume these — it talks to IIOS over REST. The SDKs are a **frontend** concern +> (chat-web, the CRM support UI, mobile). + +## One-time: get a token +Gitea → **Settings → Applications → Generate Token**: +- to **publish**: scope `write:package` +- to **install** (private packages): scope `read:package` + +Export it (never commit it): +```bash +export GITEA_TOKEN= +``` +The repo `.npmrc` already routes the `@insignia` scope to Gitea and reads `${GITEA_TOKEN}`. + +## Publish +```bash +pnpm release:dry # build all + pack (no upload) — verify the 9 packages pack cleanly +pnpm release # build all + publish the non-private packages to Gitea +``` +`pnpm -r publish` automatically **skips** `private` packages, so only the 9 SDKs go out. +Versions are **immutable** — bump before re-publishing (edit `version`, or adopt `changesets`). + +Or push a tag and let CI do it (see `.gitea/workflows/publish-sdks.yml`; needs Gitea Actions + +a runner + the `GITEA_PUBLISH_TOKEN` secret): +```bash +git tag v0.1.0 && git push origin v0.1.0 +``` + +## Consume (in chat-web / the CRM front-end) +Add an `.npmrc` to the consuming repo: +```ini +@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/ +//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN} +``` +Then: +```bash +export GITEA_TOKEN= +pnpm add @insignia/iios-kernel-client @insignia/iios-contracts +``` +This replaces the **vendored** client that chat-web copies today — one source of truth for all frontends. diff --git a/docs/insignia-platform-live-state.md b/docs/insignia-platform-live-state.md new file mode 100644 index 0000000..32f0295 --- /dev/null +++ b/docs/insignia-platform-live-state.md @@ -0,0 +1,113 @@ +# Insignia Platform — Live State (from the mesh-verify probe, 2026-07-10) + +> Distilled from the authenticated `mesh-verify.lynkedup.cloud` dashboard (`/api/results` + +> `/api/journey`). This is the **real** platform IIOS is meant to plug into — service inventory, +> the identity/session/governance flow, and the exact contracts to wire IIOS's platform ports. +> Tokens redacted (the raw JSON dumps contain live SAT/PAT/refresh tokens — do not commit them). + +## Cluster / mesh +- **Cluster:** `lynkedup-tech` (NYC2 / DigitalOcean). **Istio** (istio-envoy) + **SPIFFE/SPIRE**, + trust domain **`spiffe://insignia.tech`** (SVIDs like `spiffe://insignia.tech/ns/sre/sa/default`, + `.../sa/realmdm-sas`). mTLS **PERMISSIVE**. Reached by ClusterIP DNS. +- **Namespaces:** `sre` (MDM, OPA, misc), `cmp` (consent platform), `insignia` (identity/session/ + app-facing services), `istio-system`. +- Public edges: `*.lynkedup.cloud` (behind oauth2-proxy → Keycloak). + +## The identity/session/governance flow (7 steps — the doctrine) +> **Separate authorities:** Session Broker *authenticates*, OPA *authorizes*, CMP decides *purpose*, +> RealMDM *resolves identity*. Purpose-proof ≠ authorization. The PAT carries the external `sub` +> only as a **SHA-256 hash**, never raw; `canonical_person_id` is null until MDM VERIFIES (MDM never +> blocks login). + +1. **Anonymous consent (CMP Edge)** — browser CMP SDK → `POST /edge/v1/cache-policy` → EdDSA-signed + cache-category manifest; a ConsentReceipt goes to CMP over gRPC. Purpose proof only. +2. **External login (Supabase)** → **SAT** (ES256 JWT). Claims: `iss` (project `/auth/v1`), opaque + UUID `sub`, `aud=authenticated`, `email`, `user_metadata` (full_name, avatar_url), `aal`, `amr`. + Proves the *session*, not the person/permission. Verified via Supabase **JWKS** (kid-selected). +3. **Session Broker exchange SAT → PAT** — `POST /v1/sessions/exchange` (Bearer SAT + + `X-Client-Authorization` = BFF Keycloak client-creds, aud=session-broker). Broker verifies the + SAT, calls the MDM bridge, resolves scope from **memberships**, mints the **PAT** (~5 min). +3b. **Workload identity (SPIFFE/mTLS)** — each meshed pod gets an X.509-SVID; OPA receives **both** + the user (`principal`) and the caller (`caller.spiffe_id`) — different layers, never merged. +4. **MDM Auth-Subject Bridge** — `POST /v1/auth-subjects/resolve {issuer, subject}` → `{platform_ + principal_id, canonical_person_id (null until VERIFIED), link_state (PENDING/VERIFIED), + link_version, match_method}`. Keyed on **iss+sub** (never email). Idempotent. +5. **OPA decision** — `POST /v1/decisions` → `{decision_id, allow, reason_codes, obligations, + policy_version}`. Obligations = masks / row-filters / denied fields / audit level / ttl. A + decision, not a 50-line entitlement JWT — the PEP MUST enforce every obligation. +6. **AppShell assembles the ACE** — combines PAT + OPA obligations + CMP consent into an App + Context Envelope (HttpOnly cookie): `capabilities[]` (policy-derived), `ui_obligations` + (hide/mask/step_up), `consent`. **No tokens, no raw PII in the browser.** +7. **CRM renders** — applies OPA `row_filter` (SQL WHERE) + `mask_fields` + `deny_fields` + server-side; rows reference `canonical_person_id`, not email. + +## Service inventory + real endpoints +**Identity / session (`insignia` ns):** +- **Session Broker** `session-broker.insignia:80` — `POST /v1/sessions/exchange` (SAT→PAT). +- **Memberships** `memberships.insignia` (public `insignia-memberships.lynkedup.cloud`) — + `POST /v1/internal/resolve`, `GET /v1/memberships` (Bearer PAT) → scope tuple + `allowed[]`. +- **AppShell BFF** `appshell-bff.insignia:80` (public `insignia-appshell.lynkedup.cloud`) — + `GET /apps/crm-web/bootstrap` (Bearer PAT) → ACE. +- **Profile** `profile.insignia:80` — `GET /v1/profile`, `GET /v1/stats`. Backed by **sqlite3** + (`/data/profiles.db`, PVC `insignia-profile-data`, WAL, persistent, single-replica RWO). +- **CRM** `crm.insignia:80` — `GET /v1/leads?view=list` (applies obligations). +- **Policy Gateway** `policy-gateway.insignia:80` — `POST /v1/decisions`, `POST /v1/decisions/batch`. + +**MDM (`sre` ns):** `mdm-kernel.sre:80` (`/v1/auth-subjects/resolve`, `/healthz`, `/readyz`; auth = +Keycloak service token **aud=realmdm**) · `mdm-ai.sre:80` · `mdm-sas.sre:9090` (**gRPC** — tokenization/SAS). + +**OPA / policy (`sre` ns):** `opa.sre:8181` (`/health`, `GET /v1/data` = live policy tree; +default-deny) · `realmdm-opa.sre:8181` · `opal-server.sre:7002` (OPAL policy distribution). + +**CMP (`cmp` ns), backed by Postgres + NATS + Redis:** `cmp-core:8080` (real `CheckConsent` gRPC; +service token aud=cmp) · `cmp-admin:8086` · `cmp-evidence:8081` · `cmp-sync:8085` · +`cmp-tollgate:8082` (NATS JetStream gating) · `cmp-media:8083` · `cmp-worker:8084`. Plus +`insignia-consent-edge.insignia` (`POST /edge/v1/cache-policy`) and `insignia-consent-adapter.insignia` +(`POST /v1/consent/evaluate {purpose}` → `{permitted, legal_basis, state, consent_epoch}`). + +**Other (`sre` ns):** `poi-api:5000` · `roof:8002` (YOLO segmentation) · `commit:8081` · +`presign:8080` · `egs:8090` · `artifact-retrieval:8082` (was 503 on probe day) · `cmp-docs`. + +## The two contracts IIOS must match + +### PAT (Platform Access Token) — what IIOS should verify +`iss = https://identity.insignia.internal` · **ES256** (EC P-256), verify via the broker **JWKS** +(`.../.well-known/jwks.json`, kid-selected — **no shared secret**) · `aud` includes the app (e.g. +`crm-web`) · `lifetime ~300s`. Claims: +``` +sub = platform_principal_id app_id tenant_id org_id bu_id +region environment role aal amr auth_source +external_subject_hash = sha256:… (NOT the raw external sub) +canonical_person_id (null until VERIFIED) +session_epoch · policy_epoch · consent_epoch (stale-detection) +sid (platform_session_id) · typ = platform-access+jwt +``` + +### OPA decision — `POST http://policy-gateway.insignia.svc.cluster.local/v1/decisions` +Request `{ input: { principal{platform_principal_id, canonical_person_id, auth_source, aal, roles, +memberships[]}, caller{spiffe_id}, resource{type, id, tenant_id, classification[]}, action, +context{purpose, device_trust, consent_receipt_ids[], network_zone, time} } }` +Response `{ decision_id, allow, reason_codes[], obligations{ row_filter, allow_fields[], mask_fields{}, +deny_fields[], audit, decision_ttl_seconds }, policy_version }`. + +## What this means for wiring IIOS's ports +1. **Auth — verify the PAT, not the Supabase SAT.** IIOS today verifies the Supabase SAT directly + (a dev shortcut). In the real platform the **Session Broker** does SAT→PAT; a platform workload + verifies the **PAT**. The PAT already carries the full scope tuple + `platform_principal_id`, so + `MessagePrincipal` maps ~1:1: `userId = platform_principal_id` (→ `canonical_person_id` once + VERIFIED), `appId = app_id`, `orgId = org_id`, `tenantId = tenant_id`, `+ buId`. Wire it by adding + the broker as an `AUTH_ISSUERS` entry (iss `https://identity.insignia.internal`, ES256, its JWKS). +2. **OPA — point `OpaPort` at the Policy Gateway** (`POST /v1/decisions`). Build `{principal, caller. + spiffe_id, resource, action, context}` from the PAT + the op; `decideOrThrow` maps `allow` → proceed + and **must enforce the obligations** (masks/row-filter/deny). The gateway's input is richer than + IIOS's current `{action,…}` — that's the adapter's job to assemble. +3. **MDM — usually don't call it.** The PAT already carries `platform_principal_id`/`canonical_person_id` + (the broker resolved at login). Only call `/v1/auth-subjects/resolve` if IIOS is the identity-exchange + edge (it isn't — the Broker is). +4. **CMP — consent gate** via `insignia-consent-adapter /v1/consent/evaluate {purpose}` when processing + content for AI/analytics/marketing. +5. **SAS — tokenization/masking** is `mdm-sas` (gRPC) — the "tokenize sensitive parts before storage" + requirement. + +*Source: two API payloads captured 2026-07-10 (`result.json` = probes, `journey.json` = the CRM +first-vertical-slice journey). Re-capture from the authenticated dashboard to refresh.* diff --git a/package.json b/package.json index 0ae9bfd..cc492e1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "pnpm -r build", "typecheck": "pnpm -r typecheck", "test": "vitest run", - "boundary": "node scripts/check-import-boundary.mjs" + "boundary": "node scripts/check-import-boundary.mjs", + "release:dry": "pnpm -r build && pnpm -r publish --dry-run --no-git-checks", + "release": "pnpm -r build && pnpm -r publish --no-git-checks" }, "devDependencies": { "@types/node": "^26.0.1", diff --git a/packages/iios-adapter-sdk/package.json b/packages/iios-adapter-sdk/package.json index ba4ff3e..760da27 100644 --- a/packages/iios-adapter-sdk/package.json +++ b/packages/iios-adapter-sdk/package.json @@ -1,15 +1,19 @@ { "name": "@insignia/iios-adapter-sdk", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "main": "dist/index.js", "types": "dist/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { "@insignia/iios-contracts": "workspace:*" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } diff --git a/packages/iios-ai-web/package.json b/packages/iios-ai-web/package.json index 5f03714..5f1af7a 100644 --- a/packages/iios-ai-web/package.json +++ b/packages/iios-ai-web/package.json @@ -1,13 +1,19 @@ { "name": "@insignia/iios-ai-web", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsup", "typecheck": "tsc --noEmit" @@ -23,5 +29,8 @@ "react": "^19.0.0", "tsup": "^8.3.5", "typescript": "^5.7.3" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } diff --git a/packages/iios-community-web/package.json b/packages/iios-community-web/package.json index d336921..e20903d 100644 --- a/packages/iios-community-web/package.json +++ b/packages/iios-community-web/package.json @@ -1,13 +1,19 @@ { "name": "@insignia/iios-community-web", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsup", "typecheck": "tsc --noEmit" @@ -23,5 +29,8 @@ "react": "^19.0.0", "tsup": "^8.3.5", "typescript": "^5.7.3" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } diff --git a/packages/iios-contracts/package.json b/packages/iios-contracts/package.json index 2286f9f..fba7c80 100644 --- a/packages/iios-contracts/package.json +++ b/packages/iios-contracts/package.json @@ -1,12 +1,16 @@ { "name": "@insignia/iios-contracts", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "main": "dist/index.js", "types": "dist/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } diff --git a/packages/iios-inbox-web/package.json b/packages/iios-inbox-web/package.json index 81d1c57..92aac0e 100644 --- a/packages/iios-inbox-web/package.json +++ b/packages/iios-inbox-web/package.json @@ -1,13 +1,19 @@ { "name": "@insignia/iios-inbox-web", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsup", "typecheck": "tsc --noEmit" @@ -23,5 +29,8 @@ "react": "^19.0.0", "tsup": "^8.3.5", "typescript": "^5.7.3" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } diff --git a/packages/iios-kernel-client/package.json b/packages/iios-kernel-client/package.json index c2504da..28ec6d9 100644 --- a/packages/iios-kernel-client/package.json +++ b/packages/iios-kernel-client/package.json @@ -1,13 +1,19 @@ { "name": "@insignia/iios-kernel-client", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsup", "typecheck": "tsc --noEmit" @@ -19,5 +25,8 @@ "devDependencies": { "tsup": "^8.3.5", "typescript": "^5.7.3" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } diff --git a/packages/iios-meeting-web/package.json b/packages/iios-meeting-web/package.json index 368c160..202630f 100644 --- a/packages/iios-meeting-web/package.json +++ b/packages/iios-meeting-web/package.json @@ -1,13 +1,19 @@ { "name": "@insignia/iios-meeting-web", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsup", "typecheck": "tsc --noEmit" @@ -23,5 +29,8 @@ "react": "^19.0.0", "tsup": "^8.3.5", "typescript": "^5.7.3" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } diff --git a/packages/iios-message-web/package.json b/packages/iios-message-web/package.json index a8da0a8..371d92a 100644 --- a/packages/iios-message-web/package.json +++ b/packages/iios-message-web/package.json @@ -1,13 +1,19 @@ { "name": "@insignia/iios-message-web", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsup", "typecheck": "tsc --noEmit" @@ -23,5 +29,8 @@ "react": "^19.0.0", "tsup": "^8.3.5", "typescript": "^5.7.3" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } diff --git a/packages/iios-support-web/package.json b/packages/iios-support-web/package.json index d54cadd..1a70b0e 100644 --- a/packages/iios-support-web/package.json +++ b/packages/iios-support-web/package.json @@ -1,13 +1,19 @@ { "name": "@insignia/iios-support-web", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsup", "typecheck": "tsc --noEmit" @@ -24,5 +30,8 @@ "react": "^19.0.0", "tsup": "^8.3.5", "typescript": "^5.7.3" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } -- 2.52.0 From 8c70b6d31fc310af6140499fbc3f6dd40a2566c4 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Fri, 10 Jul 2026 17:49:25 +0530 Subject: [PATCH 05/30] feat(iios-kernel-client): reconcile to chat-web surface, publish 0.1.1 Brings the shared SDK up to the fuller client chat-web had vendored, so frontends consume one source of truth instead of copying it: - types: Message gains senderId/senderName/attachment/parentInteractionId/ annotations; add Attachment, AnnotationGroup, AnnotationEvent, ThreadSummary, SavedItem, LoginResult; MessageEvents gains `annotation`; SocketLike gains connected + optional timeout. - MessageSocket: onConnected, openThread(opts), richer sendMessage(opts) (attachment/mentions/parentInteractionId, back-compatible with contentRef), pin/save/react via annotate, focus, reconnect-re-subscribe-all. - RestClient: login, listUsers, listThreads, createThread, listSaved, addParticipant. Bump 0.1.0 -> 0.1.1. Whole monorepo still builds (Message change is additive for iios-message-web/iios-support-web/demos). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-kernel-client/package.json | 2 +- .../iios-kernel-client/src/message-socket.ts | 76 +++++++++++++++---- packages/iios-kernel-client/src/rest.ts | 53 ++++++++++++- packages/iios-kernel-client/src/types.ts | 62 +++++++++++++++ 4 files changed, 177 insertions(+), 16 deletions(-) diff --git a/packages/iios-kernel-client/package.json b/packages/iios-kernel-client/package.json index 28ec6d9..f4ef857 100644 --- a/packages/iios-kernel-client/package.json +++ b/packages/iios-kernel-client/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-kernel-client", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-kernel-client/src/message-socket.ts b/packages/iios-kernel-client/src/message-socket.ts index 689d829..3d4385c 100644 --- a/packages/iios-kernel-client/src/message-socket.ts +++ b/packages/iios-kernel-client/src/message-socket.ts @@ -8,14 +8,14 @@ export interface MessageSocketConfig { } /** - * Framework-agnostic facade over the `/message` Socket.io namespace (ports the - * support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck. - * On reconnect it re-opens the current thread so subscriptions resume with no - * lost messages (the docs' disconnect/reconnect requirement). + * Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io + * types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens + * all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI + * that watches multiple conversations keeps receiving live messages after a drop. */ export class MessageSocket { private readonly socket: SocketLike; - private currentThreadId: string | null = null; + private readonly joined = new Set(); constructor(config: MessageSocketConfig, socket?: SocketLike) { this.socket = @@ -26,9 +26,9 @@ export class MessageSocket { autoConnect: config.autoConnect ?? true, }) as unknown as SocketLike); - // Re-open the active thread after a reconnect. + // Re-subscribe to every joined thread after a reconnect. this.socket.on('connect', () => { - if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId }); + for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id }); }); } @@ -40,27 +40,70 @@ export class MessageSocket { this.socket.disconnect(); } + /** Run `handler` on every (re)connect, and immediately if already connected. */ + onConnected(handler: () => void): () => void { + this.socket.on('connect', handler); + if (this.socket.connected) handler(); + return () => this.socket.off('connect', handler); + } + /** Subscribe to a server event; returns an unsubscribe fn. */ on(event: E, handler: MessageEvents[E]): () => void { const fn = handler as (...args: unknown[]) => void; - this.socket.on(event, fn); - return () => this.socket.off(event, fn); + this.socket.on(event as string, fn); + return () => this.socket.off(event as string, fn); } - async openThread(threadId?: string): Promise { - const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult; - this.currentThreadId = result.threadId; + async openThread( + threadId?: string, + opts?: { membership?: string; creatorRole?: string; subject?: string }, + ): Promise { + // Timeout (when the transport supports it) so a server error that never acks + // can't hang the caller forever. + const ack = this.socket.timeout ? this.socket.timeout(8000) : this.socket; + const result = (await ack.emitWithAck('open_thread', { threadId, ...opts })) as OpenThreadResult & { error?: string }; + if (result?.error) throw new Error(result.error); + this.joined.add(result.threadId); return result; } - async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise { + async sendMessage( + threadId: string, + content: string, + opts?: { + contentRef?: string; + parentInteractionId?: string; + mentions?: string[]; + attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string }; + }, + ): Promise { return (await this.socket.emitWithAck('send_message', { threadId, content, - contentRef: opts?.contentRef, + contentRef: opts?.attachment?.contentRef ?? opts?.contentRef, + mimeType: opts?.attachment?.mimeType, + sizeBytes: opts?.attachment?.sizeBytes, + checksumSha256: opts?.attachment?.checksumSha256, + parentInteractionId: opts?.parentInteractionId, + mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel })) as Message; } + /** Pin a message in the thread (shared, generic annotation type "pin"). */ + async pin(threadId: string, interactionId: string): Promise { + await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'pin', value: '' }); + } + + /** Save a message for myself (personal, generic annotation type "save"). */ + async save(threadId: string, interactionId: string): Promise { + await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'save', value: '' }); + } + + /** Toggle an emoji reaction on a message (a generic annotation of type "reaction"). */ + async react(threadId: string, interactionId: string, value: string): Promise { + await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'reaction', value }); + } + async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> { return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean }; } @@ -68,4 +111,9 @@ export class MessageSocket { typing(threadId: string): void { this.socket.emit('typing', { threadId }); } + + /** Tell the server which thread is in the foreground (or null when blurred) — drives presence. */ + focus(threadId: string | null): void { + this.socket.emit('focus_thread', { threadId }); + } } diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index 9bc7572..2bf9336 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -1,5 +1,5 @@ import type { IngestInteractionRequest } from '@insignia/iios-contracts'; -import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types'; +import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types'; export interface RestConfig { serviceUrl: string; @@ -57,6 +57,57 @@ export class RestClient { return (await r.json()) as InboxItem; } + // ─── auth + threads (app surface) ───────────────────────────── + /** Dev IdP login (POST /v1/dev/login). A real IdP issues the same JWT — this is the swap point. */ + async login(username: string, password: string): Promise { + const r = await fetch(this.url('/v1/dev/login'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (r.status === 401) throw new Error('invalid username or password'); + if (!r.ok) throw new Error(`login failed (${r.status}) — is the service running with IIOS_DEV_TOKENS=1?`); + return (await r.json()) as LoginResult; + } + + /** Dev directory: known usernames. A real app validates against its user store / MDM. */ + async listUsers(): Promise { + const r = await fetch(this.url('/v1/dev/users'), { headers: this.headers() }); + if (!r.ok) return []; + return ((await r.json()) as { users: string[] }).users; + } + + /** Server-authoritative conversation list (works cross-device, shows unread + members). */ + async listThreads(): Promise { + const r = await fetch(this.url('/v1/threads'), { headers: this.headers() }); + if (!r.ok) throw new Error(`listThreads ${r.status}`); + return (await r.json()) as ThreadSummary[]; + } + + /** Create a thread with generic app attributes (membership hint, creator role, subject/name). */ + async createThread(opts: { membership?: string; creatorRole?: string; subject?: string }): Promise<{ threadId: string }> { + return this.post<{ threadId: string }>('/v1/threads', opts); + } + + /** My saved messages (personal bookmarks), newest first, with thread context. */ + async listSaved(): Promise { + const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() }); + if (!r.ok) throw new Error(`listSaved ${r.status}`); + return (await r.json()) as SavedItem[]; + } + + /** Governed add-participant. A policy 403 (e.g. DM cap) surfaces its reason. */ + async addParticipant(threadId: string, userId: string): Promise { + const r = await fetch(this.url(`/v1/threads/${threadId}/participants`), { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ userId }), + }); + if (r.ok) return; + const body = (await r.json().catch(() => ({}))) as { message?: string }; + throw new Error(body.message ?? `could not add member (${r.status})`); + } + // ─── support ────────────────────────────────────────────────── async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise { return this.post('/v1/support/tickets', body); diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts index 3be602b..5185848 100644 --- a/packages/iios-kernel-client/src/types.ts +++ b/packages/iios-kernel-client/src/types.ts @@ -1,10 +1,30 @@ +/** A media/file part attached to a message (contentRef points at object storage). */ +export interface Attachment { + contentRef: string; + mimeType: string; + sizeBytes: number; + kind: 'image' | 'video' | 'audio' | 'file'; +} + +/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */ +export interface AnnotationGroup { + type: string; + value: string; + users: string[]; // usernames who applied it +} + /** Wire shapes the kernel emits over socket / returns over REST (align with service). */ export interface Message { id: string; threadId: string; senderActorId: string; + senderId: string; // sender's email/username — reliable "is this mine?" check + senderName: string; content: string; contentRef?: string; + attachment?: Attachment; + parentInteractionId?: string; + annotations?: AnnotationGroup[]; traceId?: string; createdAt: string; } @@ -26,10 +46,48 @@ export interface TypingEvent { userId: string; } +/** Broadcast when someone toggles an annotation — carries the refreshed user list. */ +export interface AnnotationEvent { + threadId: string; + interactionId: string; + type: string; + value: string; + op: 'add' | 'remove'; + users: string[]; + userId: string; +} + export interface MessageEvents { message: (m: Message) => void; receipt: (e: ReceiptEvent) => void; typing: (e: TypingEvent) => void; + annotation: (e: AnnotationEvent) => void; +} + +/** A "my threads" entry from GET /v1/threads (server-authoritative). */ +export interface ThreadSummary { + threadId: string; + subject: string | null; + membership?: string; // 'dm' | 'group' (opaque app attribute) + participants: string[]; // member usernames + participantCount: number; + unread: number; + muted?: boolean; + lastMessage?: string; + lastAt?: string; +} + +/** A personally-saved message with its thread context (GET /v1/threads/my-annotations?type=save). */ +export interface SavedItem { + message: Message; + threadId: string; + threadSubject: string | null; +} + +/** Result of the dev IdP login (POST /v1/dev/login). A real IdP issues the same claims. */ +export interface LoginResult { + token: string; + userId: string; } export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE'; @@ -188,4 +246,8 @@ export interface SocketLike { emitWithAck(event: string, ...args: unknown[]): Promise; connect(): unknown; disconnect(): unknown; + /** True while the underlying transport is connected (socket.io exposes this). */ + connected?: boolean; + /** Per-call ack timeout (socket.io). Optional so fakes can omit it. */ + timeout?(ms: number): { emitWithAck(event: string, ...args: unknown[]): Promise }; } -- 2.52.0 From b918a2108356af313129bb854de03885e01b7d90 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Fri, 10 Jul 2026 18:39:30 +0530 Subject: [PATCH 06/30] feat(iios): generic opaque metadata + manual ticket assign (glue prereqs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic kernel primitives so app backends (e.g. a CRM support glue) can link their domain to interactions WITHOUT the kernel learning any app vocabulary: - thread: accept an opaque `metadata` bag on create (merged with membership), echo it in listThreads, and filter listThreads by `?metadata[key]=value` (equality on the opaque bag — the kernel never interprets the keys). - support: accept opaque `metadata` on createTicket/escalate (thread bag inherited); add SupportService.assignTo — a policy-gated manual assignment of a ticket to a target actor (POST /v1/support/tickets/:id/assignee). - SDK @insignia/iios-kernel-client 0.1.2: createThread(metadata), listThreads({metadata}) filter, createTicket(metadata), escalate(metadata), assignTicket; ThreadSummary/Ticket expose the opaque bag. Generic-safety gate holds: no crm/customer/lead in kernel code (opaque values only). Tests: +2 (metadata create/filter, ticket metadata + assignTo); 31 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-kernel-client/package.json | 2 +- packages/iios-kernel-client/src/rest.ts | 26 +++++--- packages/iios-kernel-client/src/types.ts | 4 ++ .../src/messaging/message.service.ts | 31 ++++++--- .../src/messaging/message.spec.ts | 17 +++++ .../src/support/support.controller.ts | 9 ++- .../iios-service/src/support/support.dto.ts | 9 ++- .../src/support/support.service.ts | 63 +++++++++++++++++-- .../iios-service/src/support/support.spec.ts | 13 ++++ .../src/threads/threads.controller.ts | 16 +++-- 10 files changed, 162 insertions(+), 28 deletions(-) diff --git a/packages/iios-kernel-client/package.json b/packages/iios-kernel-client/package.json index f4ef857..2dbf438 100644 --- a/packages/iios-kernel-client/package.json +++ b/packages/iios-kernel-client/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-kernel-client", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index 2bf9336..a355529 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -77,15 +77,21 @@ export class RestClient { return ((await r.json()) as { users: string[] }).users; } - /** Server-authoritative conversation list (works cross-device, shows unread + members). */ - async listThreads(): Promise { - const r = await fetch(this.url('/v1/threads'), { headers: this.headers() }); + /** + * Server-authoritative conversation list (works cross-device, shows unread + members). + * `filter.metadata` narrows to threads whose opaque attribute bag matches every key/value. + */ + async listThreads(filter?: { metadata?: Record }): Promise { + const qs = filter?.metadata + ? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&') + : ''; + const r = await fetch(this.url(`/v1/threads${qs}`), { headers: this.headers() }); if (!r.ok) throw new Error(`listThreads ${r.status}`); return (await r.json()) as ThreadSummary[]; } - /** Create a thread with generic app attributes (membership hint, creator role, subject/name). */ - async createThread(opts: { membership?: string; creatorRole?: string; subject?: string }): Promise<{ threadId: string }> { + /** Create a thread with generic app attributes (membership/creator role/subject + an opaque metadata bag). */ + async createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }): Promise<{ threadId: string }> { return this.post<{ threadId: string }>('/v1/threads', opts); } @@ -109,11 +115,15 @@ export class RestClient { } // ─── support ────────────────────────────────────────────────── - async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise { + async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record }): Promise { return this.post('/v1/support/tickets', body); } - async escalate(threadId: string, subject?: string): Promise { - return this.post('/v1/support/escalate', { threadId, subject }); + async escalate(threadId: string, subject?: string, metadata?: Record): Promise { + return this.post('/v1/support/escalate', { threadId, subject, metadata }); + } + /** Manually assign a ticket to a specific user (generic assignment override). */ + async assignTicket(id: string, userId: string): Promise { + return this.post(`/v1/support/tickets/${id}/assignee`, { userId }); } async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise { const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() }); diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts index 5185848..8b32d24 100644 --- a/packages/iios-kernel-client/src/types.ts +++ b/packages/iios-kernel-client/src/types.ts @@ -69,6 +69,8 @@ export interface ThreadSummary { threadId: string; subject: string | null; membership?: string; // 'dm' | 'group' (opaque app attribute) + /** The thread's opaque, app-supplied attribute bag — echoed verbatim; the kernel never interprets it. */ + metadata?: Record | null; participants: string[]; // member usernames participantCount: number; unread: number; @@ -126,6 +128,8 @@ export interface Ticket { assignedActorId?: string | null; createdAt: string; updatedAt: string; + /** Opaque, app-supplied attribute bag on the ticket — echoed verbatim; the kernel never interprets it. */ + metadata?: Record | null; threadLinks?: Array<{ threadId: string; relationKind: string }>; } diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index f48a554..82091a7 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -50,6 +50,8 @@ export interface ThreadSummary { threadId: string; subject: string | null; membership?: string; + /** The thread's opaque, app-supplied attribute bag — echoed back verbatim; the kernel never interprets it. */ + metadata?: Record | null; participants: string[]; participantCount: number; unread: number; @@ -85,19 +87,21 @@ export class MessageService { * for a group. Opening an existing thread is a GOVERNED join: only an existing member may * re-open it (policy `iios.thread.join`) — new members enter via addParticipant. */ - async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise { + async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }): Promise { if (!threadId) { await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal }); const scope = await this.actors.resolveScope(principal); const actor = await this.actors.resolveActor(scope.id, principal); - // `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the - // kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app). + // `membership`/`creatorRole`/`subject`/`metadata` are generic, app-supplied thread attributes — + // the kernel stores/echoes them as an opaque bag but never branches on their meaning (that lives + // in policy + the app). `membership` is folded into the same bag for back-compat. + const merged = { ...(opts?.metadata ?? {}), ...(opts?.membership ? { membership: opts.membership } : {}) }; const thread = await this.prisma.iiosThread.create({ data: { scopeId: scope.id, createdByActorId: actor.id, subject: opts?.subject?.trim() || undefined, - metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined, + metadata: Object.keys(merged).length > 0 ? (merged as Prisma.InputJsonValue) : undefined, }, }); await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER'); @@ -167,8 +171,12 @@ export class MessageService { return { threadId, muted }; } - /** Generic "my threads": every thread the caller participates in, with last message + unread. */ - async listThreads(principal: MessagePrincipal): Promise { + /** + * Generic "my threads": every thread the caller participates in, with last message + unread. + * An optional `filter.metadata` narrows to threads whose opaque attribute bag matches ALL of the + * given key/values (an equality match on the JSON bag — the kernel does not interpret the keys). + */ + async listThreads(principal: MessagePrincipal, filter?: { metadata?: Record }): Promise { const scope = await this.actors.findScope(principal); if (!scope) return []; const actor = await this.actors.resolveActor(scope.id, principal); @@ -177,7 +185,7 @@ export class MessageService { if (threadIds.length === 0) return []; const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted])); - const [threads, unreads, allParts] = await Promise.all([ + const [allThreads, unreads, allParts] = await Promise.all([ this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }), this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }), this.prisma.iiosThreadParticipant.findMany({ @@ -185,6 +193,14 @@ export class MessageService { include: { actor: { include: { sourceHandle: true } } }, }), ]); + // Opaque equality filter on the metadata bag (every requested key must match). + const metaFilter = filter?.metadata; + const threads = metaFilter + ? allThreads.filter((t) => { + const bag = (t.metadata as Record | null) ?? {}; + return Object.entries(metaFilter).every(([k, v]) => bag[k] === v); + }) + : allThreads; const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount])); const membersBy = new Map(); for (const p of allParts) { @@ -204,6 +220,7 @@ export class MessageService { threadId: t.id, subject: t.subject, membership: (t.metadata as { membership?: string } | null)?.membership, + metadata: (t.metadata as Record | null) ?? null, participants: members, participantCount: members.length, unread: unreadBy.get(t.id) ?? 0, diff --git a/packages/iios-service/src/messaging/message.spec.ts b/packages/iios-service/src/messaging/message.spec.ts index c6babe8..660a4ae 100644 --- a/packages/iios-service/src/messaging/message.spec.ts +++ b/packages/iios-service/src/messaging/message.spec.ts @@ -143,6 +143,23 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => { expect((await s.listThreads(bob))[0]?.unread).toBe(1); }); + it('opaque metadata: stored on create, echoed in listThreads, and a metadata filter narrows the list', async () => { + const s = svc(); + // Two threads with different opaque app attributes — the kernel never interprets these values. + await s.openThread(null, alice, { metadata: { source: 'crm-support', crmCustomerId: 'cust_1' } }); + await s.openThread(null, alice, { metadata: { source: 'other' } }); + + const all = await s.listThreads(alice); + expect(all).toHaveLength(2); + const support = all.find((t) => (t.metadata as { source?: string } | null)?.source === 'crm-support'); + expect(support?.metadata).toMatchObject({ source: 'crm-support', crmCustomerId: 'cust_1' }); + + // Equality filter on the opaque bag returns only the matching thread. + const filtered = await s.listThreads(alice, { metadata: { source: 'crm-support' } }); + expect(filtered).toHaveLength(1); + expect(filtered[0]?.metadata).toMatchObject({ crmCustomerId: 'cust_1' }); + }); + it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => { const s = gov(); const { threadId } = await s.openThread(null, alice, { membership: 'dm' }); diff --git a/packages/iios-service/src/support/support.controller.ts b/packages/iios-service/src/support/support.controller.ts index aeb51ba..14a6164 100644 --- a/packages/iios-service/src/support/support.controller.ts +++ b/packages/iios-service/src/support/support.controller.ts @@ -5,6 +5,7 @@ import { AssignmentService } from './assignment.service'; import { SessionVerifier } from '../platform/session.verifier'; import type { MessagePrincipal } from '../identity/actor.resolver'; import { + AssignTicketDto, AvailabilityDto, CallbackDto, CreateQueueDto, @@ -32,7 +33,7 @@ export class SupportController { @Post('escalate') async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) { - return this.support.escalate(body.threadId, this.principal(auth), body.subject); + return this.support.escalate(body.threadId, this.principal(auth), body.subject, body.metadata); } @Get('tickets') @@ -45,6 +46,12 @@ export class SupportController { return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason); } + /** Manually assign a ticket to a specific actor (by userId) — generic assignment override. */ + @Post('tickets/:id/assignee') + async assign(@Param('id') id: string, @Body() body: AssignTicketDto, @Headers('authorization') auth?: string) { + return this.support.assignTo(id, this.principal(auth), body.userId); + } + @Post('callbacks') async callback( @Body() body: CallbackDto, diff --git a/packages/iios-service/src/support/support.dto.ts b/packages/iios-service/src/support/support.dto.ts index 95961f5..04cefee 100644 --- a/packages/iios-service/src/support/support.dto.ts +++ b/packages/iios-service/src/support/support.dto.ts @@ -1,4 +1,4 @@ -import { IsIn, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsObject, IsOptional, IsString } from 'class-validator'; const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const; const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const; @@ -7,11 +7,18 @@ export class CreateTicketDto { @IsString() subject!: string; @IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number]; @IsOptional() @IsString() threadId?: string; + /** Opaque, app-supplied attribute bag — stored on the ticket, never interpreted by the kernel. */ + @IsOptional() @IsObject() metadata?: Record; } export class EscalateDto { @IsString() threadId!: string; @IsOptional() @IsString() subject?: string; + @IsOptional() @IsObject() metadata?: Record; +} + +export class AssignTicketDto { + @IsString() userId!: string; } export class PatchTicketDto { diff --git a/packages/iios-service/src/support/support.service.ts b/packages/iios-service/src/support/support.service.ts index 93b25de..20d1715 100644 --- a/packages/iios-service/src/support/support.service.ts +++ b/packages/iios-service/src/support/support.service.ts @@ -35,7 +35,7 @@ export class SupportService { async createTicket( principal: MessagePrincipal, - input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string }, + input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string; metadata?: Record }, idempotencyKey?: string, ) { await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal }); @@ -63,6 +63,8 @@ export class SupportService { subject: input.subject, priority: input.priority ?? 'P3', traceId, + // Opaque, app-supplied attribute bag (e.g. a caller's external ref) — stored, never interpreted. + metadata: input.metadata ? (input.metadata as Prisma.InputJsonValue) : undefined, }, }); await tx.iiosTicketStateHistory.create({ @@ -99,11 +101,17 @@ export class SupportService { return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body); } - /** Escalate a chat thread to support: create a ticket linked to that thread. */ - async escalate(threadId: string, principal: MessagePrincipal, subject?: string) { + /** Escalate a chat thread to support: create a ticket linked to that thread. `metadata` is opaque. */ + async escalate(threadId: string, principal: MessagePrincipal, subject?: string, metadata?: Record) { const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); if (!thread) throw new NotFoundException('thread not found'); - return this.createTicket(principal, { subject: subject ?? thread.subject ?? 'Support request', threadId }); + // Inherit the thread's opaque bag so the ticket carries the same app context, unless overridden. + const inherited = { ...((thread.metadata as Record | null) ?? {}), ...(metadata ?? {}) }; + return this.createTicket(principal, { + subject: subject ?? thread.subject ?? 'Support request', + threadId, + metadata: Object.keys(inherited).length > 0 ? inherited : undefined, + }); } async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise { @@ -120,6 +128,53 @@ export class SupportService { .catch(() => undefined); } + /** + * Manually assign a ticket to a specific actor (by userId) — a generic override of the + * event-driven auto-assignment. The target is resolved-or-created as an actor in the ticket's + * scope (so you can assign someone who hasn't logged in yet) and joined to the linked thread(s) + * so they can reply over the socket. Fail-closed via policy `iios.support.ticket.assign`. + */ + async assignTo(ticketId: string, principal: MessagePrincipal, targetUserId: string) { + const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId }, include: { threadLinks: true } }); + if (!ticket) throw new NotFoundException('ticket not found'); + await decideOrThrow(this.ports, { action: 'iios.support.ticket.assign', ticketId, scopeId: ticket.scopeId, targetUserId }); + + const target = await this.actors.resolveActor(ticket.scopeId, { + userId: targetUserId, + appId: principal.appId, + orgId: principal.orgId, + tenantId: principal.tenantId, + displayName: targetUserId, + }); + const toState: IiosTicketState = ticket.state === 'NEW' ? 'OPEN' : ticket.state; + const event: CloudEvent = { + specversion: '1.0', + id: `evt_assign_${ticketId}_${target.id}`, + type: IIOS_EVENTS.ticketStateChanged, + source: `iios/support/${ticket.scopeId}`, + subject: `ticket/${ticketId}`, + time: new Date().toISOString(), + datacontenttype: 'application/json', + insignia: { scopeSnapshotId: ticket.scopeId, correlationId: ticket.traceId ?? undefined, idempotencyKey: `assign:${ticketId}:${target.id}`, dataClass: 'internal' }, + data: { ticketId, fromState: ticket.state, toState, assignedActorId: target.id }, + }; + await this.prisma.$transaction([ + this.prisma.iiosTicket.update({ where: { id: ticketId }, data: { assignedActorId: target.id, state: toState } }), + this.prisma.iiosTicketStateHistory.create({ data: { ticketId, fromState: ticket.state, toState, actorId: target.id, reasonCode: 'assigned' } }), + this.prisma.iiosOutboxEvent.create({ + data: { + aggregateType: 'ticket', + aggregateId: ticketId, + eventType: IIOS_EVENTS.ticketStateChanged, + cloudEvent: event as unknown as Prisma.InputJsonValue, + partitionKey: `${ticket.scopeId}:${ticketId}`, + }, + }), + ]); + for (const link of ticket.threadLinks) await this.actors.ensureParticipant(link.threadId, target.id); + return this.prisma.iiosTicket.findUnique({ where: { id: ticketId } }); + } + async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) { const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } }); if (!ticket) throw new NotFoundException('ticket not found'); diff --git a/packages/iios-service/src/support/support.spec.ts b/packages/iios-service/src/support/support.spec.ts index 288effb..23df921 100644 --- a/packages/iios-service/src/support/support.spec.ts +++ b/packages/iios-service/src/support/support.spec.ts @@ -57,6 +57,19 @@ describe('SupportService (P4)', () => { expect(links[0]?.threadId).toBe(threadId); }); + it('stores an opaque metadata bag on the ticket; assignTo assigns to a target actor and opens it', async () => { + const t = await support().createTicket(cust, { subject: 'help', metadata: { crmCustomerId: 'cust_1', source: 'crm-support' } }); + expect(t.metadata).toMatchObject({ crmCustomerId: 'cust_1', source: 'crm-support' }); + expect(t.state).toBe('NEW'); + + const assigned = await support().assignTo(t.id, cust, 'agent_1'); + expect(assigned?.state).toBe('OPEN'); + const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'agent_1' } }); + const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } }); + expect(assigned?.assignedActorId).toBe(actor.id); + expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id, reasonCode: 'assigned' } })).toBe(1); + }); + it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => { const t = await support().createTicket(cust, { subject: 's' }); await support().transition(t.id, cust, 'OPEN'); diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 58fb66f..497d341 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -23,10 +23,14 @@ export class ThreadsController { private readonly session: SessionVerifier, ) {} - /** Generic "my threads" — every thread the caller participates in (last message + unread). */ + /** + * Generic "my threads" — every thread the caller participates in (last message + unread). + * `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key). + */ @Get() - async listThreads(@Headers('authorization') auth?: string) { - return this.messages.listThreads(this.principal(auth)); + async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record) { + const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined; + return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined); } /** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */ @@ -35,11 +39,11 @@ export class ThreadsController { return this.messages.listMyAnnotated(this.principal(auth), type); } - /** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */ + /** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */ @Post() @HttpCode(201) - async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) { - return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject }); + async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }, @Headers('authorization') auth?: string) { + return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata }); } /** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */ -- 2.52.0 From 77dd5bac82e9ac259112713925d9d9bc4a2e861a Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 11 Jul 2026 14:33:28 +0530 Subject: [PATCH 07/30] fix(iios): use extended query parser so nested metadata filters parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NestJS 11 runs Express 5, whose default 'simple' query parser ignores nested params like ?metadata[key]=value — so GET /v1/threads silently dropped the metadata filter, causing e.g. an app's "reuse existing thread for this customer" check to match ANY thread. Set the qs-based 'extended' parser. Verified: filtering by a non-existent metadata value now returns 0 (was returning all); the be-crm support smoke goes 12/12 (was 10/12 once a prior thread existed). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-service/src/main.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/iios-service/src/main.ts b/packages/iios-service/src/main.ts index 97ea751..62f0c32 100644 --- a/packages/iios-service/src/main.ts +++ b/packages/iios-service/src/main.ts @@ -9,6 +9,10 @@ import { PolicyDeniedFilter } from './platform/policy-denied.filter'; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, { rawBody: true }); + // Express 5 defaults to the 'simple' query parser, which ignores nested params like + // ?metadata[key]=value — so generic metadata filters would be silently dropped. Use the + // qs-based 'extended' parser so those parse into a nested object. + app.getHttpAdapter().getInstance().set('query parser', 'extended'); app.use(traceMiddleware); // request-scoped trace context + x-trace-id header (P9) app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), -- 2.52.0 From dd6a4cd4fa4fe40b9ffb86b7c4834a538a1fe2a6 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Mon, 13 Jul 2026 20:11:04 +0530 Subject: [PATCH 08/30] =?UTF-8?q?feat(iios):=20context-attestation=20verif?= =?UTF-8?q?ier=20=E2=80=94=20the=20July=2012=20stolen-token=20proof=20(?= =?UTF-8?q?=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A valid actor token is no longer enough. This adds the trust-layer core so IIOS can require a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.) before a privileged request — defeating a hacked app replaying a stolen token. - prisma: IiosClientRegistry (registered clients allowed to attest, per app) + IiosAttestationNonce (single-use replay ledger). Migration applied. - ContextAttestationVerifier + ports (ClientRegistryPort, NonceStorePort) + a dev signer. Verifies: registered+active client, signature, aud=iios, app_id matches the token AND is allowed for the client, freshness, single-use nonce. Fail-closed. - Prisma-backed stores; nonce reserve is atomic via the unique PK (P2002 = replay). - Tests (9 pass): happy path + the five rejection cases the CEO named (unknown client, wrong audience, expired, replayed nonce, app-mismatch) + forged-signature + disabled-client; a DB atomicity test (skips if engine offline). Decoupled from the request path on purpose — wiring it into the messaging guard (the three-proof gate) + demoting be-crm to an attestation forwarder is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 24 ++++ packages/iios-service/prisma/schema.prisma | 28 ++++ .../src/platform/attestation-stores.ts | 42 ++++++ .../src/platform/context-attestation.spec.ts | 126 ++++++++++++++++ .../src/platform/context-attestation.ts | 136 ++++++++++++++++++ 5 files changed, 356 insertions(+) create mode 100644 packages/iios-service/prisma/migrations/20260713200417_add_context_attestation/migration.sql create mode 100644 packages/iios-service/src/platform/attestation-stores.ts create mode 100644 packages/iios-service/src/platform/context-attestation.spec.ts create mode 100644 packages/iios-service/src/platform/context-attestation.ts diff --git a/packages/iios-service/prisma/migrations/20260713200417_add_context_attestation/migration.sql b/packages/iios-service/prisma/migrations/20260713200417_add_context_attestation/migration.sql new file mode 100644 index 0000000..bb861db --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260713200417_add_context_attestation/migration.sql @@ -0,0 +1,24 @@ +-- Trust plane (July 12): context attestation client registry + nonce replay ledger. +CREATE TABLE "IiosClientRegistry" ( + "clientId" TEXT NOT NULL, + "clientType" TEXT NOT NULL, + "ownerService" TEXT, + "allowedAppIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "attestSecret" TEXT, + "jwksUri" TEXT, + "spiffeId" TEXT, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "IiosClientRegistry_pkey" PRIMARY KEY ("clientId") +); + +CREATE TABLE "IiosAttestationNonce" ( + "nonce" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "IiosAttestationNonce_pkey" PRIMARY KEY ("nonce") +); + +CREATE INDEX "IiosAttestationNonce_expiresAt_idx" ON "IiosAttestationNonce"("expiresAt"); diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 154896f..9b4aa91 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -1321,3 +1321,31 @@ model IiosDlqItem { @@unique([consumerName, sourceId]) @@index([scopeId, status]) } + +// ─── Trust plane (July 12) — context attestation ────────────────── +// Registered clients/BFFs/adapters allowed to call IIOS and attest context on +// behalf of an app. A valid actor token is NOT enough; the caller must present a +// context attestation signed by one of these registered clients. +model IiosClientRegistry { + clientId String @id + clientType String // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE + ownerService String? + allowedAppIds String[] // which app_ids this client may attest for + attestSecret String? // dev: shared HS256 signing key (AppShell's key stand-in) + jwksUri String? // prod: verify the attestation signature via JWKS + spiffeId String? // prod: workload identity for mTLS proof + status String @default("ACTIVE") // ACTIVE | DISABLED + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Single-use nonce ledger: once a context attestation's nonce is seen it can +// never be replayed. Reserve-if-absent (unique PK) makes the check atomic. +model IiosAttestationNonce { + nonce String @id + clientId String + expiresAt DateTime + firstSeenAt DateTime @default(now()) + + @@index([expiresAt]) +} diff --git a/packages/iios-service/src/platform/attestation-stores.ts b/packages/iios-service/src/platform/attestation-stores.ts new file mode 100644 index 0000000..8978e7e --- /dev/null +++ b/packages/iios-service/src/platform/attestation-stores.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { ClientRegistryEntry, ClientRegistryPort, NonceStorePort } from './context-attestation'; + +/** Client registry backed by Postgres (IiosClientRegistry). */ +@Injectable() +export class PrismaClientRegistry implements ClientRegistryPort { + constructor(private readonly prisma: PrismaService) {} + + async find(clientId: string): Promise { + const r = await this.prisma.iiosClientRegistry.findUnique({ where: { clientId } }); + if (!r) return null; + return { + clientId: r.clientId, + clientType: r.clientType, + allowedAppIds: r.allowedAppIds, + attestSecret: r.attestSecret ?? undefined, + jwksUri: r.jwksUri ?? undefined, + status: r.status === 'ACTIVE' ? 'ACTIVE' : 'DISABLED', + }; + } +} + +/** + * Nonce ledger backed by Postgres (IiosAttestationNonce). Reserve-if-absent is atomic via the + * unique primary key: a duplicate insert (P2002) means the nonce was already used → replay. + */ +@Injectable() +export class PrismaNonceStore implements NonceStorePort { + constructor(private readonly prisma: PrismaService) {} + + async reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise { + try { + await this.prisma.iiosAttestationNonce.create({ data: input }); + return true; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') return false; // already seen + throw e; + } + } +} diff --git a/packages/iios-service/src/platform/context-attestation.spec.ts b/packages/iios-service/src/platform/context-attestation.spec.ts new file mode 100644 index 0000000..c17e96d --- /dev/null +++ b/packages/iios-service/src/platform/context-attestation.spec.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { + ContextAttestationVerifier, + signDevAttestation, + type ClientRegistryEntry, + type ClientRegistryPort, + type NonceStorePort, +} from './context-attestation'; +import { PrismaNonceStore } from './attestation-stores'; +import type { PrismaService } from '../prisma/prisma.service'; + +const APPSHELL_SECRET = 'appshell-dev-signing-key'; +const AUD = 'iios-core'; +const NOW = new Date('2026-07-13T12:00:00Z'); + +// ─── in-memory fakes (fast, deterministic — no DB needed) ───────── +function fakeRegistry(over: Partial = {}): ClientRegistryPort { + const entry: ClientRegistryEntry = { + clientId: 'crm-support-widget', + clientType: 'SUPPORT_BFF', + allowedAppIds: ['crm-web'], + attestSecret: APPSHELL_SECRET, + status: 'ACTIVE', + ...over, + }; + return { find: async (id) => (id === entry.clientId ? entry : null) }; +} + +function fakeNonces(): NonceStorePort { + const seen = new Set(); + return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) }; +} + +const verifier = (reg: ClientRegistryPort = fakeRegistry(), nonces: NonceStorePort = fakeNonces()) => + new ContextAttestationVerifier(reg, nonces, { audience: AUD }); + +/** A well-formed AppShell attestation, overridable per test. */ +function attest(over: Record = {}, secret = APPSHELL_SECRET, at = NOW): string { + return signDevAttestation( + { issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'crm-support-widget', appId: 'crm-web', nonce: 'n_default', ...over }, + secret, + at, + ); +} + +describe('ContextAttestationVerifier (July 12 trust proof)', () => { + it('accepts a valid attestation from a registered client for the matching app', async () => { + const res = await verifier().verify(attest({ nonce: 'n_ok' }), 'crm-web', NOW); + expect(res.ok).toBe(true); + if (res.ok) expect(res.attestation.clientId).toBe('crm-support-widget'); + }); + + // ── the five rejection cases the CEO named ── + it('rejects an unknown / unregistered client (wrong client)', async () => { + const res = await verifier().verify(attest({ clientId: 'hacker-app', nonce: 'n1' }), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'UNKNOWN_CLIENT' }); + }); + + it('rejects the wrong audience (token minted for another service)', async () => { + const res = await verifier().verify(attest({ audience: 'some-other-service', nonce: 'n2' }), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' }); + }); + + it('rejects an expired attestation', async () => { + // signed 10 min ago with a 5 min TTL → already expired at NOW + const stale = attest({ nonce: 'n3', ttlSeconds: 300 }, APPSHELL_SECRET, new Date(NOW.getTime() - 600_000)); + const res = await verifier().verify(stale, 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'EXPIRED' }); + }); + + it('rejects a replayed nonce (same attestation used twice)', async () => { + const v = verifier(); + const token = attest({ nonce: 'n_replay' }); + const first = await v.verify(token, 'crm-web', NOW); + const second = await v.verify(token, 'crm-web', NOW); + expect(first.ok).toBe(true); + expect(second).toMatchObject({ ok: false, reason: 'REPLAY' }); + }); + + it('rejects app mismatch: token app != attestation app', async () => { + const res = await verifier().verify(attest({ appId: 'crm-web', nonce: 'n4' }), 'some-other-app', NOW); + expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' }); + }); + + // ── extra hardening ── + it('rejects a forged signature (signed with the wrong key)', async () => { + const res = await verifier().verify(attest({ nonce: 'n5' }, 'attacker-key'), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' }); + }); + + it('rejects a disabled client', async () => { + const res = await verifier(fakeRegistry({ status: 'DISABLED' })).verify(attest({ nonce: 'n6' }), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'CLIENT_DISABLED' }); + }); + + it('rejects an app the client is not allowed to attest for', async () => { + const reg = fakeRegistry({ allowedAppIds: ['other-app'] }); + const res = await verifier(reg).verify(attest({ appId: 'crm-web', nonce: 'n7' }), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' }); + }); +}); + +// ─── DB-level atomicity of the nonce ledger ─────────────────────── +describe('PrismaNonceStore (atomic replay defense)', () => { + const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; + const prisma = new PrismaClient({ datasources: { db: { url } } }); + const store = new PrismaNonceStore(prisma as unknown as PrismaService); + let dbUp = false; + + beforeAll(async () => { + try { await prisma.$connect(); dbUp = true; } catch { dbUp = false; } + }); + afterAll(async () => { if (dbUp) await prisma.$disconnect(); }); + + it('reserves a nonce once; a second reserve of the same nonce is rejected', async (ctx) => { + if (!dbUp) return ctx.skip(); // DB unreachable in this environment — the in-memory REPLAY test covers the logic + const nonce = `n_db_${NOW.getTime()}_${Math.floor(Math.random() * 1e9)}`; + const expiresAt = new Date(NOW.getTime() + 300_000); + const first = await store.reserve({ nonce, clientId: 'crm-support-widget', expiresAt }); + const second = await store.reserve({ nonce, clientId: 'crm-support-widget', expiresAt }); + expect(first).toBe(true); + expect(second).toBe(false); + await prisma.iiosAttestationNonce.delete({ where: { nonce } }).catch(() => undefined); + }); +}); diff --git a/packages/iios-service/src/platform/context-attestation.ts b/packages/iios-service/src/platform/context-attestation.ts new file mode 100644 index 0000000..66d7046 --- /dev/null +++ b/packages/iios-service/src/platform/context-attestation.ts @@ -0,0 +1,136 @@ +import jwt from 'jsonwebtoken'; + +/** + * The July 12 "third proof". A cryptographically valid actor token is NOT enough — a hacked + * app can replay a stolen one. Before IIOS processes a privileged request it must also verify + * a CONTEXT ATTESTATION: a short-lived, single-use, signed statement from an authorized parent + * (AppShell for CRM, the adapter registry for channels, Calendar for calendar) that says + * "this request, this client, this app, this context is genuinely mine." + * + * This module is the verifier + its two ports (client registry, nonce ledger) + a dev signer. + * It is intentionally decoupled from the request path so it can be unit-tested in isolation and + * wired into the messaging guard as a separate, gated step. + */ + +/** The subset of AppShell's context-attestation envelope that IIOS verifies. */ +export interface ContextAttestation { + attestationId?: string; + issuer: string; + issuerType?: string; // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE + audience: string; // must equal the configured IIOS audience + clientId: string; // must be a registered, ACTIVE client + appId: string; // must match the actor token's app_id AND be allowed for this client + scope?: { orgId?: string; appId?: string; tenantId?: string; buId?: string }; + nonce: string; // single-use + iat?: number; + exp?: number; // short-lived +} + +/** A registered caller allowed to attest context on behalf of one or more apps. */ +export interface ClientRegistryEntry { + clientId: string; + clientType: string; + allowedAppIds: string[]; + attestSecret?: string; // dev: shared HS256 signing key (AppShell's key stand-in) + jwksUri?: string; // prod: asymmetric verification (seam — not used in dev) + status: 'ACTIVE' | 'DISABLED'; +} + +export interface ClientRegistryPort { + find(clientId: string): Promise; +} + +export interface NonceStorePort { + /** Atomically reserve a nonce. Returns true if fresh, false if it was already seen (replay). */ + reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise; +} + +export type AttestationReason = + | 'MALFORMED' + | 'UNKNOWN_CLIENT' + | 'CLIENT_DISABLED' + | 'NO_KEY' + | 'BAD_SIGNATURE' + | 'WRONG_AUDIENCE' + | 'APP_MISMATCH' + | 'EXPIRED' + | 'REPLAY'; + +export type AttestationResult = + | { ok: true; attestation: ContextAttestation } + | { ok: false; reason: AttestationReason; detail: string }; + +export interface AttestationConfig { + /** The audience IIOS requires on attestations addressed to it (e.g. 'iios-core'). */ + audience: string; +} + +const deny = (reason: AttestationReason, detail: string): AttestationResult => ({ ok: false, reason, detail }); + +export class ContextAttestationVerifier { + constructor( + private readonly registry: ClientRegistryPort, + private readonly nonces: NonceStorePort, + private readonly config: AttestationConfig, + ) {} + + /** + * Verify an attestation against an already-verified actor token. Fail-closed: any doubt → deny. + * @param attestationJwt the signed attestation the parent issued + * @param tokenAppId the `app_id` from the verified actor token (Level-1 proof) + * @param now current time — injected so tests are deterministic + */ + async verify(attestationJwt: string, tokenAppId: string, now: Date = new Date()): Promise { + // 1. Decode WITHOUT verifying, only to learn who claims to have signed it. + const claimed = jwt.decode(attestationJwt, { json: true }) as (ContextAttestation & jwt.JwtPayload) | null; + if (!claimed || typeof claimed !== 'object' || !claimed.clientId || !claimed.nonce) { + return deny('MALFORMED', 'attestation missing clientId/nonce'); + } + + // 2. The claimed client must be registered and ACTIVE. + const client = await this.registry.find(claimed.clientId); + if (!client) return deny('UNKNOWN_CLIENT', `client "${claimed.clientId}" is not registered`); + if (client.status !== 'ACTIVE') return deny('CLIENT_DISABLED', `client "${claimed.clientId}" is ${client.status}`); + if (!client.attestSecret) return deny('NO_KEY', `no verification key configured for "${claimed.clientId}"`); + + // 3. Verify the signature with the client's key (dev HS256; JWKS is the prod seam). + // ignoreExpiration so we can return a precise EXPIRED reason (step 6) rather than BAD_SIGNATURE. + let att: ContextAttestation & jwt.JwtPayload; + try { + att = jwt.verify(attestationJwt, client.attestSecret, { algorithms: ['HS256'], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload; + } catch (e) { + return deny('BAD_SIGNATURE', (e as Error).message); + } + + // 4. Audience must be IIOS — a token for another service can't be replayed at IIOS. + if (att.audience !== this.config.audience) return deny('WRONG_AUDIENCE', `audience "${att.audience}" != "${this.config.audience}"`); + + // 5. app binding: the attestation's app must match the token's app AND be allowed for this client. + if (att.appId !== tokenAppId) return deny('APP_MISMATCH', `attestation app "${att.appId}" != token app "${tokenAppId}"`); + if (!client.allowedAppIds.includes(att.appId)) return deny('APP_MISMATCH', `client "${client.clientId}" not allowed for app "${att.appId}"`); + + // 6. Freshness — attestations are short-lived. + const expMs = (att.exp ?? 0) * 1000; + if (!expMs || expMs <= now.getTime()) return deny('EXPIRED', 'attestation expired or missing exp'); + + // 7. Single-use — reserve the nonce. A replayed (stolen) attestation loses here. Fail-closed. + const fresh = await this.nonces.reserve({ nonce: att.nonce, clientId: client.clientId, expiresAt: new Date(expMs) }); + if (!fresh) return deny('REPLAY', `nonce "${att.nonce}" already used`); + + return { ok: true, attestation: att }; + } +} + +/** + * DEV/TEST helper: mint a signed attestation the way AppShell would (HS256 stand-in for its + * signing key). Production AppShell signs asymmetrically and IIOS verifies via the client's JWKS. + */ +export function signDevAttestation( + claims: Omit & { ttlSeconds?: number }, + secret: string, + now: Date = new Date(), +): string { + const { ttlSeconds = 300, ...rest } = claims; + const iat = Math.floor(now.getTime() / 1000); + return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, secret, { algorithm: 'HS256' }); +} -- 2.52.0 From f2d590b04d5a034a836cb325b702416b77bd16dd Mon Sep 17 00:00:00 2001 From: maaz519 Date: Mon, 13 Jul 2026 20:49:40 +0530 Subject: [PATCH 09/30] =?UTF-8?q?feat(iios):=20wire=20the=20three-proof=20?= =?UTF-8?q?gate=20into=20the=20request=20path=20(=C2=A71b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the context-attestation verifier at the request boundary via a guard, so a stolen/forged/replayed attestation is rejected before IIOS acts — while staying safe to roll out. - ContextAttestationGuard: if an X-Context-Attestation header is present, verify it (its app_id must match the actor token's app_id) → 403 on any failure; if absent, allow only when IIOS_REQUIRE_ATTESTATION != 1 (dev/zero-trust), else 403. The flag is read per-request and defaults OFF, so existing callers keep working until AppShell/ be-crm start forwarding attestations. - AttestationModule (@Global): provides the verifier + Prisma stores + guard, and dev-seeds the crm-support-widget client so locally minted attestations verify. - Guard applied to ThreadsController + SupportController. - Tests (5 pass, no DB): not-required-allows, required-rejects, valid, forged, replayed. Workload/mTLS (proof #2) stays a mesh concern. Next: SDK header passthrough + demote be-crm IiosClient to forward an attestation, then flip the flag to prove end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-service/.env.example | 10 +++ packages/iios-service/src/app.module.ts | 2 + .../src/platform/attestation.module.ts | 44 ++++++++++++ .../context-attestation.guard.spec.ts | 71 +++++++++++++++++++ .../src/platform/context-attestation.guard.ts | 52 ++++++++++++++ .../src/support/support.controller.ts | 4 +- .../src/threads/threads.controller.ts | 3 + 7 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 packages/iios-service/src/platform/attestation.module.ts create mode 100644 packages/iios-service/src/platform/context-attestation.guard.spec.ts create mode 100644 packages/iios-service/src/platform/context-attestation.guard.ts diff --git a/packages/iios-service/.env.example b/packages/iios-service/.env.example index 791514e..527e6b3 100644 --- a/packages/iios-service/.env.example +++ b/packages/iios-service/.env.example @@ -49,3 +49,13 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12) # ── Capability providers (governed egress targets) ─────────────────────────── # Per-channel provider endpoint the CapabilityBroker calls, e.g.: # IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email + +# ── Context attestation (July 12 trust layer) ── +# Audience IIOS requires on attestations addressed to it. +IIOS_ATTESTATION_AUDIENCE=iios-core +# Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the +# crm-support-widget client into the registry when IIOS_DEV_TOKENS=1). +# IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key +# When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403). +# Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless. +IIOS_REQUIRE_ATTESTATION=0 diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index e12dc3d..29129e2 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from './prisma/prisma.module'; import { PlatformModule } from './platform/platform.module'; +import { AttestationModule } from './platform/attestation.module'; import { IdentityModule } from './identity/identity.module'; import { InteractionsModule } from './interactions/interactions.module'; import { IdempotencyModule } from './idempotency/idempotency.module'; @@ -27,6 +28,7 @@ import { DevController } from './dev/dev.controller'; imports: [ PrismaModule, PlatformModule, + AttestationModule, IdentityModule, InteractionsModule, IdempotencyModule, diff --git a/packages/iios-service/src/platform/attestation.module.ts b/packages/iios-service/src/platform/attestation.module.ts new file mode 100644 index 0000000..979045e --- /dev/null +++ b/packages/iios-service/src/platform/attestation.module.ts @@ -0,0 +1,44 @@ +import { Global, Module, type OnModuleInit } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { ContextAttestationVerifier } from './context-attestation'; +import { PrismaClientRegistry, PrismaNonceStore } from './attestation-stores'; +import { ContextAttestationGuard } from './context-attestation.guard'; + +/** + * Wires the context-attestation trust layer into DI and exposes the guard globally so any + * controller can enforce the three-proof gate. Also dev-seeds a registered client so locally + * minted attestations verify (prod registers clients out-of-band). + */ +@Global() +@Module({ + providers: [ + PrismaClientRegistry, + PrismaNonceStore, + { + provide: ContextAttestationVerifier, + useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore) => + new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }), + inject: [PrismaClientRegistry, PrismaNonceStore], + }, + ContextAttestationGuard, + ], + exports: [ContextAttestationVerifier, ContextAttestationGuard], +}) +export class AttestationModule implements OnModuleInit { + constructor(private readonly prisma: PrismaService) {} + + /** Dev seed: register the CRM support client so an attestation signed with the shared dev + * secret verifies locally. Gated by IIOS_DEV_TOKENS + IIOS_ATTESTATION_DEV_SECRET; best-effort. */ + async onModuleInit(): Promise { + if (process.env.IIOS_DEV_TOKENS !== '1') return; + const secret = process.env.IIOS_ATTESTATION_DEV_SECRET; + if (!secret) return; + await this.prisma.iiosClientRegistry + .upsert({ + where: { clientId: 'crm-support-widget' }, + create: { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' }, + update: { attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' }, + }) + .catch(() => undefined); + } +} diff --git a/packages/iios-service/src/platform/context-attestation.guard.spec.ts b/packages/iios-service/src/platform/context-attestation.guard.spec.ts new file mode 100644 index 0000000..711264e --- /dev/null +++ b/packages/iios-service/src/platform/context-attestation.guard.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { ForbiddenException, type ExecutionContext } from '@nestjs/common'; +import { ContextAttestationGuard } from './context-attestation.guard'; +import { + ContextAttestationVerifier, + signDevAttestation, + type ClientRegistryPort, + type NonceStorePort, +} from './context-attestation'; +import type { SessionVerifier } from './session.verifier'; + +const SECRET = 'appshell-dev-signing-key'; + +// SessionVerifier stand-in: the token always resolves to app "crm-web". +const fakeSession = { + verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }), +} as unknown as SessionVerifier; + +function makeGuard(): ContextAttestationGuard { + const seen = new Set(); + const registry: ClientRegistryPort = { + find: async (id) => + id === 'crm-support-widget' + ? { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' } + : null, + }; + const nonces: NonceStorePort = { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) }; + const verifier = new ContextAttestationVerifier(registry, nonces, { audience: 'iios-core' }); + return new ContextAttestationGuard(fakeSession, verifier); +} + +function ctxWith(headers: Record): ExecutionContext { + return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext; +} + +function att(over: Record = {}, secret = SECRET): string { + return signDevAttestation( + { issuer: 'appshell.crm', audience: 'iios-core', clientId: 'crm-support-widget', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over }, + secret, + ); +} + +describe('ContextAttestationGuard (three-proof gate on the request path)', () => { + afterEach(() => { delete process.env.IIOS_REQUIRE_ATTESTATION; }); + + it('allows a request with no attestation when NOT required (dev / zero-trust)', async () => { + expect(await makeGuard().canActivate(ctxWith({}))).toBe(true); + }); + + it('rejects a request with no attestation when IIOS_REQUIRE_ATTESTATION=1', async () => { + process.env.IIOS_REQUIRE_ATTESTATION = '1'; + await expect(makeGuard().canActivate(ctxWith({}))).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows a valid attestation bound to the token app', async () => { + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att() }; + expect(await makeGuard().canActivate(ctxWith(headers))).toBe(true); + }); + + it('rejects a forged attestation (wrong signing key) — stolen-context defense', async () => { + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_forge' }, 'attacker-key') }; + await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects a replayed attestation (same nonce twice)', async () => { + const guard = makeGuard(); + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_replay' }) }; + expect(await guard.canActivate(ctxWith(headers))).toBe(true); + await expect(guard.canActivate(ctxWith({ ...headers }))).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/packages/iios-service/src/platform/context-attestation.guard.ts b/packages/iios-service/src/platform/context-attestation.guard.ts new file mode 100644 index 0000000..e5a240c --- /dev/null +++ b/packages/iios-service/src/platform/context-attestation.guard.ts @@ -0,0 +1,52 @@ +import { type CanActivate, type ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import type { Request } from 'express'; +import { SessionVerifier } from './session.verifier'; +import { ContextAttestationVerifier } from './context-attestation'; + +/** + * The July 12 three-proof gate on the request path. Proof #1 (a valid actor token) is done by + * the controllers; proof #2 (workload/mTLS) is the mesh's job in prod. This guard adds proof #3: + * a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.). + * + * Behaviour (safe rollout): + * • attestation header present → verify it; the attestation's app_id must match the actor + * token's app_id. Any failure → 403. + * • attestation header absent → allowed ONLY when not required (dev / a zero-trust standalone + * caller that IIOS checks fully itself). With `IIOS_REQUIRE_ATTESTATION=1`, absence is a 403. + * + * The requirement is read per-request so it can be toggled without restarts and stays OFF by + * default — existing callers that don't send an attestation keep working until the rollout flips. + */ +@Injectable() +export class ContextAttestationGuard implements CanActivate { + constructor( + private readonly session: SessionVerifier, + private readonly attest: ContextAttestationVerifier, + ) {} + + async canActivate(ctx: ExecutionContext): Promise { + const required = process.env.IIOS_REQUIRE_ATTESTATION === '1'; + const req = ctx.switchToHttp().getRequest(); + const raw = req.headers['x-context-attestation']; + const attestation = Array.isArray(raw) ? raw[0] : raw; + + if (!attestation) { + if (required) throw new ForbiddenException('context attestation required'); + return true; // dev / zero-trust: the actor token is still enforced downstream + } + + // Bind the attestation to the token's app_id, so a stolen attestation for another app fails. + const auth = req.headers['authorization'] ?? ''; + const token = String(auth).replace(/^Bearer\s+/i, ''); + let appId: string; + try { + appId = this.session.verify(token).appId ?? ''; + } catch { + throw new ForbiddenException('invalid actor token'); + } + + const result = await this.attest.verify(attestation, appId); + if (!result.ok) throw new ForbiddenException(`context attestation rejected: ${result.reason}`); + return true; + } +} diff --git a/packages/iios-service/src/support/support.controller.ts b/packages/iios-service/src/support/support.controller.ts index 14a6164..cb4a149 100644 --- a/packages/iios-service/src/support/support.controller.ts +++ b/packages/iios-service/src/support/support.controller.ts @@ -1,8 +1,9 @@ -import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { IiosTicketState } from '@prisma/client'; import { SupportService } from './support.service'; import { AssignmentService } from './assignment.service'; import { SessionVerifier } from '../platform/session.verifier'; +import { ContextAttestationGuard } from '../platform/context-attestation.guard'; import type { MessagePrincipal } from '../identity/actor.resolver'; import { AssignTicketDto, @@ -15,6 +16,7 @@ import { } from './support.dto'; @Controller('v1/support') +@UseGuards(ContextAttestationGuard) export class SupportController { constructor( private readonly support: SupportService, diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 497d341..83d28f4 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -9,13 +9,16 @@ import { Param, Post, Query, + UseGuards, } from '@nestjs/common'; import { ThreadsService } from './threads.service'; import { MessageService } from '../messaging/message.service'; import { SessionVerifier } from '../platform/session.verifier'; +import { ContextAttestationGuard } from '../platform/context-attestation.guard'; import { SendMessageDto } from './send-message.dto'; @Controller('v1/threads') +@UseGuards(ContextAttestationGuard) export class ThreadsController { constructor( private readonly threads: ThreadsService, -- 2.52.0 From 9e0411bfe650cbc98e8dfaef70347281a4887d70 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Mon, 13 Jul 2026 21:03:45 +0530 Subject: [PATCH 10/30] =?UTF-8?q?feat(iios-kernel-client):=20RestClient=20?= =?UTF-8?q?custom=20headers=20passthrough=20=E2=86=92=200.1.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional RestConfig.headers, merged into every request, so a server-side caller (be-crm's glue) can carry X-Context-Attestation (the July 12 proof #3) alongside the bearer token. Backward compatible. Published 0.1.3. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-kernel-client/package.json | 2 +- packages/iios-kernel-client/src/rest.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/iios-kernel-client/package.json b/packages/iios-kernel-client/package.json index 2dbf438..f901a99 100644 --- a/packages/iios-kernel-client/package.json +++ b/packages/iios-kernel-client/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-kernel-client", - "version": "0.1.2", + "version": "0.1.3", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index a355529..b0d98cd 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -4,6 +4,8 @@ import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackReque export interface RestConfig { serviceUrl: string; token?: string; + /** Extra headers sent on every request — e.g. `x-context-attestation` (the July 12 trust proof). */ + headers?: Record; } /** REST/polling client for kernel reads and the native-send fallback. */ @@ -15,7 +17,7 @@ export class RestClient { } private headers(extra: Record = {}): Record { - const h: Record = { 'content-type': 'application/json', ...extra }; + const h: Record = { 'content-type': 'application/json', ...this.config.headers, ...extra }; if (this.config.token) h.authorization = `Bearer ${this.config.token}`; return h; } -- 2.52.0 From 3e66c7a5db365d6a401daa5f06c75fe372165766 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Mon, 13 Jul 2026 21:28:37 +0530 Subject: [PATCH 11/30] perf(iios): listThreads fetches last-message-per-thread in one query Replaced the per-thread findFirst inside Promise.all (N parallel queries) with a single Postgres DISTINCT ON query. A caller with many threads no longer fans out and exhausts the connection pool (the conversation.list 500 under accumulated data). message.spec 16/16 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/messaging/message.service.ts | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index 82091a7..7752796 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -208,28 +208,41 @@ export class MessageService { membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]); } - const summaries = await Promise.all( - threads.map(async (t) => { - const last = await this.prisma.iiosInteraction.findFirst({ - where: { threadId: t.id }, - orderBy: { occurredAt: 'desc' }, - include: { parts: { where: { kind: 'TEXT' }, take: 1 } }, - }); - const members = membersBy.get(t.id) ?? []; - return { - threadId: t.id, - subject: t.subject, - membership: (t.metadata as { membership?: string } | null)?.membership, - metadata: (t.metadata as Record | null) ?? null, - participants: members, - participantCount: members.length, - unread: unreadBy.get(t.id) ?? 0, - muted: mutedBy.get(t.id) ?? false, - lastMessage: last?.parts[0]?.bodyText ?? undefined, - lastAt: last?.occurredAt, - }; - }), - ); + // Latest message per thread in ONE query (Postgres DISTINCT ON) instead of one findFirst + // per thread — a caller with many threads no longer fans out N parallel queries and + // exhausts the connection pool. + const lastRows = + threads.length === 0 + ? [] + : await this.prisma.$queryRaw>(Prisma.sql` + SELECT DISTINCT ON (i."threadId") + i."threadId" AS "threadId", + i."occurredAt" AS "occurredAt", + (SELECT p."bodyText" FROM "IiosMessagePart" p + WHERE p."interactionId" = i.id AND p.kind::text = 'TEXT' + ORDER BY p."partIndex" ASC LIMIT 1) AS "lastMessage" + FROM "IiosInteraction" i + WHERE i."threadId" IN (${Prisma.join(threads.map((t) => t.id))}) + ORDER BY i."threadId", i."occurredAt" DESC + `); + const lastBy = new Map(lastRows.map((r) => [r.threadId, r])); + + const summaries = threads.map((t) => { + const last = lastBy.get(t.id); + const members = membersBy.get(t.id) ?? []; + return { + threadId: t.id, + subject: t.subject, + membership: (t.metadata as { membership?: string } | null)?.membership, + metadata: (t.metadata as Record | null) ?? null, + participants: members, + participantCount: members.length, + unread: unreadBy.get(t.id) ?? 0, + muted: mutedBy.get(t.id) ?? false, + lastMessage: last?.lastMessage ?? undefined, + lastAt: last?.occurredAt, + }; + }); return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0)); } -- 2.52.0 From 85a78eb21e8558becd600b5b044ad8c6310e5792 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Mon, 13 Jul 2026 23:26:33 +0530 Subject: [PATCH 12/30] =?UTF-8?q?feat(iios-kernel-client):=20per-request?= =?UTF-8?q?=20header=20factory=20=E2=86=92=200.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RestConfig.headers now accepts a function, invoked once per request, so a caller can mint a FRESH context attestation (new single-use nonce) each call. A static header was replay-rejected on the 2nd request of a multi-call operation. Published 0.1.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-kernel-client/package.json | 2 +- packages/iios-kernel-client/src/rest.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/iios-kernel-client/package.json b/packages/iios-kernel-client/package.json index f901a99..a9ecafe 100644 --- a/packages/iios-kernel-client/package.json +++ b/packages/iios-kernel-client/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-kernel-client", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index b0d98cd..5b04608 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -4,8 +4,13 @@ import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackReque export interface RestConfig { serviceUrl: string; token?: string; - /** Extra headers sent on every request — e.g. `x-context-attestation` (the July 12 trust proof). */ - headers?: Record; + /** + * Extra headers for every request — e.g. `x-context-attestation` (the July 12 trust proof). + * Pass a FUNCTION to mint fresh headers per request: a context attestation carries a single-use + * nonce, so a static header would be replay-rejected on the 2nd call. The function is invoked + * once per request. + */ + headers?: Record | (() => Record); } /** REST/polling client for kernel reads and the native-send fallback. */ @@ -17,7 +22,8 @@ export class RestClient { } private headers(extra: Record = {}): Record { - const h: Record = { 'content-type': 'application/json', ...this.config.headers, ...extra }; + const custom = typeof this.config.headers === 'function' ? this.config.headers() : this.config.headers; + const h: Record = { 'content-type': 'application/json', ...custom, ...extra }; if (this.config.token) h.authorization = `Bearer ${this.config.token}`; return h; } -- 2.52.0 From e39caa3c80f8e6e32c2d6d032b991c60a9ff50c3 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 15 Jul 2026 12:56:10 +0530 Subject: [PATCH 13/30] feat: verify context attestations via ES256/JWKS + rename client to appshell-crm Proof #3 now supports the production asymmetric path, not just the dev shared secret. - context-attestation.ts: PublicKeyResolverPort seam; verify() picks ES256 (JWKS by kid) when the client has a jwksUri, else HS256 (dev). signDevAttestationES256 helper. - attestation-stores.ts: JwksPublicKeyResolver (jwks-rsa, one cached client per URI, refetch on rotation, fail-closed to NO_KEY). - attestation.module.ts: inject the resolver into the verifier; dev-seed the client as clientType APPSHELL (it is the CRM browser-flow parent per the July-12 notes). - rename the registered client crm-support-widget -> appshell-crm (the name reflects the attesting parent, not a widget). - specs: ES256 (valid via JWKS, wrong key -> BAD_SIGNATURE, unknown kid -> NO_KEY, no resolver -> NO_KEY) + two stolen-token gate cases (wrong app binding -> APP_MISMATCH, wrong audience -> WRONG_AUDIENCE). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-service/.env.example | 2 +- packages/iios-service/package.json | 1 + .../src/platform/attestation-stores.ts | 36 ++++++++- .../src/platform/attestation.module.ts | 15 ++-- .../context-attestation.guard.spec.ts | 45 ++++++++--- .../src/platform/context-attestation.spec.ts | 77 +++++++++++++++++-- .../src/platform/context-attestation.ts | 64 +++++++++++++-- pnpm-lock.yaml | 41 ++++++++++ 8 files changed, 252 insertions(+), 29 deletions(-) diff --git a/packages/iios-service/.env.example b/packages/iios-service/.env.example index 527e6b3..9053606 100644 --- a/packages/iios-service/.env.example +++ b/packages/iios-service/.env.example @@ -54,7 +54,7 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12) # Audience IIOS requires on attestations addressed to it. IIOS_ATTESTATION_AUDIENCE=iios-core # Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the -# crm-support-widget client into the registry when IIOS_DEV_TOKENS=1). +# appshell-crm client into the registry when IIOS_DEV_TOKENS=1). # IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key # When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403). # Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless. diff --git a/packages/iios-service/package.json b/packages/iios-service/package.json index 9705868..30298be 100644 --- a/packages/iios-service/package.json +++ b/packages/iios-service/package.json @@ -26,6 +26,7 @@ "dotenv": "^16.4.7", "ioredis": "^5.11.1", "jsonwebtoken": "^9.0.3", + "jwks-rsa": "^4.1.0", "prisma": "^6.2.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", diff --git a/packages/iios-service/src/platform/attestation-stores.ts b/packages/iios-service/src/platform/attestation-stores.ts index 8978e7e..8bf4a6c 100644 --- a/packages/iios-service/src/platform/attestation-stores.ts +++ b/packages/iios-service/src/platform/attestation-stores.ts @@ -1,7 +1,13 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import { JwksClient } from 'jwks-rsa'; import { PrismaService } from '../prisma/prisma.service'; -import type { ClientRegistryEntry, ClientRegistryPort, NonceStorePort } from './context-attestation'; +import type { + ClientRegistryEntry, + ClientRegistryPort, + NonceStorePort, + PublicKeyResolverPort, +} from './context-attestation'; /** Client registry backed by Postgres (IiosClientRegistry). */ @Injectable() @@ -40,3 +46,31 @@ export class PrismaNonceStore implements NonceStorePort { } } } + +/** + * Resolves a client's public signing key from its JWKS (prod ES256 path). Keeps ONE JwksClient + * per jwksUri — the client caches keys and refetches on a cache miss (so key rotation is picked up + * without a restart). Returns null on any failure so the verifier fails closed with NO_KEY. + */ +@Injectable() +export class JwksPublicKeyResolver implements PublicKeyResolverPort { + private readonly clients = new Map(); + + private clientFor(jwksUri: string): JwksClient { + let c = this.clients.get(jwksUri); + if (!c) { + c = new JwksClient({ jwksUri, cache: true, cacheMaxEntries: 8, cacheMaxAge: 10 * 60_000, rateLimit: true, jwksRequestsPerMinute: 12 }); + this.clients.set(jwksUri, c); + } + return c; + } + + async resolve(jwksUri: string, kid: string): Promise { + try { + const key = await this.clientFor(jwksUri).getSigningKey(kid); + return key.getPublicKey(); // PEM (SPKI) — works for EC (ES256) and RSA keys + } catch { + return null; // unknown kid / unreachable JWKS / malformed key → fail closed + } + } +} diff --git a/packages/iios-service/src/platform/attestation.module.ts b/packages/iios-service/src/platform/attestation.module.ts index 979045e..0c0acf3 100644 --- a/packages/iios-service/src/platform/attestation.module.ts +++ b/packages/iios-service/src/platform/attestation.module.ts @@ -1,7 +1,7 @@ import { Global, Module, type OnModuleInit } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { ContextAttestationVerifier } from './context-attestation'; -import { PrismaClientRegistry, PrismaNonceStore } from './attestation-stores'; +import { PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver } from './attestation-stores'; import { ContextAttestationGuard } from './context-attestation.guard'; /** @@ -14,11 +14,12 @@ import { ContextAttestationGuard } from './context-attestation.guard'; providers: [ PrismaClientRegistry, PrismaNonceStore, + JwksPublicKeyResolver, { provide: ContextAttestationVerifier, - useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore) => - new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }), - inject: [PrismaClientRegistry, PrismaNonceStore], + useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore, keys: JwksPublicKeyResolver) => + new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }, keys), + inject: [PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver], }, ContextAttestationGuard, ], @@ -35,9 +36,9 @@ export class AttestationModule implements OnModuleInit { if (!secret) return; await this.prisma.iiosClientRegistry .upsert({ - where: { clientId: 'crm-support-widget' }, - create: { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' }, - update: { attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' }, + where: { clientId: 'appshell-crm' }, + create: { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' }, + update: { clientType: 'APPSHELL', attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' }, }) .catch(() => undefined); } diff --git a/packages/iios-service/src/platform/context-attestation.guard.spec.ts b/packages/iios-service/src/platform/context-attestation.guard.spec.ts index 711264e..5993e73 100644 --- a/packages/iios-service/src/platform/context-attestation.guard.spec.ts +++ b/packages/iios-service/src/platform/context-attestation.guard.spec.ts @@ -16,26 +16,38 @@ const fakeSession = { verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }), } as unknown as SessionVerifier; -function makeGuard(): ContextAttestationGuard { +const registryFor = (): ClientRegistryPort => ({ + find: async (id) => + id === 'appshell-crm' + ? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' } + : null, +}); +const freshNonces = (): NonceStorePort => { const seen = new Set(); - const registry: ClientRegistryPort = { - find: async (id) => - id === 'crm-support-widget' - ? { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' } - : null, - }; - const nonces: NonceStorePort = { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) }; - const verifier = new ContextAttestationVerifier(registry, nonces, { audience: 'iios-core' }); + return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) }; +}; + +function makeGuard(): ContextAttestationGuard { + const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' }); return new ContextAttestationGuard(fakeSession, verifier); } +// A guard whose actor token resolves to `tokenApp` — used to test attestation↔token app binding. +function makeGuardForApp(tokenApp: string): ContextAttestationGuard { + const session = { + verify: () => ({ userId: 'agent_1', appId: tokenApp, orgId: 'org', tenantId: 'tnt', displayName: 'A' }), + } as unknown as SessionVerifier; + const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' }); + return new ContextAttestationGuard(session, verifier); +} + function ctxWith(headers: Record): ExecutionContext { return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext; } function att(over: Record = {}, secret = SECRET): string { return signDevAttestation( - { issuer: 'appshell.crm', audience: 'iios-core', clientId: 'crm-support-widget', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over }, + { issuer: 'appshell.crm', audience: 'iios-core', clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over }, secret, ); } @@ -68,4 +80,17 @@ describe('ContextAttestationGuard (three-proof gate on the request path)', () => expect(await guard.canActivate(ctxWith(headers))).toBe(true); await expect(guard.canActivate(ctxWith({ ...headers }))).rejects.toBeInstanceOf(ForbiddenException); }); + + // ── doc rejection matrix: "valid token + wrong client/context → reject" ── + it('rejects a valid attestation whose app binding != the actor token app (stolen context reused by another app)', async () => { + // Attestation is validly signed for app crm-web, but the presented actor token is for a different app. + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_appmix' }) }; + await expect(makeGuardForApp('other-app').canActivate(ctxWith(headers))).rejects.toThrow(/APP_MISMATCH/); + }); + + // ── doc rejection matrix: "valid token + wrong audience → reject before business logic" ── + it('rejects an attestation minted for another service (audience != iios-core) replayed at IIOS', async () => { + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_aud', audience: 'some-other-service' }) }; + await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toThrow(/WRONG_AUDIENCE/); + }); }); diff --git a/packages/iios-service/src/platform/context-attestation.spec.ts b/packages/iios-service/src/platform/context-attestation.spec.ts index c17e96d..79ecfa2 100644 --- a/packages/iios-service/src/platform/context-attestation.spec.ts +++ b/packages/iios-service/src/platform/context-attestation.spec.ts @@ -1,11 +1,14 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { generateKeyPairSync } from 'node:crypto'; import { PrismaClient } from '@prisma/client'; import { ContextAttestationVerifier, signDevAttestation, + signDevAttestationES256, type ClientRegistryEntry, type ClientRegistryPort, type NonceStorePort, + type PublicKeyResolverPort, } from './context-attestation'; import { PrismaNonceStore } from './attestation-stores'; import type { PrismaService } from '../prisma/prisma.service'; @@ -17,7 +20,7 @@ const NOW = new Date('2026-07-13T12:00:00Z'); // ─── in-memory fakes (fast, deterministic — no DB needed) ───────── function fakeRegistry(over: Partial = {}): ClientRegistryPort { const entry: ClientRegistryEntry = { - clientId: 'crm-support-widget', + clientId: 'appshell-crm', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: APPSHELL_SECRET, @@ -38,7 +41,7 @@ const verifier = (reg: ClientRegistryPort = fakeRegistry(), nonces: NonceStorePo /** A well-formed AppShell attestation, overridable per test. */ function attest(over: Record = {}, secret = APPSHELL_SECRET, at = NOW): string { return signDevAttestation( - { issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'crm-support-widget', appId: 'crm-web', nonce: 'n_default', ...over }, + { issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: 'n_default', ...over }, secret, at, ); @@ -48,7 +51,7 @@ describe('ContextAttestationVerifier (July 12 trust proof)', () => { it('accepts a valid attestation from a registered client for the matching app', async () => { const res = await verifier().verify(attest({ nonce: 'n_ok' }), 'crm-web', NOW); expect(res.ok).toBe(true); - if (res.ok) expect(res.attestation.clientId).toBe('crm-support-widget'); + if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm'); }); // ── the five rejection cases the CEO named ── @@ -101,6 +104,70 @@ describe('ContextAttestationVerifier (July 12 trust proof)', () => { }); }); +// ─── prod asymmetric path: ES256 verified via the client's JWKS ─── +describe('ContextAttestationVerifier — JWKS / ES256 (production signing path)', () => { + const JWKS = 'https://appshell.example/.well-known/jwks.json'; + const KID = 'appshell-key-1'; + + // A real EC P-256 keypair (what AppShell would hold; only the public half is published as JWKS). + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + const pubPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; + const { privateKey: otherPriv } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const otherPrivPem = otherPriv.export({ type: 'pkcs8', format: 'pem' }) as string; + + // Registry entry that verifies via JWKS (no shared secret) + a resolver that maps KID → pubkey. + const jwksRegistry: ClientRegistryPort = { + find: async (id) => + id === 'appshell-crm' + ? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], jwksUri: JWKS, status: 'ACTIVE' } + : null, + }; + const resolver: PublicKeyResolverPort = { resolve: async (_uri, kid) => (kid === KID ? pubPem : null) }; + const v = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD }, resolver); + // No 4th arg → no key resolver wired at all (distinct from passing undefined, which hits the default). + const vNoResolver = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD }); + + function es256(over: Record = {}, priv = privPem, kid = KID): string { + return signDevAttestationES256( + { issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over }, + priv, + kid, + NOW, + ); + } + + it('accepts an ES256 attestation whose signature verifies against the JWKS', async () => { + const res = await v().verify(es256({ nonce: 'es_ok' }), 'crm-web', NOW); + expect(res.ok).toBe(true); + if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm'); + }); + + it('rejects an ES256 attestation signed with a DIFFERENT private key (forged)', async () => { + const res = await v().verify(es256({ nonce: 'es_forged' }, otherPrivPem), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' }); + }); + + it('rejects when the JWT header carries an unknown kid (no matching JWKS key)', async () => { + const res = await v().verify(es256({ nonce: 'es_kid' }, privPem, 'rotated-away-kid'), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' }); + }); + + it('rejects when a JWKS client is configured but no key resolver is wired', async () => { + const res = await vNoResolver().verify(es256({ nonce: 'es_nores' }), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' }); + }); + + it('still enforces audience / app / replay on the ES256 path', async () => { + const vv = v(); + expect(await vv.verify(es256({ nonce: 'es_aud', audience: 'other' }), 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' }); + expect(await vv.verify(es256({ nonce: 'es_app' }), 'some-other-app', NOW)).toMatchObject({ ok: false, reason: 'APP_MISMATCH' }); + const tok = es256({ nonce: 'es_replay' }); + expect((await vv.verify(tok, 'crm-web', NOW)).ok).toBe(true); + expect(await vv.verify(tok, 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'REPLAY' }); + }); +}); + // ─── DB-level atomicity of the nonce ledger ─────────────────────── describe('PrismaNonceStore (atomic replay defense)', () => { const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; @@ -117,8 +184,8 @@ describe('PrismaNonceStore (atomic replay defense)', () => { if (!dbUp) return ctx.skip(); // DB unreachable in this environment — the in-memory REPLAY test covers the logic const nonce = `n_db_${NOW.getTime()}_${Math.floor(Math.random() * 1e9)}`; const expiresAt = new Date(NOW.getTime() + 300_000); - const first = await store.reserve({ nonce, clientId: 'crm-support-widget', expiresAt }); - const second = await store.reserve({ nonce, clientId: 'crm-support-widget', expiresAt }); + const first = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt }); + const second = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt }); expect(first).toBe(true); expect(second).toBe(false); await prisma.iiosAttestationNonce.delete({ where: { nonce } }).catch(() => undefined); diff --git a/packages/iios-service/src/platform/context-attestation.ts b/packages/iios-service/src/platform/context-attestation.ts index 66d7046..1395721 100644 --- a/packages/iios-service/src/platform/context-attestation.ts +++ b/packages/iios-service/src/platform/context-attestation.ts @@ -32,7 +32,7 @@ export interface ClientRegistryEntry { clientType: string; allowedAppIds: string[]; attestSecret?: string; // dev: shared HS256 signing key (AppShell's key stand-in) - jwksUri?: string; // prod: asymmetric verification (seam — not used in dev) + jwksUri?: string; // prod: verify the attestation signature asymmetrically (ES256) via this JWKS status: 'ACTIVE' | 'DISABLED'; } @@ -45,6 +45,16 @@ export interface NonceStorePort { reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise; } +/** + * Resolves the PUBLIC key for a JWKS uri + key id, for the prod asymmetric (ES256) path. + * A port so the verifier stays network-free and unit-testable — the real impl fetches + + * caches the client's JWKS; tests inject a fake that returns a known key. + */ +export interface PublicKeyResolverPort { + /** Return the signing key (PEM) for `kid` at `jwksUri`, or null if it can't be resolved. */ + resolve(jwksUri: string, kid: string): Promise; +} + export type AttestationReason = | 'MALFORMED' | 'UNKNOWN_CLIENT' @@ -72,6 +82,8 @@ export class ContextAttestationVerifier { private readonly registry: ClientRegistryPort, private readonly nonces: NonceStorePort, private readonly config: AttestationConfig, + /** Optional — required only for clients that verify via a JWKS (prod ES256). */ + private readonly keys?: PublicKeyResolverPort, ) {} /** @@ -91,13 +103,16 @@ export class ContextAttestationVerifier { const client = await this.registry.find(claimed.clientId); if (!client) return deny('UNKNOWN_CLIENT', `client "${claimed.clientId}" is not registered`); if (client.status !== 'ACTIVE') return deny('CLIENT_DISABLED', `client "${claimed.clientId}" is ${client.status}`); - if (!client.attestSecret) return deny('NO_KEY', `no verification key configured for "${claimed.clientId}"`); - // 3. Verify the signature with the client's key (dev HS256; JWKS is the prod seam). - // ignoreExpiration so we can return a precise EXPIRED reason (step 6) rather than BAD_SIGNATURE. + // 3. Verify the signature with the client's key. A client verifies EITHER asymmetrically via + // its published JWKS (prod: AppShell signs ES256 with a private key) OR with a shared HS256 + // secret (dev stand-in). ignoreExpiration so we can return a precise EXPIRED (step 6) rather + // than BAD_SIGNATURE. + const key = await this.resolveVerificationKey(attestationJwt, client); + if (!key.ok) return deny(key.reason, key.detail); let att: ContextAttestation & jwt.JwtPayload; try { - att = jwt.verify(attestationJwt, client.attestSecret, { algorithms: ['HS256'], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload; + att = jwt.verify(attestationJwt, key.pem, { algorithms: [key.alg], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload; } catch (e) { return deny('BAD_SIGNATURE', (e as Error).message); } @@ -119,6 +134,29 @@ export class ContextAttestationVerifier { return { ok: true, attestation: att }; } + + /** + * Pick the algorithm + verification key for a client. JWKS (ES256) wins when configured: + * read the `kid` from the JWT header and resolve the public key from the client's JWKS. + * Otherwise fall back to the shared HS256 dev secret. Either way returns a precise NO_KEY + * reason when no usable key can be obtained (fail-closed). + */ + private async resolveVerificationKey( + attestationJwt: string, + client: ClientRegistryEntry, + ): Promise<{ ok: true; pem: string; alg: 'ES256' | 'HS256' } | { ok: false; reason: AttestationReason; detail: string }> { + if (client.jwksUri) { + if (!this.keys) return { ok: false, reason: 'NO_KEY', detail: `client "${client.clientId}" uses JWKS but no key resolver is wired` }; + const decoded = jwt.decode(attestationJwt, { complete: true }); + const kid = decoded && typeof decoded === 'object' ? (decoded.header?.kid as string | undefined) : undefined; + if (!kid) return { ok: false, reason: 'NO_KEY', detail: 'attestation header missing kid (required for JWKS)' }; + const pem = await this.keys.resolve(client.jwksUri, kid).catch(() => null); + if (!pem) return { ok: false, reason: 'NO_KEY', detail: `no JWKS key for kid "${kid}"` }; + return { ok: true, pem, alg: 'ES256' }; + } + if (client.attestSecret) return { ok: true, pem: client.attestSecret, alg: 'HS256' }; + return { ok: false, reason: 'NO_KEY', detail: `no verification key configured for "${client.clientId}"` }; + } } /** @@ -134,3 +172,19 @@ export function signDevAttestation( const iat = Math.floor(now.getTime() / 1000); return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, secret, { algorithm: 'HS256' }); } + +/** + * DEV/TEST helper: mint an attestation signed ASYMMETRICALLY (ES256), the way production AppShell + * does. `privateKeyPem` is a PKCS#8 EC private key; `kid` is stamped into the JWT header so the + * verifier can pick the matching public key from the client's JWKS. + */ +export function signDevAttestationES256( + claims: Omit & { ttlSeconds?: number }, + privateKeyPem: string, + kid: string, + now: Date = new Date(), +): string { + const { ttlSeconds = 300, ...rest } = claims; + const iat = Math.floor(now.getTime() / 1000); + return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, privateKeyPem, { algorithm: 'ES256', keyid: kid }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b58d8bd..5159988 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,6 +370,9 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + jwks-rsa: + specifier: ^4.1.0 + version: 4.1.0 prisma: specifier: ^6.2.1 version: 6.19.3(typescript@5.9.3) @@ -2297,6 +2300,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2343,6 +2349,10 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@4.1.0: + resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -2353,6 +2363,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2368,6 +2381,9 @@ packages: resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} engines: {node: '>=6.11.5'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -2406,6 +2422,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-memoizer@3.0.0: + resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -5066,6 +5085,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -5113,6 +5134,17 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@4.1.0: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 6.2.3 + limiter: 1.1.5 + lru-cache: 11.5.1 + lru-memoizer: 3.0.0 + transitivePeerDependencies: + - supports-color + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -5122,6 +5154,8 @@ snapshots: lilconfig@3.1.3: {} + limiter@1.1.5: {} + lines-and-columns@1.2.4: {} load-esm@1.0.3: {} @@ -5130,6 +5164,8 @@ snapshots: loader-runner@4.3.2: {} + lodash.clonedeep@4.5.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -5159,6 +5195,11 @@ snapshots: dependencies: yallist: 3.1.1 + lru-memoizer@3.0.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 11.5.1 + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 -- 2.52.0 From 3298401772c40912fa7d73b5947ea65fafa00c51 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 15 Jul 2026 12:56:19 +0530 Subject: [PATCH 14/30] feat: enforce realtime delegation audience on the message socket The socket used to accept any valid token, so a full REST/actor token could open the live stream. Now it can require a narrowly-scoped delegated token (aud=iios-message), so a leaked socket token can't drive privileged REST, and vice-versa. - MessagePrincipal gains `audience`, surfaced from both verify paths (OIDC aud, and the app-token `aud` claim). - message.gateway: when IIOS_REALTIME_AUDIENCE is set, handleConnection accepts only a token whose aud matches (opt-in, like IIOS_REQUIRE_ATTESTATION; unset = no change). - spec: verifier surfaces aud; gateway accepts iios-message, rejects iios-core / no-aud when enforcing, passes through when off. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/identity/actor.resolver.ts | 3 + .../src/messaging/message.gateway.spec.ts | 93 +++++++++++++++++++ .../src/messaging/message.gateway.ts | 8 ++ .../src/platform/session.verifier.ts | 2 + 4 files changed, 106 insertions(+) create mode 100644 packages/iios-service/src/messaging/message.gateway.spec.ts diff --git a/packages/iios-service/src/identity/actor.resolver.ts b/packages/iios-service/src/identity/actor.resolver.ts index 6f94b39..85fe0db 100644 --- a/packages/iios-service/src/identity/actor.resolver.ts +++ b/packages/iios-service/src/identity/actor.resolver.ts @@ -8,6 +8,9 @@ export interface MessagePrincipal { appId: string; tenantId?: string; displayName?: string; + /** The token's audience (`aud`). Used to enforce realtime delegation: a socket-scoped + * token (`iios-message`) must not be a full REST/actor token (`iios-core`), and vice-versa. */ + audience?: string; } /** diff --git a/packages/iios-service/src/messaging/message.gateway.spec.ts b/packages/iios-service/src/messaging/message.gateway.spec.ts new file mode 100644 index 0000000..404ea8c --- /dev/null +++ b/packages/iios-service/src/messaging/message.gateway.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest'; +import jwt from 'jsonwebtoken'; +import type { Socket } from 'socket.io'; +import { MessageGateway } from './message.gateway'; +import type { MessageService, MessagePrincipal } from './message.service'; +import { SessionVerifier } from '../platform/session.verifier'; +import type { OutboxBus } from '../outbox/outbox.bus'; +import type { PresenceService } from '../notifications/presence.service'; + +// Realtime delegation: the browser opens the socket with a short-lived token minted for the +// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY +// that audience — a full REST/actor token (iios-core) or an un-scoped token can't open the stream. + +const APP = 'crm-web'; +const SECRET = 'dev-crm-secret'; + +// ─── the real SessionVerifier must expose `aud` so the gateway can enforce it ─── +describe('SessionVerifier — app-token audience surfacing', () => { + let v: SessionVerifier; + beforeAll(async () => { + delete process.env.AUTH_ISSUERS; + delete process.env.SUPABASE_URL; + process.env.APP_SECRETS = JSON.stringify({ [APP]: SECRET }); + v = new SessionVerifier(); + await v.onModuleInit(); + }); + afterAll(() => { delete process.env.APP_SECRETS; }); + + it('surfaces the aud claim of a realtime-scoped token', () => { + const tok = jwt.sign({ appId: APP, aud: 'iios-message' }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' }); + expect(v.verify(tok).audience).toBe('iios-message'); + }); + + it('leaves audience undefined for an un-scoped token (a REST/actor token)', () => { + const tok = jwt.sign({ appId: APP }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' }); + expect(v.verify(tok).audience).toBeUndefined(); + }); +}); + +// ─── the gateway enforces the realtime audience on connect ─── +function fakeSocket(): { sock: Socket; disconnected: () => boolean } { + let disconnected = false; + const sock = { + handshake: { auth: { token: 'tok' } }, + disconnect: () => { disconnected = true; }, + data: undefined as unknown, + } as unknown as Socket; + return { sock, disconnected: () => disconnected }; +} + +function gatewayReturning(principal: MessagePrincipal): MessageGateway { + const session = { verify: () => principal } as unknown as SessionVerifier; + return new MessageGateway( + undefined as unknown as MessageService, + session, + undefined as unknown as OutboxBus, + undefined as unknown as PresenceService, + ); +} + +const principal = (audience?: string): MessagePrincipal => ({ userId: 'u', appId: APP, orgId: 'org', audience }); + +describe('MessageGateway — realtime audience enforcement', () => { + afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; }); + + it('accepts any valid token when enforcement is OFF (env unset)', () => { + const { sock, disconnected } = fakeSocket(); + gatewayReturning(principal(undefined)).handleConnection(sock); + expect(disconnected()).toBe(false); + expect((sock.data as { principal: MessagePrincipal }).principal.userId).toBe('u'); + }); + + it('accepts a token whose aud matches the required realtime audience', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + const { sock, disconnected } = fakeSocket(); + gatewayReturning(principal('iios-message')).handleConnection(sock); + expect(disconnected()).toBe(false); + }); + + it('rejects a full REST/actor token (aud=iios-core) on the socket', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + const { sock, disconnected } = fakeSocket(); + gatewayReturning(principal('iios-core')).handleConnection(sock); + expect(disconnected()).toBe(true); + }); + + it('rejects an un-scoped token (no aud) when enforcement is on', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + const { sock, disconnected } = fakeSocket(); + gatewayReturning(principal(undefined)).handleConnection(sock); + expect(disconnected()).toBe(true); + }); +}); diff --git a/packages/iios-service/src/messaging/message.gateway.ts b/packages/iios-service/src/messaging/message.gateway.ts index 3f62629..1cef910 100644 --- a/packages/iios-service/src/messaging/message.gateway.ts +++ b/packages/iios-service/src/messaging/message.gateway.ts @@ -61,6 +61,14 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat try { const token = String(client.handshake.auth?.token ?? ''); const principal = this.session.verify(token); + // Realtime delegation (least privilege): when IIOS_REALTIME_AUDIENCE is set, the socket + // accepts ONLY a token minted for that audience (a short-lived `iios-message` delegate) — + // a full REST/actor token (`iios-core`) or an un-scoped token cannot open the stream. So a + // leaked socket token can't drive privileged REST, and vice-versa. Unset → no enforcement. + const required = process.env.IIOS_REALTIME_AUDIENCE?.trim(); + if (required && principal.audience !== required) { + throw new Error(`realtime audience "${principal.audience ?? '(none)'}" != required "${required}"`); + } client.data = { principal } satisfies SocketState; } catch (err) { this.logger.warn(`rejecting socket: ${(err as Error).message}`); diff --git a/packages/iios-service/src/platform/session.verifier.ts b/packages/iios-service/src/platform/session.verifier.ts index a218b3e..dfdeff6 100644 --- a/packages/iios-service/src/platform/session.verifier.ts +++ b/packages/iios-service/src/platform/session.verifier.ts @@ -135,6 +135,7 @@ export class SessionVerifier implements OnModuleInit { orgId: entry.orgId, tenantId: undefined, displayName: meta.full_name ?? meta.name ?? email ?? userId, + audience: typeof payload.aud === 'string' ? payload.aud : entry.audience, }; } @@ -159,6 +160,7 @@ export class SessionVerifier implements OnModuleInit { orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`, tenantId: payload.tenantId ? String(payload.tenantId) : undefined, displayName: payload.name ? String(payload.name) : undefined, + audience: typeof payload.aud === 'string' ? payload.aud : undefined, }; } -- 2.52.0 From 6dc9e4ffee64774a5a68f95107ebc05f8e1d3daa Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 15 Jul 2026 20:29:59 +0530 Subject: [PATCH 15/30] feat: reject socket-scoped tokens on REST (the other half of realtime delegation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegated socket token (aud=IIOS_REALTIME_AUDIENCE) was only narrow in one direction: the gateway accepts ONLY that audience, but REST never checked the actor token's audience — so a leaked browser socket token still drove privileged REST, i.e. a full actor token with extra steps. RealtimeTokenRestGuard closes it, registered GLOBALLY (APP_GUARD) because every REST route is a target — only 2 of 15 controllers sit behind ContextAttestationGuard, so inbox/media/interactions/ ai/... would otherwise stay open. It is a decode-only REJECT filter, never an authenticator: - does not verify signatures (controllers still call SessionVerifier); stripping `aud` to bypass it invalidates the signature downstream, - no bearer -> pass through (health, metrics, HMAC adapter webhooks), - ws context -> pass through (the gateway enforces the mirror rule), - unset IIOS_REALTIME_AUDIENCE -> no-op, the same switch that turns on the socket half. So one env now enables the whole boundary: socket accepts only iios-message, REST refuses it. 8 tests; typecheck + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform/platform.module.ts | 13 +++- .../src/platform/realtime-token.guard.spec.ts | 68 +++++++++++++++++++ .../src/platform/realtime-token.guard.ts | 43 ++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 packages/iios-service/src/platform/realtime-token.guard.spec.ts create mode 100644 packages/iios-service/src/platform/realtime-token.guard.ts diff --git a/packages/iios-service/src/platform/platform.module.ts b/packages/iios-service/src/platform/platform.module.ts index c69c2a1..e2409cf 100644 --- a/packages/iios-service/src/platform/platform.module.ts +++ b/packages/iios-service/src/platform/platform.module.ts @@ -1,10 +1,19 @@ import { Global, Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { PLATFORM_PORTS, LocalDevPorts } from './platform-ports'; +import { RealtimeTokenRestGuard } from './realtime-token.guard'; -/** Binds the platform ports (P1: the permissive in-service default). */ +/** + * Binds the platform ports (P1: the permissive in-service default), and registers the + * realtime-delegation REST guard globally — a socket-scoped token must not drive REST on ANY + * route, so it can't be per-controller (see realtime-token.guard). + */ @Global() @Module({ - providers: [{ provide: PLATFORM_PORTS, useClass: LocalDevPorts }], + providers: [ + { provide: PLATFORM_PORTS, useClass: LocalDevPorts }, + { provide: APP_GUARD, useClass: RealtimeTokenRestGuard }, + ], exports: [PLATFORM_PORTS], }) export class PlatformModule {} diff --git a/packages/iios-service/src/platform/realtime-token.guard.spec.ts b/packages/iios-service/src/platform/realtime-token.guard.spec.ts new file mode 100644 index 0000000..6cae345 --- /dev/null +++ b/packages/iios-service/src/platform/realtime-token.guard.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { ForbiddenException, type ExecutionContext } from '@nestjs/common'; +import jwt from 'jsonwebtoken'; +import { RealtimeTokenRestGuard } from './realtime-token.guard'; + +// Realtime delegation, REST half: a socket-scoped token (aud=iios-message) opens the /message +// socket and NOTHING else. The gateway enforces the mirror rule; this guard is the REST side. + +const SECRET = 'dev-crm-secret'; +const token = (payload: Record) => jwt.sign(payload, SECRET, { algorithm: 'HS256', expiresIn: '5m' }); + +/** An HTTP ExecutionContext carrying the given authorization header. */ +function httpCtx(authorization?: string): ExecutionContext { + return { + getType: () => 'http', + switchToHttp: () => ({ getRequest: () => ({ headers: authorization ? { authorization } : {} }) }), + } as unknown as ExecutionContext; +} +const wsCtx = () => ({ getType: () => 'ws' }) as unknown as ExecutionContext; + +const guard = new RealtimeTokenRestGuard(); + +describe('RealtimeTokenRestGuard (socket tokens must not drive REST)', () => { + afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; }); + + it('rejects a socket-scoped token (aud=iios-message) on REST', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web', aud: 'iios-message' })}`); + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + }); + + it('allows a normal actor token (no aud)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web' })}`))).toBe(true); + }); + + it('allows a REST-audience token (aud=iios-core)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-core' })}`))).toBe(true); + }); + + it('rejects when the socket audience is one of several in an aud array', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: ['iios-core', 'iios-message'] })}`); + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + }); + + it('is a no-op when IIOS_REALTIME_AUDIENCE is unset (same switch as the socket half)', () => { + const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-message' })}`); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('passes through unauthenticated routes (health, metrics, HMAC adapter webhooks)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(httpCtx())).toBe(true); + expect(guard.canActivate(httpCtx('Hmac abc123'))).toBe(true); // non-bearer scheme + }); + + it('never applies to the socket itself (ws context)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(wsCtx())).toBe(true); + }); + + it('passes a malformed bearer through (auth is the controller\'s job, not this filter\'s)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(httpCtx('Bearer not-a-jwt'))).toBe(true); + }); +}); diff --git a/packages/iios-service/src/platform/realtime-token.guard.ts b/packages/iios-service/src/platform/realtime-token.guard.ts new file mode 100644 index 0000000..fade300 --- /dev/null +++ b/packages/iios-service/src/platform/realtime-token.guard.ts @@ -0,0 +1,43 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import jwt from 'jsonwebtoken'; +import type { Request } from 'express'; + +/** + * Realtime delegation — the REST half. + * + * The browser's socket token is deliberately narrow: `aud = IIOS_REALTIME_AUDIENCE` (e.g. + * iios-message), minutes-long, and good for the /message socket ONLY. The gateway enforces the + * mirror of this rule (it accepts ONLY that audience). Without this guard the narrowing is + * one-directional: REST doesn't check the actor token's audience, so a leaked socket token would + * still drive privileged REST — i.e. it would be a full actor token with extra steps. + * + * Applied GLOBALLY (APP_GUARD) on purpose: every REST route is a target, not just the ones behind + * ContextAttestationGuard (inbox, media, interactions, ai, … would otherwise stay open). + * + * It is a decode-only REJECT filter, never an authenticator: + * - it does not verify signatures (each controller still calls SessionVerifier) — cheap, and it + * can't be bypassed by stripping `aud`, because that invalidates the signature downstream; + * - no bearer token → pass through (health, metrics, HMAC adapter webhooks are not its business); + * - unset IIOS_REALTIME_AUDIENCE → no-op (same switch that turns on the socket half). + */ +@Injectable() +export class RealtimeTokenRestGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + if (context.getType() !== 'http') return true; // the socket enforces its own (mirror) rule + + const socketAudience = process.env.IIOS_REALTIME_AUDIENCE?.trim(); + if (!socketAudience) return true; + + const auth = context.switchToHttp().getRequest().headers['authorization']; + const raw = Array.isArray(auth) ? auth[0] : auth; + if (typeof raw !== 'string' || !/^Bearer\s+/i.test(raw)) return true; + + const decoded = jwt.decode(raw.replace(/^Bearer\s+/i, ''), { json: true }); + const aud = decoded?.aud; + const isSocketToken = aud === socketAudience || (Array.isArray(aud) && aud.includes(socketAudience)); + if (isSocketToken) { + throw new ForbiddenException(`socket-scoped token (aud="${socketAudience}") cannot be used for REST`); + } + return true; + } +} -- 2.52.0 From cea0a27118ca38c9fac8d41d5160bc8b5ded4d11 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:18:26 +0530 Subject: [PATCH 16/30] docs(templates): email/message template module plan Design + 8-task TDD plan for the IIOS template module. Self-reviewed: provenance-write path, nullable-scope unique index, PII minimization. Co-Authored-By: Claude Opus 4.8 --- docs/email-template-module-plan.md | 277 +++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/email-template-module-plan.md diff --git a/docs/email-template-module-plan.md b/docs/email-template-module-plan.md new file mode 100644 index 0000000..ef5d87e --- /dev/null +++ b/docs/email-template-module-plan.md @@ -0,0 +1,277 @@ +# Email / Message Template Module — Implementation Plan + +**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Target:** first templates sending by Friday go-live. + +## Goal + +A reusable template module so **any** message the system sends — welcome, payment receipt, +onboarding reminders, drip — is produced from a stored (or ad-hoc) template with variables filled +in, then handed to the **existing** outbound pipeline. One render path, one send path, provenance +recorded for every send. + +From the July 16 meeting: *"template का एक पूरा module बनाना है… template में value भरोगे, और वो +outbound क्यू में डाल दोगे।"* + +## What already exists (the substrate — do NOT rebuild) + +- `OutboundService.send(channelType, target, payload, idempotencyKey?, scopeId?, purpose?)` — + idempotency + per-target/per-tenant rate limits + delivery ledger (`IiosOutboundCommand`). +- `CapabilityBroker` — policy gate + obligations + provider selection for egress. +- `EMAIL` is a registered channel; `EmailProvider` exists (**HTTP**, not SMTP — see Out of Scope). +- `EMAIL` is a first-class `IiosInteractionKind`; `IiosMessagePartKind` has `HTML`/`TEXT`. +- `IiosActorKind` includes `SERVICE`/`BOT` (system sender is first-class). +- `InboxModule` uses `OnModuleInit` — copy that pattern for the template seeder. + +The module adds **content/rendering** in front of this. It never talks to a provider directly. + +## Design decisions (locked) + +| # | Decision | Rationale | +|---|---|---| +| 1 | **Channel-generic**: one template per `(key, channel)`; channels `EMAIL` / `SMS` / `INTERNAL` | Vivek wants the same confirmation on email *and* SMS; INTERNAL = app-to-app, no SMTP | +| 2 | **Seed-from-files, DB-is-truth**: templates are files in the repo, seeded into the DB on boot if absent | Copy is version-controlled + code-reviewed; a later admin UI can edit the DB with no deploy; Friday needs no UI | +| 3 | **Handlebars** rendering | Auto-escapes HTML (customer names go into email → XSS risk), logic-less, no code execution, one dep | +| 4 | **Global default + scope override**: `scopeId` nullable — `NULL` = platform default, set = tenant override; resolve scoped-first-else-global | Seeds cleanly at boot (IIOS scopes are created lazily, so a boot seeder has no scope to seed into); leaves room for white-label | +| 5 | **Provenance on `IiosOutboundCommand`** (4 columns), not a new `template_snapshot` table | Rendered content is already in `payload`; only provenance is missing. Honours the SOT's intent at 4 columns | +| 6 | **`TemplateSource` = stored `{key,version?}` OR `{inline:{subject,html,text}}`** | Marketing hands over finished HTML (*"Maaz, HTML भेज सकते हैं"*); inline still renders vars + records provenance | +| 7 | **Integration pattern B**: pure `render()` + thin `sendTemplated()` composer | Single entry point for callers; provenance guaranteed by construction; `OutboundService` stays content-agnostic | +| 8 | **Caller = a service** (be-crm's system token); **recipient = a `target` address, never a login** | System mail has no user on the sending side; the recipient may have no account yet | + +## Caller & auth model + +Every send is authenticated as the **calling app/service** (e.g. be-crm via its `APP_SECRETS` +entry), verified by `SessionVerifier` like every other IIOS endpoint. The recipient is a plain +`target` string — **not** an IIOS principal and **not** required to be logged in or registered. +Sending a welcome email to an anonymous payer is the normal case: the app is the sender, the +address is data. `scopeId` for the send is derived from the caller's principal (`org/app/tenant`). + +## Data model + +### New table — `IiosMessageTemplate` + +```prisma +enum IiosTemplateChannel { + EMAIL + SMS + INTERNAL +} + +/// The template SOURCE. DB is runtime truth; platform defaults are seeded from repo files on boot. +/// Versions are immutable: a change writes a new (higher) version, never edits in place. +model IiosMessageTemplate { + id String @id @default(cuid()) + /// NULL = platform default (seeded). Set = a tenant scope's override of the same key. + scopeId String? + scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade) + key String // "welcome", "payment.receipt", "onboarding.reminder" + channel IiosTemplateChannel + locale String @default("en") + version Int @default(1) + subject String? // EMAIL only + bodyHtml String? // EMAIL / INTERNAL + bodyText String? // SMS, and EMAIL plaintext fallback + /// Declared variable names — render throws if a declared var is missing (fail loud). + variables Json? + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([scopeId, key, channel, locale, version]) + @@index([key, channel, locale, active]) +} +``` + +`IiosScope` needs the back-relation `messageTemplates IiosMessageTemplate[]`. + +**Resolution:** `resolve(key, channel, locale, scopeId?)` returns the highest-`version` `active` +row, preferring `scopeId = ` and falling back to `scopeId IS NULL`. No match → 404. + +### Provenance columns on `IiosOutboundCommand` + +```prisma + templateKey String? + templateVersion Int? + templateLocale String? + renderedHash String? // sha256 of the rendered content — replay/audit +``` + +All nullable: non-templated sends (if any) leave them null. + +**How provenance is written (review finding):** `OutboundService.send` **creates the command row +itself** (both the RATE_LIMITED and PENDING paths), so a caller cannot stamp provenance after the +fact without a racy update. Therefore `send()` gains one optional trailing arg — +`provenance?: { templateKey; templateVersion; templateLocale; renderedHash }` — written into the +same `create()` in both paths. `OutboundService` stays content-agnostic: it does not render or +resolve templates, it only persists four opaque strings it is handed. This is what makes +decision 7's "provenance guaranteed by construction" true. **This is a change to an existing file** +(`adapters/outbound.service.ts`) and its spec — call it out in the PR. + +### Migrations (hand-written, non-destructive) + +1. `add_message_template` — the enum + table + `IiosScope` back-relation. **Plus a partial unique + index for global rows (review finding):** the `@@unique([scopeId, key, channel, locale, version])` + does **not** prevent duplicate *platform-default* rows, because Postgres treats `NULL` scopeId as + distinct (`NULL != NULL`) — the same footgun handled in the inbox idempotency migration. Add: + `CREATE UNIQUE INDEX "IiosMessageTemplate_global_key" ON "IiosMessageTemplate"("key","channel","locale","version") WHERE "scopeId" IS NULL;` + so a double-seed or race cannot create two defaults for the same key. +2. `add_outbound_template_provenance` — the 4 nullable columns on `IiosOutboundCommand`. + +## Module structure + +``` +packages/iios-service/src/templates/ + template.channel.ts // IiosTemplateChannel re-export/helpers if needed + template.model.ts // TemplateSource, RenderedContent, CreateSendInput types + template.repository.ts // resolve(): scoped ?? global, highest active version + template.renderer.ts // Handlebars compile+render; escaping; missing-var throw + template.service.ts // render(source, vars) — pure; resolve + renderer + templated-sender.ts // sendTemplated(): render -> OutboundService.send -> stamp provenance + template.seeder.ts // OnModuleInit: seed file defaults into DB if (key,channel,locale,version) absent + template.controller.ts // POST /v1/templates/send, POST /v1/templates/preview + template.dto.ts // SendTemplateDto, PreviewTemplateDto (class-validator) + template.module.ts + seeds/ + welcome.email.ts // { key, channel, locale, version, subject, html, text, variables } + payment-receipt.email.ts + payment-receipt.sms.ts + onboarding-reminder.email.ts + templates.spec.ts // TDD, real Postgres (localhost:5434), mirrors inbox.spec.ts +``` + +**Scope boundary:** `render()` is channel-generic (it can render an `INTERNAL` template to +`{subject,html,text}`). `sendTemplated()` covers **external** channels (`EMAIL`/`SMS`) via +`OutboundService`. `INTERNAL` *delivery* (render → create an in-app `Interaction`, no SMTP) reuses +`render()` but is wired by the messaging/mirror spec — this module never imports the messaging layer. + +## Contract + +```ts +type TemplateSource = + | { key: string; version?: number } + | { inline: { subject?: string; html?: string; text?: string } }; + +interface RenderedContent { subject?: string; html?: string; text?: string } + +// template.renderer.ts — PURE (no I/O): given a template's raw strings + vars, produce content. +// template.service.ts render() — resolves the template from the DB (I/O), then calls the pure renderer. +// For an inline source there is no DB row, so declared-variable validation is skipped — inline +// content is the caller's responsibility; only stored templates enforce their declared `variables`. +render(source: TemplateSource, vars: Record, opts: { channel: IiosTemplateChannel; locale?: string; scopeId?: string }): Promise + +// templated-sender.ts — external egress +sendTemplated(input: { + source: TemplateSource; + channel: 'EMAIL' | 'SMS'; + target: string; // email address / phone + vars: Record; + scopeId?: string; + idempotencyKey: string; // REQUIRED — e.g. "receipt:" + purpose?: string; +}): Promise +``` + +**HTTP** (`SessionVerifier`-auth'd; `scopeId` from the caller's principal): +- `POST /v1/templates/send` → `sendTemplated`. OPA action `iios.template.send`; + inline source additionally gated on `iios.template.send.inline` (arbitrary HTML to customers). +- `POST /v1/templates/preview` → `render` only, returns `RenderedContent`. No send. For marketing/QA. + +## Task-by-task (TDD) + +Each task: write the failing test → run it (confirm red) → implement → run (green) → commit. + +**T1 — Renderer (`template.renderer.ts`)** +- Tests: substitutes `{{firstName}}`; **escapes `' }, + ); + expect(out.html).toBe('

<script>alert(1)</script>

'); + expect(out.html).not.toContain(''); // plaintext is not HTML → not escaped + }); + + it('supports {{#if}} and {{#each}}', () => { + const out = renderTemplate( + { bodyText: '{{#if companyName}}Company: {{companyName}}\n{{/if}}{{#each items}}- {{this}}\n{{/each}}', variables: [] }, + { companyName: 'Acme', items: ['a', 'b'] }, + ); + expect(out.text).toBe('Company: Acme\n- a\n- b\n'); + }); + + it('throws when a declared variable is missing (fail loud)', () => { + expect(() => renderTemplate({ bodyText: 'Hi {{firstName}}', variables: ['firstName'] }, {})).toThrow(MissingTemplateVariableError); + }); + + it('renders only the parts that are present', () => { + const out = renderTemplate({ bodyText: 'code {{code}}', variables: ['code'] }, { code: '123' }); + expect(out.text).toBe('code 123'); + expect(out.subject).toBeUndefined(); + expect(out.html).toBeUndefined(); + }); +}); diff --git a/packages/iios-service/src/templates/template.renderer.ts b/packages/iios-service/src/templates/template.renderer.ts new file mode 100644 index 0000000..5668f59 --- /dev/null +++ b/packages/iios-service/src/templates/template.renderer.ts @@ -0,0 +1,46 @@ +import Handlebars from 'handlebars'; + +/** The raw, unrendered strings of a template (from the DB row or an inline source). */ +export interface RawTemplate { + subject?: string | null; + bodyHtml?: string | null; + bodyText?: string | null; + /** Declared variable names. Every one MUST be supplied at render time (fail loud). */ + variables?: string[] | null; +} + +export interface RenderedContent { + subject?: string; + html?: string; + text?: string; +} + +/** A declared variable was not supplied — we refuse to send a half-rendered "Hi ," message. */ +export class MissingTemplateVariableError extends Error { + constructor(public readonly missing: string[]) { + super(`missing required template variable(s): ${missing.join(', ')}`); + this.name = 'MissingTemplateVariableError'; + } +} + +/** + * Pure render: interpolate `vars` into a template's strings. HTML output is auto-escaped (values + * like a customer name go into an email body — XSS risk); subject and plaintext are NOT escaped. + * No I/O — the caller supplies the raw template. Throws if any declared variable is absent. + */ +export function renderTemplate(tpl: RawTemplate, vars: Record): RenderedContent { + const declared = tpl.variables ?? []; + const missing = declared.filter((name) => vars[name] === undefined || vars[name] === null); + if (missing.length > 0) throw new MissingTemplateVariableError(missing); + + const out: RenderedContent = {}; + if (tpl.subject != null) out.subject = compile(tpl.subject, false)(vars); + if (tpl.bodyHtml != null) out.html = compile(tpl.bodyHtml, true)(vars); + if (tpl.bodyText != null) out.text = compile(tpl.bodyText, false)(vars); + return out; +} + +/** `escape=true` → HTML-escape interpolated values (for the html body); false → verbatim. */ +function compile(src: string, escape: boolean): Handlebars.TemplateDelegate { + return Handlebars.compile(src, { noEscape: !escape, strict: false }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5159988..32beda2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,6 +364,9 @@ importers: dotenv: specifier: ^16.4.7 version: 16.6.1 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 ioredis: specifier: ^5.11.1 version: 5.11.1 @@ -2224,6 +2227,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3052,6 +3060,11 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + uid2@1.0.0: resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} engines: {node: '>= 4.0.0'} @@ -3243,6 +3256,9 @@ packages: engines: {node: '>=8'} hasBin: true + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -5013,6 +5029,15 @@ snapshots: graceful-fs@4.2.11: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5821,6 +5846,9 @@ snapshots: ufo@1.6.4: {} + uglify-js@3.19.3: + optional: true + uid2@1.0.0: {} uid@2.0.2: @@ -6008,6 +6036,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 -- 2.52.0 From b3afd44d0024a89fbcbfc84f0d212ad04fb0bb9c Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:23:44 +0530 Subject: [PATCH 18/30] feat(templates): T2 IiosMessageTemplate table + resolve() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema + migration for the template source table, and TemplateRepository.resolve: highest active version for (key,channel,locale), scoped override preferred over the global (scopeId NULL) default. Migration adds a PARTIAL unique index WHERE scopeId IS NULL so two platform defaults for the same key can't coexist (Postgres treats NULL as distinct under a plain UNIQUE — the scoped constraint alone wouldn't catch it). 6 tests. Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 39 +++++++++++ packages/iios-service/prisma/schema.prisma | 34 ++++++++++ .../src/templates/template.repository.spec.ts | 65 +++++++++++++++++++ .../src/templates/template.repository.ts | 42 ++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 packages/iios-service/prisma/migrations/20260718100000_add_message_template/migration.sql create mode 100644 packages/iios-service/src/templates/template.repository.spec.ts create mode 100644 packages/iios-service/src/templates/template.repository.ts diff --git a/packages/iios-service/prisma/migrations/20260718100000_add_message_template/migration.sql b/packages/iios-service/prisma/migrations/20260718100000_add_message_template/migration.sql new file mode 100644 index 0000000..6052957 --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260718100000_add_message_template/migration.sql @@ -0,0 +1,39 @@ +-- Reusable message templates (email/SMS/in-app). DB is runtime truth; platform defaults seeded +-- from repo files on boot. Versions are immutable (a change = a new higher version). +CREATE TYPE "IiosTemplateChannel" AS ENUM ('EMAIL', 'SMS', 'INTERNAL'); + +CREATE TABLE "IiosMessageTemplate" ( + "id" TEXT NOT NULL, + "scopeId" TEXT, + "key" TEXT NOT NULL, + "channel" "IiosTemplateChannel" NOT NULL, + "locale" TEXT NOT NULL DEFAULT 'en', + "version" INTEGER NOT NULL DEFAULT 1, + "subject" TEXT, + "bodyHtml" TEXT, + "bodyText" TEXT, + "variables" JSONB, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "IiosMessageTemplate_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "IiosMessageTemplate" + ADD CONSTRAINT "IiosMessageTemplate_scopeId_fkey" + FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Uniqueness for SCOPED rows (scopeId NOT NULL). +CREATE UNIQUE INDEX "IiosMessageTemplate_scopeId_key_channel_locale_version_key" + ON "IiosMessageTemplate" ("scopeId", "key", "channel", "locale", "version"); + +-- Uniqueness for GLOBAL rows (scopeId IS NULL). Postgres treats NULL as distinct under a normal +-- UNIQUE, so the constraint above does NOT stop two platform defaults for the same key — this +-- partial index does. (Same footgun handled in the inbox idempotency migration.) +CREATE UNIQUE INDEX "IiosMessageTemplate_global_key" + ON "IiosMessageTemplate" ("key", "channel", "locale", "version") + WHERE "scopeId" IS NULL; + +-- Resolution lookup: by key/channel/locale among active rows. +CREATE INDEX "IiosMessageTemplate_key_channel_locale_active_idx" + ON "IiosMessageTemplate" ("key", "channel", "locale", "active"); diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 9b4aa91..59b2b1a 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -96,6 +96,12 @@ enum IiosInboxState { STALE } +enum IiosTemplateChannel { + EMAIL + SMS + INTERNAL +} + enum IiosTicketState { NEW OPEN @@ -310,6 +316,7 @@ model IiosScope { tickets IiosTicket[] callbacks IiosCallbackRequest[] notificationSubscriptions IiosNotificationSubscription[] + messageTemplates IiosMessageTemplate[] @@index([orgId, appId, tenantId]) } @@ -677,6 +684,33 @@ model IiosInboxItem { @@index([ownerActorId, state, priority]) } +/// A reusable message template (email/SMS/in-app). DB is runtime truth; platform defaults are +/// seeded from repo files on boot. Versions are immutable — a change writes a new (higher) version, +/// never edits in place, so a rendered send can always be traced to the exact source it used. +/// scopeId NULL = a platform default; set = a tenant scope's override of the same key. Resolution +/// prefers the scoped row, else the global default. (The global-uniqueness of NULL-scope rows is +/// enforced by a partial unique index added in the migration — Postgres treats NULL as distinct.) +model IiosMessageTemplate { + id String @id @default(cuid()) + scopeId String? + scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade) + key String + channel IiosTemplateChannel + locale String @default("en") + version Int @default(1) + subject String? + bodyHtml String? + bodyText String? + /// Declared variable names — render throws if a declared var is missing (fail loud). + variables Json? + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([scopeId, key, channel, locale, version]) + @@index([key, channel, locale, active]) +} + /// Audit trail of inbox-item state transitions. model IiosInboxItemStateHistory { id String @id @default(cuid()) diff --git a/packages/iios-service/src/templates/template.repository.spec.ts b/packages/iios-service/src/templates/template.repository.spec.ts new file mode 100644 index 0000000..1e10040 --- /dev/null +++ b/packages/iios-service/src/templates/template.repository.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateNotFoundError, TemplateRepository } from './template.repository'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const repo = new TemplateRepository(prisma as unknown as PrismaService); + +async function scope(): Promise { + const s = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } }); + return s.id; +} +async function seed(data: Partial[0]['data']> & { key: string }) { + return prisma.iiosMessageTemplate.create({ + data: { channel: 'EMAIL', locale: 'en', version: 1, bodyText: 't', ...data } as never, + }); +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateRepository.resolve', () => { + it('returns the global default when no scope override exists', async () => { + await seed({ key: 'welcome', bodyText: 'global' }); + const t = await repo.resolve('welcome', 'EMAIL', 'en', await scope()); + expect(t.bodyText).toBe('global'); + expect(t.scopeId).toBeNull(); + }); + + it('prefers the scoped override over the global default', async () => { + const scopeId = await scope(); + await seed({ key: 'welcome', bodyText: 'global' }); // scopeId NULL + await seed({ key: 'welcome', bodyText: 'scoped', scopeId }); // override + const t = await repo.resolve('welcome', 'EMAIL', 'en', scopeId); + expect(t.bodyText).toBe('scoped'); + expect(t.scopeId).toBe(scopeId); + }); + + it('returns the highest active version', async () => { + await seed({ key: 'welcome', version: 1, bodyText: 'v1' }); + await seed({ key: 'welcome', version: 3, bodyText: 'v3' }); + await seed({ key: 'welcome', version: 2, bodyText: 'v2' }); + const t = await repo.resolve('welcome', 'EMAIL', 'en'); + expect(t.version).toBe(3); + }); + + it('ignores inactive versions (a retracted v3 falls back to v2)', async () => { + await seed({ key: 'welcome', version: 2, bodyText: 'v2' }); + await seed({ key: 'welcome', version: 3, bodyText: 'v3', active: false }); + const t = await repo.resolve('welcome', 'EMAIL', 'en'); + expect(t.version).toBe(2); + }); + + it('throws TemplateNotFoundError for an unknown key', async () => { + await expect(repo.resolve('nope', 'EMAIL', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError); + }); + + it('does not cross channels (an EMAIL template is not an SMS template)', async () => { + await seed({ key: 'welcome', channel: 'EMAIL' }); + await expect(repo.resolve('welcome', 'SMS', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError); + }); +}); diff --git a/packages/iios-service/src/templates/template.repository.ts b/packages/iios-service/src/templates/template.repository.ts new file mode 100644 index 0000000..b34d988 --- /dev/null +++ b/packages/iios-service/src/templates/template.repository.ts @@ -0,0 +1,42 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { IiosMessageTemplate, IiosTemplateChannel } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +export class TemplateNotFoundError extends NotFoundException { + constructor(key: string, channel: string, locale: string) { + super(`no active template for key="${key}" channel=${channel} locale=${locale}`); + this.name = 'TemplateNotFoundError'; + } +} + +@Injectable() +export class TemplateRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * Resolve the template to use: the highest active version for (key, channel, locale), preferring + * a row owned by `scopeId` (a tenant override) and falling back to the global default (scopeId + * NULL). Throws if neither exists. + */ + async resolve( + key: string, + channel: IiosTemplateChannel, + locale = 'en', + scopeId?: string, + ): Promise { + if (scopeId) { + const scoped = await this.highest({ key, channel, locale, scopeId }); + if (scoped) return scoped; + } + const global = await this.highest({ key, channel, locale, scopeId: null }); + if (global) return global; + throw new TemplateNotFoundError(key, channel, locale); + } + + private highest(where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null }) { + return this.prisma.iiosMessageTemplate.findFirst({ + where: { ...where, active: true }, + orderBy: { version: 'desc' }, + }); + } +} -- 2.52.0 From 8ce647552ad9604168ddf0cd3fecd67746281424 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:25:27 +0530 Subject: [PATCH 19/30] feat(templates): T3 render() service (resolve+render, inline, version pin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TemplateService.render(source, vars, opts) → { content, provenance }. Stored key → resolve+render; inline → render without a DB row (key/version null); explicit version pins. Provenance = {templateKey, version, locale, sha256(content)}. 10 tests (svc + repo). Co-Authored-By: Claude Opus 4.8 --- .../src/templates/template.model.ts | 16 ++++++ .../src/templates/template.repository.ts | 15 +++-- .../src/templates/template.service.spec.ts | 55 +++++++++++++++++++ .../src/templates/template.service.ts | 55 +++++++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 packages/iios-service/src/templates/template.model.ts create mode 100644 packages/iios-service/src/templates/template.service.spec.ts create mode 100644 packages/iios-service/src/templates/template.service.ts diff --git a/packages/iios-service/src/templates/template.model.ts b/packages/iios-service/src/templates/template.model.ts new file mode 100644 index 0000000..20bf6a6 --- /dev/null +++ b/packages/iios-service/src/templates/template.model.ts @@ -0,0 +1,16 @@ +import type { IiosTemplateChannel } from '@prisma/client'; + +/** What to render: a STORED template (by key, optionally pinned to a version) or INLINE content. */ +export type TemplateSource = + | { key: string; version?: number } + | { inline: { subject?: string; html?: string; text?: string; variables?: string[] } }; + +export interface RenderOptions { + channel: IiosTemplateChannel; + locale?: string; + scopeId?: string; +} + +export function isInlineSource(s: TemplateSource): s is { inline: { subject?: string; html?: string; text?: string; variables?: string[] } } { + return 'inline' in s; +} diff --git a/packages/iios-service/src/templates/template.repository.ts b/packages/iios-service/src/templates/template.repository.ts index b34d988..248ad0a 100644 --- a/packages/iios-service/src/templates/template.repository.ts +++ b/packages/iios-service/src/templates/template.repository.ts @@ -23,19 +23,26 @@ export class TemplateRepository { channel: IiosTemplateChannel, locale = 'en', scopeId?: string, + version?: number, ): Promise { + // Scoped override first, then the global default — for both the pinned and highest-active paths. if (scopeId) { - const scoped = await this.highest({ key, channel, locale, scopeId }); + const scoped = await this.pick({ key, channel, locale, scopeId }, version); if (scoped) return scoped; } - const global = await this.highest({ key, channel, locale, scopeId: null }); + const global = await this.pick({ key, channel, locale, scopeId: null }, version); if (global) return global; throw new TemplateNotFoundError(key, channel, locale); } - private highest(where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null }) { + /** A pinned `version` fetches that exact version (the caller was explicit); otherwise the highest + * active version — a retracted (inactive) newest version falls back to the last good one. */ + private pick( + where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null }, + version?: number, + ) { return this.prisma.iiosMessageTemplate.findFirst({ - where: { ...where, active: true }, + where: version != null ? { ...where, version } : { ...where, active: true }, orderBy: { version: 'desc' }, }); } diff --git a/packages/iios-service/src/templates/template.service.spec.ts b/packages/iios-service/src/templates/template.service.spec.ts new file mode 100644 index 0000000..e3b171d --- /dev/null +++ b/packages/iios-service/src/templates/template.service.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateRepository } from './template.repository'; +import { TemplateService } from './template.service'; +import { TemplateNotFoundError } from './template.repository'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const svc = new TemplateService(new TemplateRepository(prisma as unknown as PrismaService)); + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateService.render', () => { + it('resolves a stored template and renders it, with provenance', async () => { + await prisma.iiosMessageTemplate.create({ + data: { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'Hi {{firstName}}', bodyHtml: '

{{firstName}}

', variables: ['firstName'] }, + }); + const { content, provenance } = await svc.render({ key: 'welcome' }, { firstName: 'Dana' }, { channel: 'EMAIL' }); + expect(content.subject).toBe('Hi Dana'); + expect(content.html).toBe('

Dana

'); + expect(provenance).toMatchObject({ templateKey: 'welcome', templateVersion: 2, templateLocale: 'en' }); + expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('renders an inline source without a DB row (key/version null)', async () => { + const { content, provenance } = await svc.render( + { inline: { subject: 'Ad-hoc {{x}}', html: '{{x}}', variables: ['x'] } }, + { x: 'Y' }, + { channel: 'EMAIL' }, + ); + expect(content.subject).toBe('Ad-hoc Y'); + expect(content.html).toBe('Y'); + expect(provenance.templateKey).toBeNull(); + expect(provenance.templateVersion).toBeNull(); + expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('pins to an explicit version when asked', async () => { + await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 1, bodyText: 'v1' } }); + await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 2, bodyText: 'v2' } }); + const latest = await svc.render({ key: 'k' }, {}, { channel: 'EMAIL' }); + const pinned = await svc.render({ key: 'k', version: 1 }, {}, { channel: 'EMAIL' }); + expect(latest.content.text).toBe('v2'); + expect(pinned.content.text).toBe('v1'); + expect(pinned.provenance.templateVersion).toBe(1); + }); + + it('throws for an unknown stored key', async () => { + await expect(svc.render({ key: 'missing' }, {}, { channel: 'EMAIL' })).rejects.toBeInstanceOf(TemplateNotFoundError); + }); +}); diff --git a/packages/iios-service/src/templates/template.service.ts b/packages/iios-service/src/templates/template.service.ts new file mode 100644 index 0000000..4ed9bc5 --- /dev/null +++ b/packages/iios-service/src/templates/template.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { TemplateRepository } from './template.repository'; +import { renderTemplate, type RawTemplate, type RenderedContent } from './template.renderer'; +import { isInlineSource, type RenderOptions, type TemplateSource } from './template.model'; + +/** The provenance of a rendered send — carried onto the outbound command for replay/audit. */ +export interface RenderProvenance { + templateKey: string | null; // null for inline sources + templateVersion: number | null; + templateLocale: string | null; + renderedHash: string; // sha256 of the rendered content +} + +export interface RenderResult { + content: RenderedContent; + provenance: RenderProvenance; +} + +@Injectable() +export class TemplateService { + constructor(private readonly repo: TemplateRepository) {} + + /** + * Resolve (if a stored key) and render a template with `vars`, returning the content plus the + * provenance to record on the send. Inline sources skip the DB and carry null key/version — the + * caller owns inline content, so no declared-variable contract is enforced for it. + */ + async render(source: TemplateSource, vars: Record, opts: RenderOptions): Promise { + const locale = opts.locale ?? 'en'; + + if (isInlineSource(source)) { + const raw: RawTemplate = { + subject: source.inline.subject, + bodyHtml: source.inline.html, + bodyText: source.inline.text, + variables: source.inline.variables ?? [], + }; + const content = renderTemplate(raw, vars); + return { content, provenance: this.provenance(content, null, null, null) }; + } + + const tpl = await this.repo.resolve(source.key, opts.channel, locale, opts.scopeId, source.version); + const content = renderTemplate( + { subject: tpl.subject, bodyHtml: tpl.bodyHtml, bodyText: tpl.bodyText, variables: (tpl.variables as string[] | null) ?? [] }, + vars, + ); + return { content, provenance: this.provenance(content, tpl.key, tpl.version, tpl.locale) }; + } + + private provenance(content: RenderedContent, key: string | null, version: number | null, locale: string | null): RenderProvenance { + const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex'); + return { templateKey: key, templateVersion: version, templateLocale: locale, renderedHash: hash }; + } +} -- 2.52.0 From 10f3545a2557cad627a42ea242d75c0acb7f158d Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:28:28 +0530 Subject: [PATCH 20/30] feat(templates): T4 sendTemplated() + outbound provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IiosOutboundCommand gains templateKey/version/locale/renderedHash (migration). - OutboundService.send accepts an optional opaque `provenance` and writes it into the command on BOTH the PENDING and RATE_LIMITED create paths — the only way to stamp provenance by construction, since send() creates the row itself (review finding). OutboundService still neither renders nor resolves templates. - TemplatedSender.sendTemplated: render -> OutboundService.send -> provenance, one entry point. EMAIL payload = {subject,html,text}; SMS = {text}. Idempotent per key. Tests: 4 sender + 16 adapters regression green. Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 9 +++ packages/iios-service/prisma/schema.prisma | 5 ++ .../src/adapters/outbound.service.ts | 14 +++- .../src/capability/capability.registry.ts | 2 +- .../src/templates/templated-sender.spec.ts | 80 +++++++++++++++++++ .../src/templates/templated-sender.ts | 55 +++++++++++++ 6 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 packages/iios-service/prisma/migrations/20260718110000_add_outbound_template_provenance/migration.sql create mode 100644 packages/iios-service/src/templates/templated-sender.spec.ts create mode 100644 packages/iios-service/src/templates/templated-sender.ts diff --git a/packages/iios-service/prisma/migrations/20260718110000_add_outbound_template_provenance/migration.sql b/packages/iios-service/prisma/migrations/20260718110000_add_outbound_template_provenance/migration.sql new file mode 100644 index 0000000..99beb7f --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260718110000_add_outbound_template_provenance/migration.sql @@ -0,0 +1,9 @@ +-- Template provenance on the outbound command: which template (key/version/locale) produced this +-- send, plus a hash of the rendered content. The rendered content itself already lives in `payload`; +-- these columns answer "which template version produced this send?" for replay/audit. All nullable — +-- non-templated sends leave them null. +ALTER TABLE "IiosOutboundCommand" + ADD COLUMN "templateKey" TEXT, + ADD COLUMN "templateVersion" INTEGER, + ADD COLUMN "templateLocale" TEXT, + ADD COLUMN "renderedHash" TEXT; diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 59b2b1a..bda1038 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -868,6 +868,11 @@ model IiosOutboundCommand { idempotencyKey String @unique providerRef String? consentReceiptRef String? // CMP consent receipt this send went out under (P9) + // Template provenance (which source produced this send) — null for non-templated sends. + templateKey String? + templateVersion Int? + templateLocale String? + renderedHash String? // sha256 of the rendered content, for replay/audit createdAt DateTime @default(now()) attempts IiosDeliveryAttempt[] diff --git a/packages/iios-service/src/adapters/outbound.service.ts b/packages/iios-service/src/adapters/outbound.service.ts index c0370c8..a6ea69a 100644 --- a/packages/iios-service/src/adapters/outbound.service.ts +++ b/packages/iios-service/src/adapters/outbound.service.ts @@ -34,8 +34,18 @@ export class OutboundService { idempotencyKey?: string, scopeId?: string, purpose?: string, + /** Opaque template provenance recorded on the command. OutboundService neither renders nor + * resolves templates — it only persists the four strings it is handed (guaranteed by the + * templated sender, never by caller convention). */ + provenance?: { templateKey?: string | null; templateVersion?: number | null; templateLocale?: string | null; renderedHash?: string | null }, ) { const key = idempotencyKey ?? randomUUID(); + const prov = { + templateKey: provenance?.templateKey ?? null, + templateVersion: provenance?.templateVersion ?? null, + templateLocale: provenance?.templateLocale ?? null, + renderedHash: provenance?.renderedHash ?? null, + }; // The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried // command returns the existing row instead of colliding on idempotencyKey @unique. @@ -46,14 +56,14 @@ export class OutboundService { // Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress). if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) { const cmd = await this.prisma.iiosOutboundCommand.create({ - data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key }, + data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key, ...prov }, }); await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } }); return cmd; } const cmd = await this.prisma.iiosOutboundCommand.create({ - data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key }, + data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key, ...prov }, }); let result; diff --git a/packages/iios-service/src/capability/capability.registry.ts b/packages/iios-service/src/capability/capability.registry.ts index 4e4161d..c055fc0 100644 --- a/packages/iios-service/src/capability/capability.registry.ts +++ b/packages/iios-service/src/capability/capability.registry.ts @@ -4,7 +4,7 @@ import { SandboxProvider } from './sandbox.provider'; import { HttpProvider } from './http.provider'; import { EmailProvider } from './email.provider'; -const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']; +const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; /** * Maps a channelType to the provider that executes egress for it. Sandbox by diff --git a/packages/iios-service/src/templates/templated-sender.spec.ts b/packages/iios-service/src/templates/templated-sender.spec.ts new file mode 100644 index 0000000..4c05745 --- /dev/null +++ b/packages/iios-service/src/templates/templated-sender.spec.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { resetDb } from '../test-utils/reset-db'; +import { OutboundService } from '../adapters/outbound.service'; +import { CapabilityBroker } from '../capability/capability.broker'; +import { CapabilityProviderRegistry } from '../capability/capability.registry'; +import { IdempotencyService } from '../idempotency/idempotency.service'; +import { TemplateRepository } from './template.repository'; +import { TemplateService } from './template.service'; +import { TemplatedSender } from './templated-sender'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const asService = prisma as unknown as PrismaService; + +function sender(): TemplatedSender { + const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)); + const templates = new TemplateService(new TemplateRepository(asService)); + return new TemplatedSender(templates, outbound); +} + +async function seedReceipt() { + await prisma.iiosMessageTemplate.create({ + data: { key: 'payment.receipt', channel: 'EMAIL', locale: 'en', version: 1, subject: 'Thanks {{firstName}}', bodyHtml: '

{{amount}}

', bodyText: '{{amount}}', variables: ['firstName', 'amount'] }, + }); +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplatedSender.sendTemplated', () => { + it('renders a stored template, queues an outbound command, and stamps provenance', async () => { + await seedReceipt(); + const cmd = await sender().sendTemplated({ + source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com', + vars: { firstName: 'Dana', amount: '$2,000' }, idempotencyKey: 'receipt:cs_1', + }); + expect(cmd.channelType).toBe('EMAIL'); + expect(cmd.target).toBe('dana@acme.com'); + expect((cmd.payload as { subject: string }).subject).toBe('Thanks Dana'); + expect(cmd.templateKey).toBe('payment.receipt'); + expect(cmd.templateVersion).toBe(1); + expect(cmd.templateLocale).toBe('en'); + expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('is idempotent per key: a replay yields ONE command', async () => { + await seedReceipt(); + const s = sender(); + const input = { source: { key: 'payment.receipt' }, channel: 'EMAIL' as const, target: 'dana@acme.com', vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'receipt:cs_dup' }; + const a = await s.sendTemplated(input); + const b = await s.sendTemplated(input); + expect(b.id).toBe(a.id); + expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'receipt:cs_dup' } })).toBe(1); + }); + + it('sends inline content with null template key but a hash', async () => { + const cmd = await sender().sendTemplated({ + source: { inline: { subject: 'Hi {{n}}', html: '{{n}}', variables: ['n'] } }, + channel: 'EMAIL', target: 'x@y.com', vars: { n: 'Z' }, idempotencyKey: 'inline:1', + }); + expect((cmd.payload as { subject: string }).subject).toBe('Hi Z'); + expect(cmd.templateKey).toBeNull(); + expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('shapes an SMS send as text only', async () => { + await prisma.iiosMessageTemplate.create({ + data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] }, + }); + const cmd = await sender().sendTemplated({ + source: { key: 'payment.receipt' }, channel: 'SMS', target: '+1415', vars: { amount: '$2,000' }, idempotencyKey: 'sms:1', + }); + expect(cmd.channelType).toBe('SMS'); + expect(cmd.payload).toEqual({ text: 'Paid $2,000' }); + }); +}); diff --git a/packages/iios-service/src/templates/templated-sender.ts b/packages/iios-service/src/templates/templated-sender.ts new file mode 100644 index 0000000..4b4373f --- /dev/null +++ b/packages/iios-service/src/templates/templated-sender.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { OutboundService } from '../adapters/outbound.service'; +import { TemplateService } from './template.service'; +import type { RenderedContent } from './template.renderer'; +import type { TemplateSource } from './template.model'; + +/** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */ +export type ExternalChannel = 'EMAIL' | 'SMS'; + +export interface SendTemplatedInput { + source: TemplateSource; + channel: ExternalChannel; + target: string; // email address / phone number + vars: Record; + scopeId?: string; + locale?: string; + idempotencyKey: string; // REQUIRED — e.g. "receipt:" + purpose?: string; +} + +/** + * The single entry point for a templated external send: render → OutboundService.send → provenance + * stamped in one place (by construction, not caller convention). Rendering stays pure/testable; + * OutboundService stays content-agnostic (it only records the provenance it is handed). + */ +@Injectable() +export class TemplatedSender { + constructor( + private readonly templates: TemplateService, + private readonly outbound: OutboundService, + ) {} + + async sendTemplated(input: SendTemplatedInput) { + const { content, provenance } = await this.templates.render(input.source, input.vars, { + channel: input.channel, + locale: input.locale, + scopeId: input.scopeId, + }); + return this.outbound.send( + input.channel, + input.target, + this.toPayload(input.channel, content), + input.idempotencyKey, + input.scopeId, + input.purpose, + provenance, + ); + } + + /** Shape rendered content into the channel's egress envelope (EmailProvider reads subject/text/html). */ + private toPayload(channel: ExternalChannel, content: RenderedContent): Record { + if (channel === 'EMAIL') return { subject: content.subject, html: content.html, text: content.text }; + return { text: content.text ?? content.subject ?? '' }; // SMS: text only + } +} -- 2.52.0 From 37407cb0af58db25479a7e7bb914edfb0b65f2bb Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:33:12 +0530 Subject: [PATCH 21/30] feat(templates): T5 boot seeder + default seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TemplateSeeder (OnModuleInit) seeds repo file defaults as global (scopeId NULL) templates if absent. Idempotent per (key,channel,locale,version): re-boot inserts nothing; a bumped version adds a new row and keeps the old for audit. Seeds: welcome (email), payment.receipt (email+sms), onboarding.reminder (email) — placeholder copy for marketing to replace. 3 tests. Co-Authored-By: Claude Opus 4.8 --- .../iios-service/src/templates/seeds/index.ts | 79 +++++++++++++++++++ .../src/templates/template.seeder.spec.ts | 42 ++++++++++ .../src/templates/template.seeder.ts | 49 ++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 packages/iios-service/src/templates/seeds/index.ts create mode 100644 packages/iios-service/src/templates/template.seeder.spec.ts create mode 100644 packages/iios-service/src/templates/template.seeder.ts diff --git a/packages/iios-service/src/templates/seeds/index.ts b/packages/iios-service/src/templates/seeds/index.ts new file mode 100644 index 0000000..848b0f9 --- /dev/null +++ b/packages/iios-service/src/templates/seeds/index.ts @@ -0,0 +1,79 @@ +import type { IiosTemplateChannel } from '@prisma/client'; + +/** A platform-default template shipped in the repo and seeded at boot (scopeId NULL). */ +export interface TemplateSeed { + key: string; + channel: IiosTemplateChannel; + locale: string; + version: number; + subject?: string; + bodyHtml?: string; + bodyText?: string; + variables: string[]; +} + +// Placeholder copy — marketing (Nikita/Himanshu) owns the words, Gautam the HTML. These are +// functional defaults so the pipeline works end-to-end before final copy lands; bump `version` +// when the content changes (the old version stays for audit/replay). + +const WELCOME_EMAIL: TemplateSeed = { + key: 'welcome', + channel: 'EMAIL', + locale: 'en', + version: 1, + subject: 'Welcome to the Founders Club, {{firstName}}', + bodyHtml: + '

Hi {{firstName}},

' + + '

Thanks for joining the Founders Club. Your account is ready to set up.

' + + '

Create your account

' + + '

We are launching soon — we will keep you posted.

', + bodyText: + 'Hi {{firstName}},\n\nThanks for joining the Founders Club. Create your account: {{signupLink}}\n\nWe are launching soon.', + variables: ['firstName', 'signupLink'], +}; + +const PAYMENT_RECEIPT_EMAIL: TemplateSeed = { + key: 'payment.receipt', + channel: 'EMAIL', + locale: 'en', + version: 1, + subject: 'Thanks for your payment, {{firstName}}', + bodyHtml: + '

Hi {{firstName}},

' + + '

We have received your payment of {{amount}} for {{licenseCount}} license(s).

' + + '

A separate tax receipt from our payment processor will follow.

', + bodyText: + 'Hi {{firstName}},\n\nWe have received your payment of {{amount}} for {{licenseCount}} license(s).\nA separate tax receipt will follow.', + variables: ['firstName', 'amount', 'licenseCount'], +}; + +const PAYMENT_RECEIPT_SMS: TemplateSeed = { + key: 'payment.receipt', + channel: 'SMS', + locale: 'en', + version: 1, + bodyText: 'Thanks {{firstName}}! We received your payment of {{amount}}. A receipt is on its way to your email.', + variables: ['firstName', 'amount'], +}; + +const ONBOARDING_REMINDER_EMAIL: TemplateSeed = { + key: 'onboarding.reminder', + channel: 'EMAIL', + locale: 'en', + version: 1, + subject: 'Looks like you have not finished setting up', + bodyHtml: + '

Hi {{firstName}},

' + + '

You paid but have not created your account yet. It only takes a minute.

' + + '

Finish creating your account

', + bodyText: + 'Hi {{firstName}},\n\nYou paid but have not created your account yet. Finish here: {{signupLink}}', + variables: ['firstName', 'signupLink'], +}; + +export const TEMPLATE_SEEDS: TemplateSeed[] = [ + WELCOME_EMAIL, + PAYMENT_RECEIPT_EMAIL, + PAYMENT_RECEIPT_SMS, + ONBOARDING_REMINDER_EMAIL, +]; diff --git a/packages/iios-service/src/templates/template.seeder.spec.ts b/packages/iios-service/src/templates/template.seeder.spec.ts new file mode 100644 index 0000000..3459a5c --- /dev/null +++ b/packages/iios-service/src/templates/template.seeder.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateSeeder } from './template.seeder'; +import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const seeder = new TemplateSeeder(prisma as unknown as PrismaService); + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateSeeder', () => { + it('seeds the file defaults as global (scopeId NULL) templates', async () => { + const n = await seeder.seed(TEMPLATE_SEEDS); + expect(n).toBe(TEMPLATE_SEEDS.length); + const rows = await prisma.iiosMessageTemplate.findMany(); + expect(rows).toHaveLength(TEMPLATE_SEEDS.length); + expect(rows.every((r) => r.scopeId === null)).toBe(true); + const welcome = rows.find((r) => r.key === 'welcome' && r.channel === 'EMAIL'); + expect(welcome?.variables).toEqual(['firstName', 'signupLink']); + }); + + it('is idempotent — re-seeding inserts nothing and creates no duplicates', async () => { + await seeder.seed(TEMPLATE_SEEDS); + const second = await seeder.seed(TEMPLATE_SEEDS); + expect(second).toBe(0); + expect(await prisma.iiosMessageTemplate.count()).toBe(TEMPLATE_SEEDS.length); + }); + + it('a bumped version inserts a new row and leaves the old one', async () => { + await seeder.seed(TEMPLATE_SEEDS); + const bumped: TemplateSeed = { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'v2', bodyText: 'v2', variables: [] }; + const n = await seeder.seed([bumped]); + expect(n).toBe(1); + const welcomes = await prisma.iiosMessageTemplate.findMany({ where: { key: 'welcome', channel: 'EMAIL' }, orderBy: { version: 'asc' } }); + expect(welcomes.map((w) => w.version)).toEqual([1, 2]); + }); +}); diff --git a/packages/iios-service/src/templates/template.seeder.ts b/packages/iios-service/src/templates/template.seeder.ts new file mode 100644 index 0000000..8412edf --- /dev/null +++ b/packages/iios-service/src/templates/template.seeder.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds'; + +/** + * Seeds platform-default templates (scopeId NULL) from the repo files on boot — DB is runtime truth, + * files are the version-controlled source. Idempotent: a seed is inserted only if its exact + * (key, channel, locale, version) default is absent, so re-boots never duplicate and a bumped + * version adds a new row while leaving the old one for audit/replay. + */ +@Injectable() +export class TemplateSeeder implements OnModuleInit { + private readonly log = new Logger(TemplateSeeder.name); + + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit(): Promise { + const inserted = await this.seed(TEMPLATE_SEEDS); + if (inserted > 0) this.log.log(`seeded ${inserted} default template(s)`); + } + + /** Returns how many rows were newly inserted. */ + async seed(seeds: TemplateSeed[]): Promise { + let inserted = 0; + for (const s of seeds) { + const exists = await this.prisma.iiosMessageTemplate.findFirst({ + where: { scopeId: null, key: s.key, channel: s.channel, locale: s.locale, version: s.version }, + select: { id: true }, + }); + if (exists) continue; + await this.prisma.iiosMessageTemplate.create({ + data: { + scopeId: null, + key: s.key, + channel: s.channel, + locale: s.locale, + version: s.version, + subject: s.subject, + bodyHtml: s.bodyHtml, + bodyText: s.bodyText, + variables: s.variables as Prisma.InputJsonValue, + }, + }); + inserted++; + } + return inserted; + } +} -- 2.52.0 From b44f795ba403d7f9a5f6c9600277a038eba79f7b Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:39:04 +0530 Subject: [PATCH 22/30] feat(templates): T6 controller + DTOs + module wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /v1/templates/send (render→queue) and /preview (render only). OPA-gated in the sender: iios.template.send, and iios.template.send.inline for ad-hoc HTML (arbitrary markup to a customer is riskier than a reviewed stored template). Scope resolved from the caller's principal. TemplateModule registered in AppModule. Verified over HTTP against a local boot: preview + send of the seeded payment.receipt (SENT via sandbox, provenance persisted), XSS name escaped, idempotent replay → 1 row, unknown key 404, bad channel/no-source/no-auth 400. Co-Authored-By: Claude Opus 4.8 --- packages/iios-service/src/app.module.ts | 2 + .../src/templates/template.controller.ts | 62 +++++++++++++++++++ .../src/templates/template.dto.ts | 38 ++++++++++++ .../src/templates/template.module.ts | 21 +++++++ .../src/templates/templated-sender.spec.ts | 2 +- .../src/templates/templated-sender.ts | 13 +++- 6 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 packages/iios-service/src/templates/template.controller.ts create mode 100644 packages/iios-service/src/templates/template.dto.ts create mode 100644 packages/iios-service/src/templates/template.module.ts diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index 29129e2..a1345a2 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -10,6 +10,7 @@ import { OutboxModule } from './outbox/outbox.module'; import { ThreadsModule } from './threads/threads.module'; import { MessageModule } from './messaging/message.module'; import { InboxModule } from './inbox/inbox.module'; +import { TemplateModule } from './templates/template.module'; import { MediaModule } from './media/media.module'; import { NotificationModule } from './notifications/notification.module'; import { SupportModule } from './support/support.module'; @@ -37,6 +38,7 @@ import { DevController } from './dev/dev.controller'; ThreadsModule, MessageModule, InboxModule, + TemplateModule, MediaModule, NotificationModule, SupportModule, diff --git a/packages/iios-service/src/templates/template.controller.ts b/packages/iios-service/src/templates/template.controller.ts new file mode 100644 index 0000000..bfa5bda --- /dev/null +++ b/packages/iios-service/src/templates/template.controller.ts @@ -0,0 +1,62 @@ +import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common'; +import { IiosTemplateChannel } from '@prisma/client'; +import { SessionVerifier } from '../platform/session.verifier'; +import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { TemplatedSender, type ExternalChannel } from './templated-sender'; +import { TemplateService } from './template.service'; +import { PreviewTemplateDto, SendTemplateDto } from './template.dto'; +import type { TemplateSource } from './template.model'; + +@Controller('v1/templates') +export class TemplateController { + constructor( + private readonly sender: TemplatedSender, + private readonly templates: TemplateService, + private readonly session: SessionVerifier, + private readonly actors: ActorResolver, + ) {} + + /** Render a stored/inline template and queue it for external delivery (EMAIL/SMS). */ + @Post('send') + async send(@Body() body: SendTemplateDto, @Headers('authorization') authorization?: string) { + const principal = this.principal(authorization); + const scope = await this.actors.resolveScope(principal); + return this.sender.sendTemplated({ + source: this.source(body), + channel: body.channel as ExternalChannel, + target: body.target, + vars: body.vars ?? {}, + scopeId: scope.id, + ...(body.locale ? { locale: body.locale } : {}), + idempotencyKey: body.idempotencyKey, + ...(body.purpose ? { purpose: body.purpose } : {}), + }); + } + + /** Render only — no send. For marketing/QA to eyeball copy before it goes out. */ + @Post('preview') + async preview(@Body() body: PreviewTemplateDto, @Headers('authorization') authorization?: string) { + const principal = this.principal(authorization); + const scope = await this.actors.resolveScope(principal); + const { content } = await this.templates.render(this.source(body), body.vars ?? {}, { + channel: body.channel as IiosTemplateChannel, + ...(body.locale ? { locale: body.locale } : {}), + scopeId: scope.id, + }); + return content; + } + + /** Map the DTO's key|inline into a TemplateSource (exactly one must be present). */ + private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource { + if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both'); + if (body.inline) return { inline: body.inline }; + if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key }; + throw new BadRequestException('one of "key" or "inline" is required'); + } + + private principal(authorization?: string): MessagePrincipal { + const token = (authorization ?? '').replace(/^Bearer\s+/i, ''); + if (!token) throw new BadRequestException('Authorization bearer token is required'); + return this.session.verify(token); + } +} diff --git a/packages/iios-service/src/templates/template.dto.ts b/packages/iios-service/src/templates/template.dto.ts new file mode 100644 index 0000000..cd14284 --- /dev/null +++ b/packages/iios-service/src/templates/template.dto.ts @@ -0,0 +1,38 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; + +const EXTERNAL_CHANNELS = ['EMAIL', 'SMS'] as const; +const ALL_CHANNELS = ['EMAIL', 'SMS', 'INTERNAL'] as const; + +/** Inline template content — an ad-hoc source (e.g. finished HTML from marketing). */ +export class InlineTemplateDto { + @IsOptional() @IsString() subject?: string; + @IsOptional() @IsString() html?: string; + @IsOptional() @IsString() text?: string; + @IsOptional() @IsString({ each: true }) variables?: string[]; +} + +/** Body of POST /v1/templates/send. Provide EITHER `key` (stored) OR `inline` (ad-hoc). */ +export class SendTemplateDto { + @IsOptional() @IsString() @IsNotEmpty() key?: string; + @IsOptional() @IsInt() version?: number; + @IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto; + + @IsIn(EXTERNAL_CHANNELS) channel!: (typeof EXTERNAL_CHANNELS)[number]; + @IsString() @IsNotEmpty() target!: string; + @IsOptional() @IsObject() vars?: Record; + @IsOptional() @IsString() locale?: string; + @IsString() @IsNotEmpty() idempotencyKey!: string; + @IsOptional() @IsString() purpose?: string; +} + +/** Body of POST /v1/templates/preview — render only, no send. INTERNAL allowed (preview only). */ +export class PreviewTemplateDto { + @IsOptional() @IsString() @IsNotEmpty() key?: string; + @IsOptional() @IsInt() version?: number; + @IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto; + + @IsIn(ALL_CHANNELS) channel!: (typeof ALL_CHANNELS)[number]; + @IsOptional() @IsObject() vars?: Record; + @IsOptional() @IsString() locale?: string; +} diff --git a/packages/iios-service/src/templates/template.module.ts b/packages/iios-service/src/templates/template.module.ts new file mode 100644 index 0000000..d8e13e0 --- /dev/null +++ b/packages/iios-service/src/templates/template.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { AdaptersModule } from '../adapters/adapters.module'; +import { TemplateRepository } from './template.repository'; +import { TemplateService } from './template.service'; +import { TemplatedSender } from './templated-sender'; +import { TemplateSeeder } from './template.seeder'; +import { TemplateController } from './template.controller'; + +/** + * Reusable message-template module: render a stored/inline template → hand it to the existing + * outbound pipeline (via AdaptersModule's OutboundService). Content lives here; delivery stays in + * the outbound/capability layers. SessionVerifier, ActorResolver (IdentityModule) and PLATFORM_PORTS + * (PlatformModule) are global. + */ +@Module({ + imports: [AdaptersModule], + controllers: [TemplateController], + providers: [TemplateRepository, TemplateService, TemplatedSender, TemplateSeeder], + exports: [TemplateService, TemplatedSender], +}) +export class TemplateModule {} diff --git a/packages/iios-service/src/templates/templated-sender.spec.ts b/packages/iios-service/src/templates/templated-sender.spec.ts index 4c05745..b1f52e2 100644 --- a/packages/iios-service/src/templates/templated-sender.spec.ts +++ b/packages/iios-service/src/templates/templated-sender.spec.ts @@ -18,7 +18,7 @@ const asService = prisma as unknown as PrismaService; function sender(): TemplatedSender { const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)); const templates = new TemplateService(new TemplateRepository(asService)); - return new TemplatedSender(templates, outbound); + return new TemplatedSender(templates, outbound, makeFakePorts()); } async function seedReceipt() { diff --git a/packages/iios-service/src/templates/templated-sender.ts b/packages/iios-service/src/templates/templated-sender.ts index 4b4373f..b7a96b6 100644 --- a/packages/iios-service/src/templates/templated-sender.ts +++ b/packages/iios-service/src/templates/templated-sender.ts @@ -1,8 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import type { IiosPlatformPorts } from '@insignia/iios-contracts'; import { OutboundService } from '../adapters/outbound.service'; +import { PLATFORM_PORTS } from '../platform/platform-ports'; +import { decideOrThrow } from '../platform/fail-closed'; import { TemplateService } from './template.service'; import type { RenderedContent } from './template.renderer'; -import type { TemplateSource } from './template.model'; +import { isInlineSource, type TemplateSource } from './template.model'; /** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */ export type ExternalChannel = 'EMAIL' | 'SMS'; @@ -28,9 +31,15 @@ export class TemplatedSender { constructor( private readonly templates: TemplateService, private readonly outbound: OutboundService, + @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, ) {} async sendTemplated(input: SendTemplatedInput) { + // Inline HTML to a customer is more dangerous than a reviewed stored template — gate it apart, + // so policy can permit stored sends while restricting ad-hoc ones. Fail-closed. + const action = isInlineSource(input.source) ? 'iios.template.send.inline' : 'iios.template.send'; + await decideOrThrow(this.ports, { action, scopeId: input.scopeId, channel: input.channel }); + const { content, provenance } = await this.templates.render(input.source, input.vars, { channel: input.channel, locale: input.locale, -- 2.52.0 From 65023ce404c6f046b5ab2b59b4b3220d7f0057fa Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:56:22 +0530 Subject: [PATCH 23/30] feat(templates): T8 PII redaction of outbound commands (fast-follow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An email/SMS command holds PII: the recipient address (target) and the rendered body (payload). RetentionService now snapshots outbound commands and, once aged, redacts target->'[redacted]' and payload->{redacted:true} in place while KEEPING the template provenance (key/version/locale/hash) — so 'which template version did we send?' stays answerable after the PII is gone. - ensureOutboundSnapshots: one snapshot per command, dataClass 'outbound', archiveAfter==deleteAfter (PII goes straight to redact, no archive phase). Nullable command scope uses an 'unscoped' tag so the global sweep still reaches it. - applySweep redact branch switches on targetType; compliance holds honored; audit 'retention.redacted' resourceType 'outbound_command'. Closes lever #2 of the PII-minimization plan. 3 new + 6 regression tests. Co-Authored-By: Claude Opus 4.8 --- .../src/retention/retention.service.spec.ts | 56 +++++++++++++++++ .../src/retention/retention.service.ts | 61 ++++++++++++++++--- 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/packages/iios-service/src/retention/retention.service.spec.ts b/packages/iios-service/src/retention/retention.service.spec.ts index 3c51f75..40d88d1 100644 --- a/packages/iios-service/src/retention/retention.service.spec.ts +++ b/packages/iios-service/src/retention/retention.service.spec.ts @@ -117,3 +117,59 @@ describe('RetentionService (P9 — retention sweep)', () => { expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched }); }); + +// T8: an outbound email/SMS command holds PII (the recipient address, and the rendered body with +// their name). Once aged, redact those while KEEPING the template provenance for audit/replay. +describe('RetentionService — outbound command PII (T8)', () => { + const setOutboundSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) => + prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'outbound_command', targetId } }, data }); + + async function command(target: string): Promise<{ id: string; scopeId: string }> { + const scope = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } }); + const cmd = await prisma.iiosOutboundCommand.create({ + data: { + channelType: 'EMAIL', target, scopeId: scope.id, status: 'SENT', idempotencyKey: `k-${randomUUID()}`, + payload: { subject: 'Hi Dana', html: '

secret body

' }, + templateKey: 'welcome', templateVersion: 1, templateLocale: 'en', renderedHash: 'abc123', + }, + }); + return { id: cmd.id, scopeId: scope.id }; + } + + it('ensureOutboundSnapshots captures one snapshot per outbound command', async () => { + const { id } = await command('dana@acme.com'); + const n = await svc().ensureOutboundSnapshots(); + expect(n).toBe(1); + const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'outbound_command', targetId: id } } }); + expect(snap.targetType).toBe('outbound_command'); + expect(snap.dataClass).toBe('outbound'); + }); + + it('redacts target + payload of an aged command but keeps template provenance', async () => { + const { id } = await command('dana@acme.com'); + await svc().ensureOutboundSnapshots(); + await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past }); + + const res = await svc().applySweep(); + expect(res.redacted).toBe(1); + const after = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } }); + expect(after.target).toBe('[redacted]'); + expect(after.payload).toEqual({ redacted: true }); + // Provenance survives — you can still answer "which template version did we send?" + expect(after.templateKey).toBe('welcome'); + expect(after.templateVersion).toBe(1); + expect(after.renderedHash).toBe('abc123'); + expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted', resourceType: 'outbound_command' } })).toBe(1); + }); + + it('an active compliance hold blocks the command sweep', async () => { + const { id, scopeId } = await command('held@acme.com'); + await svc().ensureOutboundSnapshots(); + await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past }); + await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'outbound_command', targetId: id, holdReason: 'legal' } }); + + const res = await svc().applySweep(); + expect(res.skippedHeld).toBe(1); + expect((await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } })).target).toBe('held@acme.com'); + }); +}); diff --git a/packages/iios-service/src/retention/retention.service.ts b/packages/iios-service/src/retention/retention.service.ts index b5831a7..f88b409 100644 --- a/packages/iios-service/src/retention/retention.service.ts +++ b/packages/iios-service/src/retention/retention.service.ts @@ -72,6 +72,41 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy { return created; } + /** + * Capture a retention snapshot for any outbound command that lacks one. An email/SMS command + * holds PII (the recipient address, and the rendered body with their name), so it gets a single + * window and goes STRAIGHT to redact (archiveAfter == deleteAfter — no archive phase). Unscoped + * system sends use an 'unscoped' partition tag so the global sweep still reaches them. + */ + async ensureOutboundSnapshots(scopeId?: string): Promise { + const commands = await this.prisma.iiosOutboundCommand.findMany({ + where: scopeId ? { scopeId } : {}, + select: { id: true, scopeId: true, createdAt: true }, + }); + let created = 0; + for (const c of commands) { + const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({ + where: { targetType_targetId: { targetType: 'outbound_command', targetId: c.id } }, + }); + if (exists) continue; + const deleteAt = new Date(c.createdAt.getTime() + this.windowDays('outbound', 'DELETE') * DAY_MS); + await this.prisma.iiosRetentionPolicySnapshot.create({ + data: { + policyKey: 'outbound:pii:v1', + scopeSnapshotId: c.scopeId ?? 'unscoped', + targetType: 'outbound_command', + targetId: c.id, + dataClass: 'outbound', + archiveAfter: deleteAt, + deleteAfter: deleteAt, + sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1', + }, + }); + created++; + } + return created; + } + /** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */ async applySweep(scopeId?: string): Promise { const now = new Date(); @@ -90,13 +125,22 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy { } if (s.deleteAfter <= now) { - await this.prisma.$transaction([ - this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }), - this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }), - this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }), - this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }), - ]); - await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId }); + if (s.targetType === 'outbound_command') { + // Redact the recipient address + rendered body; KEEP the template provenance columns. + await this.prisma.$transaction([ + this.prisma.iiosOutboundCommand.update({ where: { id: s.targetId }, data: { target: '[redacted]', payload: { redacted: true } } }), + this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }), + ]); + await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'outbound_command', resourceId: s.targetId, scopeId: s.scopeSnapshotId }); + } else { + await this.prisma.$transaction([ + this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }), + this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }), + this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }), + this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }), + ]); + await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId }); + } result.redacted++; } else if (s.status === 'ACTIVE') { await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } }); @@ -107,9 +151,10 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy { return result; } - /** Full sweep: capture missing snapshots, then act on due ones. */ + /** Full sweep: capture missing snapshots (interactions + outbound commands), then act on due ones. */ async sweep(scopeId?: string): Promise { await this.ensureSnapshots(scopeId); + await this.ensureOutboundSnapshots(scopeId); return this.applySweep(scopeId); } -- 2.52.0 From 7d7c75915a943b75cb67d2c77c64fd140dbf82b9 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 13:25:37 +0530 Subject: [PATCH 24/30] docs(smtp): SMTP provider plan Co-Authored-By: Claude Opus 4.8 --- docs/smtp-provider-plan.md | 139 +++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/smtp-provider-plan.md diff --git a/docs/smtp-provider-plan.md b/docs/smtp-provider-plan.md new file mode 100644 index 0000000..298df07 --- /dev/null +++ b/docs/smtp-provider-plan.md @@ -0,0 +1,139 @@ +# SMTP Provider — Implementation Plan + +**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** make external email *actually leave the building* (welcome / receipt), the critical path for Friday. + +## Goal + +Add an `SmtpProvider` so the `EMAIL` channel delivers via real SMTP (`accounts@lynkeduppro.com`, +fallback `ceo@lynkeduppro.com`) instead of the sandbox. The template module already renders and +queues to the `EMAIL` channel; this is the one piece between "queued (SENT via sandbox)" and "the +customer receives it." From the meeting: *"जो पहला जा रहा है, वो SMTP से जा रहा है, क्योंकि हमें तुरंत चाहिए."* + +## What already exists (do NOT rebuild) + +- `CapabilityProvider { name, channelTypes, capabilities, send(req) }` — the seam. +- `CapabilityProviderRegistry` binds a provider per channel: **sandbox by default**; `EmailProvider` + (HTTP) when `IIOS_PROVIDER_URL_EMAIL` is set. Unknown channels fail closed. +- `OutboundService.send` → `CapabilityBroker` (policy + obligations) → the bound provider. Idempotency, + rate limits, ledger, provenance all upstream — untouched. +- `req.payload` for EMAIL is `{ subject, text, html, inReplyTo }` (from `TemplatedSender`). + +## Design decisions (locked) + +| # | Decision | Why | +|---|---|---| +| 1 | New `SmtpProvider implements CapabilityProvider`, `channelTypes=['EMAIL']`, via **nodemailer** | The established provider pattern; nodemailer is the standard SMTP client | +| 2 | **Env-driven activation**, like `IIOS_PROVIDER_URL_EMAIL` | Off by default (sandbox); flip on by setting SMTP env — no code change to enable | +| 3 | **Registry precedence for EMAIL: SMTP > HTTP > sandbox** | SMTP is the intended prod path; HTTP relay stays available; sandbox is the safe default | +| 4 | **Transporter is injected** (constructor takes a `Transporter` or a factory) | SMTP is untestable against a live server in CI; inject a stub/`jsonTransport` to assert the envelope | +| 5 | **Optional fallback sender** (`accounts@` primary → `ceo@` on failure) | The meeting's fallback: if the primary mailbox send fails, retry once via the fallback identity | +| 6 | **Never throw** — a transport error returns `{ outcome: 'FAILED', errorCode }` | Adapter doctrine; the command is marked FAILED, the caller isn't broken | + +## Config (env) + +``` +IIOS_SMTP_HOST=smtp. # e.g. smtp.gmail.com (Google Workspace) +IIOS_SMTP_PORT=587 +IIOS_SMTP_SECURE=false # true for 465, false for 587/STARTTLS +IIOS_SMTP_USER=accounts@lynkeduppro.com +IIOS_SMTP_PASS= # Workspace App Password, NOT the account password +IIOS_SMTP_FROM="LynkedUp Pro " # defaults to USER +# optional fallback identity used only if the primary send FAILS +IIOS_SMTP_FALLBACK_USER=ceo@lynkeduppro.com +IIOS_SMTP_FALLBACK_PASS= +IIOS_SMTP_FALLBACK_FROM="Justin Johnson " +``` + +Activation rule: `SmtpProvider` is bound for `EMAIL` iff `IIOS_SMTP_HOST` + `IIOS_SMTP_USER` + +`IIOS_SMTP_PASS` are all set. Fallback transporter built only if the `_FALLBACK_*` trio is set. + +## Files + +``` +src/capability/smtp.provider.ts # new — the provider +src/capability/smtp.provider.spec.ts # new — injected-transport tests +src/capability/capability.registry.ts # modify — bind SMTP for EMAIL when configured (precedence) +package.json # add nodemailer + @types/nodemailer +``` + +## Contract + +```ts +interface SmtpIdentity { host: string; port: number; secure: boolean; user: string; pass: string; from: string } + +class SmtpProvider implements CapabilityProvider { + readonly name = 'smtp'; + readonly channelTypes = ['EMAIL']; + readonly capabilities = { canSend: true }; + // `makeTransport` is injectable so tests pass a stub / nodemailer jsonTransport. + constructor(primary: SmtpIdentity, fallback?: SmtpIdentity, makeTransport?: (id: SmtpIdentity) => Transporter) {} + async send(req: CapabilityRequest): Promise; +} +``` + +`send()` builds the mail from `req.target` (recipient) + `req.payload`: +``` +{ from, to: req.target, subject, text, html, + inReplyTo?, references?, // threading, from payload.inReplyTo + messageId } // generated; returned as providerRef so replies can thread +``` +Primary transporter sends; on throw, if a fallback identity exists, retry once via it; still failing +→ `FAILED`. Success → `{ outcome: 'SENT', providerRef: messageId, latencyMs }`. + +**Review findings folded in:** +- **`providerRef` = nodemailer's returned `info.messageId`**, not a hand-generated id — nodemailer + stamps the real `Message-ID` it sent, which is what a reply's `In-Reply-To` will actually match. +- **Fallback only on PRE-acceptance failures** (connection refused, auth failure, timeout) — NOT on + an error raised after the SMTP server already accepted the message. Retrying a post-acceptance + failure via the fallback identity would **double-deliver**. `send()` inspects the error (nodemailer + `err.responseCode` / code) and falls back only when the server never accepted. +- **Registry precedence is registration ORDER:** `register()` does `byChannel.set(ch, provider)`, so + the LAST registration for `EMAIL` wins. Bind sandbox first (all channels), then HTTP `EmailProvider` + if its URL is set, then `SmtpProvider` **last** if SMTP env is set → SMTP > HTTP > sandbox falls out. + +## Task-by-task (TDD) + +Each: failing test → red → implement → green → commit. Tests inject a stub transporter (no network). + +**T1 — provider skeleton + config parse** +- `smtpIdentityFromEnv()` reads the env trio; returns null if incomplete. +- Test: full env → identity; missing pass → null; fallback trio → fallback identity. + +**T2 — `send()` builds the correct envelope** +- Inject a recording stub transporter. Test: `from`/`to`/`subject`/`html`/`text` map from target+payload; + `inReplyTo` → header set when present; `messageId` generated and returned as `providerRef`; outcome `SENT`. + +**T3 — failure handling + fallback** +- Stub throws on primary. Test: with a fallback identity → retries via fallback, `SENT` via fallback + transporter; without fallback → `FAILED` with `errorCode`, **never throws**. + +**T4 — registry precedence** +- `capability.registry.spec` (or extend): with SMTP env set, `forChannel('EMAIL')` returns the SMTP + provider (not sandbox/HTTP); with only `IIOS_PROVIDER_URL_EMAIL` → HTTP; with neither → sandbox. +- The registry reads env in its constructor, so each case sets env, constructs a fresh + `CapabilityProviderRegistry`, asserts, then restores env (mirror the env save/restore other specs use). + +**T5 — gate + manual real send** +- `vitest run` (all), `boundary`, `build` green. +- **Manual (ops):** point env at a real mailbox (or nodemailer **Ethereal** test SMTP for a no-mailbox + end-to-end), boot, `POST /v1/templates/send` the `welcome` seed to your own address, confirm receipt + and that From = `accounts@lynkeduppro.com`. + +## Error handling +- Incomplete SMTP env → provider not bound → EMAIL falls back to sandbox (no accidental silent prod send). +- Transport failure → `FAILED` on the command (+ delivery attempt), never thrown. +- Fallback used → `providerRef` notes the fallback identity for audit. + +## Risks / prerequisites +- 🔴 **Rotate `Qwerty@a2` BEFORE enabling.** This is the switch that turns queued sends into real + emails to real addresses — a forgeable IIOS token now reaches customer inboxes under your brand. +- **Workspace App Password, not the account password** (2FA accounts reject the raw password over SMTP). +- **Deliverability:** SPF + DKIM + DMARC on `lynkeduppro.com` or mail lands in spam. Ops task, before real customers. +- **Sending limits:** Google Workspace SMTP ≈ 2000/day. Fine — instant welcome/receipt is low volume; the + drip goes via Mailchimp, not SMTP. +- **Test safety:** never point CI/test env at a real mailbox; tests use an injected stub, the manual step + uses Ethereal or a throwaway inbox. + +## Out of scope (separate plans) +Attachments over SMTP (extends `EmailPayload` + this provider); the inbox mirror / INTERNAL delivery +(no SMTP dependency). -- 2.52.0 From 74cca2d53444916296c8692e340b1c44a80184a3 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 13:31:04 +0530 Subject: [PATCH 25/30] feat(smtp): SMTP egress provider (nodemailer) for the EMAIL channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes external email actually leave the building (was sandbox-only). SmtpProvider implements CapabilityProvider; env-activated (accounts@ primary, ceo@ fallback); transporter injected for tests. - send() maps target+payload -> {from,to,subject,html,text,inReplyTo,references}; providerRef = nodemailer's real Message-ID (so replies thread via In-Reply-To). - Fallback ONLY on pre-acceptance failures (connect/auth/timeout) — a post-acceptance error is terminal, so a message the server already took can't be double-delivered. - Never throws — transport failure -> FAILED, per the adapter doctrine. - Registry precedence via registration order: SMTP > HTTP relay > sandbox for EMAIL. Verified: 13 unit tests (config/envelope/fallback/precedence) + a REAL SMTP round-trip against nodemailer Ethereal (SENT, genuine Message-ID). Full suite 281/281, boundary+build clean. Co-Authored-By: Claude Opus 4.8 --- packages/iios-service/package.json | 2 + .../capability/capability.registry.spec.ts | 34 ++++ .../src/capability/capability.registry.ts | 7 + .../src/capability/smtp.provider.spec.ts | 90 +++++++++++ .../src/capability/smtp.provider.ts | 145 ++++++++++++++++++ pnpm-lock.yaml | 19 +++ 6 files changed, 297 insertions(+) create mode 100644 packages/iios-service/src/capability/capability.registry.spec.ts create mode 100644 packages/iios-service/src/capability/smtp.provider.spec.ts create mode 100644 packages/iios-service/src/capability/smtp.provider.ts diff --git a/packages/iios-service/package.json b/packages/iios-service/package.json index 1113aec..2d11fc5 100644 --- a/packages/iios-service/package.json +++ b/packages/iios-service/package.json @@ -28,6 +28,7 @@ "ioredis": "^5.11.1", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^4.1.0", + "nodemailer": "^9.0.3", "prisma": "^6.2.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", @@ -41,6 +42,7 @@ "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^26.0.1", + "@types/nodemailer": "^8.0.1", "@types/web-push": "^3.6.4", "socket.io-client": "^4.8.3", "typescript": "^5.7.3" diff --git a/packages/iios-service/src/capability/capability.registry.spec.ts b/packages/iios-service/src/capability/capability.registry.spec.ts new file mode 100644 index 0000000..7f5f1f8 --- /dev/null +++ b/packages/iios-service/src/capability/capability.registry.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { CapabilityProviderRegistry } from './capability.registry'; + +// The registry reads env in its constructor, so each case sets env → constructs a fresh registry → +// asserts → restores env. +const SMTP_KEYS = ['IIOS_SMTP_HOST', 'IIOS_SMTP_USER', 'IIOS_SMTP_PASS', 'IIOS_PROVIDER_URL_EMAIL']; +const saved: Record = {}; +function set(env: Record) { + for (const k of SMTP_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } + for (const [k, v] of Object.entries(env)) if (v != null) process.env[k] = v; +} +afterEach(() => { for (const k of SMTP_KEYS) { if (saved[k] == null) delete process.env[k]; else process.env[k] = saved[k]; } }); + +describe('CapabilityProviderRegistry — EMAIL precedence (SMTP > HTTP > sandbox)', () => { + it('binds SMTP for EMAIL when the SMTP env trio is set', () => { + set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' }); + expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp'); + }); + + it('binds the HTTP EmailProvider when only IIOS_PROVIDER_URL_EMAIL is set', () => { + set({ IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' }); + expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('email-http'); + }); + + it('SMTP wins over the HTTP relay when both are set', () => { + set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p', IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' }); + expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp'); + }); + + it('falls back to the sandbox when neither is configured', () => { + set({}); + expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('sandbox'); + }); +}); diff --git a/packages/iios-service/src/capability/capability.registry.ts b/packages/iios-service/src/capability/capability.registry.ts index c055fc0..fa5cd98 100644 --- a/packages/iios-service/src/capability/capability.registry.ts +++ b/packages/iios-service/src/capability/capability.registry.ts @@ -3,6 +3,7 @@ import type { CapabilityProvider } from '@insignia/iios-contracts'; import { SandboxProvider } from './sandbox.provider'; import { HttpProvider } from './http.provider'; import { EmailProvider } from './email.provider'; +import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv } from './smtp.provider'; const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; @@ -11,6 +12,9 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; * default; if `IIOS_PROVIDER_URL_` is set, a real HttpProvider * overrides the sandbox for that channel (the "flip the binding" swap). Unknown * channels fail closed — no silent egress path. + * + * Precedence is registration ORDER (register() does Map.set → last wins). For EMAIL: + * sandbox → HTTP EmailProvider (if URL set) → SMTP (if SMTP env set), so SMTP > HTTP > sandbox. */ @Injectable() export class CapabilityProviderRegistry { @@ -24,6 +28,9 @@ export class CapabilityProviderRegistry { // EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one. this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url)); } + // Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). + const smtp = smtpIdentityFromEnv(); + if (smtp) this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined)); } register(provider: CapabilityProvider): void { diff --git a/packages/iios-service/src/capability/smtp.provider.spec.ts b/packages/iios-service/src/capability/smtp.provider.spec.ts new file mode 100644 index 0000000..eb99c47 --- /dev/null +++ b/packages/iios-service/src/capability/smtp.provider.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, type MailTransport, type SmtpIdentity } from './smtp.provider'; +import type { CapabilityRequest } from '@insignia/iios-contracts'; + +const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' }; +const FB: SmtpIdentity = { ...ID, user: 'ceo@lynkeduppro.com', from: 'Justin ' }; + +const req = (payload: Record): CapabilityRequest => ({ + capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1', +}); + +/** A recording transport; optionally throws a given error on send. */ +function stub(opts: { throwErr?: unknown; messageId?: string } = {}) { + const calls: Array<{ id: SmtpIdentity; mail: Parameters[0] }> = []; + const make = (id: SmtpIdentity): MailTransport => ({ + async sendMail(mail) { + calls.push({ id, mail }); + if (opts.throwErr) throw opts.throwErr; + return { messageId: opts.messageId ?? '' }; + }, + }); + return { make, calls }; +} + +describe('smtpIdentityFromEnv', () => { + const base = { IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' }; + + it('builds the identity from a complete env trio (defaults port 587, secure false)', () => { + const id = smtpIdentityFromEnv({ ...base } as NodeJS.ProcessEnv); + expect(id).toMatchObject({ host: 'smtp.test', port: 587, secure: false, user: 'accounts@x', from: 'accounts@x' }); + }); + + it('returns null when the trio is incomplete', () => { + expect(smtpIdentityFromEnv({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'a@x' } as NodeJS.ProcessEnv)).toBeNull(); + }); + + it('reads the fallback identity, reusing the primary host', () => { + const fb = smtpFallbackFromEnv({ ...base, IIOS_SMTP_FALLBACK_USER: 'ceo@x', IIOS_SMTP_FALLBACK_PASS: 'q', IIOS_SMTP_FALLBACK_FROM: 'CEO ' } as NodeJS.ProcessEnv); + expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO ' }); + }); + + it('returns null fallback when not configured', () => { + expect(smtpFallbackFromEnv({ ...base } as NodeJS.ProcessEnv)).toBeNull(); + }); +}); + +describe('SmtpProvider.send — envelope', () => { + it('maps target + payload into the mail and returns the messageId as providerRef', async () => { + const t = stub({ messageId: '' }); + const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '

x

', text: 'x' })); + expect(res).toMatchObject({ outcome: 'SENT', providerRef: '' }); + expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '

x

', text: 'x' }); + }); + + it('sets In-Reply-To + References headers for a reply', async () => { + const t = stub(); + await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 're', inReplyTo: '' })); + expect(t.calls[0].mail).toMatchObject({ inReplyTo: '', references: '' }); + }); +}); + +describe('SmtpProvider.send — failure + fallback', () => { + it('retries via the fallback identity on a pre-acceptance failure (SENT via fallback)', async () => { + // primary throws ECONNREFUSED (server never accepted) → fallback used. + const primaryThrows = { make: (id: SmtpIdentity): MailTransport => ({ + async sendMail(mail) { + if (id.user === ID.user) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }); + return { messageId: '' }; + }, + }) }; + const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' })); + expect(res.outcome).toBe('SENT'); + expect(res.providerRef).toBe('fallback:'); + }); + + it('does NOT retry a post-acceptance failure (avoids double delivery) → FAILED', async () => { + // responseCode present = the server already spoke; retrying could double-send. + const t = stub({ throwErr: Object.assign(new Error('rejected after data'), { responseCode: 550 }) }); + const res = await new SmtpProvider(ID, FB, t.make).send(req({ subject: 'x' })); + expect(res.outcome).toBe('FAILED'); + expect(t.calls).toHaveLength(1); // primary only — no fallback attempt + }); + + it('with no fallback, a failure is FAILED and never throws', async () => { + const t = stub({ throwErr: Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) }); + const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'x' })); + expect(res.outcome).toBe('FAILED'); + expect(res.errorCode).toBe('ETIMEDOUT'); + }); +}); diff --git a/packages/iios-service/src/capability/smtp.provider.ts b/packages/iios-service/src/capability/smtp.provider.ts new file mode 100644 index 0000000..675a5c6 --- /dev/null +++ b/packages/iios-service/src/capability/smtp.provider.ts @@ -0,0 +1,145 @@ +import { randomUUID } from 'node:crypto'; +import { createTransport } from 'nodemailer'; +import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts'; + +/** One SMTP sending identity (a mailbox + how to reach its server). */ +export interface SmtpIdentity { + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + from: string; +} + +/** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */ +export interface MailTransport { + sendMail(mail: { + from: string; + to: string; + subject?: string; + text?: string; + html?: string; + inReplyTo?: string; + references?: string; + }): Promise<{ messageId: string; accepted?: unknown[] }>; +} + +const bool = (v: string | undefined): boolean => v === 'true' || v === '1'; + +/** Build the primary SMTP identity from env, or null if the required trio is incomplete. */ +export function smtpIdentityFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null { + return identityFrom(env, ''); +} + +/** Build the optional fallback identity (accounts@ → ceo@), or null if not configured. */ +export function smtpFallbackFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null { + const fb = identityFrom(env, 'FALLBACK_'); + if (fb) return fb; + // Fallback may reuse the primary host/port and only override the mailbox identity. + const host = env.IIOS_SMTP_HOST; + const user = env.IIOS_SMTP_FALLBACK_USER; + const pass = env.IIOS_SMTP_FALLBACK_PASS; + if (!host || !user || !pass) return null; + return { + host, + port: Number(env.IIOS_SMTP_PORT ?? 587), + secure: bool(env.IIOS_SMTP_SECURE), + user, + pass, + from: env.IIOS_SMTP_FALLBACK_FROM ?? user, + }; +} + +function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | null { + const host = env[`IIOS_SMTP_${prefix}HOST`] ?? (prefix ? undefined : env.IIOS_SMTP_HOST); + const user = env[`IIOS_SMTP_${prefix}USER`]; + const pass = env[`IIOS_SMTP_${prefix}PASS`]; + if (!host || !user || !pass) return null; + return { + host, + port: Number(env[`IIOS_SMTP_${prefix}PORT`] ?? env.IIOS_SMTP_PORT ?? 587), + secure: bool(env[`IIOS_SMTP_${prefix}SECURE`] ?? env.IIOS_SMTP_SECURE), + user, + pass, + from: env[`IIOS_SMTP_${prefix}FROM`] ?? user, + }; +} + +interface EmailPayload { + subject?: string; + text?: string; + html?: string; + inReplyTo?: string; +} + +/** + * SMTP egress provider (nodemailer). Bound for EMAIL when the SMTP env trio is set (else the sandbox + * stays). A transport failure surfaces as FAILED, never thrown. On a PRE-acceptance failure it retries + * once via the fallback identity (accounts@ → ceo@); a post-acceptance failure is NOT retried, so a + * message the server already accepted can't be double-delivered. + */ +export class SmtpProvider implements CapabilityProvider { + readonly name = 'smtp'; + readonly channelTypes = ['EMAIL']; + readonly capabilities = { canSend: true }; + + private readonly makeTransport: (id: SmtpIdentity) => MailTransport; + + constructor( + private readonly primary: SmtpIdentity, + private readonly fallback?: SmtpIdentity, + makeTransport?: (id: SmtpIdentity) => MailTransport, + ) { + this.makeTransport = makeTransport ?? defaultTransport; + } + + async send(req: CapabilityRequest): Promise { + const started = Date.now(); + const p = (req.payload ?? {}) as EmailPayload; + + const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> => + this.makeTransport(id).sendMail({ + from: id.from, + to: req.target, + subject: p.subject ?? '(no subject)', + text: p.text, + html: p.html, + ...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}), + }); + + try { + const info = await attempt(this.primary); + return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started }; + } catch (err) { + // Retry via the fallback identity ONLY if the primary never got the message accepted. + if (this.fallback && isPreAcceptanceFailure(err)) { + try { + const info = await attempt(this.fallback); + return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started }; + } catch (err2) { + return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started }; + } + } + return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started }; + } + } +} + +/** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */ +function isPreAcceptanceFailure(err: unknown): boolean { + const e = err as { code?: string; responseCode?: number }; + const preCodes = ['ECONNECTION', 'ETIMEDOUT', 'ECONNREFUSED', 'EDNS', 'EAUTH', 'ESOCKET', 'EENVELOPE']; + if (e.code && preCodes.includes(e.code)) return true; + // A responseCode present means the server spoke — treat 5xx after acceptance as terminal (no retry). + return e.responseCode == null && e.code == null; +} + +function codeOf(err: unknown): string { + const e = err as { code?: string; message?: string }; + return (e.code ?? e.message ?? 'SMTP_ERROR').slice(0, 60); +} + +function defaultTransport(id: SmtpIdentity): MailTransport { + return createTransport({ host: id.host, port: id.port, secure: id.secure, auth: { user: id.user, pass: id.pass } }) as unknown as MailTransport; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32beda2..bab7336 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,6 +376,9 @@ importers: jwks-rsa: specifier: ^4.1.0 version: 4.1.0 + nodemailer: + specifier: ^9.0.3 + version: 9.0.3 prisma: specifier: ^6.2.1 version: 6.19.3(typescript@5.9.3) @@ -410,6 +413,9 @@ importers: '@types/node': specifier: ^26.0.1 version: 26.0.1 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/web-push': specifier: ^3.6.4 version: 3.6.4 @@ -1520,6 +1526,9 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -2545,6 +2554,10 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + notepack.io@3.0.1: resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} @@ -4195,6 +4208,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 26.0.1 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -5317,6 +5334,8 @@ snapshots: node-releases@2.0.50: {} + nodemailer@9.0.3: {} + notepack.io@3.0.1: {} nypm@0.6.8: -- 2.52.0 From bba5fae0617f748aaa64fd8b11e049d7cc73df1f Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 13:49:19 +0530 Subject: [PATCH 26/30] docs(mail): inbox mirror + INTERNAL delivery plan Co-Authored-By: Claude Opus 4.8 --- docs/inbox-mirror-internal-delivery-plan.md | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/inbox-mirror-internal-delivery-plan.md diff --git a/docs/inbox-mirror-internal-delivery-plan.md b/docs/inbox-mirror-internal-delivery-plan.md new file mode 100644 index 0000000..fb9d1fc --- /dev/null +++ b/docs/inbox-mirror-internal-delivery-plan.md @@ -0,0 +1,113 @@ +# Inbox Mirror + INTERNAL Delivery — Implementation Plan + +**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** every message the app +sends appears in the customer's in-app inbox; and users can send app-to-app "mail" with no SMTP. + +## Goal + +Two capabilities on one mechanism: +1. **Mirror** — when an external EMAIL is sent, also record it as an in-app interaction so the + customer sees a copy in their CRM inbox. Vivek: *"जो भी communication…उसकी एक copy inbox में चाहिए ही चाहिए."* +2. **INTERNAL delivery** — a user sends a mail-style message (subject + body) to another user with + **no SMTP**; it lands only in the recipient's in-app inbox. Vivek: *"app-to-app…without smtp."* + +Both reduce to the same primitive: **render a template → create an `Interaction(kind=EMAIL)` with +subject + HTML + TEXT parts on a thread.** External additionally does the SMTP send (already built). + +## Architectural guardrail (carried from the earlier inbox work) + +This is the **mail-style inbox (a projection over `Interaction`s)** — NOT the `InboxItem` work-surface. +- An email/message becomes an `Interaction(kind=EMAIL)` on a thread. The inbox view lists interactions. +- An `InboxItem` is created ONLY when the projector decides action is needed (NEEDS_REPLY/MENTION) — + that's the existing projector, unchanged. **We do not write InboxItems here.** Mixing them is the + KG-15 "inbox fatigue" failure. + +## What already exists (reuse, do NOT rebuild) + +- `IngestService.ingest(req, idempotencyKey)` — the generic create-an-interaction entry: resolves + source handle → actor → channel → thread, writes `Interaction` (kind from `req.kind`) + parts + + outbox event, idempotent per (scope, idempotencyKey). Inbound email already uses it to make + `EMAIL` interactions with HTML/TEXT parts — **the exact model for the outbound mirror.** +- `TemplateService.render()` (exported) → `{subject, html, text}`. +- `TemplatedSender.sendTemplated()` → SMTP egress (built). +- `IiosMessagePartKind` has `HTML` + `TEXT`; `IiosInteractionKind` has `EMAIL`. + +## Design (locked) + +- **New `MailService`** (new `src/mail/` module) orchestrates `TemplateService` + `IngestService` + + `TemplatedSender` + `ActorResolver`. Templates/outbound stay unaware of each other. + - `postInternal(...)` — render → `ingest()` an `EMAIL` interaction on a per-email thread. No SMTP. + - `sendExternalWithMirror(...)` — render → `TemplatedSender.sendTemplated()` (SMTP) → **and** mirror + via `ingest()` **iff the recipient is a registered user** (timing rule below). +- **Visibility (resolved review finding):** `ingest()` creates the interaction + thread but adds NO + participants, and `listThreads` shows only threads where the caller is a participant. So after each + ingest the MailService `ensureParticipant`s **both** the sender's actor and the recipient's actor + (`ActorResolver.resolveActor` → `ensureParticipant`). Without this the mirror is invisible. +- **`ingest()` returns `threadId`** — used directly to add the two participants. +- **Rendered content → parts:** part 0 `HTML` (bodyHtml), part 1 `TEXT` (bodyText); `subject` → the + thread subject (email threads share a subject). Attachments are the separate attachments plan. +- **Idempotency:** the ingest idempotencyKey = the send's key (e.g. `mirror:`), so a + retried send never doubles the inbox copy. +- **Reply/threading:** `parentInteractionId` for in-thread replies (already modeled); a mirrored + email's `inReplyTo` maps to the parent interaction. + +## The timing rule (locked, from the meeting) + +**Mirror only AFTER the recipient is registered.** The welcome/receipt go out *before* registration — +there is no in-app inbox to mirror into yet. So `sendExternalWithMirror` mirrors only when the target +resolves to a registered actor; pre-registration sends are email-only. Vivek: *"just time app pe +register kar liya, uske baad se jitna communication…uske inbox mein chahiye."* + +## Thread model (DECIDED: one thread per email) + +**Each send is its own thread / inbox entry; a reply threads onto it.** Matches email semantics and +pairs with the reply (`parentInteractionId`) feature. Implementation: the ingest `externalThreadId` +is **derived from the send's idempotency key**, so a retried send reuses the same thread (no dupe) +while distinct emails get distinct threads. A reply posts onto the parent's thread. + +## Files + +``` +src/mail/mail.service.ts # new — postInternal, sendExternalWithMirror +src/mail/mail.service.spec.ts # new — DB-backed +src/mail/mail.module.ts # new — imports TemplateModule + AdaptersModule + interactions +src/mail/mail.controller.ts # new? — OR extend template.controller with a `deliverInternal` route +``` +(Whether INTERNAL gets its own HTTP route or rides the template controller is a small call made at build time.) + +## Task-by-task (TDD) — pending the thread-model decision + +**T1 — `renderToParts()` helper**: `{subject,html,text}` → `IngestInteractionRequest.parts` + +thread subject. Test: HTML+TEXT parts produced; empty parts omitted. + +**T2 — `postInternal()`**: render an INTERNAL template → `ingest()` an `EMAIL` interaction on the +thread between sender + recipient (thread model per the decision). Test: interaction created with +kind EMAIL + parts; idempotent per key; lands on the recipient's thread. + +**T3 — `sendExternalWithMirror()`**: render → `sendTemplated` (SMTP/sandbox) → mirror `ingest()` +**only if** the recipient resolves to a registered actor. Test: registered → one outbound command + +one mirror interaction; unregistered → outbound only, no mirror; idempotent (replay → no dupes). + +**T4 — controller/module wiring + HTTP verify** (route for INTERNAL send; mirror invoked from the +external send path). Boot + drive over HTTP against the sandbox. + +**T5 — gate**: full suite + boundary + build; manual: send external → confirm a mirror interaction +appears on the recipient's thread. + +## Out of scope (follow-ons) +- **Frontend mail-inbox view** — surfacing `EMAIL` interactions as a mail-style inbox in the CRM + (the current CRM inbox is the InboxItem work-surface; the mail view is separate UI). +- **Attachments** (separate plan). **Stripe webhook** (be-crm) — the trigger. + +## Risks +- **Don't write InboxItems here** (KG-15). Interactions only; the projector owns InboxItems. +- **Idempotency must cover BOTH** the SMTP send and the mirror ingest, or a retried webhook doubles + the inbox copy. Same key threaded through both. +- **Unregistered recipients:** resolving "is this a registered user?" must be cheap and correct, or a + pre-registration send could either error or wrongly mirror into a non-existent inbox. +- **Review finding — recipient participation:** `ingest()` resolves and attaches the *source* actor. + For the interaction to appear in the *recipient's* inbox, the **recipient must be a thread + participant.** T2/T3 must ensure this — either by making the thread's participant set include the + recipient at create time, or an explicit `ensureParticipant` after ingest. A mirror the recipient + isn't a participant of is invisible — silent failure. Cover it with an assertion in the tests + ("recipient can list the thread / the interaction shows in their inbox query"). -- 2.52.0 From 9b075f46f9fd0e845e399453171d97757d283b2c Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 13:54:32 +0530 Subject: [PATCH 27/30] feat(mail): inbox mirror + INTERNAL (app-to-app) delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MailService renders a template then deposits it as an EMAIL interaction on a per-email thread (thread model: one thread per email), reusing IngestService. Because ingest adds no participants and a thread is only visible to its participants, it ensureParticipant()s BOTH sender and recipient — so the mirror is actually visible. - postInternal: app-to-app mail, no SMTP → recipient's in-app inbox. - sendExternalWithMirror: SMTP send (TemplatedSender) + mirror an interaction ONLY for a registered recipient (pre-registration sends are email-only — no inbox exists yet). Idempotent across both the send and the mirror. - Writes Interactions, NEVER InboxItems (the projector owns those — KG-15). - POST /v1/mail/internal, POST /v1/mail/send. MailModule in AppModule. Verified over HTTP: internal mail → recipient SEES the thread in their inbox; external send → command + mirror; no-source/no-auth 400. 7 unit tests. Co-Authored-By: Claude Opus 4.8 --- packages/iios-service/src/app.module.ts | 2 + .../iios-service/src/mail/mail.controller.ts | 55 +++++++++ packages/iios-service/src/mail/mail.dto.ts | 30 +++++ packages/iios-service/src/mail/mail.module.ts | 18 +++ .../src/mail/mail.service.spec.ts | 106 ++++++++++++++++++ .../iios-service/src/mail/mail.service.ts | 105 +++++++++++++++++ 6 files changed, 316 insertions(+) create mode 100644 packages/iios-service/src/mail/mail.controller.ts create mode 100644 packages/iios-service/src/mail/mail.dto.ts create mode 100644 packages/iios-service/src/mail/mail.module.ts create mode 100644 packages/iios-service/src/mail/mail.service.spec.ts create mode 100644 packages/iios-service/src/mail/mail.service.ts diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index a1345a2..1afdae9 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -11,6 +11,7 @@ import { ThreadsModule } from './threads/threads.module'; import { MessageModule } from './messaging/message.module'; import { InboxModule } from './inbox/inbox.module'; import { TemplateModule } from './templates/template.module'; +import { MailModule } from './mail/mail.module'; import { MediaModule } from './media/media.module'; import { NotificationModule } from './notifications/notification.module'; import { SupportModule } from './support/support.module'; @@ -39,6 +40,7 @@ import { DevController } from './dev/dev.controller'; MessageModule, InboxModule, TemplateModule, + MailModule, MediaModule, NotificationModule, SupportModule, diff --git a/packages/iios-service/src/mail/mail.controller.ts b/packages/iios-service/src/mail/mail.controller.ts new file mode 100644 index 0000000..968b1ff --- /dev/null +++ b/packages/iios-service/src/mail/mail.controller.ts @@ -0,0 +1,55 @@ +import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common'; +import { SessionVerifier } from '../platform/session.verifier'; +import type { MessagePrincipal } from '../identity/actor.resolver'; +import { MailService } from './mail.service'; +import { MailInternalDto, MailSendDto } from './mail.dto'; +import type { TemplateSource } from '../templates/template.model'; + +@Controller('v1/mail') +export class MailController { + constructor( + private readonly mail: MailService, + private readonly session: SessionVerifier, + ) {} + + /** App-to-app mail — renders a template and posts it into the recipient's in-app inbox (no SMTP). */ + @Post('internal') + async internal(@Body() body: MailInternalDto, @Headers('authorization') authorization?: string) { + const principal = this.principal(authorization); + return this.mail.postInternal(principal, { + source: this.source(body), + recipientUserId: body.recipientUserId, + vars: body.vars ?? {}, + ...(body.locale ? { locale: body.locale } : {}), + idempotencyKey: body.idempotencyKey, + }); + } + + /** External email (SMTP) + an in-app mirror when a registered recipient is named. */ + @Post('send') + async send(@Body() body: MailSendDto, @Headers('authorization') authorization?: string) { + const principal = this.principal(authorization); + return this.mail.sendExternalWithMirror(principal, { + source: this.source(body), + target: body.target, + vars: body.vars ?? {}, + ...(body.locale ? { locale: body.locale } : {}), + idempotencyKey: body.idempotencyKey, + ...(body.purpose ? { purpose: body.purpose } : {}), + ...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}), + }); + } + + private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource { + if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both'); + if (body.inline) return { inline: body.inline }; + if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key }; + throw new BadRequestException('one of "key" or "inline" is required'); + } + + private principal(authorization?: string): MessagePrincipal { + const token = (authorization ?? '').replace(/^Bearer\s+/i, ''); + if (!token) throw new BadRequestException('Authorization bearer token is required'); + return this.session.verify(token); + } +} diff --git a/packages/iios-service/src/mail/mail.dto.ts b/packages/iios-service/src/mail/mail.dto.ts new file mode 100644 index 0000000..92844aa --- /dev/null +++ b/packages/iios-service/src/mail/mail.dto.ts @@ -0,0 +1,30 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { InlineTemplateDto } from '../templates/template.dto'; + +/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */ +export class MailInternalDto { + @IsOptional() @IsString() @IsNotEmpty() key?: string; + @IsOptional() @IsInt() version?: number; + @IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto; + + @IsString() @IsNotEmpty() recipientUserId!: string; + @IsOptional() @IsObject() vars?: Record; + @IsOptional() @IsString() locale?: string; + @IsString() @IsNotEmpty() idempotencyKey!: string; +} + +/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */ +export class MailSendDto { + @IsOptional() @IsString() @IsNotEmpty() key?: string; + @IsOptional() @IsInt() version?: number; + @IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto; + + @IsString() @IsNotEmpty() target!: string; + @IsOptional() @IsObject() vars?: Record; + @IsOptional() @IsString() locale?: string; + @IsString() @IsNotEmpty() idempotencyKey!: string; + @IsOptional() @IsString() purpose?: string; + /** The registered recipient to mirror to; omit for a pre-registration send (email only). */ + @IsOptional() @IsString() mirrorToUserId?: string; +} diff --git a/packages/iios-service/src/mail/mail.module.ts b/packages/iios-service/src/mail/mail.module.ts new file mode 100644 index 0000000..3ff835d --- /dev/null +++ b/packages/iios-service/src/mail/mail.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TemplateModule } from '../templates/template.module'; +import { InteractionsModule } from '../interactions/interactions.module'; +import { MailService } from './mail.service'; +import { MailController } from './mail.controller'; + +/** + * Mail orchestration: render a template (TemplateModule) → deliver it either app-to-app (an EMAIL + * interaction via IngestService) or externally (TemplatedSender/SMTP) with an in-app mirror. + * SessionVerifier + ActorResolver are global. + */ +@Module({ + imports: [TemplateModule, InteractionsModule], + controllers: [MailController], + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/packages/iios-service/src/mail/mail.service.spec.ts b/packages/iios-service/src/mail/mail.service.spec.ts new file mode 100644 index 0000000..ea1dc08 --- /dev/null +++ b/packages/iios-service/src/mail/mail.service.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { resetDb } from '../test-utils/reset-db'; +import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { IngestService } from '../interactions/ingest.service'; +import { MessageService } from '../messaging/message.service'; +import { OutboundService } from '../adapters/outbound.service'; +import { CapabilityBroker } from '../capability/capability.broker'; +import { CapabilityProviderRegistry } from '../capability/capability.registry'; +import { IdempotencyService } from '../idempotency/idempotency.service'; +import { TemplateRepository } from '../templates/template.repository'; +import { TemplateService } from '../templates/template.service'; +import { TemplatedSender } from '../templates/templated-sender'; +import { MailService, contentToParts } from './mail.service'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios_test?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const asService = prisma as unknown as PrismaService; +const actors = new ActorResolver(asService); + +function mail(): MailService { + const templates = new TemplateService(new TemplateRepository(asService)); + const ingest = new IngestService(asService, makeFakePorts(), actors); + const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)); + const sender = new TemplatedSender(templates, outbound, makeFakePorts()); + return new MailService(templates, ingest, sender, actors); +} +const messages = () => new MessageService(asService, makeFakePorts(), actors); + +const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' }; +const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' }; +const inline = { inline: { subject: 'Welcome Dana', html: '

Hi Dana

', text: 'Hi Dana', variables: [] } }; + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('contentToParts (pure)', () => { + it('produces HTML + TEXT parts and the subject', () => { + expect(contentToParts({ subject: 'S', html: '

h

', text: 't' })).toEqual({ subject: 'S', parts: [{ kind: 'HTML', bodyText: '

h

' }, { kind: 'TEXT', bodyText: 't' }] }); + }); + it('never yields zero parts (empty text fallback)', () => { + expect(contentToParts({ subject: 'S' }).parts).toEqual([{ kind: 'TEXT', bodyText: '' }]); + }); +}); + +describe('MailService.postInternal', () => { + it('creates an EMAIL interaction and makes the thread visible to BOTH sender and recipient', async () => { + const res = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:1' }); + + const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } }); + expect(interaction.kind).toBe('EMAIL'); + expect(interaction.parts.map((p) => p.kind).sort()).toEqual(['HTML', 'TEXT']); + + const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: res.threadId } }); + expect(thread.subject).toBe('Welcome Dana'); + + // The load-bearing assertion: the RECIPIENT can see the thread in their inbox. + const bobThreads = await messages().listThreads(bob); + expect(bobThreads.map((t) => t.threadId)).toContain(res.threadId); + // ...and so can the sender. + const aliceThreads = await messages().listThreads(alice); + expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId); + }); + + it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => { + const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); + const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); + expect(b.threadId).toBe(a.threadId); + expect(await prisma.iiosInteraction.count({ where: { threadId: a.threadId } })).toBe(1); + }); +}); + +describe('MailService.sendExternalWithMirror', () => { + const ext = { source: inline, target: 'dana@acme.com', idempotencyKey: 'recv:1' } as const; + + it('sends via the outbound pipeline AND mirrors into a registered recipient inbox', async () => { + const res = await mail().sendExternalWithMirror(alice, { ...ext, mirrorToUserId: 'bob' }); + // outbound command exists (SENT via sandbox in tests) + const cmd = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id: res.commandId } }); + expect(cmd.channelType).toBe('EMAIL'); + expect(cmd.target).toBe('dana@acme.com'); + // mirror interaction visible to the recipient + expect(res.mirror).toBeDefined(); + const bobThreads = await messages().listThreads(bob); + expect(bobThreads.map((t) => t.threadId)).toContain(res.mirror!.threadId); + }); + + it('does NOT mirror when there is no registered recipient (pre-registration send)', async () => { + const res = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:2' }); // no mirrorToUserId + expect(res.mirror).toBeUndefined(); + // an outbound command was created, but no mirror interaction + expect(await prisma.iiosOutboundCommand.count({ where: { id: res.commandId } })).toBe(1); + expect(await prisma.iiosInteraction.count()).toBe(0); + }); + + it('is idempotent — a replay yields one command and one mirror', async () => { + const a = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' }); + const b = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' }); + expect(b.commandId).toBe(a.commandId); + expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'recv:dup' } })).toBe(1); + expect(await prisma.iiosInteraction.count({ where: { threadId: a.mirror!.threadId } })).toBe(1); + }); +}); diff --git a/packages/iios-service/src/mail/mail.service.ts b/packages/iios-service/src/mail/mail.service.ts new file mode 100644 index 0000000..7d696f5 --- /dev/null +++ b/packages/iios-service/src/mail/mail.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import { IngestService } from '../interactions/ingest.service'; +import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { TemplateService } from '../templates/template.service'; +import { TemplatedSender } from '../templates/templated-sender'; +import type { RenderedContent } from '../templates/template.renderer'; +import type { TemplateSource } from '../templates/template.model'; + +export interface MailPart { kind: 'HTML' | 'TEXT'; bodyText: string } + +/** Turn rendered content into ingest parts (HTML + TEXT) + the thread subject. Pure. */ +export function contentToParts(content: RenderedContent): { subject?: string; parts: MailPart[] } { + const parts: MailPart[] = []; + if (content.html != null) parts.push({ kind: 'HTML', bodyText: content.html }); + if (content.text != null) parts.push({ kind: 'TEXT', bodyText: content.text }); + // A message must carry at least one part; fall back to an empty text part rather than fail ingest. + if (parts.length === 0) parts.push({ kind: 'TEXT', bodyText: '' }); + return { ...(content.subject != null ? { subject: content.subject } : {}), parts }; +} + +export interface PostInternalInput { + source: TemplateSource; + recipientUserId: string; + vars?: Record; + locale?: string; + idempotencyKey: string; +} + +export interface SendExternalInput { + source: TemplateSource; + target: string; // email address (external) + vars?: Record; + locale?: string; + idempotencyKey: string; + purpose?: string; + /** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */ + mirrorToUserId?: string; +} + +/** + * Mail orchestration: render a template, then deliver it. + * - INTERNAL (app-to-app): create an EMAIL interaction on a per-email thread — no SMTP. + * - EXTERNAL: send via SMTP (TemplatedSender) AND mirror a copy into the recipient's in-app inbox, + * but only when they are a registered user (pre-registration sends have no inbox yet). + * + * ingest() writes the interaction + thread but adds no participants, and a thread is only visible to + * its participants — so after each ingest we add BOTH the sender and the recipient as participants. + */ +@Injectable() +export class MailService { + constructor( + private readonly templates: TemplateService, + private readonly ingest: IngestService, + private readonly sender: TemplatedSender, + private readonly actors: ActorResolver, + ) {} + + async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> { + const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) }); + return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL'); + } + + async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> { + const command = await this.sender.sendTemplated({ + source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {}, + ...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}), + }); + + // Mirror only for a registered recipient (timing rule: no inbox exists pre-registration). + if (!input.mirrorToUserId) return { commandId: command.id }; + + const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) }); + const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL'); + return { commandId: command.id, mirror }; + } + + /** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */ + private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string): Promise<{ threadId: string; interactionId: string }> { + const { subject, parts } = contentToParts(content); + const res = await this.ingest.ingest( + { + scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) }, + channel: { type: channelType, externalChannelId: channelType.toLowerCase() }, + source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) }, + kind: 'EMAIL', + thread: { externalThreadId: key, ...(subject ? { subject } : {}) }, + parts, + occurredAt: new Date().toISOString(), + providerEventId: key, + }, + key, + ); + + // Make the thread visible to both the sender and the recipient (ingest adds no participants). + const scope = await this.actors.resolveScope(principal); + const senderActor = await this.actors.resolveActor(scope.id, principal); + const recipientActor = await this.actors.resolveActor(scope.id, { + userId: recipientUserId, appId: principal.appId, orgId: principal.orgId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}), + }); + await this.actors.ensureParticipant(res.threadId, senderActor.id); + await this.actors.ensureParticipant(res.threadId, recipientActor.id); + + return { threadId: res.threadId, interactionId: res.interactionId }; + } +} -- 2.52.0 From d28c873d4069cf249097d96a499d64ed80813c40 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 14:14:20 +0530 Subject: [PATCH 28/30] feat(mail): email attachments over SMTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email can now carry attachments (e.g. an invoice PDF). The kernel already stored attachments as message parts + the media StoragePort holds the bytes; the gap was the email envelope. - EMAIL payload carries attachment REFS ({filename, contentRef, mimeType}), not bytes — the ledger + T8 PII redaction stay small; bytes are fetched at send time. - SmtpProvider takes an AttachmentResolver; resolves each ref via storage and attaches (nodemailer). FAILS CLOSED if a declared attachment can't be resolved (or no resolver is wired) — never send a receipt/invoice missing its file; a FAILED command retries. - CapabilityProviderRegistry injects the resolver from STORAGE_PORT (@Optional); MediaModule exports STORAGE_PORT, CapabilityModule imports MediaModule. No cycle. - TemplatedSender + MailService + the /v1/mail/send DTO pass attachments through. Verified: 6 new unit tests (attach, fail-closed x2, plain-unaffected, resolver, pass-through) + a REAL Ethereal SMTP send WITH a PDF attachment (SENT). Full suite 294/294, boundary + build clean. Co-Authored-By: Claude Opus 4.8 --- docs/email-attachments-plan.md | 67 +++++++++++++++++++ .../src/capability/capability.module.ts | 2 + .../src/capability/capability.registry.ts | 15 +++-- .../src/capability/smtp.provider.spec.ts | 47 ++++++++++++- .../src/capability/smtp.provider.ts | 40 +++++++++++ .../iios-service/src/mail/mail.controller.ts | 1 + packages/iios-service/src/mail/mail.dto.ts | 10 ++- .../iios-service/src/mail/mail.service.ts | 3 + .../iios-service/src/media/media.module.ts | 2 + .../src/templates/templated-sender.spec.ts | 10 +++ .../src/templates/templated-sender.ts | 12 +++- 11 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 docs/email-attachments-plan.md diff --git a/docs/email-attachments-plan.md b/docs/email-attachments-plan.md new file mode 100644 index 0000000..855d304 --- /dev/null +++ b/docs/email-attachments-plan.md @@ -0,0 +1,67 @@ +# Email Attachments — Implementation Plan + +**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · Extends the SMTP provider + mail plumbing. + +## Goal + +Let an external email carry attachments (e.g. an invoice PDF). The kernel already STORES attachments +as message parts (`contentRef` + mime + size); the media `StoragePort` holds the bytes. The one gap is +the **email envelope** — `SmtpProvider` (and the payload) don't carry attachments. Close that. + +## Design (locked) + +- **Payload carries REFS, not bytes:** the EMAIL payload gains + `attachments?: [{ filename, contentRef, mimeType? }]`. Refs keep the outbound-command ledger small + (and keep T8's PII redaction cheap) — bytes are fetched at send time. +- **Resolver seam:** `AttachmentResolver = (contentRef) => Promise<{ filename?; content: Buffer; contentType? } | null>`. + `SmtpProvider` takes an optional resolver; on send it resolves each ref and attaches + (nodemailer `attachments: [{ filename, content, contentType }]`). +- **Fail closed on a missing attachment:** if a declared attachment can't be resolved, the send is + `FAILED` (so it retries) — NOT sent without it. A receipt/invoice missing its file is worse than a + retry. (No resolver wired at all + attachments present → also FAILED, same reasoning.) +- **Wiring:** `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; + `CapabilityProviderRegistry` `@Optional() @Inject(STORAGE_PORT)` → builds the resolver from + `storage.get(contentRef)` → passes it to `SmtpProvider`. `@Optional` so contexts without storage + still boot (attachments simply can't resolve → FAILED if any are declared). +- **Scope:** SMTP path only. The HTTP relay `EmailProvider` attachment support is a separate follow-up + (it would base64 the bytes into the relay POST). INTERNAL/mirror attachments already work via message + parts and are not this plan. + +## Files + +``` +src/capability/smtp.provider.ts # attachments in EmailPayload + resolve+attach in send() +src/capability/smtp.provider.spec.ts # attach resolved bytes; missing → FAILED +src/capability/capability.registry.ts # inject STORAGE_PORT → resolver → SmtpProvider +src/capability/capability.module.ts # import MediaModule +src/media/media.module.ts # export STORAGE_PORT +src/templates/templated-sender.ts # accept + pass `attachments` +src/mail/mail.service.ts # accept + pass `attachments` (external send) +``` + +## Tasks (TDD) + +**T1 — SmtpProvider attaches / fails closed** +- Inject a stub resolver. Tests: two refs → nodemailer `attachments` has both (filename + content + + contentType); a ref the resolver returns `null` for → outcome `FAILED`, nothing sent; no attachments + in payload → unchanged (plain send still SENT). + +**T2 — registry wires the resolver from STORAGE_PORT** +- `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; registry injects it + `@Optional`. Test: with a fake storage bound, `forChannel('EMAIL')` SMTP resolves an attachment; + without storage, the registry still constructs (attachments would FAIL, but boot is fine). + +**T3 — pass-through: TemplatedSender + MailService** +- `sendTemplated`/`sendExternalWithMirror` accept `attachments` and place them in the EMAIL payload. + Tests: the outbound command's payload carries the attachment refs. + +**T4 — gate + real send** +- Full suite + boundary + build. Manual: a real Ethereal send with a small attachment → SENT, and the + Ethereal message shows the attachment. + +## Risks +- **Fail-closed is deliberate** — don't silently send an invoice email without the invoice. +- **Payload holds refs, not bytes** — so the ledger and T8 redaction stay small; the resolver reads + bytes only at send time. +- **`@Optional` storage** — a context without `STORAGE_PORT` boots fine but can't send attachments; + that's correct (fail-closed), not a silent drop. diff --git a/packages/iios-service/src/capability/capability.module.ts b/packages/iios-service/src/capability/capability.module.ts index 2809916..6eac20c 100644 --- a/packages/iios-service/src/capability/capability.module.ts +++ b/packages/iios-service/src/capability/capability.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { MediaModule } from '../media/media.module'; import { CapabilityProviderRegistry } from './capability.registry'; import { CapabilityBroker } from './capability.broker'; @@ -8,6 +9,7 @@ import { CapabilityBroker } from './capability.broker'; * provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule. */ @Module({ + imports: [MediaModule], providers: [CapabilityProviderRegistry, CapabilityBroker], exports: [CapabilityBroker, CapabilityProviderRegistry], }) diff --git a/packages/iios-service/src/capability/capability.registry.ts b/packages/iios-service/src/capability/capability.registry.ts index fa5cd98..4728e7a 100644 --- a/packages/iios-service/src/capability/capability.registry.ts +++ b/packages/iios-service/src/capability/capability.registry.ts @@ -1,9 +1,10 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common'; import type { CapabilityProvider } from '@insignia/iios-contracts'; import { SandboxProvider } from './sandbox.provider'; import { HttpProvider } from './http.provider'; import { EmailProvider } from './email.provider'; -import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv } from './smtp.provider'; +import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider'; +import { STORAGE_PORT, type StoragePort } from '../media/storage.port'; const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; @@ -20,7 +21,7 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL']; export class CapabilityProviderRegistry { private readonly byChannel = new Map(); - constructor() { + constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) { for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch])); for (const ch of DEFAULT_CHANNELS) { const url = process.env[`IIOS_PROVIDER_URL_${ch}`]; @@ -28,9 +29,13 @@ export class CapabilityProviderRegistry { // EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one. this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url)); } - // Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). + // Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments + // resolve through the media StoragePort when one is bound (else attachments FAIL closed). const smtp = smtpIdentityFromEnv(); - if (smtp) this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined)); + if (smtp) { + const resolver = this.storage ? storageResolver(this.storage) : undefined; + this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver)); + } } register(provider: CapabilityProvider): void { diff --git a/packages/iios-service/src/capability/smtp.provider.spec.ts b/packages/iios-service/src/capability/smtp.provider.spec.ts index eb99c47..4aed5b6 100644 --- a/packages/iios-service/src/capability/smtp.provider.spec.ts +++ b/packages/iios-service/src/capability/smtp.provider.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, type MailTransport, type SmtpIdentity } from './smtp.provider'; +import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, type MailTransport, type SmtpIdentity } from './smtp.provider'; import type { CapabilityRequest } from '@insignia/iios-contracts'; const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' }; @@ -88,3 +88,48 @@ describe('SmtpProvider.send — failure + fallback', () => { expect(res.errorCode).toBe('ETIMEDOUT'); }); }); + +describe('SmtpProvider.send — attachments', () => { + const resolver = (map: Record) => + async (ref: string) => map[ref] ?? null; + + it('resolves attachment refs to bytes and attaches them', async () => { + const t = stub(); + const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } })) + .send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] })); + expect(res.outcome).toBe('SENT'); + expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]); + }); + + it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => { + const t = stub(); + const res = await new SmtpProvider(ID, undefined, t.make, resolver({})) + .send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] })); + expect(res.outcome).toBe('FAILED'); + expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED'); + expect(t.calls).toHaveLength(0); // nothing sent + }); + + it('FAILS when attachments are requested but no resolver is wired', async () => { + const t = stub(); + const res = await new SmtpProvider(ID, undefined, t.make) // no resolver + .send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] })); + expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' }); + }); + + it('a plain send with no attachments is unaffected', async () => { + const t = stub(); + const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' })); + expect(res.outcome).toBe('SENT'); + expect(t.calls[0].mail.attachments).toBeUndefined(); + }); +}); + +describe('storageResolver (media StoragePort → AttachmentResolver)', () => { + it('maps storage bytes into an attachment; missing ref → null', async () => { + const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) }; + const r = storageResolver(storage); + expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' }); + expect(await r('nope')).toBeNull(); + }); +}); diff --git a/packages/iios-service/src/capability/smtp.provider.ts b/packages/iios-service/src/capability/smtp.provider.ts index 675a5c6..2485b3a 100644 --- a/packages/iios-service/src/capability/smtp.provider.ts +++ b/packages/iios-service/src/capability/smtp.provider.ts @@ -12,6 +12,23 @@ export interface SmtpIdentity { from: string; } +export interface MailAttachment { + filename: string; + content: Buffer; + contentType?: string; +} + +/** Fetch an attachment's bytes by its opaque contentRef (backed by the media StoragePort). */ +export type AttachmentResolver = (contentRef: string) => Promise<{ filename?: string; content: Buffer; contentType?: string } | null>; + +/** Build an AttachmentResolver over the media StoragePort (contentRef = the storage object key). */ +export function storageResolver(storage: { get(key: string): Promise<{ data: Buffer; mime: string } | null> }): AttachmentResolver { + return async (contentRef) => { + const o = await storage.get(contentRef); + return o ? { content: o.data, contentType: o.mime } : null; + }; +} + /** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */ export interface MailTransport { sendMail(mail: { @@ -22,6 +39,7 @@ export interface MailTransport { html?: string; inReplyTo?: string; references?: string; + attachments?: MailAttachment[]; }): Promise<{ messageId: string; accepted?: unknown[] }>; } @@ -71,6 +89,8 @@ interface EmailPayload { text?: string; html?: string; inReplyTo?: string; + /** Attachment REFS (not bytes) — resolved to bytes at send time via the injected resolver. */ + attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>; } /** @@ -90,6 +110,7 @@ export class SmtpProvider implements CapabilityProvider { private readonly primary: SmtpIdentity, private readonly fallback?: SmtpIdentity, makeTransport?: (id: SmtpIdentity) => MailTransport, + private readonly resolveAttachment?: AttachmentResolver, ) { this.makeTransport = makeTransport ?? defaultTransport; } @@ -98,6 +119,20 @@ export class SmtpProvider implements CapabilityProvider { const started = Date.now(); const p = (req.payload ?? {}) as EmailPayload; + // Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing + // its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch. + let attachments: MailAttachment[] | undefined; + if (p.attachments && p.attachments.length > 0) { + if (!this.resolveAttachment) return this.failed('NO_ATTACHMENT_RESOLVER', started); + const out: MailAttachment[] = []; + for (const a of p.attachments) { + const r = await this.resolveAttachment(a.contentRef).catch(() => null); + if (!r) return this.failed('ATTACHMENT_UNRESOLVED', started); + out.push({ filename: a.filename ?? r.filename ?? 'attachment', content: r.content, ...(a.mimeType ?? r.contentType ? { contentType: a.mimeType ?? r.contentType } : {}) }); + } + attachments = out; + } + const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> => this.makeTransport(id).sendMail({ from: id.from, @@ -106,6 +141,7 @@ export class SmtpProvider implements CapabilityProvider { text: p.text, html: p.html, ...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}), + ...(attachments ? { attachments } : {}), }); try { @@ -124,6 +160,10 @@ export class SmtpProvider implements CapabilityProvider { return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started }; } } + + private failed(errorCode: string, started: number): ProviderResult { + return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode, latencyMs: Date.now() - started }; + } } /** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */ diff --git a/packages/iios-service/src/mail/mail.controller.ts b/packages/iios-service/src/mail/mail.controller.ts index 968b1ff..8cc2b1c 100644 --- a/packages/iios-service/src/mail/mail.controller.ts +++ b/packages/iios-service/src/mail/mail.controller.ts @@ -36,6 +36,7 @@ export class MailController { ...(body.locale ? { locale: body.locale } : {}), idempotencyKey: body.idempotencyKey, ...(body.purpose ? { purpose: body.purpose } : {}), + ...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}), ...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}), }); } diff --git a/packages/iios-service/src/mail/mail.dto.ts b/packages/iios-service/src/mail/mail.dto.ts index 92844aa..0847751 100644 --- a/packages/iios-service/src/mail/mail.dto.ts +++ b/packages/iios-service/src/mail/mail.dto.ts @@ -1,7 +1,14 @@ import { Type } from 'class-transformer'; -import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsArray, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; import { InlineTemplateDto } from '../templates/template.dto'; +/** An email attachment reference — bytes live in the media store under `contentRef`. */ +export class AttachmentDto { + @IsOptional() @IsString() filename?: string; + @IsString() @IsNotEmpty() contentRef!: string; + @IsOptional() @IsString() mimeType?: string; +} + /** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */ export class MailInternalDto { @IsOptional() @IsString() @IsNotEmpty() key?: string; @@ -25,6 +32,7 @@ export class MailSendDto { @IsOptional() @IsString() locale?: string; @IsString() @IsNotEmpty() idempotencyKey!: string; @IsOptional() @IsString() purpose?: string; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[]; /** The registered recipient to mirror to; omit for a pre-registration send (email only). */ @IsOptional() @IsString() mirrorToUserId?: string; } diff --git a/packages/iios-service/src/mail/mail.service.ts b/packages/iios-service/src/mail/mail.service.ts index 7d696f5..f0ed3a3 100644 --- a/packages/iios-service/src/mail/mail.service.ts +++ b/packages/iios-service/src/mail/mail.service.ts @@ -33,6 +33,8 @@ export interface SendExternalInput { locale?: string; idempotencyKey: string; purpose?: string; + /** Attachment refs (bytes resolved at send time). */ + attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>; /** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */ mirrorToUserId?: string; } @@ -64,6 +66,7 @@ export class MailService { const command = await this.sender.sendTemplated({ source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {}, ...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}), + ...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}), }); // Mirror only for a registered recipient (timing rule: no inbox exists pre-registration). diff --git a/packages/iios-service/src/media/media.module.ts b/packages/iios-service/src/media/media.module.ts index 7f1df2a..a9c00ff 100644 --- a/packages/iios-service/src/media/media.module.ts +++ b/packages/iios-service/src/media/media.module.ts @@ -11,5 +11,7 @@ import { STORAGE_PORT } from './storage.port'; controllers: [MediaController], // Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter. providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }], + // Exported so the capability layer can resolve email attachment bytes at send time. + exports: [STORAGE_PORT], }) export class MediaModule {} diff --git a/packages/iios-service/src/templates/templated-sender.spec.ts b/packages/iios-service/src/templates/templated-sender.spec.ts index b1f52e2..fc2d6b5 100644 --- a/packages/iios-service/src/templates/templated-sender.spec.ts +++ b/packages/iios-service/src/templates/templated-sender.spec.ts @@ -67,6 +67,16 @@ describe('TemplatedSender.sendTemplated', () => { expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/); }); + it('carries attachment refs into the EMAIL command payload', async () => { + await seedReceipt(); + const cmd = await sender().sendTemplated({ + source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com', + vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'att:1', + attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }], + }); + expect((cmd.payload as { attachments: unknown[] }).attachments).toEqual([{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }]); + }); + it('shapes an SMS send as text only', async () => { await prisma.iiosMessageTemplate.create({ data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] }, diff --git a/packages/iios-service/src/templates/templated-sender.ts b/packages/iios-service/src/templates/templated-sender.ts index b7a96b6..3d8acaf 100644 --- a/packages/iios-service/src/templates/templated-sender.ts +++ b/packages/iios-service/src/templates/templated-sender.ts @@ -10,6 +10,9 @@ import { isInlineSource, type TemplateSource } from './template.model'; /** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */ export type ExternalChannel = 'EMAIL' | 'SMS'; +/** An email attachment REF (bytes resolved by the provider at send time). */ +export interface AttachmentRef { filename?: string; contentRef: string; mimeType?: string } + export interface SendTemplatedInput { source: TemplateSource; channel: ExternalChannel; @@ -19,6 +22,7 @@ export interface SendTemplatedInput { locale?: string; idempotencyKey: string; // REQUIRED — e.g. "receipt:" purpose?: string; + attachments?: AttachmentRef[]; // EMAIL only } /** @@ -48,7 +52,7 @@ export class TemplatedSender { return this.outbound.send( input.channel, input.target, - this.toPayload(input.channel, content), + this.toPayload(input.channel, content, input.attachments), input.idempotencyKey, input.scopeId, input.purpose, @@ -57,8 +61,10 @@ export class TemplatedSender { } /** Shape rendered content into the channel's egress envelope (EmailProvider reads subject/text/html). */ - private toPayload(channel: ExternalChannel, content: RenderedContent): Record { - if (channel === 'EMAIL') return { subject: content.subject, html: content.html, text: content.text }; + private toPayload(channel: ExternalChannel, content: RenderedContent, attachments?: AttachmentRef[]): Record { + if (channel === 'EMAIL') { + return { subject: content.subject, html: content.html, text: content.text, ...(attachments && attachments.length > 0 ? { attachments } : {}) }; + } return { text: content.text ?? content.subject ?? '' }; // SMS: text only } } -- 2.52.0 From 0c6650f3c6a06510e83e017129b7598e2cba040d Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 14:26:53 +0530 Subject: [PATCH 29/30] feat(media): S3/MinIO storage adapter (StoragePort) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop-in S3-compatible backend for object storage (media + email attachments) — the swap the StoragePort seam was designed for. MinIO/self-hosted/R2/Supabase all work via endpoint + path-style. - S3Storage implements StoragePort (put/get/remove); sha256 computed locally on put (matches LocalDiskStorage); a missing object reads back as null, not an error. - Env-driven binding in MediaModule: IIOS_S3_BUCKET + keys set → S3Storage, else LocalDiskStorage. forcePathStyle defaults true (MinIO); endpoint omitted → AWS. - S3 client is injectable so tests run with no live server. 6 unit tests (config parse, put size/sha, get round-trip, NoSuchKey→null, remove). Full suite 300/300, boundary + build clean. Co-Authored-By: Claude Opus 4.8 --- packages/iios-service/.env.example | 9 + packages/iios-service/package.json | 1 + .../iios-service/src/media/media.module.ts | 18 +- .../iios-service/src/media/s3.storage.spec.ts | 66 ++++ packages/iios-service/src/media/s3.storage.ts | 87 +++++ pnpm-lock.yaml | 302 ++++++++++++++++++ 6 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 packages/iios-service/src/media/s3.storage.spec.ts create mode 100644 packages/iios-service/src/media/s3.storage.ts diff --git a/packages/iios-service/.env.example b/packages/iios-service/.env.example index 9053606..04e6e61 100644 --- a/packages/iios-service/.env.example +++ b/packages/iios-service/.env.example @@ -59,3 +59,12 @@ IIOS_ATTESTATION_AUDIENCE=iios-core # When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403). # Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless. IIOS_REQUIRE_ATTESTATION=0 + +# ─── Object storage (media + email attachments) ─────────────────── +# Unset → local disk (MEDIA_DIR). Set these → S3-compatible (AWS S3 / MinIO / R2 / Supabase). +# IIOS_S3_ENDPOINT=https://minio.your-server:9000 # omit for AWS S3 +# IIOS_S3_BUCKET=iios-media +# IIOS_S3_ACCESS_KEY=... +# IIOS_S3_SECRET_KEY=... +# IIOS_S3_REGION=us-east-1 # any value for MinIO +# IIOS_S3_FORCE_PATH_STYLE=true # true for MinIO/self-hosted diff --git a/packages/iios-service/package.json b/packages/iios-service/package.json index 2d11fc5..07b68b7 100644 --- a/packages/iios-service/package.json +++ b/packages/iios-service/package.json @@ -12,6 +12,7 @@ "prisma:studio": "prisma studio" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1090.0", "@insignia/iios-adapter-sdk": "workspace:*", "@insignia/iios-contracts": "workspace:*", "@nestjs/common": "^11.1.27", diff --git a/packages/iios-service/src/media/media.module.ts b/packages/iios-service/src/media/media.module.ts index a9c00ff..2350ef7 100644 --- a/packages/iios-service/src/media/media.module.ts +++ b/packages/iios-service/src/media/media.module.ts @@ -1,16 +1,26 @@ -import { Module } from '@nestjs/common'; +import { Module, Logger } from '@nestjs/common'; import { PlatformModule } from '../platform/platform.module'; import { IdentityModule } from '../identity/identity.module'; import { MediaController } from './media.controller'; import { MediaService } from './media.service'; import { LocalDiskStorage } from './local-disk.storage'; -import { STORAGE_PORT } from './storage.port'; +import { S3Storage, s3ConfigFromEnv } from './s3.storage'; +import { STORAGE_PORT, type StoragePort } from './storage.port'; + +/** S3/MinIO when its env is set (IIOS_S3_BUCKET + keys), else local disk. Env-driven swap. */ +function makeStorage(): StoragePort { + const cfg = s3ConfigFromEnv(); + if (cfg) { + new Logger('MediaStorage').log(`using S3 storage (bucket "${cfg.bucket}"${cfg.endpoint ? ` @ ${cfg.endpoint}` : ''})`); + return new S3Storage(cfg); + } + return new LocalDiskStorage(); +} @Module({ imports: [PlatformModule, IdentityModule], controllers: [MediaController], - // Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter. - providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }], + providers: [MediaService, { provide: STORAGE_PORT, useFactory: makeStorage }], // Exported so the capability layer can resolve email attachment bytes at send time. exports: [STORAGE_PORT], }) diff --git a/packages/iios-service/src/media/s3.storage.spec.ts b/packages/iios-service/src/media/s3.storage.spec.ts new file mode 100644 index 0000000..abadaf1 --- /dev/null +++ b/packages/iios-service/src/media/s3.storage.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { S3Storage, s3ConfigFromEnv, type S3Config, type S3Like } from './s3.storage'; + +const CFG: S3Config = { endpoint: 'https://minio.test', region: 'us-east-1', bucket: 'iios', accessKeyId: 'k', secretAccessKey: 's', forcePathStyle: true }; + +/** A recording stub S3 client; GetObject returns whatever `store[key]` holds (or a NoSuchKey error). */ +function stub(store: Record = {}) { + const calls: unknown[] = []; + const client: S3Like = { + async send(command: unknown) { + calls.push(command); + if (command instanceof PutObjectCommand) { store[command.input.Key!] = { body: command.input.Body as Buffer, mime: command.input.ContentType ?? '' }; return {}; } + if (command instanceof DeleteObjectCommand) { delete store[command.input.Key!]; return {}; } + if (command instanceof GetObjectCommand) { + const hit = store[command.input.Key!]; + if (!hit) throw Object.assign(new Error('missing'), { name: 'NoSuchKey' }); + return { Body: { transformToByteArray: async () => new Uint8Array(hit.body) }, ContentType: hit.mime }; + } + return {}; + }, + }; + return { client, calls, store }; +} + +describe('s3ConfigFromEnv', () => { + it('builds config from env (path-style default true for MinIO)', () => { + const cfg = s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x', IIOS_S3_BUCKET: 'b', IIOS_S3_ACCESS_KEY: 'k', IIOS_S3_SECRET_KEY: 's' } as NodeJS.ProcessEnv); + expect(cfg).toMatchObject({ endpoint: 'https://minio.x', bucket: 'b', region: 'us-east-1', forcePathStyle: true }); + }); + it('returns null when bucket/keys are missing', () => { + expect(s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x' } as NodeJS.ProcessEnv)).toBeNull(); + }); +}); + +describe('S3Storage (StoragePort over S3/MinIO)', () => { + it('put stores the object and returns size + sha256', async () => { + const s = stub(); + const store = new S3Storage(CFG, s.client); + const res = await store.put('scope/obj1', Buffer.from('hello'), 'text/plain'); + expect(res.sizeBytes).toBe(5); + expect(res.checksumSha256).toMatch(/^[a-f0-9]{64}$/); + const put = s.calls[0] as PutObjectCommand; + expect(put.input).toMatchObject({ Bucket: 'iios', Key: 'scope/obj1', ContentType: 'text/plain' }); + }); + + it('get round-trips the bytes + mime', async () => { + const s = stub(); + const store = new S3Storage(CFG, s.client); + await store.put('scope/obj2', Buffer.from('PDFDATA'), 'application/pdf'); + const got = await store.get('scope/obj2'); + expect(got).toEqual({ data: Buffer.from('PDFDATA'), mime: 'application/pdf', sizeBytes: 7 }); + }); + + it('get returns null for a missing key (NoSuchKey → null, not throw)', async () => { + const store = new S3Storage(CFG, stub().client); + expect(await store.get('nope')).toBeNull(); + }); + + it('remove issues a DeleteObject', async () => { + const s = stub({ 'k': { body: Buffer.from('x'), mime: 't' } }); + await new S3Storage(CFG, s.client).remove('k'); + expect(s.calls.some((c) => c instanceof DeleteObjectCommand)).toBe(true); + expect(s.store['k']).toBeUndefined(); + }); +}); diff --git a/packages/iios-service/src/media/s3.storage.ts b/packages/iios-service/src/media/s3.storage.ts new file mode 100644 index 0000000..55667e6 --- /dev/null +++ b/packages/iios-service/src/media/s3.storage.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto'; +import { Injectable } from '@nestjs/common'; +import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import type { StoragePort } from './storage.port'; + +export interface S3Config { + endpoint?: string; // MinIO/self-hosted URL; omit for AWS + region: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; + forcePathStyle: boolean; // true for MinIO +} + +/** The one method this adapter uses — lets tests inject a stub client (no live S3/MinIO). */ +export interface S3Like { + send(command: unknown): Promise; +} + +/** Build an S3Config from env, or null if the required bits are missing (→ fall back to disk). */ +export function s3ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): S3Config | null { + const bucket = env.IIOS_S3_BUCKET; + const accessKeyId = env.IIOS_S3_ACCESS_KEY; + const secretAccessKey = env.IIOS_S3_SECRET_KEY; + if (!bucket || !accessKeyId || !secretAccessKey) return null; + return { + ...(env.IIOS_S3_ENDPOINT ? { endpoint: env.IIOS_S3_ENDPOINT } : {}), + region: env.IIOS_S3_REGION ?? 'us-east-1', + bucket, + accessKeyId, + secretAccessKey, + // MinIO/self-hosted needs path-style; default true unless explicitly disabled for AWS. + forcePathStyle: env.IIOS_S3_FORCE_PATH_STYLE !== 'false', + }; +} + +/** + * S3-compatible object storage (AWS S3, MinIO, R2, Supabase Storage). A drop-in for LocalDiskStorage: + * same StoragePort contract. sha256 is computed locally on put (S3 doesn't return it), matching the + * disk adapter. A missing object reads back as null (not an error). + */ +@Injectable() +export class S3Storage implements StoragePort { + private readonly client: S3Like; + private readonly bucket: string; + + constructor(config: S3Config, client?: S3Like) { + this.bucket = config.bucket; + this.client = client ?? new S3Client({ + region: config.region, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + forcePathStyle: config.forcePathStyle, + credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }, + }); + } + + async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> { + const checksumSha256 = createHash('sha256').update(data).digest('hex'); + await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, Body: data, ContentType: mime })); + return { sizeBytes: data.length, checksumSha256 }; + } + + async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> { + try { + const res = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }))) as { + Body?: { transformToByteArray(): Promise }; + ContentType?: string; + }; + if (!res.Body) return null; + const bytes = await res.Body.transformToByteArray(); + const data = Buffer.from(bytes); + return { data, mime: res.ContentType ?? 'application/octet-stream', sizeBytes: data.length }; + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + } + + async remove(objectKey: string): Promise { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: objectKey })); + } +} + +function isNotFound(err: unknown): boolean { + const e = err as { name?: string; $metadata?: { httpStatusCode?: number } }; + return e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bab7336..251f3b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: packages/iios-service: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1090.0 + version: 3.1090.0 '@insignia/iios-adapter-sdk': specifier: workspace:* version: link:../iios-adapter-sdk @@ -487,6 +490,78 @@ packages: resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@aws-sdk/checksums@3.1000.18': + resolution: {integrity: sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1090.0': + resolution: {integrity: sha512-R6GX9cd1jljwzZ8xFmgAI/hHCuX1MobIKBdsymv7WL9SENvO9Vgz9KOR6avTnu0Ao+w1LmxnTe+jqmZXEn7Q/Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.975.3': + resolution: {integrity: sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.59': + resolution: {integrity: sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.61': + resolution: {integrity: sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.4': + resolution: {integrity: sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.66': + resolution: {integrity: sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.70': + resolution: {integrity: sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.59': + resolution: {integrity: sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.3': + resolution: {integrity: sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.65': + resolution: {integrity: sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.64': + resolution: {integrity: sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.33': + resolution: {integrity: sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.41': + resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1088.0': + resolution: {integrity: sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.36': + resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1450,6 +1525,30 @@ packages: cpu: [x64] os: [win32] + '@smithy/core@3.29.5': + resolution: {integrity: sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.10': + resolution: {integrity: sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.7': + resolution: {integrity: sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.7': + resolution: {integrity: sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.6': + resolution: {integrity: sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -1766,6 +1865,9 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -3362,6 +3464,171 @@ snapshots: transitivePeerDependencies: - chokidar + '@aws-sdk/checksums@3.1000.18': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1090.0': + dependencies: + '@aws-sdk/checksums': 3.1000.18 + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-node': 3.972.70 + '@aws-sdk/middleware-sdk-s3': 3.972.64 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.975.3': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.36 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.5 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.61': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.4': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-login': 3.972.66 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.66': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.70': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-ini': 3.973.4 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.3': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/token-providers': 3.1088.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.65': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.64': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.33': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.41': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1088.0': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.36': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4105,6 +4372,39 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@smithy/core@3.29.5': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.10': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.6': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.8)': @@ -4492,6 +4792,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 -- 2.52.0 From 40c98522dce3950507b387a3b9d3c368908ebfc2 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 16:30:28 +0530 Subject: [PATCH 30/30] feat(mail): tag mail threads source=crm-mail (list separately from chat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ingest() puts metadata on the interaction, not the thread, and listThreads filters THREAD metadata — so MailService now tags the thread directly after deposit (source=crm-mail). Lets the CRM list mail threads apart from Messenger chat (source=crm-messenger). 7 tests. NOTE: requires an IIOS redeploy. Co-Authored-By: Claude Opus 4.8 --- packages/iios-service/src/mail/mail.service.spec.ts | 3 ++- packages/iios-service/src/mail/mail.service.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/iios-service/src/mail/mail.service.spec.ts b/packages/iios-service/src/mail/mail.service.spec.ts index ea1dc08..21c2b8f 100644 --- a/packages/iios-service/src/mail/mail.service.spec.ts +++ b/packages/iios-service/src/mail/mail.service.spec.ts @@ -25,7 +25,7 @@ function mail(): MailService { const ingest = new IngestService(asService, makeFakePorts(), actors); const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)); const sender = new TemplatedSender(templates, outbound, makeFakePorts()); - return new MailService(templates, ingest, sender, actors); + return new MailService(templates, ingest, sender, actors, asService); } const messages = () => new MessageService(asService, makeFakePorts(), actors); @@ -56,6 +56,7 @@ describe('MailService.postInternal', () => { const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: res.threadId } }); expect(thread.subject).toBe('Welcome Dana'); + expect((thread.metadata as { source?: string } | null)?.source).toBe('crm-mail'); // lists separately from chat // The load-bearing assertion: the RECIPIENT can see the thread in their inbox. const bobThreads = await messages().listThreads(bob); diff --git a/packages/iios-service/src/mail/mail.service.ts b/packages/iios-service/src/mail/mail.service.ts index f0ed3a3..9aeb4b4 100644 --- a/packages/iios-service/src/mail/mail.service.ts +++ b/packages/iios-service/src/mail/mail.service.ts @@ -1,4 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; import { IngestService } from '../interactions/ingest.service'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { TemplateService } from '../templates/template.service'; @@ -55,8 +57,12 @@ export class MailService { private readonly ingest: IngestService, private readonly sender: TemplatedSender, private readonly actors: ActorResolver, + private readonly prisma: PrismaService, ) {} + /** Marks a thread as CRM mail so it lists separately from chat (Messenger uses source=crm-messenger). */ + static readonly SOURCE = 'crm-mail'; + async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> { const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) }); return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL'); @@ -103,6 +109,13 @@ export class MailService { await this.actors.ensureParticipant(res.threadId, senderActor.id); await this.actors.ensureParticipant(res.threadId, recipientActor.id); + // Tag the thread as CRM mail (thread metadata) so it lists separately from Messenger chat. + // ingest() puts req.metadata on the interaction, not the thread, so we set it here directly. + await this.prisma.iiosThread.update({ + where: { id: res.threadId }, + data: { metadata: { source: MailService.SOURCE } as Prisma.InputJsonValue }, + }); + return { threadId: res.threadId, interactionId: res.interactionId }; } } -- 2.52.0