Compare commits
2 Commits
32aa04503d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eb385707fa | |||
| 0bdae453fc |
@@ -1,12 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
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]].
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
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]].
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
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]].
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
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]].
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
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://<ref>.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`.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -42,6 +42,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Bump k8s-pods image tag (ArgoCD deploys)
|
- name: Bump k8s-pods image tag (ArgoCD deploys)
|
||||||
run: |
|
run: |
|
||||||
|
rm -rf /tmp/kp # dind-builder is a persistent host: clear any stale checkout from a prior run
|
||||||
git clone --depth 1 -b main \
|
git clone --depth 1 -b main \
|
||||||
"https://mcp-bot:${{ secrets.K8S_PODS_TOKEN }}@git.lynkedup.cloud/platform-engineering/k8s-pods.git" /tmp/kp
|
"https://mcp-bot:${{ secrets.K8S_PODS_TOKEN }}@git.lynkedup.cloud/platform-engineering/k8s-pods.git" /tmp/kp
|
||||||
cd /tmp/kp
|
cd /tmp/kp
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# @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}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# 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
|
|
||||||
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://<ref>.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.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# 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=<your-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=<read-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.
|
|
||||||
@@ -54,10 +54,6 @@ for the full, commented list. Highlights:
|
|||||||
app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests.
|
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
|
- **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.
|
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/*`
|
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*`
|
||||||
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
|
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
|
||||||
single most important prod-hardening flag.
|
single most important prod-hardening flag.
|
||||||
|
|||||||
@@ -243,54 +243,6 @@ 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
|
## 6. SDK reference
|
||||||
|
|
||||||
Two layers: a low-level `RestClient` (+ socket) and per-domain React hook packages.
|
Two layers: a low-level `RestClient` (+ socket) and per-domain React hook packages.
|
||||||
@@ -304,7 +256,6 @@ Methods (all return typed promises):
|
|||||||
- **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})`
|
- **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.
|
- **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`.*
|
- **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?})`
|
- **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)`
|
- **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)`
|
- **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)`
|
||||||
@@ -382,7 +333,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/<name>`)
|
|||||||
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
|
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
|
||||||
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation |
|
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation |
|
||||||
|
|
||||||
Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`.
|
Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check: `pnpm boundary`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -407,7 +358,6 @@ Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check
|
|||||||
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
|
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
|
||||||
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
|
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
|
||||||
| `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media 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/*` |
|
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
|
||||||
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
|
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
|
||||||
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
|
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
|
||||||
|
|||||||
@@ -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 |
|
| Calendar/Zoom providers | Simulated sync | P9 |
|
||||||
| Multi-tenant scale, retention, SLOs | Not yet | P9 |
|
| Multi-tenant scale, retention, SLOs | Not yet | P9 |
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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, 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.*
|
*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.*
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
# 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 = <caller scope>` 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<string, unknown>, opts: { channel: IiosTemplateChannel; locale?: string; scopeId?: string }): Promise<RenderedContent>
|
|
||||||
|
|
||||||
// templated-sender.ts — external egress
|
|
||||||
sendTemplated(input: {
|
|
||||||
source: TemplateSource;
|
|
||||||
channel: 'EMAIL' | 'SMS';
|
|
||||||
target: string; // email address / phone
|
|
||||||
vars: Record<string, unknown>;
|
|
||||||
scopeId?: string;
|
|
||||||
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
|
|
||||||
purpose?: string;
|
|
||||||
}): Promise<IiosOutboundCommand>
|
|
||||||
```
|
|
||||||
|
|
||||||
**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 `<script>` in a name**; `{{#if}}`/`{{#each}}`;
|
|
||||||
a declared-but-missing variable throws; renders `bodyHtml` and `bodyText` independently.
|
|
||||||
- Impl: Handlebars, `noEscape:false`; validate declared `variables` present.
|
|
||||||
|
|
||||||
**T2 — Migrations + repository (`template.repository.ts`)**
|
|
||||||
- Migration 1 (table+enum). Regenerate client.
|
|
||||||
- Tests: `resolve` returns highest active version; **scoped overrides global**; unknown key → NotFound;
|
|
||||||
inactive versions ignored.
|
|
||||||
|
|
||||||
**T3 — `render()` service tying resolve+renderer, incl. inline source**
|
|
||||||
- Tests: stored `{key}` resolves+renders; `{inline}` renders without a DB row; wrong channel → 404.
|
|
||||||
|
|
||||||
**T4 — Provenance: `OutboundService.send` param + migration + `sendTemplated()`**
|
|
||||||
- Migration 2 (4 columns).
|
|
||||||
- Modify `OutboundService.send` to accept the optional `provenance` arg and write it into the command
|
|
||||||
`create()` on both the RATE_LIMITED and PENDING paths. Extend `outbound.service.spec.ts`: a send
|
|
||||||
with provenance persists all four fields; a send without leaves them null (no regression).
|
|
||||||
- Then `sendTemplated()`. Tests: composes render→`OutboundService.send`; **stamps
|
|
||||||
`templateKey/version/locale/renderedHash`** on the command; **idempotent per key** (replay → one
|
|
||||||
`IiosOutboundCommand`); inline → `templateKey` null, `renderedHash` set.
|
|
||||||
|
|
||||||
**T5 — Seeder (`template.seeder.ts`, `seeds/*`)**
|
|
||||||
- Tests: boot seeds the file defaults as `scopeId NULL`; **re-seed is idempotent** (no dupes);
|
|
||||||
a bumped file version inserts a new row, leaves the old.
|
|
||||||
|
|
||||||
**T6 — Controller + DTOs**
|
|
||||||
- Tests (HTTP, boot against sandbox provider so no real mail): `POST /send` renders+queues;
|
|
||||||
unknown key → 404; bad body → 400; missing auth → 400/401; `POST /preview` returns content, sends nothing.
|
|
||||||
|
|
||||||
**T7 — Whole-suite gate**
|
|
||||||
- `vitest run` (all packages), `npm run boundary`, `npm run build` all green.
|
|
||||||
- Manual: boot locally, `POST /v1/templates/send` with the `welcome` seed via sandbox, inspect the
|
|
||||||
`IiosOutboundCommand` row for payload + provenance.
|
|
||||||
|
|
||||||
**T8 — PII redaction of outbound commands (fast-follow; lever #2)**
|
|
||||||
- Extend `RetentionService` so its sweep also redacts aged `IiosOutboundCommand` rows: raw `target`
|
|
||||||
and rendered `payload` → redacted, while `templateKey/version/renderedHash` + `scopeId` are kept
|
|
||||||
for audit. Reuse the existing redact-in-place pattern (currently applied to `iiosMessagePart`).
|
|
||||||
- Tests: a command past its window has `target`/`payload` redacted but provenance intact; a command
|
|
||||||
under compliance hold is skipped; audit row `retention.redacted` written.
|
|
||||||
- Independent of T1–T7 — can land immediately after the module without blocking Friday.
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- Unknown key/channel/locale → `NotFoundException` (404).
|
|
||||||
- Declared variable missing → throw (400) — never send a half-rendered receipt.
|
|
||||||
- Transport failure → surfaced as `FAILED` on the command by `OutboundService`, never thrown to caller.
|
|
||||||
- Idempotent replay (same key) → returns the existing command; never double-sends.
|
|
||||||
|
|
||||||
## Out of scope (separate specs — this module unblocks them)
|
|
||||||
|
|
||||||
1. **`SmtpProvider`** (nodemailer, `accounts@`/`ceo@lynkeduppro.com`) in IIOS's capability registry.
|
|
||||||
Today's `EmailProvider` is HTTP; SMTP is a new provider flipped on by env. Without it, sends land
|
|
||||||
in the sandbox.
|
|
||||||
2. **`POST /webhooks/stripe` in be-crm** — verify Stripe signature → mint a **service token** →
|
|
||||||
call `POST /v1/templates/send` (welcome + receipt), keyed on the Stripe object id. This is the
|
|
||||||
"backend" the frontend-only payment site lacks.
|
|
||||||
3. **Inbox mirror** (post-registration): render an email → also create an `Interaction(kind=EMAIL)`
|
|
||||||
so it appears in the customer's in-app inbox. `INTERNAL` delivery lives here. Mirror only *after*
|
|
||||||
registration (no inbox exists before).
|
|
||||||
|
|
||||||
## PII minimization
|
|
||||||
|
|
||||||
Sending email means IIOS must *touch* the recipient's address (you can't mail without it) and the
|
|
||||||
rendered body holds their name — so `IiosOutboundCommand.target`/`payload` become PII. You cannot
|
|
||||||
avoid IIOS touching it; you avoid it **accumulating** and being **cheaply reachable**. Three levers,
|
|
||||||
by impact:
|
|
||||||
|
|
||||||
1. **Rotate the `Qwerty@a2` signing secret — precondition for prod, highest impact, not code.**
|
|
||||||
The stored PII is only dangerous because any leaked token can be brute-forced back to the secret,
|
|
||||||
letting an attacker forge a token and read the outbound table. A strong random secret leaves the
|
|
||||||
PII in place but **unreachable by forgery** — 90% of the risk closed by one env change. **Gate:
|
|
||||||
do not point the real SMTP provider at production until this is rotated.**
|
|
||||||
|
|
||||||
2. **Redact the raw address + body after delivery (fast-follow task — see T8).**
|
|
||||||
IIOS keeps `templateKey/version/renderedHash` + the opaque `scopeId` for audit, but the raw
|
|
||||||
`target` and rendered `payload` are redacted-in-place once past their retention window. The
|
|
||||||
`RetentionService` already does exactly this for interaction message parts (`bodyText →
|
|
||||||
'[redacted]'`); it just doesn't cover `IiosOutboundCommand` yet. Reuse the same sweep.
|
|
||||||
|
|
||||||
3. **Tokenized recipient (future, when MDM ships).** The clean model is: callers pass a `userId`,
|
|
||||||
IIOS resolves the address from MDM *at send time* and never persists it. Not available today
|
|
||||||
(MDM isn't deployed; `externalId` is a UUID with no email). **Design accommodation now:** keep
|
|
||||||
`target: string` for Friday, but treat it as an opaque "where to send" so it can later become
|
|
||||||
`{ userId }` resolved via MDM without changing the `sendTemplated` contract shape.
|
|
||||||
|
|
||||||
**Rejected shortcut:** having be-crm send SMTP directly so IIOS never sees the address. It "avoids"
|
|
||||||
the PII but breaks the inbox mirror and the single-send-path (IIOS would have no record to copy into
|
|
||||||
the inbox) — trading a solvable security problem for a broken feature.
|
|
||||||
|
|
||||||
## Other risks
|
|
||||||
|
|
||||||
- **Friday, no test env:** first real sends go to paying customers from an unproven path. Insist on a
|
|
||||||
sandbox target / 100%-off test coupon before wiring the real SMTP provider (compounds with lever #1
|
|
||||||
above — don't send real mail from prod until the secret is rotated *and* a safe test target exists).
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# 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:<stripe_session>`), 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").
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# 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.*
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# 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.<mail-host> # 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=<app password> # Workspace App Password, NOT the account password
|
|
||||||
IIOS_SMTP_FROM="LynkedUp Pro <accounts@lynkeduppro.com>" # defaults to USER
|
|
||||||
# optional fallback identity used only if the primary send FAILS
|
|
||||||
IIOS_SMTP_FALLBACK_USER=ceo@lynkeduppro.com
|
|
||||||
IIOS_SMTP_FALLBACK_PASS=<app password>
|
|
||||||
IIOS_SMTP_FALLBACK_FROM="Justin Johnson <ceo@lynkeduppro.com>"
|
|
||||||
```
|
|
||||||
|
|
||||||
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<ProviderResult>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`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).
|
|
||||||
+1
-3
@@ -7,9 +7,7 @@
|
|||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"typecheck": "pnpm -r typecheck",
|
"typecheck": "pnpm -r typecheck",
|
||||||
"test": "vitest run",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-adapter-sdk",
|
"name": "@insignia/iios-adapter-sdk",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"files": [
|
"files": ["dist"],
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@insignia/iios-contracts": "workspace:*"
|
"@insignia/iios-contracts": "workspace:*"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-ai-web",
|
"name": "@insignia/iios-ai-web",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||||
".": {
|
"files": ["dist"],
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
@@ -29,8 +23,5 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-community-web",
|
"name": "@insignia/iios-community-web",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||||
".": {
|
"files": ["dist"],
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
@@ -29,8 +23,5 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-contracts",
|
"name": "@insignia/iios-contracts",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"files": [
|
"files": ["dist"],
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export interface IngestInteractionRequest {
|
|||||||
bodyText?: string;
|
bodyText?: string;
|
||||||
contentRef?: string;
|
contentRef?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
sizeBytes?: number;
|
|
||||||
}>;
|
}>;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
providerEventId?: string;
|
providerEventId?: string;
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-inbox-web",
|
"name": "@insignia/iios-inbox-web",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||||
".": {
|
"files": ["dist"],
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
@@ -29,8 +23,5 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-kernel-client",
|
"name": "@insignia/iios-kernel-client",
|
||||||
"version": "0.1.4",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||||
".": {
|
"files": ["dist"],
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
@@ -25,8 +19,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ export interface MessageSocketConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io
|
* Framework-agnostic facade over the `/message` Socket.io namespace (ports the
|
||||||
* types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens
|
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck.
|
||||||
* all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI
|
* On reconnect it re-opens the current thread so subscriptions resume with no
|
||||||
* that watches multiple conversations keeps receiving live messages after a drop.
|
* lost messages (the docs' disconnect/reconnect requirement).
|
||||||
*/
|
*/
|
||||||
export class MessageSocket {
|
export class MessageSocket {
|
||||||
private readonly socket: SocketLike;
|
private readonly socket: SocketLike;
|
||||||
private readonly joined = new Set<string>();
|
private currentThreadId: string | null = null;
|
||||||
|
|
||||||
constructor(config: MessageSocketConfig, socket?: SocketLike) {
|
constructor(config: MessageSocketConfig, socket?: SocketLike) {
|
||||||
this.socket =
|
this.socket =
|
||||||
@@ -26,9 +26,9 @@ export class MessageSocket {
|
|||||||
autoConnect: config.autoConnect ?? true,
|
autoConnect: config.autoConnect ?? true,
|
||||||
}) as unknown as SocketLike);
|
}) as unknown as SocketLike);
|
||||||
|
|
||||||
// Re-subscribe to every joined thread after a reconnect.
|
// Re-open the active thread after a reconnect.
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', () => {
|
||||||
for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
|
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,70 +40,27 @@ export class MessageSocket {
|
|||||||
this.socket.disconnect();
|
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. */
|
/** Subscribe to a server event; returns an unsubscribe fn. */
|
||||||
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
|
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
|
||||||
const fn = handler as (...args: unknown[]) => void;
|
const fn = handler as (...args: unknown[]) => void;
|
||||||
this.socket.on(event as string, fn);
|
this.socket.on(event, fn);
|
||||||
return () => this.socket.off(event as string, fn);
|
return () => this.socket.off(event, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
async openThread(
|
async openThread(threadId?: string): Promise<OpenThreadResult> {
|
||||||
threadId?: string,
|
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult;
|
||||||
opts?: { membership?: string; creatorRole?: string; subject?: string },
|
this.currentThreadId = result.threadId;
|
||||||
): Promise<OpenThreadResult> {
|
|
||||||
// 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(
|
async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise<Message> {
|
||||||
threadId: string,
|
|
||||||
content: string,
|
|
||||||
opts?: {
|
|
||||||
contentRef?: string;
|
|
||||||
parentInteractionId?: string;
|
|
||||||
mentions?: string[];
|
|
||||||
attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string };
|
|
||||||
},
|
|
||||||
): Promise<Message> {
|
|
||||||
return (await this.socket.emitWithAck('send_message', {
|
return (await this.socket.emitWithAck('send_message', {
|
||||||
threadId,
|
threadId,
|
||||||
content,
|
content,
|
||||||
contentRef: opts?.attachment?.contentRef ?? opts?.contentRef,
|
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;
|
})) as Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pin a message in the thread (shared, generic annotation type "pin"). */
|
|
||||||
async pin(threadId: string, interactionId: string): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'reaction', value });
|
|
||||||
}
|
|
||||||
|
|
||||||
async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
|
async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
|
||||||
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
|
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
|
||||||
}
|
}
|
||||||
@@ -111,9 +68,4 @@ export class MessageSocket {
|
|||||||
typing(threadId: string): void {
|
typing(threadId: string): void {
|
||||||
this.socket.emit('typing', { threadId });
|
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||||
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
|
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types';
|
||||||
|
|
||||||
export interface RestConfig {
|
export interface RestConfig {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
/**
|
|
||||||
* 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<string, string> | (() => Record<string, string>);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** REST/polling client for kernel reads and the native-send fallback. */
|
/** REST/polling client for kernel reads and the native-send fallback. */
|
||||||
@@ -22,8 +15,7 @@ export class RestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||||
const custom = typeof this.config.headers === 'function' ? this.config.headers() : this.config.headers;
|
const h: Record<string, string> = { 'content-type': 'application/json', ...extra };
|
||||||
const h: Record<string, string> = { 'content-type': 'application/json', ...custom, ...extra };
|
|
||||||
if (this.config.token) h.authorization = `Bearer ${this.config.token}`;
|
if (this.config.token) h.authorization = `Bearer ${this.config.token}`;
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
@@ -65,73 +57,12 @@ export class RestClient {
|
|||||||
return (await r.json()) as InboxItem;
|
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<LoginResult> {
|
|
||||||
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<string[]> {
|
|
||||||
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).
|
|
||||||
* `filter.metadata` narrows to threads whose opaque attribute bag matches every key/value.
|
|
||||||
*/
|
|
||||||
async listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
|
|
||||||
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/creator role/subject + an opaque metadata bag). */
|
|
||||||
async createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }> {
|
|
||||||
return this.post<{ threadId: string }>('/v1/threads', opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** My saved messages (personal bookmarks), newest first, with thread context. */
|
|
||||||
async listSaved(): Promise<SavedItem[]> {
|
|
||||||
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<void> {
|
|
||||||
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 ──────────────────────────────────────────────────
|
// ─── support ──────────────────────────────────────────────────
|
||||||
async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record<string, unknown> }): Promise<Ticket> {
|
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> {
|
||||||
return this.post<Ticket>('/v1/support/tickets', body);
|
return this.post<Ticket>('/v1/support/tickets', body);
|
||||||
}
|
}
|
||||||
async escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
|
async escalate(threadId: string, subject?: string): Promise<Ticket> {
|
||||||
return this.post<Ticket>('/v1/support/escalate', { threadId, subject, metadata });
|
return this.post<Ticket>('/v1/support/escalate', { threadId, subject });
|
||||||
}
|
|
||||||
/** Manually assign a ticket to a specific user (generic assignment override). */
|
|
||||||
async assignTicket(id: string, userId: string): Promise<Ticket> {
|
|
||||||
return this.post<Ticket>(`/v1/support/tickets/${id}/assignee`, { userId });
|
|
||||||
}
|
}
|
||||||
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
|
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
|
||||||
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
|
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
|
||||||
|
|||||||
@@ -1,30 +1,10 @@
|
|||||||
/** 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). */
|
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
senderActorId: string;
|
senderActorId: string;
|
||||||
senderId: string; // sender's email/username — reliable "is this mine?" check
|
|
||||||
senderName: string;
|
|
||||||
content: string;
|
content: string;
|
||||||
contentRef?: string;
|
contentRef?: string;
|
||||||
attachment?: Attachment;
|
|
||||||
parentInteractionId?: string;
|
|
||||||
annotations?: AnnotationGroup[];
|
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -46,50 +26,10 @@ export interface TypingEvent {
|
|||||||
userId: string;
|
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 {
|
export interface MessageEvents {
|
||||||
message: (m: Message) => void;
|
message: (m: Message) => void;
|
||||||
receipt: (e: ReceiptEvent) => void;
|
receipt: (e: ReceiptEvent) => void;
|
||||||
typing: (e: TypingEvent) => 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)
|
|
||||||
/** The thread's opaque, app-supplied attribute bag — echoed verbatim; the kernel never interprets it. */
|
|
||||||
metadata?: Record<string, unknown> | null;
|
|
||||||
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';
|
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
|
||||||
@@ -128,8 +68,6 @@ export interface Ticket {
|
|||||||
assignedActorId?: string | null;
|
assignedActorId?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
/** Opaque, app-supplied attribute bag on the ticket — echoed verbatim; the kernel never interprets it. */
|
|
||||||
metadata?: Record<string, unknown> | null;
|
|
||||||
threadLinks?: Array<{ threadId: string; relationKind: string }>;
|
threadLinks?: Array<{ threadId: string; relationKind: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,8 +188,4 @@ export interface SocketLike {
|
|||||||
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
|
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
|
||||||
connect(): unknown;
|
connect(): unknown;
|
||||||
disconnect(): 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<unknown> };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-meeting-web",
|
"name": "@insignia/iios-meeting-web",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||||
".": {
|
"files": ["dist"],
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
@@ -29,8 +23,5 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-message-web",
|
"name": "@insignia/iios-message-web",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||||
".": {
|
"files": ["dist"],
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
@@ -29,8 +23,5 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,22 +49,3 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
|
|||||||
# ── Capability providers (governed egress targets) ───────────────────────────
|
# ── Capability providers (governed egress targets) ───────────────────────────
|
||||||
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
|
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
|
||||||
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
|
# 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
|
|
||||||
# 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.
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1090.0",
|
|
||||||
"@insignia/iios-adapter-sdk": "workspace:*",
|
"@insignia/iios-adapter-sdk": "workspace:*",
|
||||||
"@insignia/iios-contracts": "workspace:*",
|
"@insignia/iios-contracts": "workspace:*",
|
||||||
"@nestjs/common": "^11.1.27",
|
"@nestjs/common": "^11.1.27",
|
||||||
@@ -25,16 +24,16 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"handlebars": "^4.7.9",
|
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jwks-rsa": "^4.1.0",
|
|
||||||
"nodemailer": "^9.0.3",
|
|
||||||
"prisma": "^6.2.1",
|
"prisma": "^6.2.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7",
|
||||||
|
"@opentelemetry/api": "^1.9.1",
|
||||||
|
"@opentelemetry/sdk-node": "^0.220.0",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.78.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@insignia/iios-testkit": "workspace:*",
|
"@insignia/iios-testkit": "workspace:*",
|
||||||
@@ -43,7 +42,6 @@
|
|||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
"@types/nodemailer": "^8.0.1",
|
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
|
|||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -96,12 +96,6 @@ enum IiosInboxState {
|
|||||||
STALE
|
STALE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum IiosTemplateChannel {
|
|
||||||
EMAIL
|
|
||||||
SMS
|
|
||||||
INTERNAL
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IiosTicketState {
|
enum IiosTicketState {
|
||||||
NEW
|
NEW
|
||||||
OPEN
|
OPEN
|
||||||
@@ -316,7 +310,6 @@ model IiosScope {
|
|||||||
tickets IiosTicket[]
|
tickets IiosTicket[]
|
||||||
callbacks IiosCallbackRequest[]
|
callbacks IiosCallbackRequest[]
|
||||||
notificationSubscriptions IiosNotificationSubscription[]
|
notificationSubscriptions IiosNotificationSubscription[]
|
||||||
messageTemplates IiosMessageTemplate[]
|
|
||||||
|
|
||||||
@@index([orgId, appId, tenantId])
|
@@index([orgId, appId, tenantId])
|
||||||
}
|
}
|
||||||
@@ -684,33 +677,6 @@ model IiosInboxItem {
|
|||||||
@@index([ownerActorId, state, priority])
|
@@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.
|
/// Audit trail of inbox-item state transitions.
|
||||||
model IiosInboxItemStateHistory {
|
model IiosInboxItemStateHistory {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -868,11 +834,6 @@ model IiosOutboundCommand {
|
|||||||
idempotencyKey String @unique
|
idempotencyKey String @unique
|
||||||
providerRef String?
|
providerRef String?
|
||||||
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
|
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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
attempts IiosDeliveryAttempt[]
|
attempts IiosDeliveryAttempt[]
|
||||||
@@ -1360,31 +1321,3 @@ model IiosDlqItem {
|
|||||||
@@unique([consumerName, sourceId])
|
@@unique([consumerName, sourceId])
|
||||||
@@index([scopeId, status])
|
@@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])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,18 +34,8 @@ export class OutboundService {
|
|||||||
idempotencyKey?: string,
|
idempotencyKey?: string,
|
||||||
scopeId?: string,
|
scopeId?: string,
|
||||||
purpose?: 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 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
|
// 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.
|
// command returns the existing row instead of colliding on idempotencyKey @unique.
|
||||||
@@ -56,14 +46,14 @@ export class OutboundService {
|
|||||||
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
// 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))) {
|
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key, ...prov },
|
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key },
|
||||||
});
|
});
|
||||||
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key, ...prov },
|
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key },
|
||||||
});
|
});
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { PlatformModule } from './platform/platform.module';
|
import { PlatformModule } from './platform/platform.module';
|
||||||
import { AttestationModule } from './platform/attestation.module';
|
|
||||||
import { IdentityModule } from './identity/identity.module';
|
import { IdentityModule } from './identity/identity.module';
|
||||||
import { InteractionsModule } from './interactions/interactions.module';
|
import { InteractionsModule } from './interactions/interactions.module';
|
||||||
import { IdempotencyModule } from './idempotency/idempotency.module';
|
import { IdempotencyModule } from './idempotency/idempotency.module';
|
||||||
@@ -10,8 +9,6 @@ import { OutboxModule } from './outbox/outbox.module';
|
|||||||
import { ThreadsModule } from './threads/threads.module';
|
import { ThreadsModule } from './threads/threads.module';
|
||||||
import { MessageModule } from './messaging/message.module';
|
import { MessageModule } from './messaging/message.module';
|
||||||
import { InboxModule } from './inbox/inbox.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 { MediaModule } from './media/media.module';
|
||||||
import { NotificationModule } from './notifications/notification.module';
|
import { NotificationModule } from './notifications/notification.module';
|
||||||
import { SupportModule } from './support/support.module';
|
import { SupportModule } from './support/support.module';
|
||||||
@@ -30,7 +27,6 @@ import { DevController } from './dev/dev.controller';
|
|||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
AttestationModule,
|
|
||||||
IdentityModule,
|
IdentityModule,
|
||||||
InteractionsModule,
|
InteractionsModule,
|
||||||
IdempotencyModule,
|
IdempotencyModule,
|
||||||
@@ -39,8 +35,6 @@ import { DevController } from './dev/dev.controller';
|
|||||||
ThreadsModule,
|
ThreadsModule,
|
||||||
MessageModule,
|
MessageModule,
|
||||||
InboxModule,
|
InboxModule,
|
||||||
TemplateModule,
|
|
||||||
MailModule,
|
|
||||||
MediaModule,
|
MediaModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
SupportModule,
|
SupportModule,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MediaModule } from '../media/media.module';
|
|
||||||
import { CapabilityProviderRegistry } from './capability.registry';
|
import { CapabilityProviderRegistry } from './capability.registry';
|
||||||
import { CapabilityBroker } from './capability.broker';
|
import { CapabilityBroker } from './capability.broker';
|
||||||
|
|
||||||
@@ -9,7 +8,6 @@ import { CapabilityBroker } from './capability.broker';
|
|||||||
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
|
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MediaModule],
|
|
||||||
providers: [CapabilityProviderRegistry, CapabilityBroker],
|
providers: [CapabilityProviderRegistry, CapabilityBroker],
|
||||||
exports: [CapabilityBroker, CapabilityProviderRegistry],
|
exports: [CapabilityBroker, CapabilityProviderRegistry],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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<string, string | undefined> = {};
|
|
||||||
function set(env: Record<string, string | undefined>) {
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +1,22 @@
|
|||||||
import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import type { CapabilityProvider } from '@insignia/iios-contracts';
|
import type { CapabilityProvider } from '@insignia/iios-contracts';
|
||||||
import { SandboxProvider } from './sandbox.provider';
|
import { SandboxProvider } from './sandbox.provider';
|
||||||
import { HttpProvider } from './http.provider';
|
import { HttpProvider } from './http.provider';
|
||||||
import { EmailProvider } from './email.provider';
|
import { EmailProvider } from './email.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'];
|
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a channelType to the provider that executes egress for it. Sandbox by
|
* Maps a channelType to the provider that executes egress for it. Sandbox by
|
||||||
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
|
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
|
||||||
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
|
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
|
||||||
* channels fail closed — no silent egress path.
|
* 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()
|
@Injectable()
|
||||||
export class CapabilityProviderRegistry {
|
export class CapabilityProviderRegistry {
|
||||||
private readonly byChannel = new Map<string, CapabilityProvider>();
|
private readonly byChannel = new Map<string, CapabilityProvider>();
|
||||||
|
|
||||||
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) {
|
constructor() {
|
||||||
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
||||||
for (const ch of DEFAULT_CHANNELS) {
|
for (const ch of DEFAULT_CHANNELS) {
|
||||||
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
||||||
@@ -29,13 +24,6 @@ export class CapabilityProviderRegistry {
|
|||||||
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
|
// 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));
|
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
|
||||||
}
|
}
|
||||||
// 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) {
|
|
||||||
const resolver = this.storage ? storageResolver(this.storage) : undefined;
|
|
||||||
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
register(provider: CapabilityProvider): void {
|
register(provider: CapabilityProvider): void {
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
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' };
|
|
||||||
const FB: SmtpIdentity = { ...ID, user: 'ceo@lynkeduppro.com', from: 'Justin <ceo@lynkeduppro.com>' };
|
|
||||||
|
|
||||||
const req = (payload: Record<string, unknown>): 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<MailTransport['sendMail']>[0] }> = [];
|
|
||||||
const make = (id: SmtpIdentity): MailTransport => ({
|
|
||||||
async sendMail(mail) {
|
|
||||||
calls.push({ id, mail });
|
|
||||||
if (opts.throwErr) throw opts.throwErr;
|
|
||||||
return { messageId: opts.messageId ?? '<generated@smtp.test>' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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 <ceo@x>' } as NodeJS.ProcessEnv);
|
|
||||||
expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO <ceo@x>' });
|
|
||||||
});
|
|
||||||
|
|
||||||
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: '<abc@smtp.test>' });
|
|
||||||
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '<p>x</p>', text: 'x' }));
|
|
||||||
expect(res).toMatchObject({ outcome: 'SENT', providerRef: '<abc@smtp.test>' });
|
|
||||||
expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '<p>x</p>', 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: '<parent@smtp.test>' }));
|
|
||||||
expect(t.calls[0].mail).toMatchObject({ inReplyTo: '<parent@smtp.test>', references: '<parent@smtp.test>' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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: '<viaFallback@smtp.test>' };
|
|
||||||
},
|
|
||||||
}) };
|
|
||||||
const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' }));
|
|
||||||
expect(res.outcome).toBe('SENT');
|
|
||||||
expect(res.providerRef).toBe('fallback:<viaFallback@smtp.test>');
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SmtpProvider.send — attachments', () => {
|
|
||||||
const resolver = (map: Record<string, { content: Buffer; contentType?: string; filename?: string }>) =>
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
subject?: string;
|
|
||||||
text?: string;
|
|
||||||
html?: string;
|
|
||||||
inReplyTo?: string;
|
|
||||||
references?: string;
|
|
||||||
attachments?: MailAttachment[];
|
|
||||||
}): 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;
|
|
||||||
/** Attachment REFS (not bytes) — resolved to bytes at send time via the injected resolver. */
|
|
||||||
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: 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,
|
|
||||||
private readonly resolveAttachment?: AttachmentResolver,
|
|
||||||
) {
|
|
||||||
this.makeTransport = makeTransport ?? defaultTransport;
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(req: CapabilityRequest): Promise<ProviderResult> {
|
|
||||||
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,
|
|
||||||
to: req.target,
|
|
||||||
subject: p.subject ?? '(no subject)',
|
|
||||||
text: p.text,
|
|
||||||
html: p.html,
|
|
||||||
...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}),
|
|
||||||
...(attachments ? { attachments } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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. */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,6 @@ export interface MessagePrincipal {
|
|||||||
appId: string;
|
appId: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
displayName?: 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsInt,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -41,7 +40,6 @@ class PartDto {
|
|||||||
@IsOptional() @IsString() bodyText?: string;
|
@IsOptional() @IsString() bodyText?: string;
|
||||||
@IsOptional() @IsString() contentRef?: string;
|
@IsOptional() @IsString() contentRef?: string;
|
||||||
@IsOptional() @IsString() mimeType?: string;
|
@IsOptional() @IsString() mimeType?: string;
|
||||||
@IsOptional() @IsInt() sizeBytes?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
|
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
|
||||||
|
|||||||
@@ -178,7 +178,6 @@ export class IngestService {
|
|||||||
bodyText: p.bodyText,
|
bodyText: p.bodyText,
|
||||||
contentRef: p.contentRef,
|
contentRef: p.contentRef,
|
||||||
mimeType: p.mimeType,
|
mimeType: p.mimeType,
|
||||||
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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,
|
|
||||||
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
|
|
||||||
...(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Type } from 'class-transformer';
|
|
||||||
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;
|
|
||||||
@IsOptional() @IsInt() sizeBytes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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<string, unknown>;
|
|
||||||
@IsOptional() @IsString() locale?: string;
|
|
||||||
@IsString() @IsNotEmpty() idempotencyKey!: string;
|
|
||||||
/** In-app attachment refs — stored as message parts so the recipient's inbox can render them. */
|
|
||||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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<string, unknown>;
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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, asService);
|
|
||||||
}
|
|
||||||
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: '<p>Hi <b>Dana</b></p>', 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: '<p>h</p>', text: 't' })).toEqual({ subject: 'S', parts: [{ kind: 'HTML', bodyText: '<p>h</p>' }, { 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');
|
|
||||||
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);
|
|
||||||
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('stores an in-app attachment as a media part alongside the body', async () => {
|
|
||||||
const res = await mail().postInternal(alice, {
|
|
||||||
source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att',
|
|
||||||
attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }],
|
|
||||||
});
|
|
||||||
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
|
|
||||||
const file = interaction.parts.find((p) => p.contentRef);
|
|
||||||
expect(file).toBeDefined();
|
|
||||||
expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF
|
|
||||||
expect(file!.contentRef).toBe('scope/roof.png');
|
|
||||||
expect(file!.mimeType).toBe('image/png');
|
|
||||||
expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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';
|
|
||||||
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 MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number }
|
|
||||||
|
|
||||||
export interface PostInternalInput {
|
|
||||||
source: TemplateSource;
|
|
||||||
recipientUserId: string;
|
|
||||||
vars?: Record<string, unknown>;
|
|
||||||
locale?: string;
|
|
||||||
idempotencyKey: string;
|
|
||||||
/** In-app attachment refs — stored as FILE message parts alongside the body. */
|
|
||||||
attachments?: MailAttachment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendExternalInput {
|
|
||||||
source: TemplateSource;
|
|
||||||
target: string; // email address (external)
|
|
||||||
vars?: Record<string, unknown>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
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', input.attachments);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 } : {}),
|
|
||||||
...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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', input.attachments);
|
|
||||||
return { commandId: command.id, mirror };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A stored media ref → a message part; kind follows the mime (image/video → MEDIA_REF, audio → VOICE_REF, else FILE_REF). */
|
|
||||||
private attachmentPart(a: MailAttachment): { kind: 'MEDIA_REF' | 'VOICE_REF' | 'FILE_REF'; bodyText?: string; contentRef: string; mimeType?: string; sizeBytes?: number } {
|
|
||||||
const mime = a.mimeType ?? '';
|
|
||||||
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
|
|
||||||
return { kind, ...(a.filename ? { bodyText: a.filename } : {}), contentRef: a.contentRef, ...(a.mimeType ? { mimeType: a.mimeType } : {}), ...(a.sizeBytes != null ? { sizeBytes: a.sizeBytes } : {}) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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, attachments?: MailAttachment[]): Promise<{ threadId: string; interactionId: string }> {
|
|
||||||
const { subject, parts } = contentToParts(content);
|
|
||||||
const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))];
|
|
||||||
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: allParts,
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import './observability/tracing'; // MUST be first — auto-instrumentation patches modules on require
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -9,10 +10,6 @@ import { PolicyDeniedFilter } from './platform/policy-denied.filter';
|
|||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
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.use(traceMiddleware); // request-scoped trace context + x-trace-id header (P9)
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import { Module, Logger } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { IdentityModule } from '../identity/identity.module';
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
import { MediaController } from './media.controller';
|
import { MediaController } from './media.controller';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
import { LocalDiskStorage } from './local-disk.storage';
|
import { LocalDiskStorage } from './local-disk.storage';
|
||||||
import { S3Storage, s3ConfigFromEnv } from './s3.storage';
|
import { STORAGE_PORT } from './storage.port';
|
||||||
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({
|
@Module({
|
||||||
imports: [PlatformModule, IdentityModule],
|
imports: [PlatformModule, IdentityModule],
|
||||||
controllers: [MediaController],
|
controllers: [MediaController],
|
||||||
providers: [MediaService, { provide: STORAGE_PORT, useFactory: makeStorage }],
|
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter.
|
||||||
// Exported so the capability layer can resolve email attachment bytes at send time.
|
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }],
|
||||||
exports: [STORAGE_PORT],
|
|
||||||
})
|
})
|
||||||
export class MediaModule {}
|
export class MediaModule {}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
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<string, { body: Buffer; mime: string }> = {}) {
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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<Uint8Array> };
|
|
||||||
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<void> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -61,14 +61,6 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
try {
|
try {
|
||||||
const token = String(client.handshake.auth?.token ?? '');
|
const token = String(client.handshake.auth?.token ?? '');
|
||||||
const principal = this.session.verify(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;
|
client.data = { principal } satisfies SocketState;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`rejecting socket: ${(err as Error).message}`);
|
this.logger.warn(`rejecting socket: ${(err as Error).message}`);
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ export interface ThreadSummary {
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
subject: string | null;
|
subject: string | null;
|
||||||
membership?: string;
|
membership?: string;
|
||||||
/** The thread's opaque, app-supplied attribute bag — echoed back verbatim; the kernel never interprets it. */
|
|
||||||
metadata?: Record<string, unknown> | null;
|
|
||||||
participants: string[];
|
participants: string[];
|
||||||
participantCount: number;
|
participantCount: number;
|
||||||
unread: number;
|
unread: number;
|
||||||
@@ -87,21 +85,19 @@ export class MessageService {
|
|||||||
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may
|
* 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.
|
* 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; metadata?: Record<string, unknown> }): Promise<OpenThreadResult> {
|
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> {
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
|
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
|
||||||
const scope = await this.actors.resolveScope(principal);
|
const scope = await this.actors.resolveScope(principal);
|
||||||
const actor = await this.actors.resolveActor(scope.id, principal);
|
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||||
// `membership`/`creatorRole`/`subject`/`metadata` are generic, app-supplied thread attributes —
|
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the
|
||||||
// the kernel stores/echoes them as an opaque bag but never branches on their meaning (that lives
|
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app).
|
||||||
// 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({
|
const thread = await this.prisma.iiosThread.create({
|
||||||
data: {
|
data: {
|
||||||
scopeId: scope.id,
|
scopeId: scope.id,
|
||||||
createdByActorId: actor.id,
|
createdByActorId: actor.id,
|
||||||
subject: opts?.subject?.trim() || undefined,
|
subject: opts?.subject?.trim() || undefined,
|
||||||
metadata: Object.keys(merged).length > 0 ? (merged as Prisma.InputJsonValue) : undefined,
|
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
|
await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
|
||||||
@@ -159,75 +155,6 @@ export class MessageService {
|
|||||||
return { threadId, participantCount: participantCount + 1 };
|
return { threadId, participantCount: participantCount + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Governed thread rename (a generic subject update). Policy decides who may rename — for a
|
|
||||||
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
|
|
||||||
* the subject; "group settings" meaning lives in the app + policy, not here.
|
|
||||||
*/
|
|
||||||
async renameThread(threadId: string, principal: MessagePrincipal, subject: string): Promise<{ threadId: string; subject: string }> {
|
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
|
||||||
if (!thread) throw new NotFoundException('thread not found');
|
|
||||||
const caller = await this.actors.resolveActor(thread.scopeId, principal);
|
|
||||||
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
|
|
||||||
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
|
||||||
|
|
||||||
await decideOrThrow(this.ports, {
|
|
||||||
action: 'iios.thread.update',
|
|
||||||
threadId,
|
|
||||||
scopeId: thread.scopeId,
|
|
||||||
membership,
|
|
||||||
callerRole: callerP?.participantRole,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.iiosThread.update({ where: { id: threadId }, data: { subject } });
|
|
||||||
return { threadId, subject };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Governed participant removal. Policy decides who may remove — for a membership thread the dev
|
|
||||||
* OPA requires the caller be a group ADMIN. Removing a non-participant is a no-op success.
|
|
||||||
*/
|
|
||||||
async removeParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string): Promise<{ threadId: string; participantCount: number }> {
|
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
|
||||||
if (!thread) throw new NotFoundException('thread not found');
|
|
||||||
const caller = await this.actors.resolveActor(thread.scopeId, principal);
|
|
||||||
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
|
|
||||||
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
|
||||||
|
|
||||||
await decideOrThrow(this.ports, {
|
|
||||||
action: 'iios.thread.participant.remove',
|
|
||||||
threadId,
|
|
||||||
scopeId: thread.scopeId,
|
|
||||||
membership,
|
|
||||||
callerRole: callerP?.participantRole,
|
|
||||||
targetUserId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scope = await this.actors.resolveScope(principal);
|
|
||||||
const target = await this.actors.resolveActor(scope.id, {
|
|
||||||
userId: targetUserId, appId: principal.appId, orgId: principal.orgId, tenantId: principal.tenantId, displayName: targetUserId,
|
|
||||||
});
|
|
||||||
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: target.id } });
|
|
||||||
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
|
|
||||||
return { threadId, participantCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Members of a thread with their role — drives the group settings member list. Read is policy-scoped. */
|
|
||||||
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
|
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
|
||||||
if (!thread) throw new NotFoundException('thread not found');
|
|
||||||
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
|
|
||||||
const parts = await this.prisma.iiosThreadParticipant.findMany({
|
|
||||||
where: { threadId },
|
|
||||||
include: { actor: { include: { sourceHandle: true } } },
|
|
||||||
});
|
|
||||||
return parts.map((p) => ({
|
|
||||||
userId: p.actor?.sourceHandle?.externalId ?? '',
|
|
||||||
displayName: p.actor?.displayName ?? p.actor?.sourceHandle?.externalId ?? 'unknown',
|
|
||||||
role: p.participantRole ?? 'MEMBER',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toggle the caller's per-thread notification mute flag. */
|
/** Toggle the caller's per-thread notification mute flag. */
|
||||||
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
@@ -240,12 +167,8 @@ export class MessageService {
|
|||||||
return { threadId, muted };
|
return { threadId, muted };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Generic "my threads": every thread the caller participates in, with last message + unread. */
|
||||||
* Generic "my threads": every thread the caller participates in, with last message + unread.
|
async listThreads(principal: MessagePrincipal): Promise<ThreadSummary[]> {
|
||||||
* 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<string, string> }): Promise<ThreadSummary[]> {
|
|
||||||
const scope = await this.actors.findScope(principal);
|
const scope = await this.actors.findScope(principal);
|
||||||
if (!scope) return [];
|
if (!scope) return [];
|
||||||
const actor = await this.actors.resolveActor(scope.id, principal);
|
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||||
@@ -254,7 +177,7 @@ export class MessageService {
|
|||||||
if (threadIds.length === 0) return [];
|
if (threadIds.length === 0) return [];
|
||||||
const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted]));
|
const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted]));
|
||||||
|
|
||||||
const [allThreads, unreads, allParts] = await Promise.all([
|
const [threads, unreads, allParts] = await Promise.all([
|
||||||
this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }),
|
this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }),
|
||||||
this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }),
|
this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }),
|
||||||
this.prisma.iiosThreadParticipant.findMany({
|
this.prisma.iiosThreadParticipant.findMany({
|
||||||
@@ -262,14 +185,6 @@ export class MessageService {
|
|||||||
include: { actor: { include: { sourceHandle: true } } },
|
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<string, unknown> | null) ?? {};
|
|
||||||
return Object.entries(metaFilter).every(([k, v]) => bag[k] === v);
|
|
||||||
})
|
|
||||||
: allThreads;
|
|
||||||
const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
|
const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
|
||||||
const membersBy = new Map<string, string[]>();
|
const membersBy = new Map<string, string[]>();
|
||||||
for (const p of allParts) {
|
for (const p of allParts) {
|
||||||
@@ -277,41 +192,27 @@ export class MessageService {
|
|||||||
membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]);
|
membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latest message per thread in ONE query (Postgres DISTINCT ON) instead of one findFirst
|
const summaries = await Promise.all(
|
||||||
// per thread — a caller with many threads no longer fans out N parallel queries and
|
threads.map(async (t) => {
|
||||||
// exhausts the connection pool.
|
const last = await this.prisma.iiosInteraction.findFirst({
|
||||||
const lastRows =
|
where: { threadId: t.id },
|
||||||
threads.length === 0
|
orderBy: { occurredAt: 'desc' },
|
||||||
? []
|
include: { parts: { where: { kind: 'TEXT' }, take: 1 } },
|
||||||
: await this.prisma.$queryRaw<Array<{ threadId: string; occurredAt: Date; lastMessage: string | null }>>(Prisma.sql`
|
});
|
||||||
SELECT DISTINCT ON (i."threadId")
|
const members = membersBy.get(t.id) ?? [];
|
||||||
i."threadId" AS "threadId",
|
return {
|
||||||
i."occurredAt" AS "occurredAt",
|
threadId: t.id,
|
||||||
(SELECT p."bodyText" FROM "IiosMessagePart" p
|
subject: t.subject,
|
||||||
WHERE p."interactionId" = i.id AND p.kind::text = 'TEXT'
|
membership: (t.metadata as { membership?: string } | null)?.membership,
|
||||||
ORDER BY p."partIndex" ASC LIMIT 1) AS "lastMessage"
|
participants: members,
|
||||||
FROM "IiosInteraction" i
|
participantCount: members.length,
|
||||||
WHERE i."threadId" IN (${Prisma.join(threads.map((t) => t.id))})
|
unread: unreadBy.get(t.id) ?? 0,
|
||||||
ORDER BY i."threadId", i."occurredAt" DESC
|
muted: mutedBy.get(t.id) ?? false,
|
||||||
`);
|
lastMessage: last?.parts[0]?.bodyText ?? undefined,
|
||||||
const lastBy = new Map(lastRows.map((r) => [r.threadId, r]));
|
lastAt: last?.occurredAt,
|
||||||
|
};
|
||||||
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<string, unknown> | 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));
|
return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,28 +124,6 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
|||||||
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
|
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
|
||||||
});
|
});
|
||||||
|
|
||||||
it('group settings: admin renames + lists members + removes; a plain member cannot rename/remove', async () => {
|
|
||||||
const s = gov();
|
|
||||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
|
|
||||||
await s.addParticipant(threadId, alice, 'bob');
|
|
||||||
|
|
||||||
// admin renames
|
|
||||||
expect((await s.renameThread(threadId, alice, 'Design Team')).subject).toBe('Design Team');
|
|
||||||
// a plain member cannot rename
|
|
||||||
await expect(s.renameThread(threadId, bob, 'Hacked')).rejects.toBeInstanceOf(PolicyDeniedError);
|
|
||||||
|
|
||||||
// member list carries roles
|
|
||||||
const members = await s.listParticipants(threadId, alice);
|
|
||||||
expect(members.map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
|
|
||||||
expect(members.find((m) => m.userId === 'alice')?.role).toBe('ADMIN');
|
|
||||||
|
|
||||||
// a plain member cannot remove
|
|
||||||
await expect(s.removeParticipant(threadId, bob, 'alice')).rejects.toBeInstanceOf(PolicyDeniedError);
|
|
||||||
// admin removes bob
|
|
||||||
expect((await s.removeParticipant(threadId, alice, 'bob')).participantCount).toBe(1);
|
|
||||||
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
|
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
|
||||||
const s = gov();
|
const s = gov();
|
||||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
@@ -165,23 +143,6 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
|||||||
expect((await s.listThreads(bob))[0]?.unread).toBe(1);
|
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 () => {
|
it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => {
|
||||||
const s = gov();
|
const s = gov();
|
||||||
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
|
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { trace } from '@opentelemetry/api';
|
||||||
import { runWithTrace, newTraceId, traceIdFromTraceparent } from './trace-context';
|
import { runWithTrace, newTraceId, traceIdFromTraceparent } from './trace-context';
|
||||||
import { logJson } from './logger';
|
import { logJson } from './logger';
|
||||||
|
|
||||||
@@ -9,8 +10,13 @@ import { logJson } from './logger';
|
|||||||
* the response finishes (method, path, status, ms). Wired via app.use() in main.ts.
|
* the response finishes (method, path, status, ms). Wired via app.use() in main.ts.
|
||||||
*/
|
*/
|
||||||
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
|
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
// Prefer the ACTIVE OpenTelemetry trace id: that is what makes this log line
|
||||||
|
// joinable to its distributed trace in Grafana (Loki -> Tempo). Falls back to
|
||||||
|
// the inbound header, then traceparent, then a generated id, so a correlation
|
||||||
|
// id is always present even when tracing is disabled.
|
||||||
|
const otelTraceId = trace.getActiveSpan()?.spanContext().traceId;
|
||||||
const headerTrace = (req.headers['x-trace-id'] as string | undefined)?.trim();
|
const headerTrace = (req.headers['x-trace-id'] as string | undefined)?.trim();
|
||||||
const traceId = headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
|
const traceId = otelTraceId || headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
|
||||||
res.setHeader('x-trace-id', traceId);
|
res.setHeader('x-trace-id', traceId);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
runWithTrace(traceId, () => {
|
runWithTrace(traceId, () => {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* OpenTelemetry bootstrap. MUST be imported before anything else in main.ts —
|
||||||
|
* auto-instrumentation works by patching modules (http, express, pg, redis, …)
|
||||||
|
* as they are require()d, so any module loaded before this runs is never traced.
|
||||||
|
*
|
||||||
|
* Env-driven on purpose: the OTel SDK reads OTEL_SERVICE_NAME,
|
||||||
|
* OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL itself, so the
|
||||||
|
* collector target is a deployment concern rather than a code change. When
|
||||||
|
* OTEL_EXPORTER_OTLP_ENDPOINT is unset the SDK never starts, so local dev and
|
||||||
|
* tests run with zero tracing overhead and no exporter errors.
|
||||||
|
*
|
||||||
|
* Traces land in Tempo and are viewable in Grafana. The existing P9 trace
|
||||||
|
* middleware adopts the active OTel trace id, so an `http.request` log line and
|
||||||
|
* its distributed trace share one id (Loki -> Tempo pivot).
|
||||||
|
*/
|
||||||
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||||
|
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||||
|
|
||||||
|
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||||
|
|
||||||
|
if (endpoint) {
|
||||||
|
const sdk = new NodeSDK({
|
||||||
|
instrumentations: [
|
||||||
|
getNodeAutoInstrumentations({
|
||||||
|
// Noisy and low value: every file read becomes a span.
|
||||||
|
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||||
|
// k8s probes hit /health constantly; tracing them would swamp Tempo
|
||||||
|
// and bury the real request traces.
|
||||||
|
'@opentelemetry/instrumentation-http': {
|
||||||
|
ignoreIncomingRequestHook: (req) => {
|
||||||
|
const url = req.url ?? '';
|
||||||
|
return ['/health', '/ready', '/healthz', '/readyz', '/metrics'].some((p) =>
|
||||||
|
url.startsWith(p),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.start();
|
||||||
|
|
||||||
|
const shutdown = (): void => {
|
||||||
|
// Flush buffered spans before exit, else the last requests before a
|
||||||
|
// rollout are lost.
|
||||||
|
void sdk.shutdown().finally(() => process.exit(0));
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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,
|
|
||||||
PublicKeyResolverPort,
|
|
||||||
} 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<ClientRegistryEntry | null> {
|
|
||||||
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<boolean> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string, JwksClient>();
|
|
||||||
|
|
||||||
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<string | null> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Global, Module, type OnModuleInit } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { ContextAttestationVerifier } from './context-attestation';
|
|
||||||
import { PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver } 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,
|
|
||||||
JwksPublicKeyResolver,
|
|
||||||
{
|
|
||||||
provide: ContextAttestationVerifier,
|
|
||||||
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,
|
|
||||||
],
|
|
||||||
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<void> {
|
|
||||||
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: '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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
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<string>();
|
|
||||||
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<string, string>): ExecutionContext {
|
|
||||||
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
function att(over: Record<string, unknown> = {}, secret = SECRET): string {
|
|
||||||
return signDevAttestation(
|
|
||||||
{ issuer: 'appshell.crm', audience: 'iios-core', clientId: 'appshell-crm', 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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<boolean> {
|
|
||||||
const required = process.env.IIOS_REQUIRE_ATTESTATION === '1';
|
|
||||||
const req = ctx.switchToHttp().getRequest<Request>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
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<ClientRegistryEntry> = {}): ClientRegistryPort {
|
|
||||||
const entry: ClientRegistryEntry = {
|
|
||||||
clientId: 'appshell-crm',
|
|
||||||
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<string>();
|
|
||||||
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<string, unknown> = {}, secret = APPSHELL_SECRET, at = NOW): string {
|
|
||||||
return signDevAttestation(
|
|
||||||
{ issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', 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('appshell-crm');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── 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<string, unknown> = {}, 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';
|
|
||||||
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: '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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
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: verify the attestation signature asymmetrically (ES256) via this JWKS
|
|
||||||
status: 'ACTIVE' | 'DISABLED';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientRegistryPort {
|
|
||||||
find(clientId: string): Promise<ClientRegistryEntry | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
/** Optional — required only for clients that verify via a JWKS (prod ES256). */
|
|
||||||
private readonly keys?: PublicKeyResolverPort,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<AttestationResult> {
|
|
||||||
// 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}`);
|
|
||||||
|
|
||||||
// 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, key.pem, { algorithms: [key.alg], 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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}"` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<ContextAttestation, 'iat' | 'exp'> & { 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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<ContextAttestation, 'iat' | 'exp'> & { 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 });
|
|
||||||
}
|
|
||||||
@@ -33,22 +33,6 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
|
|||||||
expect(asAdmin.allow).toBe(true);
|
expect(asAdmin.allow).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => {
|
|
||||||
const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' });
|
|
||||||
expect(asMember.allow).toBe(false);
|
|
||||||
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
|
||||||
expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
|
||||||
expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow
|
|
||||||
});
|
|
||||||
|
|
||||||
it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => {
|
|
||||||
const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' });
|
|
||||||
expect(asMember.allow).toBe(false);
|
|
||||||
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
|
||||||
expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
|
|
||||||
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('self-join is governed only on membership threads', async () => {
|
it('self-join is governed only on membership threads', async () => {
|
||||||
// generic / support thread (no membership attr) → open join, unchanged
|
// generic / support thread (no membership attr) → open join, unchanged
|
||||||
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
||||||
|
|||||||
@@ -42,18 +42,6 @@ export class DevOpaPort {
|
|||||||
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
||||||
return allow();
|
return allow();
|
||||||
}
|
}
|
||||||
case 'iios.thread.participant.remove': {
|
|
||||||
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
|
|
||||||
// (no membership attr) threads allow removal by any member.
|
|
||||||
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
|
||||||
if (i.membership && i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can remove participants');
|
|
||||||
return allow();
|
|
||||||
}
|
|
||||||
case 'iios.thread.update': {
|
|
||||||
// Rename / settings change: for a group, only an ADMIN. Ungoverned threads allow it.
|
|
||||||
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can change group settings');
|
|
||||||
return allow();
|
|
||||||
}
|
|
||||||
case 'iios.thread.join': {
|
case 'iios.thread.join': {
|
||||||
// Only threads that opted into a membership model (chat dm/group) are governed;
|
// Only threads that opted into a membership model (chat dm/group) are governed;
|
||||||
// generic/support threads (no membership attr) keep open-join. For a governed
|
// generic/support threads (no membership attr) keep open-join. For a governed
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
|
||||||
import { PLATFORM_PORTS, LocalDevPorts } from './platform-ports';
|
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()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [{ provide: PLATFORM_PORTS, useClass: LocalDevPorts }],
|
||||||
{ provide: PLATFORM_PORTS, useClass: LocalDevPorts },
|
|
||||||
{ provide: APP_GUARD, useClass: RealtimeTokenRestGuard },
|
|
||||||
],
|
|
||||||
exports: [PLATFORM_PORTS],
|
exports: [PLATFORM_PORTS],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
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<string, unknown>) => 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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<Request>().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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -135,7 +135,6 @@ export class SessionVerifier implements OnModuleInit {
|
|||||||
orgId: entry.orgId,
|
orgId: entry.orgId,
|
||||||
tenantId: undefined,
|
tenantId: undefined,
|
||||||
displayName: meta.full_name ?? meta.name ?? email ?? userId,
|
displayName: meta.full_name ?? meta.name ?? email ?? userId,
|
||||||
audience: typeof payload.aud === 'string' ? payload.aud : entry.audience,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +159,6 @@ export class SessionVerifier implements OnModuleInit {
|
|||||||
orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`,
|
orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`,
|
||||||
tenantId: payload.tenantId ? String(payload.tenantId) : undefined,
|
tenantId: payload.tenantId ? String(payload.tenantId) : undefined,
|
||||||
displayName: payload.name ? String(payload.name) : undefined,
|
displayName: payload.name ? String(payload.name) : undefined,
|
||||||
audience: typeof payload.aud === 'string' ? payload.aud : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,59 +117,3 @@ describe('RetentionService (P9 — retention sweep)', () => {
|
|||||||
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
|
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: '<p>secret body</p>' },
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -72,41 +72,6 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return created;
|
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<number> {
|
|
||||||
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. */
|
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
|
||||||
async applySweep(scopeId?: string): Promise<SweepResult> {
|
async applySweep(scopeId?: string): Promise<SweepResult> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -125,22 +90,13 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (s.deleteAfter <= now) {
|
if (s.deleteAfter <= now) {
|
||||||
if (s.targetType === 'outbound_command') {
|
await this.prisma.$transaction([
|
||||||
// Redact the recipient address + rendered body; KEEP the template provenance columns.
|
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }),
|
||||||
await this.prisma.$transaction([
|
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
|
||||||
this.prisma.iiosOutboundCommand.update({ where: { id: s.targetId }, data: { target: '[redacted]', 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' } }),
|
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 });
|
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', 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++;
|
result.redacted++;
|
||||||
} else if (s.status === 'ACTIVE') {
|
} else if (s.status === 'ACTIVE') {
|
||||||
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
|
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
|
||||||
@@ -151,10 +107,9 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full sweep: capture missing snapshots (interactions + outbound commands), then act on due ones. */
|
/** Full sweep: capture missing snapshots, then act on due ones. */
|
||||||
async sweep(scopeId?: string): Promise<SweepResult> {
|
async sweep(scopeId?: string): Promise<SweepResult> {
|
||||||
await this.ensureSnapshots(scopeId);
|
await this.ensureSnapshots(scopeId);
|
||||||
await this.ensureOutboundSnapshots(scopeId);
|
|
||||||
return this.applySweep(scopeId);
|
return this.applySweep(scopeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { IiosTicketState } from '@prisma/client';
|
import { IiosTicketState } from '@prisma/client';
|
||||||
import { SupportService } from './support.service';
|
import { SupportService } from './support.service';
|
||||||
import { AssignmentService } from './assignment.service';
|
import { AssignmentService } from './assignment.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
|
|
||||||
import type { MessagePrincipal } from '../identity/actor.resolver';
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||||
import {
|
import {
|
||||||
AssignTicketDto,
|
|
||||||
AvailabilityDto,
|
AvailabilityDto,
|
||||||
CallbackDto,
|
CallbackDto,
|
||||||
CreateQueueDto,
|
CreateQueueDto,
|
||||||
@@ -16,7 +14,6 @@ import {
|
|||||||
} from './support.dto';
|
} from './support.dto';
|
||||||
|
|
||||||
@Controller('v1/support')
|
@Controller('v1/support')
|
||||||
@UseGuards(ContextAttestationGuard)
|
|
||||||
export class SupportController {
|
export class SupportController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly support: SupportService,
|
private readonly support: SupportService,
|
||||||
@@ -35,7 +32,7 @@ export class SupportController {
|
|||||||
|
|
||||||
@Post('escalate')
|
@Post('escalate')
|
||||||
async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) {
|
async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) {
|
||||||
return this.support.escalate(body.threadId, this.principal(auth), body.subject, body.metadata);
|
return this.support.escalate(body.threadId, this.principal(auth), body.subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('tickets')
|
@Get('tickets')
|
||||||
@@ -48,12 +45,6 @@ export class SupportController {
|
|||||||
return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason);
|
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')
|
@Post('callbacks')
|
||||||
async callback(
|
async callback(
|
||||||
@Body() body: CallbackDto,
|
@Body() body: CallbackDto,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const;
|
const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const;
|
||||||
const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const;
|
const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const;
|
||||||
@@ -7,18 +7,11 @@ export class CreateTicketDto {
|
|||||||
@IsString() subject!: string;
|
@IsString() subject!: string;
|
||||||
@IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number];
|
@IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number];
|
||||||
@IsOptional() @IsString() threadId?: string;
|
@IsOptional() @IsString() threadId?: string;
|
||||||
/** Opaque, app-supplied attribute bag — stored on the ticket, never interpreted by the kernel. */
|
|
||||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EscalateDto {
|
export class EscalateDto {
|
||||||
@IsString() threadId!: string;
|
@IsString() threadId!: string;
|
||||||
@IsOptional() @IsString() subject?: string;
|
@IsOptional() @IsString() subject?: string;
|
||||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AssignTicketDto {
|
|
||||||
@IsString() userId!: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PatchTicketDto {
|
export class PatchTicketDto {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class SupportService {
|
|||||||
|
|
||||||
async createTicket(
|
async createTicket(
|
||||||
principal: MessagePrincipal,
|
principal: MessagePrincipal,
|
||||||
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string; metadata?: Record<string, unknown> },
|
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string },
|
||||||
idempotencyKey?: string,
|
idempotencyKey?: string,
|
||||||
) {
|
) {
|
||||||
await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal });
|
await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal });
|
||||||
@@ -63,8 +63,6 @@ export class SupportService {
|
|||||||
subject: input.subject,
|
subject: input.subject,
|
||||||
priority: input.priority ?? 'P3',
|
priority: input.priority ?? 'P3',
|
||||||
traceId,
|
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({
|
await tx.iiosTicketStateHistory.create({
|
||||||
@@ -101,17 +99,11 @@ export class SupportService {
|
|||||||
return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body);
|
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. `metadata` is opaque. */
|
/** Escalate a chat thread to support: create a ticket linked to that thread. */
|
||||||
async escalate(threadId: string, principal: MessagePrincipal, subject?: string, metadata?: Record<string, unknown>) {
|
async escalate(threadId: string, principal: MessagePrincipal, subject?: string) {
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
if (!thread) throw new NotFoundException('thread not found');
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
// Inherit the thread's opaque bag so the ticket carries the same app context, unless overridden.
|
return this.createTicket(principal, { subject: subject ?? thread.subject ?? 'Support request', threadId });
|
||||||
const inherited = { ...((thread.metadata as Record<string, unknown> | 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<void> {
|
async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise<void> {
|
||||||
@@ -128,53 +120,6 @@ export class SupportService {
|
|||||||
.catch(() => undefined);
|
.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) {
|
async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) {
|
||||||
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
|
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
|
||||||
if (!ticket) throw new NotFoundException('ticket not found');
|
if (!ticket) throw new NotFoundException('ticket not found');
|
||||||
|
|||||||
@@ -57,19 +57,6 @@ describe('SupportService (P4)', () => {
|
|||||||
expect(links[0]?.threadId).toBe(threadId);
|
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 () => {
|
it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => {
|
||||||
const t = await support().createTicket(cust, { subject: 's' });
|
const t = await support().createTicket(cust, { subject: 's' });
|
||||||
await support().transition(t.id, cust, 'OPEN');
|
await support().transition(t.id, cust, 'OPEN');
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
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:
|
|
||||||
'<p>Hi {{firstName}},</p>' +
|
|
||||||
'<p>Thanks for joining the Founders Club. Your account is ready to set up.</p>' +
|
|
||||||
'<p><a href="{{signupLink}}">Create your account</a></p>' +
|
|
||||||
'<p>We are launching soon — we will keep you posted.</p>',
|
|
||||||
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:
|
|
||||||
'<p>Hi {{firstName}},</p>' +
|
|
||||||
'<p>We have received your payment of <strong>{{amount}}</strong> for {{licenseCount}} license(s).</p>' +
|
|
||||||
'<p>A separate tax receipt from our payment processor will follow.</p>',
|
|
||||||
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:
|
|
||||||
'<p>Hi {{firstName}},</p>' +
|
|
||||||
'<p>You paid but have not created your account yet. It only takes a minute.</p>' +
|
|
||||||
'<p><a href="{{signupLink}}">Finish creating your account</a></p>',
|
|
||||||
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,
|
|
||||||
];
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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<string, unknown>;
|
|
||||||
@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<string, unknown>;
|
|
||||||
@IsOptional() @IsString() locale?: string;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { renderTemplate, MissingTemplateVariableError } from './template.renderer';
|
|
||||||
|
|
||||||
describe('renderTemplate (pure)', () => {
|
|
||||||
it('substitutes variables into subject / html / text', () => {
|
|
||||||
const out = renderTemplate(
|
|
||||||
{ subject: 'Receipt for {{firstName}}', bodyHtml: '<p>Hi {{firstName}}</p>', bodyText: 'Hi {{firstName}}', variables: ['firstName'] },
|
|
||||||
{ firstName: 'Dana' },
|
|
||||||
);
|
|
||||||
expect(out.subject).toBe('Receipt for Dana');
|
|
||||||
expect(out.html).toBe('<p>Hi Dana</p>');
|
|
||||||
expect(out.text).toBe('Hi Dana');
|
|
||||||
});
|
|
||||||
|
|
||||||
// The load-bearing safety property: a customer-supplied name goes into an HTML email body.
|
|
||||||
it('escapes HTML in the html body, but leaves subject/text verbatim (XSS)', () => {
|
|
||||||
const out = renderTemplate(
|
|
||||||
{ subject: 'Hi {{firstName}}', bodyHtml: '<p>{{firstName}}</p>', bodyText: '{{firstName}}', variables: ['firstName'] },
|
|
||||||
{ firstName: '<script>alert(1)</script>' },
|
|
||||||
);
|
|
||||||
expect(out.html).toBe('<p><script>alert(1)</script></p>');
|
|
||||||
expect(out.html).not.toContain('<script>');
|
|
||||||
expect(out.text).toBe('<script>alert(1)</script>'); // 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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<string, unknown>): 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 });
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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<string> {
|
|
||||||
const s = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } });
|
|
||||||
return s.id;
|
|
||||||
}
|
|
||||||
async function seed(data: Partial<Parameters<typeof prisma.iiosMessageTemplate.create>[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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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,
|
|
||||||
version?: number,
|
|
||||||
): Promise<IiosMessageTemplate> {
|
|
||||||
// Scoped override first, then the global default — for both the pinned and highest-active paths.
|
|
||||||
if (scopeId) {
|
|
||||||
const scoped = await this.pick({ key, channel, locale, scopeId }, version);
|
|
||||||
if (scoped) return scoped;
|
|
||||||
}
|
|
||||||
const global = await this.pick({ key, channel, locale, scopeId: null }, version);
|
|
||||||
if (global) return global;
|
|
||||||
throw new TemplateNotFoundError(key, channel, locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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: version != null ? { ...where, version } : { ...where, active: true },
|
|
||||||
orderBy: { version: 'desc' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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<void> {
|
|
||||||
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<number> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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: '<p>{{firstName}}</p>', variables: ['firstName'] },
|
|
||||||
});
|
|
||||||
const { content, provenance } = await svc.render({ key: 'welcome' }, { firstName: 'Dana' }, { channel: 'EMAIL' });
|
|
||||||
expect(content.subject).toBe('Hi Dana');
|
|
||||||
expect(content.html).toBe('<p>Dana</p>');
|
|
||||||
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: '<b>{{x}}</b>', variables: ['x'] } },
|
|
||||||
{ x: 'Y' },
|
|
||||||
{ channel: 'EMAIL' },
|
|
||||||
);
|
|
||||||
expect(content.subject).toBe('Ad-hoc Y');
|
|
||||||
expect(content.html).toBe('<b>Y</b>');
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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<string, unknown>, opts: RenderOptions): Promise<RenderResult> {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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, makeFakePorts());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedReceipt() {
|
|
||||||
await prisma.iiosMessageTemplate.create({
|
|
||||||
data: { key: 'payment.receipt', channel: 'EMAIL', locale: 'en', version: 1, subject: 'Thanks {{firstName}}', bodyHtml: '<p>{{amount}}</p>', 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: '<b>{{n}}</b>', 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('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'] },
|
|
||||||
});
|
|
||||||
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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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 { 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;
|
|
||||||
target: string; // email address / phone number
|
|
||||||
vars: Record<string, unknown>;
|
|
||||||
scopeId?: string;
|
|
||||||
locale?: string;
|
|
||||||
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
|
|
||||||
purpose?: string;
|
|
||||||
attachments?: AttachmentRef[]; // EMAIL only
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
@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,
|
|
||||||
scopeId: input.scopeId,
|
|
||||||
});
|
|
||||||
return this.outbound.send(
|
|
||||||
input.channel,
|
|
||||||
input.target,
|
|
||||||
this.toPayload(input.channel, content, input.attachments),
|
|
||||||
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, attachments?: AttachmentRef[]): Record<string, unknown> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,24 +3,19 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
Headers,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ThreadsService } from './threads.service';
|
import { ThreadsService } from './threads.service';
|
||||||
import { MessageService } from '../messaging/message.service';
|
import { MessageService } from '../messaging/message.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
|
|
||||||
import { SendMessageDto } from './send-message.dto';
|
import { SendMessageDto } from './send-message.dto';
|
||||||
|
|
||||||
@Controller('v1/threads')
|
@Controller('v1/threads')
|
||||||
@UseGuards(ContextAttestationGuard)
|
|
||||||
export class ThreadsController {
|
export class ThreadsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly threads: ThreadsService,
|
private readonly threads: ThreadsService,
|
||||||
@@ -28,14 +23,10 @@ export class ThreadsController {
|
|||||||
private readonly session: SessionVerifier,
|
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()
|
@Get()
|
||||||
async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
|
async listThreads(@Headers('authorization') auth?: string) {
|
||||||
const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
|
return this.messages.listThreads(this.principal(auth));
|
||||||
return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
|
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
|
||||||
@@ -44,11 +35,11 @@ export class ThreadsController {
|
|||||||
return this.messages.listMyAnnotated(this.principal(auth), type);
|
return this.messages.listMyAnnotated(this.principal(auth), type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
|
/** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }, @Headers('authorization') auth?: string) {
|
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, metadata: body?.metadata });
|
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
|
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
|
||||||
@@ -58,27 +49,6 @@ export class ThreadsController {
|
|||||||
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
|
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Members of a thread with their role — drives the group settings member list. */
|
|
||||||
@Get(':id/participants')
|
|
||||||
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
|
||||||
return this.messages.listParticipants(id, this.principal(auth));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Governed removal: remove a user from a thread — policy enforces group-admin. */
|
|
||||||
@Delete(':id/participants/:userId')
|
|
||||||
@HttpCode(200)
|
|
||||||
async removeParticipant(@Param('id') id: string, @Param('userId') userId: string, @Headers('authorization') auth?: string) {
|
|
||||||
return this.messages.removeParticipant(id, this.principal(auth), userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Governed rename (group settings): change a thread subject — policy enforces group-admin. */
|
|
||||||
@Patch(':id')
|
|
||||||
@HttpCode(200)
|
|
||||||
async updateThread(@Param('id') id: string, @Body() body: { subject?: string }, @Headers('authorization') auth?: string) {
|
|
||||||
if (body?.subject == null) throw new BadRequestException('subject is required');
|
|
||||||
return this.messages.renameThread(id, this.principal(auth), body.subject);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id/messages')
|
@Get(':id/messages')
|
||||||
async listMessages(@Param('id') id: string) {
|
async listMessages(@Param('id') id: string) {
|
||||||
return this.threads.getMessages(id);
|
return this.threads.getMessages(id);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ThreadMessage {
|
|||||||
actorId: string | null;
|
actorId: string | null;
|
||||||
kind: string;
|
kind: string;
|
||||||
occurredAt: Date;
|
occurredAt: Date;
|
||||||
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null; mimeType: string | null; sizeBytes: number | null }>;
|
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -39,7 +39,7 @@ export class ThreadsService {
|
|||||||
actorId: i.actorId,
|
actorId: i.actorId,
|
||||||
kind: i.kind,
|
kind: i.kind,
|
||||||
occurredAt: i.occurredAt,
|
occurredAt: i.occurredAt,
|
||||||
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef, mimeType: p.mimeType, sizeBytes: p.sizeBytes != null ? Number(p.sizeBytes) : null })),
|
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef })),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-support-web",
|
"name": "@insignia/iios-support-web",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||||
".": {
|
"files": ["dist"],
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
@@ -30,8 +24,5 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user