Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4104b9769 | |||
| 32aa04503d | |||
| c8c2d0811b | |||
| ce33834d56 | |||
| b164ef945c | |||
| 18498ee9fa | |||
| 40c98522dc | |||
| 50f9b3213d | |||
| 0c6650f3c6 | |||
| d28c873d40 | |||
| 9b075f46f9 | |||
| bba5fae061 | |||
| 74cca2d534 | |||
| 7d7c75915a | |||
| 65023ce404 | |||
| b44f795ba4 | |||
| 37407cb0af | |||
| 10f3545a25 | |||
| 8ce647552a | |||
| b3afd44d00 | |||
| 8768af96c1 | |||
| cea0a27118 | |||
| 6dc9e4ffee | |||
| 3298401772 | |||
| e39caa3c80 | |||
| 85a78eb21e | |||
| 3e66c7a5db | |||
| 9e0411bfe6 | |||
| f2d590b04d | |||
| dd6a4cd4fa | |||
| 77dd5bac82 | |||
| b918a21083 | |||
| 8c70b6d31f | |||
| 64c498a97a | |||
| 5abee1b5b7 | |||
| 58ebaf1982 | |||
| 6fc03fa4c3 | |||
| 0a8544b6fc | |||
| f2ef8922ce | |||
| 48c2e6589c | |||
| ba2d5f4193 | |||
| e49a71feaf | |||
| ac7f790303 | |||
| 24a87f6fb6 | |||
| 1256664361 | |||
| 0ecc8a4ada | |||
| e956ad3cb9 | |||
| 8c814d9b86 | |||
| f3c4ba72b5 | |||
| c4206f9809 | |||
| 1af10d0f4a | |||
| 31f7682a46 | |||
| ba745bb71a | |||
| 2056391f9d | |||
| 23f5159521 | |||
| 3abad4970f | |||
| d5e0dacae3 | |||
| 43e9928ce3 | |||
| f0ea1ca553 | |||
| f0afca89e3 | |||
| ae50f56404 | |||
| 427396653f | |||
| a520b26398 | |||
| 5bd1f646ca | |||
| a933e2e3af | |||
| cdba0f0179 | |||
| 035315ef73 | |||
| a5cd8f0ec4 |
@@ -0,0 +1,12 @@
|
|||||||
|
# IIOS Project Memory — Index
|
||||||
|
|
||||||
|
Repo-local, versioned project memory. The full canonical rules are in the repo root
|
||||||
|
**`CLAUDE.md`**; these files are granular, per-topic memories that accumulate across sessions.
|
||||||
|
Read the relevant ones before working in that area; add/update files as the project evolves.
|
||||||
|
|
||||||
|
- [project_iios_overview.md](project_iios_overview.md) — what IIOS is (generic interaction OS; kernel + specializations + platform ports)
|
||||||
|
- [feedback_generic_safety.md](feedback_generic_safety.md) — THE #1 rule: no chat/domain vocabulary in the kernel
|
||||||
|
- [reference_run_and_test.md](reference_run_and_test.md) — run command, isolated `iios_test` DB, and the `replay.spec` flake workaround
|
||||||
|
- [project_recent_features.md](project_recent_features.md) — the "real-providers" era: Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications
|
||||||
|
- [reference_chat_web_consumer.md](reference_chat_web_consumer.md) — chat-web is the reference app driving IIOS feature work
|
||||||
|
- [feedback_workflow.md](feedback_workflow.md) — commits (email + co-author), IIOS_DEV_TOKENS off in prod, TDD, build-before-restart
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: feedback_generic_safety
|
||||||
|
description: THE #1 locked rule — the kernel must never hardcode chat/domain vocabulary
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
**The kernel must stay generic.** No `'dm'` / `'group'` / `'reaction'` / `'emoji'` / `'mention'`
|
||||||
|
literals in kernel or messaging *logic*. If a reviewer asked "is this a chat backend now?" the
|
||||||
|
answer must stay **no**.
|
||||||
|
|
||||||
|
**Why:** IIOS is a generic interaction OS; chat is one consumer. Baking chat meaning into the
|
||||||
|
kernel destroys reuse (support/community/meetings share the same core).
|
||||||
|
|
||||||
|
**How to apply:** domain meaning lives in exactly three places, never the kernel —
|
||||||
|
1. **OPA policy** (`DevOpaPort` → real OPA): DM-cap, group-admin, governed-join, media limits,
|
||||||
|
notification triggers.
|
||||||
|
2. **Opaque attributes** the kernel stores but never interprets: `thread.metadata.membership`
|
||||||
|
(`dm`/`group`), interaction annotations (opaque `annotationType`+`value` → app writes
|
||||||
|
`reaction`/`pin`/`save`), `mentions[]` (opaque userId notify-list; kernel never parses `@`).
|
||||||
|
3. **The app** (chat-web): rendering + product semantics.
|
||||||
|
|
||||||
|
Reading an opaque attr inside a **policy/notification gate** (`membership === 'dm'` in
|
||||||
|
`DevOpaPort` or `notification.projector.ts`) is OK — that file IS the policy plane. Everywhere
|
||||||
|
else, keep generic. Verify before committing:
|
||||||
|
`grep -rniE "'dm'|'group'|reaction|emoji" packages/iios-service/src | grep -v spec` — hits only
|
||||||
|
in policy/notification/app-facing layers or comments. Also run `pnpm boundary`.
|
||||||
|
Related: [[project_iios_overview]].
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: feedback_workflow
|
||||||
|
description: Working conventions — commits, prod flags, TDD, rebuild-before-restart
|
||||||
|
metadata:
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email
|
||||||
|
**`maaz@insigniaconsultancy.com`**, co-author every commit with Claude. The user commits
|
||||||
|
directly to `main` in this project (fine); branch only if asked.
|
||||||
|
- **⚠ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token
|
||||||
|
minting, chaos, retention sweep). The single most important prod-hardening flag.
|
||||||
|
- **TDD** — write the failing spec first; verify it fails; implement; verify it passes.
|
||||||
|
- **Rebuild before restart** — after backend changes, `nest build` then restart from `dist`;
|
||||||
|
`lsof -ti :3200 | xargs kill -9` first or the old build keeps serving (stale-dist bites).
|
||||||
|
- **New kernel capability = a generic primitive only** — add domain meaning in OPA policy + the
|
||||||
|
app, never the kernel. See [[feedback_generic_safety]].
|
||||||
|
- The user prefers **direct, fast iteration** (implement → verify end-to-end → commit), not
|
||||||
|
heavyweight multi-agent/spec ceremony. Include "how to test" in summaries; report failures honestly.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: project_iios_overview
|
||||||
|
description: What IIOS is — a generic multi-tenant interaction OS; kernel + specializations + swappable platform ports
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
IIOS (Insignia Interaction OS) = one NestJS service (`@insignia/iios-service`) + SDKs, in a
|
||||||
|
pnpm monorepo (`packages/*`). Every interaction (chat message, ticket, routed post, AI
|
||||||
|
suggestion, meeting) is the **same kernel object** inside a **tenant scope** (`IiosScope`:
|
||||||
|
org/app/tenant/…), behind the **same fail-closed gates**, emitting the **same audit trail**.
|
||||||
|
|
||||||
|
Products (messaging, inbox, support, routing, AI, calendar, media, notifications) are thin
|
||||||
|
**specializations** on top of a tiny kernel — never the reverse (`pnpm boundary` enforces it).
|
||||||
|
|
||||||
|
Platform seams are **ports** (`IiosPlatformPorts`, DI token `PLATFORM_PORTS`; dev = permissive
|
||||||
|
`LocalDevPorts`): session, opa, cmp (consent), mdm, sas, capability, plus `StoragePort` (media)
|
||||||
|
and `NotificationPort` (push). **Dev stubs swap to real adapters with zero consumer changes.**
|
||||||
|
Every op passes `decideOrThrow(ports, {action,…})` fail-closed. Events go through a
|
||||||
|
transactional outbox → `OutboxBus` → idempotent projectors (inbox, notifications).
|
||||||
|
|
||||||
|
See the repo `CLAUDE.md` and `docs/IIOS_API_AND_SDK_GUIDE.md` (as-built reference) for detail.
|
||||||
|
Related: [[feedback_generic_safety]].
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: project_recent_features
|
||||||
|
description: The "real-providers" era — Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Beyond P0–P8 (kernel → messaging → inbox → support → adapters → routing → AI →
|
||||||
|
calendar/meetings), recent work (mostly driven by the chat app, the start of P9 "real
|
||||||
|
providers"):
|
||||||
|
|
||||||
|
- **Real Supabase auth** — `SessionVerifier` verifies real OIDC tokens against issuer JWKS
|
||||||
|
(ES256, no secret), a **multi-issuer registry** routed by the `iss` claim → per-issuer `appId`
|
||||||
|
scope (`AUTH_ISSUERS` JSON, or `SUPABASE_URL` single-issuer shorthand). `userId = email`.
|
||||||
|
Legacy HS256 app-token path (`APP_SECRETS`) stays for dev/tests.
|
||||||
|
- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive (opaque
|
||||||
|
`annotationType`+`value`); socket `annotate` event → `annotation` broadcast;
|
||||||
|
`GET /v1/threads/my-annotations?type=save`.
|
||||||
|
- **@mentions → Inbox** — `send(... mentions[])` (opaque userId list) → `InboxProjector` fans out
|
||||||
|
a `MENTION` inbox item to mentioned participants; reading resolves it.
|
||||||
|
- **Media** — `StoragePort` (dev = local disk `MEDIA_DIR`; prod swap to S3/Supabase), presigned
|
||||||
|
upload/download (signed HS256 tokens, OPA-gated size/type, tenant-fenced), `attachment` on
|
||||||
|
`MessageDto`; parts use generic `MEDIA_REF/VOICE_REF/FILE_REF`.
|
||||||
|
- **Notifications** — presence-gated Web Push: `NotificationProjector` runs 3 gates
|
||||||
|
(policy=DM/mention/reply-to-you · presence=`focus_thread` signal · per-thread `muted`) →
|
||||||
|
swappable `NotificationPort` (Web Push/VAPID); dead sub (410) pruned. Presence is in-memory
|
||||||
|
(single-instance) → Redis for multi-replica.
|
||||||
|
- `senderId` (stable externalId) on `MessageDto` for reliable "is this mine?".
|
||||||
|
|
||||||
|
~205 tests. Keep `docs/IIOS_API_AND_SDK_GUIDE.md` current when adding endpoints.
|
||||||
|
Related: [[reference_chat_web_consumer]], [[feedback_generic_safety]].
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: reference_chat_web_consumer
|
||||||
|
description: chat-web is the reference consumer app that drives IIOS feature work
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
**chat-web** (separate repo, `~/Documents/insignia-work/chat-web`) is the reference app on IIOS —
|
||||||
|
a 1:1 + group chat UI (Vite + React + TanStack Router/Query + socket.io-client + supabase-js).
|
||||||
|
It's frontend-only; IIOS provides identity, threads, messages, realtime, reactions, mentions,
|
||||||
|
media, notifications.
|
||||||
|
|
||||||
|
Feature work usually spans **both repos**: a generic primitive/port in iios + the app UI in
|
||||||
|
chat-web. The layer split we follow: **service** owns storage/auth/governance, the **SDK layer**
|
||||||
|
(`chat-web/src/lib/*`, mirrors `@insignia/iios-kernel-client`) owns client plumbing
|
||||||
|
(e.g. `uploadMedia`/`mediaUrl`, `registerPush`), the **app** owns rendering.
|
||||||
|
|
||||||
|
chat-web uses real Supabase login (`VITE_SUPABASE_URL` + anon key in its `.env`); identity =
|
||||||
|
email. Run it with `pnpm dev`. Commit both repos with git email `maaz@insigniaconsultancy.com`.
|
||||||
|
Related: [[project_recent_features]].
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: reference_run_and_test
|
||||||
|
description: How to run the service + the test-DB isolation and the replay.spec flake workaround
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
**Infra:** Postgres in docker `iios-db` on **:5434** (db `iios`), Redis on :6379. If Docker is
|
||||||
|
down: `open -a OrbStack` then `docker start iios-db`.
|
||||||
|
|
||||||
|
**Run** (dev auth via Supabase; media + push enabled):
|
||||||
|
```
|
||||||
|
pnpm --filter @insignia/iios-service exec nest build # rebuild after backend changes
|
||||||
|
SUPABASE_URL=https://<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`.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Build context hygiene — keep the image small & builds reproducible.
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.turbo
|
||||||
|
**/.next
|
||||||
|
**/coverage
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
/tmp
|
||||||
|
scratchpad
|
||||||
|
docs/openapi.yaml
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: publish-sdks
|
||||||
|
|
||||||
|
# Publish the @insignia/iios-* SDK packages to the Gitea npm registry on a version tag.
|
||||||
|
# Requires: Gitea Actions enabled + a runner, and a repo secret GITEA_PUBLISH_TOKEN
|
||||||
|
# (a token with `write:package` scope for the `insignia` org).
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm -r build
|
||||||
|
- run: pnpm -r publish --no-git-checks
|
||||||
|
env:
|
||||||
|
# maps to ${GITEA_TOKEN} in .npmrc; private packages (service, testkit) are skipped
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_PUBLISH_TOKEN }}
|
||||||
@@ -7,7 +7,13 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
# Host-mode runner (docker talks to the host daemon). We publish a pgvector
|
||||||
|
# Postgres to localhost:5434 and connect over localhost — the service-name
|
||||||
|
# networking of `services:` containers is not wired to jobs on this runner,
|
||||||
|
# so we start the DB explicitly (same proven pattern as the mdm CI).
|
||||||
|
runs-on: dind-builder
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://iios:iios@localhost:5434/iios?schema=public
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
@@ -17,8 +23,37 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
|
# pgvector Postgres for the DB-backed *.spec.ts integration suites.
|
||||||
|
- name: Start pgvector Postgres
|
||||||
|
run: |
|
||||||
|
docker rm -f iios-ci-pg-${{ github.run_id }} >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name iios-ci-pg-${{ github.run_id }} \
|
||||||
|
-e POSTGRES_USER=iios -e POSTGRES_PASSWORD=iios -e POSTGRES_DB=iios \
|
||||||
|
-p 5434:5432 pgvector/pgvector:pg16
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker exec iios-ci-pg-${{ github.run_id }} pg_isready -U iios >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
docker exec iios-ci-pg-${{ github.run_id }} pg_isready -U iios \
|
||||||
|
|| { echo "Postgres did not become ready"; exit 1; }
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm boundary
|
- run: pnpm boundary
|
||||||
- run: pnpm -r typecheck
|
# iios-service uses Prisma: generate the client so its generated types
|
||||||
|
# (schema enums, Prisma.InputJsonValue) are available for typecheck/build.
|
||||||
|
- run: pnpm --filter @insignia/iios-service prisma:generate
|
||||||
|
# Build before typecheck: the @insignia/* workspace packages publish their
|
||||||
|
# types via built dist/*.d.ts, so consumers (iios-kernel-client, etc.)
|
||||||
|
# can only resolve them once dist exists. Typechecking first fails with
|
||||||
|
# TS2307 "Cannot find module '@insignia/iios-contracts'".
|
||||||
- run: pnpm -r build
|
- run: pnpm -r build
|
||||||
|
- run: pnpm -r typecheck
|
||||||
|
# Apply the schema to the CI Postgres before the DB-backed specs run
|
||||||
|
# (reset-db.ts TRUNCATEs existing tables; migrations must exist first).
|
||||||
|
- run: pnpm --filter @insignia/iios-service exec prisma migrate deploy
|
||||||
- run: pnpm test
|
- run: pnpm test
|
||||||
|
|
||||||
|
- name: Stop Postgres
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f iios-ci-pg-${{ github.run_id }} >/dev/null 2>&1 || true
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Build iios-service and deploy it via k8s-pods (ArgoCD). Runs on the dind-builder
|
||||||
|
# host-mode runner (mounts the host docker socket). On every push to main that
|
||||||
|
# touches the service, it builds+pushes the image (SHA-tagged) and bumps the tag in
|
||||||
|
# platform-engineering/k8s-pods -> ArgoCD rolls it.
|
||||||
|
name: Deploy iios-service
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "packages/iios-service/**"
|
||||||
|
- "packages/iios-adapter-sdk/**"
|
||||||
|
- "packages/iios-contracts/**"
|
||||||
|
- "pnpm-lock.yaml"
|
||||||
|
- ".github/workflows/deploy-iios-service.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-deploy:
|
||||||
|
runs-on: dind-builder
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Gitea container registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lynkedup.cloud -u mcp-bot --password-stdin
|
||||||
|
|
||||||
|
- name: Ensure docker buildx (the Dockerfile uses BuildKit --mount)
|
||||||
|
run: |
|
||||||
|
if ! docker buildx version >/dev/null 2>&1; then
|
||||||
|
mkdir -p ~/.docker/cli-plugins
|
||||||
|
curl -sSL https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx
|
||||||
|
chmod +x ~/.docker/cli-plugins/docker-buildx
|
||||||
|
fi
|
||||||
|
docker buildx create --use --name iios-ci 2>/dev/null || docker buildx use iios-ci
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
run: |
|
||||||
|
IMG=git.lynkedup.cloud/platform-engineering/iios-service
|
||||||
|
TAG=$(git rev-parse --short HEAD)
|
||||||
|
echo "TAG=$TAG" >> "$GITHUB_ENV"
|
||||||
|
docker buildx build --builder iios-ci --push \
|
||||||
|
-t "$IMG:$TAG" -f packages/iios-service/Dockerfile .
|
||||||
|
|
||||||
|
- name: Bump k8s-pods image tag (ArgoCD deploys)
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 -b main \
|
||||||
|
"https://mcp-bot:${{ secrets.K8S_PODS_TOKEN }}@git.lynkedup.cloud/platform-engineering/k8s-pods.git" /tmp/kp
|
||||||
|
cd /tmp/kp
|
||||||
|
sed -i "s#image: git.lynkedup.cloud/platform-engineering/iios-service:.*#image: git.lynkedup.cloud/platform-engineering/iios-service:${TAG}#" services/iios/deployment.yaml
|
||||||
|
git config user.name "iios-ci"; git config user.email "ci@lynkedup.cloud"
|
||||||
|
if git diff --quiet; then echo "image tag unchanged"; exit 0; fi
|
||||||
|
git commit -am "ci(iios): deploy iios-service ${TAG}"
|
||||||
|
git push origin main
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# @insignia SDK packages publish to / install from the Gitea package registry.
|
||||||
|
# Auth comes from the GITEA_TOKEN env var (never commit the token itself).
|
||||||
|
# publish → token needs the `write:package` scope
|
||||||
|
# install → token needs `read:package`
|
||||||
|
@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/
|
||||||
|
//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Publishing the IIOS SDKs (Gitea package registry)
|
||||||
|
|
||||||
|
The `@insignia/iios-*` **frontend SDKs** publish to our self-hosted Gitea npm registry:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://git.lynkedup.cloud/api/packages/insignia/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Published (public in the registry):** `iios-contracts`, `iios-kernel-client`, `iios-adapter-sdk`,
|
||||||
|
and the React hook packages `iios-message-web`, `iios-inbox-web`, `iios-support-web`,
|
||||||
|
`iios-ai-web`, `iios-community-web`, `iios-meeting-web`.
|
||||||
|
**Kept private (never published):** `iios-service` (the deployed backend) and `iios-testkit` (dev fakes).
|
||||||
|
|
||||||
|
> be-crm does **not** consume these — it talks to IIOS over REST. The SDKs are a **frontend** concern
|
||||||
|
> (chat-web, the CRM support UI, mobile).
|
||||||
|
|
||||||
|
## One-time: get a token
|
||||||
|
Gitea → **Settings → Applications → Generate Token**:
|
||||||
|
- to **publish**: scope `write:package`
|
||||||
|
- to **install** (private packages): scope `read:package`
|
||||||
|
|
||||||
|
Export it (never commit it):
|
||||||
|
```bash
|
||||||
|
export GITEA_TOKEN=<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.
|
||||||
@@ -16,5 +16,18 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
# Realtime fan-out across replicas (socket.io Redis adapter). Point the service at it
|
||||||
|
# with REDIS_URL=redis://localhost:6379; without REDIS_URL the in-memory adapter is used.
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: iios-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
iios_postgres_data:
|
iios_postgres_data:
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Deploying IIOS
|
||||||
|
|
||||||
|
IIOS deploys as **one runtime service** (`@insignia/iios-service`) plus **one datastore**
|
||||||
|
(Postgres). Everything else in the repo is a **library** (SDKs) or a **dev demo**, not a
|
||||||
|
deployed service.
|
||||||
|
|
||||||
|
| Artifact | Type | How it ships |
|
||||||
|
|---|---|---|
|
||||||
|
| `@insignia/iios-service` | **Runtime service** | Docker image (this guide) |
|
||||||
|
| `iios-contracts`, `iios-adapter-sdk`, `iios-kernel-client`, `iios-testkit` | Library | published to npm; imported by host apps |
|
||||||
|
| `iios-message-web`, `iios-inbox-web`, `iios-support-web`, `iios-ai-web`, `iios-meeting-web`, `iios-community-web` | Web SDK (library) | published to npm; **bundled into host-app frontends** (optionally one CDN widget) |
|
||||||
|
| `apps/*` (message-demo, ai-studio, route-admin…) | Dev demo | not deployed |
|
||||||
|
|
||||||
|
The service is a **modular monolith**: a single NestJS process serves the HTTP API, the
|
||||||
|
WebSocket (socket.io) gateway, the outbox relay, and the projectors together.
|
||||||
|
|
||||||
|
## Build the image
|
||||||
|
|
||||||
|
Build context is the **monorepo root** (the image needs the workspace + lockfile):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f packages/iios-service/Dockerfile -t iios-service:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
The multi-stage build compiles the service + its workspace deps, generates the Prisma
|
||||||
|
client, and prunes to a self-contained prod bundle (`pnpm deploy --legacy`). The runtime
|
||||||
|
image runs as the non-root `node` user and starts via `docker-entrypoint.sh`, which runs
|
||||||
|
`prisma migrate deploy` then `node dist/main.js`.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 3200:3200 \
|
||||||
|
-e DATABASE_URL='postgresql://USER:PASS@HOST:5432/iios?schema=public' \
|
||||||
|
-e APP_SECRETS='{"portal-demo":"<secret>"}' \
|
||||||
|
-e IIOS_DEV_TOKENS=0 \
|
||||||
|
iios-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Health check: `GET /health` → `200`. Metrics/ops: `GET /metrics` (relay lag, projection
|
||||||
|
cursors, retention snapshot counts).
|
||||||
|
|
||||||
|
## Configuration & secrets contract
|
||||||
|
|
||||||
|
Every knob is an environment variable — see [`packages/iios-service/.env.example`](../packages/iios-service/.env.example)
|
||||||
|
for the full, commented list. Highlights:
|
||||||
|
|
||||||
|
- **Secrets** (inject from a vault, never bake into the image): `DATABASE_URL`,
|
||||||
|
`APP_SECRETS` (per-app HS256 JWT keys), `ADAPTER_SECRETS` (webhook HMAC keys),
|
||||||
|
`MEDIA_SECRET` (signs presigned media upload/download URLs).
|
||||||
|
- **Auth (real IdP):** set `SUPABASE_URL` or `AUTH_ISSUERS` to trust real OIDC access
|
||||||
|
tokens, verified against the issuer's public **JWKS** (ES256) — **no secret to store**.
|
||||||
|
`AUTH_ISSUERS` is a JSON registry `[{url, appId, orgId?}]` routing many issuers → isolated
|
||||||
|
app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests.
|
||||||
|
- **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for
|
||||||
|
prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change.
|
||||||
|
- **Notifications (Web Push):** set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT`
|
||||||
|
to enable push (unset → the engine no-ops). ⚠️ Presence (the "are you viewing this thread?"
|
||||||
|
gate) is **in-memory / single-instance** today — with N>1 replicas, back `PresenceService`
|
||||||
|
with Redis so the projector on one replica sees focus from another.
|
||||||
|
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*`
|
||||||
|
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
|
||||||
|
single most important prod-hardening flag.
|
||||||
|
- **`IIOS_CELL_ID`** tags which physical cell this instance serves — the hook for splitting
|
||||||
|
noisy/regulated tenants into isolated cells later, with no code change.
|
||||||
|
- **Worker timers** (`IIOS_RELAY_INTERVAL_MS`, `IIOS_RETENTION_SWEEP_INTERVAL_MS`) — see
|
||||||
|
scaling notes below.
|
||||||
|
|
||||||
|
## Topology
|
||||||
|
|
||||||
|
```
|
||||||
|
host apps ──HTTPS/WSS──► [ LB / ingress ] ──► iios-service ×N ──► Postgres (pgvector)
|
||||||
|
(embed SDKs) (WS + sticky) (API+relay+ │
|
||||||
|
projectors) (+ Redis, + OPA/CMP/MDM
|
||||||
|
as external ports — later)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Postgres** — managed (RDS / Cloud SQL / Neon). One logical DB, tenant-isolated by scope.
|
||||||
|
- **Redis** — **not required for a single instance.** Set **`REDIS_URL`** when you run
|
||||||
|
**multiple replicas with live chat**: the built-in [socket.io Redis adapter](../packages/iios-service/src/realtime/redis-io.adapter.ts)
|
||||||
|
fans room emits across all instances, so a client on replica B sees messages emitted by
|
||||||
|
replica A. Without `REDIS_URL` the in-memory adapter is used (single instance). Verified by
|
||||||
|
`scripts/smoke-realtime-cluster.mjs` (two instances + one Redis; cross-instance delivery
|
||||||
|
fails without Redis, passes with it).
|
||||||
|
> Delivery is **at-least-once** across instances — with N>1 the inline send-emit and the
|
||||||
|
> relay's outbox-bridge emit can both fire for the same message, so clients should **dedupe
|
||||||
|
> by message `id`** (the payload always carries one).
|
||||||
|
- **Platform ports** (OPA policy, CMP consent, MDM, CRRE, SAS) — today in-process permissive
|
||||||
|
stubs (`LocalDevPorts`). For production, point these at real external services; the service
|
||||||
|
already calls them **fail-closed**. The **session** port already verifies real OIDC tokens
|
||||||
|
(Supabase/JWKS) when configured.
|
||||||
|
- **Media storage** (`StoragePort`) — dev = **local disk** (`MEDIA_DIR`). ⚠️ Local disk is
|
||||||
|
**single-instance and non-durable**; with N>1 replicas or for persistence, bind it to shared
|
||||||
|
**object storage** (S3/R2/Supabase Storage). Bytes always flow **client ↔ storage directly**
|
||||||
|
via presigned URLs — they never transit the service — so this scales independently.
|
||||||
|
|
||||||
|
## Scaling & release strategy
|
||||||
|
|
||||||
|
**Horizontal scaling is safe** because the event core was hardened for it:
|
||||||
|
|
||||||
|
- The outbox relay claims rows with `FOR UPDATE SKIP LOCKED` → each event is relayed by
|
||||||
|
exactly one replica; its co-located projectors process it once (idempotent `claim()` +
|
||||||
|
the projection-cursor + idempotency-command ledgers give exactly-once effects).
|
||||||
|
- The **DLQ + replay** path means a bad deploy loses nothing — fix and replay.
|
||||||
|
|
||||||
|
**Rolling / blue-green deploys:**
|
||||||
|
|
||||||
|
1. **Migrations:** run `prisma migrate deploy` as a **one-off pre-deploy job**, then set
|
||||||
|
`IIOS_SKIP_MIGRATE=1` on the replicas so N pods don't race the migration. (For a single
|
||||||
|
instance, the default boot-time migration is fine.)
|
||||||
|
2. Use **expand-contract (backward-compatible) schema changes** so old and new pods can run
|
||||||
|
against the same schema during the roll — this is the prerequisite for zero-downtime.
|
||||||
|
3. Roll replicas with a `/health` readiness gate; drain WebSocket connections on `SIGTERM`.
|
||||||
|
4. **WebSocket ingress** needs sticky sessions (and the Redis socket.io adapter once N>1).
|
||||||
|
|
||||||
|
## Not yet built (prod-readiness gaps)
|
||||||
|
|
||||||
|
- **CI/CD pipeline** to build/push this image and run migrations.
|
||||||
|
- **Real platform-port services** (OPA/CMP/MDM) — today permissive stubs.
|
||||||
|
- **Secrets manager** integration (currently plain env).
|
||||||
|
- A **schema-compatibility gate** in CI to enforce expand-contract migrations.
|
||||||
@@ -58,6 +58,8 @@ Use it on every call: `-H "authorization: Bearer <token>"`.
|
|||||||
- **Different `orgId` = different tenant.** Mint two tokens with `org_A` / `org_B` to test isolation.
|
- **Different `orgId` = different tenant.** Mint two tokens with `org_A` / `org_B` to test isolation.
|
||||||
- Token TTL: 2h.
|
- Token TTL: 2h.
|
||||||
|
|
||||||
|
**Real IdP tokens (production path).** Beyond the dev HS256 tokens, `SessionVerifier` also verifies **real OIDC access tokens** (ES256) against a trusted issuer's public **JWKS** — set `SUPABASE_URL` (single issuer) or `AUTH_ISSUERS` (a registry mapping many issuers → app scopes). The verifier routes a token by its `iss` claim, so two projects/IdPs map to two isolated `appId` scopes on one service, and app A's tokens can't reach app B. No shared secret needed. The dev token path stays for tests/local.
|
||||||
|
|
||||||
### Error responses (what QA will see)
|
### Error responses (what QA will see)
|
||||||
| Status | When |
|
| Status | When |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -101,12 +103,24 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
|
|||||||
### 5.3 Threads & Messaging (native chat)
|
### 5.3 Threads & Messaging (native chat)
|
||||||
| Method | Path | Body / Headers | Returns |
|
| Method | Path | Body / Headers | Returns |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| GET | `/v1/threads` | — | `ThreadSummary[]` — your threads (members, unread, last message, `membership`) |
|
||||||
|
| POST | `/v1/threads` | `{membership?, creatorRole?, subject?}` | `{threadId, status, history}` (201) — `membership`/`creatorRole`/`subject` are **opaque app attributes** the kernel stores but never interprets |
|
||||||
|
| POST | `/v1/threads/:id/participants` | `{userId, role?}` | `{threadId, participantCount}` (201) — **governed** (DM cap / group-admin via OPA) |
|
||||||
| GET | `/v1/threads/:id/messages` | — | `{threadId, messages[]}` |
|
| GET | `/v1/threads/:id/messages` | — | `{threadId, messages[]}` |
|
||||||
| POST | `/v1/threads/:id/messages` | `{content, contentRef?}` + `idempotency-key?` header | `Message` (201) |
|
| POST | `/v1/threads/:id/messages` | `{content, contentRef?, mimeType?, sizeBytes?, checksumSha256?, parentInteractionId?}` + `idempotency-key?` header | `Message` (201) |
|
||||||
|
| GET | `/v1/threads/my-annotations` | `?type=save` | `{message, threadId, threadSubject}[]` — messages you annotated (e.g. saved) |
|
||||||
|
|
||||||
**Realtime (Socket.IO, namespace `/message`):** connect, then emit client→server events:
|
A **`Message`** carries `{id, threadId, senderId, senderName, content, attachment?, parentInteractionId?, annotations[], traceId, createdAt}`. `senderId` is the sender's stable externalId (email/username) — the reliable "is this mine?" check. `attachment` = `{contentRef, mimeType, sizeBytes, kind}` (§5.10). `annotations` = `[{type, value, users[]}]` — the reaction/pin/save aggregate.
|
||||||
- `open_thread` `{threadId?|otherUserId}`, `send_message` `{threadId, content, idempotencyKey?}`, `read` `{threadId}`, `delivered` `{threadId}`.
|
|
||||||
- Server emits to the thread room: `message` (a Message) and `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`.
|
**Realtime (Socket.IO, namespace `/message`).** Token verified on connect (`auth.token`), then emit client→server:
|
||||||
|
- `open_thread` `{threadId?, membership?, creatorRole?, subject?}` — opens, or **creates** when no id; a **governed join** for membership threads. Returns `{threadId, status, history}` or `{error}` (acked, never a throw — clients don't hang).
|
||||||
|
- `send_message` `{threadId, content, contentRef?, mimeType?, sizeBytes?, parentInteractionId?, mentions?}` — `mentions` is an **opaque userId notify-list** (the app parses `@`; the kernel never does).
|
||||||
|
- `add_participant` `{threadId, userId, role?}` · `read` `{threadId, interactionId}` · `delivered` `{…}` · `typing` `{threadId}`.
|
||||||
|
- `annotate` `{threadId, interactionId, type, value}` — toggle a **generic annotation** (chat app uses `type: reaction|pin|save`; `value` = emoji, or empty).
|
||||||
|
|
||||||
|
Server → thread room: `message` (a Message), `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`, `typing` `{threadId, userId}`, `annotation` `{interactionId, type, value, op: add|remove, users[], userId}`.
|
||||||
|
|
||||||
|
**Governance & primitives (all fail-closed via OPA).** Membership (`iios.thread.participant.add`, `iios.thread.join`), annotating (`iios.interaction.annotate` — participant-only), and send all gate on policy. **Reactions/pins/saves are one generic primitive** — an *interaction annotation* the kernel stores + aggregates as opaque `(type, value)` but never interprets (the same primitive backs pins/saves/flags/tags). **Mentions → Inbox:** `mentions[]` flows into the message event; the inbox projector fans out a `MENTION` inbox item to each mentioned participant (never the sender); reading the thread resolves it.
|
||||||
|
|
||||||
### 5.4 Inbox (work queue)
|
### 5.4 Inbox (work queue)
|
||||||
| Method | Path | Query / Body | Returns |
|
| Method | Path | Query / Body | Returns |
|
||||||
@@ -114,6 +128,8 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
|
|||||||
| GET | `/v1/inbox/items` | `?state=OPEN|SNOOZED|DONE|ARCHIVED|CANCELLED|STALE` | `InboxItem[]` |
|
| GET | `/v1/inbox/items` | `?state=OPEN|SNOOZED|DONE|ARCHIVED|CANCELLED|STALE` | `InboxItem[]` |
|
||||||
| PATCH | `/v1/inbox/items/:id` | `{state, reason?}` | `InboxItem` |
|
| PATCH | `/v1/inbox/items/:id` | `{state, reason?}` | `InboxItem` |
|
||||||
|
|
||||||
|
Item **kinds** include `NEEDS_REPLY` (unreplied thread activity, one per owner+thread) and `MENTION` (someone @-mentioned you; `priority: HIGH`, one per source message). Reading the thread resolves both to `DONE`.
|
||||||
|
|
||||||
### 5.5 Support (tickets, escalation, callbacks, queues, agents)
|
### 5.5 Support (tickets, escalation, callbacks, queues, agents)
|
||||||
| Method | Path | Body / Query | Returns |
|
| Method | Path | Body / Query | Returns |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -179,6 +195,100 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
|
|||||||
|
|
||||||
`ScheduleMeetingDto`: `{meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}`. `meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}`; consent `status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}`. **Transcript/summary is `BLOCKED` until every attendee has `GRANTED` consent.**
|
`ScheduleMeetingDto`: `{meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}`. `meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}`; consent `status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}`. **Transcript/summary is `BLOCKED` until every attendee has `GRANTED` consent.**
|
||||||
|
|
||||||
|
### 5.10 Media (attachments)
|
||||||
|
|
||||||
|
The DB stores a **reference**; the bytes live behind a **storage port** (dev = local disk `MEDIA_DIR`; prod = swap to S3/R2/Supabase). Bytes go **client ↔ storage directly** via short-lived signed URLs — they never pass through the kernel.
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body | Returns |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| POST | `/v1/media/presign-upload` | Bearer | `{mime, sizeBytes}` | `{objectKey, uploadUrl}` — **OPA-gated** (size/type) |
|
||||||
|
| PUT | `/v1/media/upload/:token` | token in URL | raw bytes | `{objectKey, sizeBytes, checksumSha256}` |
|
||||||
|
| POST | `/v1/media/presign-download` | Bearer | `{contentRef, mime?}` | `{url}` — **tenant-fenced**, signed 1h |
|
||||||
|
| GET | `/v1/media/blob/:token` | token in URL | — | streams the bytes with their `Content-Type` |
|
||||||
|
|
||||||
|
**Attaching to a message:** `send`/`send_message` accept `{contentRef, mimeType, sizeBytes, checksumSha256?}`. The stored `Message` then carries `attachment: { contentRef, mimeType, sizeBytes, kind }` where `kind ∈ {image, video, audio, file}` (a friendly view of the generic `MEDIA_REF`/`VOICE_REF`/`FILE_REF` part the kernel writes).
|
||||||
|
|
||||||
|
**Governance (fail-closed, built in):**
|
||||||
|
- **Upload policy** `iios.media.upload` — **≤ 25 MB** and an allowlist (`image/*`, `video/*`, `audio/*`, `application/pdf`, common Office/text/zip). A violation → **403** *before* any bytes are sent.
|
||||||
|
- **Signed tokens** (HS256, `MEDIA_SECRET`) — upload URL lives **5 min**, download URL **1 h**; a tampered/expired token → 403.
|
||||||
|
- **Tenant fence** — object keys are prefixed with the caller's `scopeId`; a download for an object outside your scope → 403.
|
||||||
|
|
||||||
|
**The flow (with governance):**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant C as Client (SDK uploadMedia)
|
||||||
|
participant API as IIOS Media API
|
||||||
|
participant OPA as OPA policy
|
||||||
|
participant ST as StoragePort (disk → S3)
|
||||||
|
participant K as Message kernel
|
||||||
|
|
||||||
|
C->>API: POST /v1/media/presign-upload {mime, sizeBytes}
|
||||||
|
API->>OPA: decide iios.media.upload (≤25MB? type allowed?)
|
||||||
|
alt denied
|
||||||
|
OPA-->>C: 403 (too large / type not allowed)
|
||||||
|
else allowed
|
||||||
|
API-->>C: { objectKey, uploadUrl } (signed, 5-min)
|
||||||
|
C->>ST: PUT bytes → uploadUrl (direct, not via kernel)
|
||||||
|
ST-->>C: { sizeBytes, checksumSha256 }
|
||||||
|
C->>K: send_message { contentRef, mime, size }
|
||||||
|
K-->>C: Message { attachment }
|
||||||
|
C->>API: POST /v1/media/presign-download { contentRef }
|
||||||
|
API->>API: tenant-fence (objectKey in my scope?)
|
||||||
|
API-->>C: { url } (signed, 1-hour)
|
||||||
|
C->>ST: GET url → bytes (stream, inline render / download)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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
|
||||||
@@ -191,7 +301,10 @@ import { RestClient } from '@insignia/iios-kernel-client';
|
|||||||
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });
|
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });
|
||||||
```
|
```
|
||||||
Methods (all return typed promises):
|
Methods (all return typed promises):
|
||||||
- **Messaging:** `getThreadMessages(threadId)`, `sendMessage(threadId, content, {contentRef?, 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.
|
||||||
|
- **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)`
|
||||||
@@ -269,7 +382,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` (114 tests). Import-boundary check: `pnpm boundary`.
|
Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -289,7 +402,12 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `3200` | HTTP port |
|
| `PORT` | `3200` | HTTP port |
|
||||||
| `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres |
|
| `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres |
|
||||||
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app JWT secrets (`{appId: secret}`) |
|
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app HS256 JWT secrets (`{appId: secret}`) |
|
||||||
|
| `SUPABASE_URL` / `AUTH_ISSUERS` | — | trusted OIDC issuer(s) — verify real IdP tokens (ES256) via JWKS. `AUTH_ISSUERS` is a JSON array `[{url, appId, orgId?}]` mapping issuers → app scopes; `SUPABASE_URL` is the single-issuer shorthand |
|
||||||
|
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
|
||||||
|
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
|
||||||
|
| `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs |
|
||||||
|
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | — | Web Push (notifications). Unset → push disabled (engine no-ops). Generate once: `node -e "console.log(require('web-push').generateVAPIDKeys())"` |
|
||||||
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
|
| `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 |
|
||||||
@@ -305,7 +423,9 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
|
|||||||
|
|
||||||
- **Interaction kind:** MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
|
- **Interaction kind:** MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
|
||||||
- **Channel types:** WEBHOOK, EMAIL, WHATSAPP, PORTAL
|
- **Channel types:** WEBHOOK, EMAIL, WHATSAPP, PORTAL
|
||||||
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
|
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, **MENTION**, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
|
||||||
|
- **Message part kind:** TEXT, HTML, MARKDOWN, MEDIA_REF, FILE_REF, VOICE_REF, LOCATION, STRUCTURED_JSON · **Attachment kind (DTO):** image, video, audio, file
|
||||||
|
- **Interaction annotation (app-level, opaque to the kernel):** `type` = reaction | pin | save … ; `value` = emoji (reactions) or empty
|
||||||
- **Ticket state:** NEW, OPEN, PENDING, RESOLVED, CLOSED · **priority:** LOW, NORMAL, HIGH, URGENT
|
- **Ticket state:** NEW, OPEN, PENDING, RESOLVED, CLOSED · **priority:** LOW, NORMAL, HIGH, URGENT
|
||||||
- **Route mode:** MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · **output format:** FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · **decision:** ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
|
- **Route mode:** MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · **output format:** FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · **decision:** ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
|
||||||
- **AI job:** CLASSIFY, SUMMARIZE, EXTRACT · **artifact:** CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · **artifact status:** PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
|
- **AI job:** CLASSIFY, SUMMARIZE, EXTRACT · **artifact:** CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · **artifact status:** PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Today the external world (real WhatsApp, real AI models, real calendars, the rea
|
|||||||
| **P8** | **Calendar & meetings** — schedule, attendee consent, transcript, summary, action items | `iios-meeting-web` | Support/scheduling; callback→meeting | meeting-studio (5178) |
|
| **P8** | **Calendar & meetings** — schedule, attendee consent, transcript, summary, action items | `iios-meeting-web` | Support/scheduling; callback→meeting | meeting-studio (5178) |
|
||||||
| **P9** | *(next)* **Production hardening** — real providers, real policy/consent, scale, retention | — | Ops / platform | — |
|
| **P9** | *(next)* **Production hardening** — real providers, real policy/consent, scale, retention | — | Ops / platform | — |
|
||||||
|
|
||||||
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (46 tables across 9 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
|
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (53 tables across 19 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -159,7 +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:** 95 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
|
**Proof it works:** 205 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
|
||||||
|
|
||||||
|
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,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, 46 Postgres tables across 9 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar). Boundary-enforced dependency law; 95 passing tests; 7 end-to-end smoke scripts.*
|
*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.*
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# ADR-0001: `support-service` → IIOS naming migration
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-03
|
||||||
|
- **Deciders:** IIOS core
|
||||||
|
- **Context tags:** naming, packaging, migration, KG-16
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
IIOS (Insignia Interaction OS) is the greenfield realization of a vision first
|
||||||
|
prototyped in the standalone **`support-service`** app. That prototype already stated the
|
||||||
|
core idea — *"Everything is a message: a generic Message core (conversation / message /
|
||||||
|
channel / user) is the foundation; Support (ticket / agent / callback) is a thin
|
||||||
|
specialization on top"* — but it did so with **support-centric naming** and as a
|
||||||
|
single-purpose support desk.
|
||||||
|
|
||||||
|
IIOS generalizes that same idea to an **interaction OS** of much broader scope
|
||||||
|
(messaging, inbox, routing, AI enrichment, calendar, plus support as *one*
|
||||||
|
specialization among many). During P0–P9 it landed a clean, generic vocabulary:
|
||||||
|
an `Iios`-prefixed persistence layer and `@insignia/iios-*` packages.
|
||||||
|
|
||||||
|
Two problems make an explicit decision necessary:
|
||||||
|
|
||||||
|
1. **Naming drift.** The prototype and IIOS use different words for the same concepts
|
||||||
|
(`Conversation` vs `IiosThread`, `Message` vs `IiosInteraction` + `IiosMessagePart`,
|
||||||
|
`User` vs `IiosSourceHandle` + `IiosActorRef`). Without a canonical mapping, contributors
|
||||||
|
coming from the prototype reintroduce support-centric names, and host apps don't know
|
||||||
|
which SDK is authoritative.
|
||||||
|
2. **KG-16 — "support wedge evidence overgeneralized."** The critics' analysis flags the
|
||||||
|
risk of treating the `support-service` architecture as proof of *all* IIOS capabilities.
|
||||||
|
Support is a **specialization**, not the core; the naming must make that structural
|
||||||
|
(support names are namespaced *under* the generic core, never the core itself).
|
||||||
|
|
||||||
|
An unresolved sub-question also blocks packaging: the workspace scope was historically
|
||||||
|
ambiguous between `@insignia/*` and `@lynkeduppro/*`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**1. Canonical vocabulary is interaction-centric and `Iios`-namespaced.**
|
||||||
|
The generic core is the noun set already shipped in `@insignia/iios-service`:
|
||||||
|
`IiosScope`, `IiosSourceHandle`/`IiosActorRef`, `IiosChannel`, `IiosThread`,
|
||||||
|
`IiosInteraction`/`IiosMessagePart`. **Support is a specialization layered on top** and is
|
||||||
|
always namespaced as such (`IiosSupport*`, `IiosTicket*`, `IiosCallbackRequest`) — never
|
||||||
|
promoted to a core concept. "Conversation" and support-centric "Message-as-ticket" framing
|
||||||
|
are retired.
|
||||||
|
|
||||||
|
**2. Package scope is `@insignia/iios-*`.** This resolves the `@insignia` vs `@lynkeduppro`
|
||||||
|
ambiguity in favour of `@insignia`. All IIOS packages already follow `@insignia/iios-<name>`;
|
||||||
|
that is now the standard.
|
||||||
|
|
||||||
|
**3. Migration is greenfield-and-deprecate (strangler), not rename-in-place.**
|
||||||
|
`support-service` is **frozen legacy** — we do not rename its models or ship a new version of
|
||||||
|
it. New work happens in IIOS; host apps cut over to the `@insignia/iios-*` SDKs feature-by-
|
||||||
|
feature. `support-service` is retired once every host app it serves has migrated.
|
||||||
|
|
||||||
|
### Canonical naming map (legacy → IIOS)
|
||||||
|
|
||||||
|
| `support-service` (legacy) | IIOS (canonical) | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `Channel` | `IiosChannel` | generic ingress/egress channel |
|
||||||
|
| `User` | `IiosSourceHandle` + `IiosActorRef` | identity split: external handle vs resolved actor |
|
||||||
|
| `Conversation` | `IiosThread` | "conversation" retired; thread is generic |
|
||||||
|
| `Message` | `IiosInteraction` + `IiosMessagePart` | envelope vs content parts |
|
||||||
|
| `Attachment` | `IiosMessagePart` (`kind=MEDIA`, `contentRef`) | attachments are just parts |
|
||||||
|
| `Team` / `TeamMember` | `IiosSupportQueue` / `IiosSupportTeamMember` | **support specialization**, namespaced |
|
||||||
|
| `ConversationAssignment` | support assignment (`IiosSupport*`) | specialization, not core |
|
||||||
|
| `Ticket` / `TicketConversation` | `IiosTicket` / `IiosTicketThreadLink` | ticket links to generic threads |
|
||||||
|
| `CallbackRequest` | `IiosCallbackRequest` | |
|
||||||
|
| `Meeting` | `IiosMeeting` (+ P8 calendar models) | promoted to first-class in IIOS |
|
||||||
|
| `Notification` | `notification_outbox` (planned) | not yet built in IIOS |
|
||||||
|
| `ProcessedCommand` | `IiosProcessedEvent` + `IiosIdempotencyCommand` | consumer ledger vs command ledger |
|
||||||
|
| `KnowledgeArticle` | (deferred — AI/RAG) | out of current scope |
|
||||||
|
|
||||||
|
### Rules going forward
|
||||||
|
|
||||||
|
- **Every persisted model is `Iios`-prefixed.** No un-prefixed domain models.
|
||||||
|
- **Support (and any future vertical) is namespaced under the generic core**, never the other
|
||||||
|
way round. If a name reads as "support-only," it must carry the `IiosSupport*`/`IiosTicket*`
|
||||||
|
prefix and depend on the core — the core never depends on it (enforced by the import-boundary
|
||||||
|
check).
|
||||||
|
- **New packages are `@insignia/iios-<name>`.**
|
||||||
|
- **Legacy references** in prose cite `support-service` explicitly as *legacy*, with a pointer
|
||||||
|
to this ADR.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
- One authoritative vocabulary; contributors from the prototype have a lookup table.
|
||||||
|
- KG-16 is structurally addressed: support cannot masquerade as the core because its names are
|
||||||
|
subordinate and the boundary check enforces the dependency direction.
|
||||||
|
- Packaging is unblocked (`@insignia/iios-*` is canonical).
|
||||||
|
- No risky in-place rename of a running legacy service; cutover is incremental per host app.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
- Two systems coexist during the strangler window; the legacy `support-service` must be kept
|
||||||
|
running (but frozen) until host apps migrate.
|
||||||
|
- The legacy↔IIOS mapping must be consulted when porting features; this ADR is that map.
|
||||||
|
- `Notification` / `KnowledgeArticle` have no IIOS home yet — tracked as follow-ups, not blockers.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Rename `support-service` in place** to the IIOS vocabulary. Rejected: high-risk migration
|
||||||
|
of a live service + DB for a system we intend to retire anyway; no benefit over greenfield.
|
||||||
|
- **Keep both vocabularies and bridge with adapters.** Rejected: perpetuates naming drift and
|
||||||
|
the KG-16 confusion indefinitely.
|
||||||
|
- **`@lynkeduppro/*` scope.** Rejected: all IIOS packages already ship under `@insignia`;
|
||||||
|
switching scopes now is churn with no upside. (A future rebrand can supersede this ADR.)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Architecture Decision Records (ADRs)
|
||||||
|
|
||||||
|
This directory records the significant, hard-to-reverse decisions behind IIOS —
|
||||||
|
the *why*, not just the *what*. Code shows what we did; ADRs explain the choice so a
|
||||||
|
future contributor doesn't relitigate it or unknowingly violate it.
|
||||||
|
|
||||||
|
## Convention
|
||||||
|
|
||||||
|
- One file per decision: `NNNN-kebab-title.md` (zero-padded, monotonically increasing).
|
||||||
|
- Each ADR has: **Status**, **Context**, **Decision**, **Consequences**, and (where useful)
|
||||||
|
**Alternatives considered**.
|
||||||
|
- Status is one of `Proposed` · `Accepted` · `Superseded by ADR-XXXX` · `Deprecated`.
|
||||||
|
- ADRs are immutable once `Accepted`. To change a decision, write a new ADR that supersedes it.
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
| ADR | Title | Status |
|
||||||
|
|-----|-------|--------|
|
||||||
|
| [0001](0001-support-service-to-iios-naming-migration.md) | `support-service` → IIOS naming migration | Accepted |
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Email Attachments — Implementation Plan
|
||||||
|
|
||||||
|
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · Extends the SMTP provider + mail plumbing.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let an external email carry attachments (e.g. an invoice PDF). The kernel already STORES attachments
|
||||||
|
as message parts (`contentRef` + mime + size); the media `StoragePort` holds the bytes. The one gap is
|
||||||
|
the **email envelope** — `SmtpProvider` (and the payload) don't carry attachments. Close that.
|
||||||
|
|
||||||
|
## Design (locked)
|
||||||
|
|
||||||
|
- **Payload carries REFS, not bytes:** the EMAIL payload gains
|
||||||
|
`attachments?: [{ filename, contentRef, mimeType? }]`. Refs keep the outbound-command ledger small
|
||||||
|
(and keep T8's PII redaction cheap) — bytes are fetched at send time.
|
||||||
|
- **Resolver seam:** `AttachmentResolver = (contentRef) => Promise<{ filename?; content: Buffer; contentType? } | null>`.
|
||||||
|
`SmtpProvider` takes an optional resolver; on send it resolves each ref and attaches
|
||||||
|
(nodemailer `attachments: [{ filename, content, contentType }]`).
|
||||||
|
- **Fail closed on a missing attachment:** if a declared attachment can't be resolved, the send is
|
||||||
|
`FAILED` (so it retries) — NOT sent without it. A receipt/invoice missing its file is worse than a
|
||||||
|
retry. (No resolver wired at all + attachments present → also FAILED, same reasoning.)
|
||||||
|
- **Wiring:** `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`;
|
||||||
|
`CapabilityProviderRegistry` `@Optional() @Inject(STORAGE_PORT)` → builds the resolver from
|
||||||
|
`storage.get(contentRef)` → passes it to `SmtpProvider`. `@Optional` so contexts without storage
|
||||||
|
still boot (attachments simply can't resolve → FAILED if any are declared).
|
||||||
|
- **Scope:** SMTP path only. The HTTP relay `EmailProvider` attachment support is a separate follow-up
|
||||||
|
(it would base64 the bytes into the relay POST). INTERNAL/mirror attachments already work via message
|
||||||
|
parts and are not this plan.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
src/capability/smtp.provider.ts # attachments in EmailPayload + resolve+attach in send()
|
||||||
|
src/capability/smtp.provider.spec.ts # attach resolved bytes; missing → FAILED
|
||||||
|
src/capability/capability.registry.ts # inject STORAGE_PORT → resolver → SmtpProvider
|
||||||
|
src/capability/capability.module.ts # import MediaModule
|
||||||
|
src/media/media.module.ts # export STORAGE_PORT
|
||||||
|
src/templates/templated-sender.ts # accept + pass `attachments`
|
||||||
|
src/mail/mail.service.ts # accept + pass `attachments` (external send)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tasks (TDD)
|
||||||
|
|
||||||
|
**T1 — SmtpProvider attaches / fails closed**
|
||||||
|
- Inject a stub resolver. Tests: two refs → nodemailer `attachments` has both (filename + content +
|
||||||
|
contentType); a ref the resolver returns `null` for → outcome `FAILED`, nothing sent; no attachments
|
||||||
|
in payload → unchanged (plain send still SENT).
|
||||||
|
|
||||||
|
**T2 — registry wires the resolver from STORAGE_PORT**
|
||||||
|
- `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; registry injects it
|
||||||
|
`@Optional`. Test: with a fake storage bound, `forChannel('EMAIL')` SMTP resolves an attachment;
|
||||||
|
without storage, the registry still constructs (attachments would FAIL, but boot is fine).
|
||||||
|
|
||||||
|
**T3 — pass-through: TemplatedSender + MailService**
|
||||||
|
- `sendTemplated`/`sendExternalWithMirror` accept `attachments` and place them in the EMAIL payload.
|
||||||
|
Tests: the outbound command's payload carries the attachment refs.
|
||||||
|
|
||||||
|
**T4 — gate + real send**
|
||||||
|
- Full suite + boundary + build. Manual: a real Ethereal send with a small attachment → SENT, and the
|
||||||
|
Ethereal message shows the attachment.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- **Fail-closed is deliberate** — don't silently send an invoice email without the invoice.
|
||||||
|
- **Payload holds refs, not bytes** — so the ledger and T8 redaction stay small; the resolver reads
|
||||||
|
bytes only at send time.
|
||||||
|
- **`@Optional` storage** — a context without `STORAGE_PORT` boots fine but can't send attachments;
|
||||||
|
that's correct (fail-closed), not a silent drop.
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Email / Message Template Module — Implementation Plan
|
||||||
|
|
||||||
|
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Target:** first templates sending by Friday go-live.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A reusable template module so **any** message the system sends — welcome, payment receipt,
|
||||||
|
onboarding reminders, drip — is produced from a stored (or ad-hoc) template with variables filled
|
||||||
|
in, then handed to the **existing** outbound pipeline. One render path, one send path, provenance
|
||||||
|
recorded for every send.
|
||||||
|
|
||||||
|
From the July 16 meeting: *"template का एक पूरा module बनाना है… template में value भरोगे, और वो
|
||||||
|
outbound क्यू में डाल दोगे।"*
|
||||||
|
|
||||||
|
## What already exists (the substrate — do NOT rebuild)
|
||||||
|
|
||||||
|
- `OutboundService.send(channelType, target, payload, idempotencyKey?, scopeId?, purpose?)` —
|
||||||
|
idempotency + per-target/per-tenant rate limits + delivery ledger (`IiosOutboundCommand`).
|
||||||
|
- `CapabilityBroker` — policy gate + obligations + provider selection for egress.
|
||||||
|
- `EMAIL` is a registered channel; `EmailProvider` exists (**HTTP**, not SMTP — see Out of Scope).
|
||||||
|
- `EMAIL` is a first-class `IiosInteractionKind`; `IiosMessagePartKind` has `HTML`/`TEXT`.
|
||||||
|
- `IiosActorKind` includes `SERVICE`/`BOT` (system sender is first-class).
|
||||||
|
- `InboxModule` uses `OnModuleInit` — copy that pattern for the template seeder.
|
||||||
|
|
||||||
|
The module adds **content/rendering** in front of this. It never talks to a provider directly.
|
||||||
|
|
||||||
|
## Design decisions (locked)
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Channel-generic**: one template per `(key, channel)`; channels `EMAIL` / `SMS` / `INTERNAL` | Vivek wants the same confirmation on email *and* SMS; INTERNAL = app-to-app, no SMTP |
|
||||||
|
| 2 | **Seed-from-files, DB-is-truth**: templates are files in the repo, seeded into the DB on boot if absent | Copy is version-controlled + code-reviewed; a later admin UI can edit the DB with no deploy; Friday needs no UI |
|
||||||
|
| 3 | **Handlebars** rendering | Auto-escapes HTML (customer names go into email → XSS risk), logic-less, no code execution, one dep |
|
||||||
|
| 4 | **Global default + scope override**: `scopeId` nullable — `NULL` = platform default, set = tenant override; resolve scoped-first-else-global | Seeds cleanly at boot (IIOS scopes are created lazily, so a boot seeder has no scope to seed into); leaves room for white-label |
|
||||||
|
| 5 | **Provenance on `IiosOutboundCommand`** (4 columns), not a new `template_snapshot` table | Rendered content is already in `payload`; only provenance is missing. Honours the SOT's intent at 4 columns |
|
||||||
|
| 6 | **`TemplateSource` = stored `{key,version?}` OR `{inline:{subject,html,text}}`** | Marketing hands over finished HTML (*"Maaz, HTML भेज सकते हैं"*); inline still renders vars + records provenance |
|
||||||
|
| 7 | **Integration pattern B**: pure `render()` + thin `sendTemplated()` composer | Single entry point for callers; provenance guaranteed by construction; `OutboundService` stays content-agnostic |
|
||||||
|
| 8 | **Caller = a service** (be-crm's system token); **recipient = a `target` address, never a login** | System mail has no user on the sending side; the recipient may have no account yet |
|
||||||
|
|
||||||
|
## Caller & auth model
|
||||||
|
|
||||||
|
Every send is authenticated as the **calling app/service** (e.g. be-crm via its `APP_SECRETS`
|
||||||
|
entry), verified by `SessionVerifier` like every other IIOS endpoint. The recipient is a plain
|
||||||
|
`target` string — **not** an IIOS principal and **not** required to be logged in or registered.
|
||||||
|
Sending a welcome email to an anonymous payer is the normal case: the app is the sender, the
|
||||||
|
address is data. `scopeId` for the send is derived from the caller's principal (`org/app/tenant`).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### New table — `IiosMessageTemplate`
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum IiosTemplateChannel {
|
||||||
|
EMAIL
|
||||||
|
SMS
|
||||||
|
INTERNAL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The template SOURCE. DB is runtime truth; platform defaults are seeded from repo files on boot.
|
||||||
|
/// Versions are immutable: a change writes a new (higher) version, never edits in place.
|
||||||
|
model IiosMessageTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// NULL = platform default (seeded). Set = a tenant scope's override of the same key.
|
||||||
|
scopeId String?
|
||||||
|
scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||||
|
key String // "welcome", "payment.receipt", "onboarding.reminder"
|
||||||
|
channel IiosTemplateChannel
|
||||||
|
locale String @default("en")
|
||||||
|
version Int @default(1)
|
||||||
|
subject String? // EMAIL only
|
||||||
|
bodyHtml String? // EMAIL / INTERNAL
|
||||||
|
bodyText String? // SMS, and EMAIL plaintext fallback
|
||||||
|
/// Declared variable names — render throws if a declared var is missing (fail loud).
|
||||||
|
variables Json?
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([scopeId, key, channel, locale, version])
|
||||||
|
@@index([key, channel, locale, active])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`IiosScope` needs the back-relation `messageTemplates IiosMessageTemplate[]`.
|
||||||
|
|
||||||
|
**Resolution:** `resolve(key, channel, locale, scopeId?)` returns the highest-`version` `active`
|
||||||
|
row, preferring `scopeId = <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).
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Inbox Mirror + INTERNAL Delivery — Implementation Plan
|
||||||
|
|
||||||
|
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** every message the app
|
||||||
|
sends appears in the customer's in-app inbox; and users can send app-to-app "mail" with no SMTP.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Two capabilities on one mechanism:
|
||||||
|
1. **Mirror** — when an external EMAIL is sent, also record it as an in-app interaction so the
|
||||||
|
customer sees a copy in their CRM inbox. Vivek: *"जो भी communication…उसकी एक copy inbox में चाहिए ही चाहिए."*
|
||||||
|
2. **INTERNAL delivery** — a user sends a mail-style message (subject + body) to another user with
|
||||||
|
**no SMTP**; it lands only in the recipient's in-app inbox. Vivek: *"app-to-app…without smtp."*
|
||||||
|
|
||||||
|
Both reduce to the same primitive: **render a template → create an `Interaction(kind=EMAIL)` with
|
||||||
|
subject + HTML + TEXT parts on a thread.** External additionally does the SMTP send (already built).
|
||||||
|
|
||||||
|
## Architectural guardrail (carried from the earlier inbox work)
|
||||||
|
|
||||||
|
This is the **mail-style inbox (a projection over `Interaction`s)** — NOT the `InboxItem` work-surface.
|
||||||
|
- An email/message becomes an `Interaction(kind=EMAIL)` on a thread. The inbox view lists interactions.
|
||||||
|
- An `InboxItem` is created ONLY when the projector decides action is needed (NEEDS_REPLY/MENTION) —
|
||||||
|
that's the existing projector, unchanged. **We do not write InboxItems here.** Mixing them is the
|
||||||
|
KG-15 "inbox fatigue" failure.
|
||||||
|
|
||||||
|
## What already exists (reuse, do NOT rebuild)
|
||||||
|
|
||||||
|
- `IngestService.ingest(req, idempotencyKey)` — the generic create-an-interaction entry: resolves
|
||||||
|
source handle → actor → channel → thread, writes `Interaction` (kind from `req.kind`) + parts +
|
||||||
|
outbox event, idempotent per (scope, idempotencyKey). Inbound email already uses it to make
|
||||||
|
`EMAIL` interactions with HTML/TEXT parts — **the exact model for the outbound mirror.**
|
||||||
|
- `TemplateService.render()` (exported) → `{subject, html, text}`.
|
||||||
|
- `TemplatedSender.sendTemplated()` → SMTP egress (built).
|
||||||
|
- `IiosMessagePartKind` has `HTML` + `TEXT`; `IiosInteractionKind` has `EMAIL`.
|
||||||
|
|
||||||
|
## Design (locked)
|
||||||
|
|
||||||
|
- **New `MailService`** (new `src/mail/` module) orchestrates `TemplateService` + `IngestService` +
|
||||||
|
`TemplatedSender` + `ActorResolver`. Templates/outbound stay unaware of each other.
|
||||||
|
- `postInternal(...)` — render → `ingest()` an `EMAIL` interaction on a per-email thread. No SMTP.
|
||||||
|
- `sendExternalWithMirror(...)` — render → `TemplatedSender.sendTemplated()` (SMTP) → **and** mirror
|
||||||
|
via `ingest()` **iff the recipient is a registered user** (timing rule below).
|
||||||
|
- **Visibility (resolved review finding):** `ingest()` creates the interaction + thread but adds NO
|
||||||
|
participants, and `listThreads` shows only threads where the caller is a participant. So after each
|
||||||
|
ingest the MailService `ensureParticipant`s **both** the sender's actor and the recipient's actor
|
||||||
|
(`ActorResolver.resolveActor` → `ensureParticipant`). Without this the mirror is invisible.
|
||||||
|
- **`ingest()` returns `threadId`** — used directly to add the two participants.
|
||||||
|
- **Rendered content → parts:** part 0 `HTML` (bodyHtml), part 1 `TEXT` (bodyText); `subject` → the
|
||||||
|
thread subject (email threads share a subject). Attachments are the separate attachments plan.
|
||||||
|
- **Idempotency:** the ingest idempotencyKey = the send's key (e.g. `mirror:<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").
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Insignia Platform — Live State (from the mesh-verify probe, 2026-07-10)
|
||||||
|
|
||||||
|
> Distilled from the authenticated `mesh-verify.lynkedup.cloud` dashboard (`/api/results` +
|
||||||
|
> `/api/journey`). This is the **real** platform IIOS is meant to plug into — service inventory,
|
||||||
|
> the identity/session/governance flow, and the exact contracts to wire IIOS's platform ports.
|
||||||
|
> Tokens redacted (the raw JSON dumps contain live SAT/PAT/refresh tokens — do not commit them).
|
||||||
|
|
||||||
|
## Cluster / mesh
|
||||||
|
- **Cluster:** `lynkedup-tech` (NYC2 / DigitalOcean). **Istio** (istio-envoy) + **SPIFFE/SPIRE**,
|
||||||
|
trust domain **`spiffe://insignia.tech`** (SVIDs like `spiffe://insignia.tech/ns/sre/sa/default`,
|
||||||
|
`.../sa/realmdm-sas`). mTLS **PERMISSIVE**. Reached by ClusterIP DNS.
|
||||||
|
- **Namespaces:** `sre` (MDM, OPA, misc), `cmp` (consent platform), `insignia` (identity/session/
|
||||||
|
app-facing services), `istio-system`.
|
||||||
|
- Public edges: `*.lynkedup.cloud` (behind oauth2-proxy → Keycloak).
|
||||||
|
|
||||||
|
## The identity/session/governance flow (7 steps — the doctrine)
|
||||||
|
> **Separate authorities:** Session Broker *authenticates*, OPA *authorizes*, CMP decides *purpose*,
|
||||||
|
> RealMDM *resolves identity*. Purpose-proof ≠ authorization. The PAT carries the external `sub`
|
||||||
|
> only as a **SHA-256 hash**, never raw; `canonical_person_id` is null until MDM VERIFIES (MDM never
|
||||||
|
> blocks login).
|
||||||
|
|
||||||
|
1. **Anonymous consent (CMP Edge)** — browser CMP SDK → `POST /edge/v1/cache-policy` → EdDSA-signed
|
||||||
|
cache-category manifest; a ConsentReceipt goes to CMP over gRPC. Purpose proof only.
|
||||||
|
2. **External login (Supabase)** → **SAT** (ES256 JWT). Claims: `iss` (project `/auth/v1`), opaque
|
||||||
|
UUID `sub`, `aud=authenticated`, `email`, `user_metadata` (full_name, avatar_url), `aal`, `amr`.
|
||||||
|
Proves the *session*, not the person/permission. Verified via Supabase **JWKS** (kid-selected).
|
||||||
|
3. **Session Broker exchange SAT → PAT** — `POST /v1/sessions/exchange` (Bearer SAT +
|
||||||
|
`X-Client-Authorization` = BFF Keycloak client-creds, aud=session-broker). Broker verifies the
|
||||||
|
SAT, calls the MDM bridge, resolves scope from **memberships**, mints the **PAT** (~5 min).
|
||||||
|
3b. **Workload identity (SPIFFE/mTLS)** — each meshed pod gets an X.509-SVID; OPA receives **both**
|
||||||
|
the user (`principal`) and the caller (`caller.spiffe_id`) — different layers, never merged.
|
||||||
|
4. **MDM Auth-Subject Bridge** — `POST /v1/auth-subjects/resolve {issuer, subject}` → `{platform_
|
||||||
|
principal_id, canonical_person_id (null until VERIFIED), link_state (PENDING/VERIFIED),
|
||||||
|
link_version, match_method}`. Keyed on **iss+sub** (never email). Idempotent.
|
||||||
|
5. **OPA decision** — `POST /v1/decisions` → `{decision_id, allow, reason_codes, obligations,
|
||||||
|
policy_version}`. Obligations = masks / row-filters / denied fields / audit level / ttl. A
|
||||||
|
decision, not a 50-line entitlement JWT — the PEP MUST enforce every obligation.
|
||||||
|
6. **AppShell assembles the ACE** — combines PAT + OPA obligations + CMP consent into an App
|
||||||
|
Context Envelope (HttpOnly cookie): `capabilities[]` (policy-derived), `ui_obligations`
|
||||||
|
(hide/mask/step_up), `consent`. **No tokens, no raw PII in the browser.**
|
||||||
|
7. **CRM renders** — applies OPA `row_filter` (SQL WHERE) + `mask_fields` + `deny_fields`
|
||||||
|
server-side; rows reference `canonical_person_id`, not email.
|
||||||
|
|
||||||
|
## Service inventory + real endpoints
|
||||||
|
**Identity / session (`insignia` ns):**
|
||||||
|
- **Session Broker** `session-broker.insignia:80` — `POST /v1/sessions/exchange` (SAT→PAT).
|
||||||
|
- **Memberships** `memberships.insignia` (public `insignia-memberships.lynkedup.cloud`) —
|
||||||
|
`POST /v1/internal/resolve`, `GET /v1/memberships` (Bearer PAT) → scope tuple + `allowed[]`.
|
||||||
|
- **AppShell BFF** `appshell-bff.insignia:80` (public `insignia-appshell.lynkedup.cloud`) —
|
||||||
|
`GET /apps/crm-web/bootstrap` (Bearer PAT) → ACE.
|
||||||
|
- **Profile** `profile.insignia:80` — `GET /v1/profile`, `GET /v1/stats`. Backed by **sqlite3**
|
||||||
|
(`/data/profiles.db`, PVC `insignia-profile-data`, WAL, persistent, single-replica RWO).
|
||||||
|
- **CRM** `crm.insignia:80` — `GET /v1/leads?view=list` (applies obligations).
|
||||||
|
- **Policy Gateway** `policy-gateway.insignia:80` — `POST /v1/decisions`, `POST /v1/decisions/batch`.
|
||||||
|
|
||||||
|
**MDM (`sre` ns):** `mdm-kernel.sre:80` (`/v1/auth-subjects/resolve`, `/healthz`, `/readyz`; auth =
|
||||||
|
Keycloak service token **aud=realmdm**) · `mdm-ai.sre:80` · `mdm-sas.sre:9090` (**gRPC** — tokenization/SAS).
|
||||||
|
|
||||||
|
**OPA / policy (`sre` ns):** `opa.sre:8181` (`/health`, `GET /v1/data` = live policy tree;
|
||||||
|
default-deny) · `realmdm-opa.sre:8181` · `opal-server.sre:7002` (OPAL policy distribution).
|
||||||
|
|
||||||
|
**CMP (`cmp` ns), backed by Postgres + NATS + Redis:** `cmp-core:8080` (real `CheckConsent` gRPC;
|
||||||
|
service token aud=cmp) · `cmp-admin:8086` · `cmp-evidence:8081` · `cmp-sync:8085` ·
|
||||||
|
`cmp-tollgate:8082` (NATS JetStream gating) · `cmp-media:8083` · `cmp-worker:8084`. Plus
|
||||||
|
`insignia-consent-edge.insignia` (`POST /edge/v1/cache-policy`) and `insignia-consent-adapter.insignia`
|
||||||
|
(`POST /v1/consent/evaluate {purpose}` → `{permitted, legal_basis, state, consent_epoch}`).
|
||||||
|
|
||||||
|
**Other (`sre` ns):** `poi-api:5000` · `roof:8002` (YOLO segmentation) · `commit:8081` ·
|
||||||
|
`presign:8080` · `egs:8090` · `artifact-retrieval:8082` (was 503 on probe day) · `cmp-docs`.
|
||||||
|
|
||||||
|
## The two contracts IIOS must match
|
||||||
|
|
||||||
|
### PAT (Platform Access Token) — what IIOS should verify
|
||||||
|
`iss = https://identity.insignia.internal` · **ES256** (EC P-256), verify via the broker **JWKS**
|
||||||
|
(`.../.well-known/jwks.json`, kid-selected — **no shared secret**) · `aud` includes the app (e.g.
|
||||||
|
`crm-web`) · `lifetime ~300s`. Claims:
|
||||||
|
```
|
||||||
|
sub = platform_principal_id app_id tenant_id org_id bu_id
|
||||||
|
region environment role aal amr auth_source
|
||||||
|
external_subject_hash = sha256:… (NOT the raw external sub)
|
||||||
|
canonical_person_id (null until VERIFIED)
|
||||||
|
session_epoch · policy_epoch · consent_epoch (stale-detection)
|
||||||
|
sid (platform_session_id) · typ = platform-access+jwt
|
||||||
|
```
|
||||||
|
|
||||||
|
### OPA decision — `POST http://policy-gateway.insignia.svc.cluster.local/v1/decisions`
|
||||||
|
Request `{ input: { principal{platform_principal_id, canonical_person_id, auth_source, aal, roles,
|
||||||
|
memberships[]}, caller{spiffe_id}, resource{type, id, tenant_id, classification[]}, action,
|
||||||
|
context{purpose, device_trust, consent_receipt_ids[], network_zone, time} } }`
|
||||||
|
Response `{ decision_id, allow, reason_codes[], obligations{ row_filter, allow_fields[], mask_fields{},
|
||||||
|
deny_fields[], audit, decision_ttl_seconds }, policy_version }`.
|
||||||
|
|
||||||
|
## What this means for wiring IIOS's ports
|
||||||
|
1. **Auth — verify the PAT, not the Supabase SAT.** IIOS today verifies the Supabase SAT directly
|
||||||
|
(a dev shortcut). In the real platform the **Session Broker** does SAT→PAT; a platform workload
|
||||||
|
verifies the **PAT**. The PAT already carries the full scope tuple + `platform_principal_id`, so
|
||||||
|
`MessagePrincipal` maps ~1:1: `userId = platform_principal_id` (→ `canonical_person_id` once
|
||||||
|
VERIFIED), `appId = app_id`, `orgId = org_id`, `tenantId = tenant_id`, `+ buId`. Wire it by adding
|
||||||
|
the broker as an `AUTH_ISSUERS` entry (iss `https://identity.insignia.internal`, ES256, its JWKS).
|
||||||
|
2. **OPA — point `OpaPort` at the Policy Gateway** (`POST /v1/decisions`). Build `{principal, caller.
|
||||||
|
spiffe_id, resource, action, context}` from the PAT + the op; `decideOrThrow` maps `allow` → proceed
|
||||||
|
and **must enforce the obligations** (masks/row-filter/deny). The gateway's input is richer than
|
||||||
|
IIOS's current `{action,…}` — that's the adapter's job to assemble.
|
||||||
|
3. **MDM — usually don't call it.** The PAT already carries `platform_principal_id`/`canonical_person_id`
|
||||||
|
(the broker resolved at login). Only call `/v1/auth-subjects/resolve` if IIOS is the identity-exchange
|
||||||
|
edge (it isn't — the Broker is).
|
||||||
|
4. **CMP — consent gate** via `insignia-consent-adapter /v1/consent/evaluate {purpose}` when processing
|
||||||
|
content for AI/analytics/marketing.
|
||||||
|
5. **SAS — tokenization/masking** is `mdm-sas` (gRPC) — the "tokenize sensitive parts before storage"
|
||||||
|
requirement.
|
||||||
|
|
||||||
|
*Source: two API payloads captured 2026-07-10 (`result.json` = probes, `journey.json` = the CRM
|
||||||
|
first-vertical-slice journey). Re-capture from the authenticated dashboard to refresh.*
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# SMTP Provider — Implementation Plan
|
||||||
|
|
||||||
|
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** make external email *actually leave the building* (welcome / receipt), the critical path for Friday.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add an `SmtpProvider` so the `EMAIL` channel delivers via real SMTP (`accounts@lynkeduppro.com`,
|
||||||
|
fallback `ceo@lynkeduppro.com`) instead of the sandbox. The template module already renders and
|
||||||
|
queues to the `EMAIL` channel; this is the one piece between "queued (SENT via sandbox)" and "the
|
||||||
|
customer receives it." From the meeting: *"जो पहला जा रहा है, वो SMTP से जा रहा है, क्योंकि हमें तुरंत चाहिए."*
|
||||||
|
|
||||||
|
## What already exists (do NOT rebuild)
|
||||||
|
|
||||||
|
- `CapabilityProvider { name, channelTypes, capabilities, send(req) }` — the seam.
|
||||||
|
- `CapabilityProviderRegistry` binds a provider per channel: **sandbox by default**; `EmailProvider`
|
||||||
|
(HTTP) when `IIOS_PROVIDER_URL_EMAIL` is set. Unknown channels fail closed.
|
||||||
|
- `OutboundService.send` → `CapabilityBroker` (policy + obligations) → the bound provider. Idempotency,
|
||||||
|
rate limits, ledger, provenance all upstream — untouched.
|
||||||
|
- `req.payload` for EMAIL is `{ subject, text, html, inReplyTo }` (from `TemplatedSender`).
|
||||||
|
|
||||||
|
## Design decisions (locked)
|
||||||
|
|
||||||
|
| # | Decision | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | New `SmtpProvider implements CapabilityProvider`, `channelTypes=['EMAIL']`, via **nodemailer** | The established provider pattern; nodemailer is the standard SMTP client |
|
||||||
|
| 2 | **Env-driven activation**, like `IIOS_PROVIDER_URL_EMAIL` | Off by default (sandbox); flip on by setting SMTP env — no code change to enable |
|
||||||
|
| 3 | **Registry precedence for EMAIL: SMTP > HTTP > sandbox** | SMTP is the intended prod path; HTTP relay stays available; sandbox is the safe default |
|
||||||
|
| 4 | **Transporter is injected** (constructor takes a `Transporter` or a factory) | SMTP is untestable against a live server in CI; inject a stub/`jsonTransport` to assert the envelope |
|
||||||
|
| 5 | **Optional fallback sender** (`accounts@` primary → `ceo@` on failure) | The meeting's fallback: if the primary mailbox send fails, retry once via the fallback identity |
|
||||||
|
| 6 | **Never throw** — a transport error returns `{ outcome: 'FAILED', errorCode }` | Adapter doctrine; the command is marked FAILED, the caller isn't broken |
|
||||||
|
|
||||||
|
## Config (env)
|
||||||
|
|
||||||
|
```
|
||||||
|
IIOS_SMTP_HOST=smtp.<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).
|
||||||
+3
-1
@@ -7,7 +7,9 @@
|
|||||||
"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,15 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-adapter-sdk",
|
"name": "@insignia/iios-adapter-sdk",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"files": ["dist"],
|
"files": [
|
||||||
|
"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/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { EmailAdapter, emailInboundFixture, emailReplyFixture, signedFixture } from './index';
|
||||||
|
|
||||||
|
const ctx = { scope: { orgId: 'o', appId: 'a' } };
|
||||||
|
|
||||||
|
describe('EmailAdapter', () => {
|
||||||
|
const adapter = new EmailAdapter();
|
||||||
|
|
||||||
|
it('normalizes an inbound email into an ingest request', () => {
|
||||||
|
const req = adapter.normalize(emailInboundFixture, ctx);
|
||||||
|
expect(req.channel.type).toBe('EMAIL');
|
||||||
|
expect(req.source.handleKind).toBe('EXTERNAL_VISITOR');
|
||||||
|
expect(req.source.externalId).toBe(emailInboundFixture.from);
|
||||||
|
expect(req.parts[0]?.bodyText).toBe(emailInboundFixture.text);
|
||||||
|
expect(req.providerEventId).toBe('<m1@example.com>');
|
||||||
|
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>');
|
||||||
|
expect(req.thread?.subject).toBe('Need help with my order'); // Re:/Fwd: stripped
|
||||||
|
expect((req.metadata as { messageId: string }).messageId).toBe('<m1@example.com>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads a reply onto the SAME email thread as the original', () => {
|
||||||
|
const first = adapter.normalize(emailInboundFixture, ctx);
|
||||||
|
const reply = adapter.normalize(emailReplyFixture, ctx);
|
||||||
|
expect(reply.thread?.externalThreadId).toBe(first.thread?.externalThreadId); // email:<m1@example.com>
|
||||||
|
expect(reply.providerEventId).toBe('<m2@example.com>'); // but its own dedup id
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the reply target when there is no references chain', () => {
|
||||||
|
const req = adapter.normalize({ ...emailReplyFixture, references: undefined }, ctx);
|
||||||
|
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>'); // uses inReplyTo
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives text from html when no plain text is present', () => {
|
||||||
|
const req = adapter.normalize({ messageId: '<h@x>', from: 'x@y', html: '<p>Hello <b>there</b></p>' }, ctx);
|
||||||
|
expect(req.parts[0]?.bodyText).toBe('Hello there');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies an HMAC-signed body and rejects a bad/absent signature', () => {
|
||||||
|
const secret = 'sekret';
|
||||||
|
const { body, headers } = signedFixture(emailInboundFixture, secret);
|
||||||
|
expect(adapter.verifySignature(body, headers, secret)).toBe(true);
|
||||||
|
expect(adapter.verifySignature(body, { 'x-iios-signature': 'sha256=bad' }, secret)).toBe(false);
|
||||||
|
expect(adapter.verifySignature(body, {}, secret)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||||
|
import type { AdapterCapability, ChannelAdapter, NormalizeContext, OutboundCommandInput, SendResult } from './types';
|
||||||
|
import { hmacVerify } from './hmac';
|
||||||
|
import { SandboxSink } from './sandbox';
|
||||||
|
|
||||||
|
/** Canonical inbound-email shape. Vendor payloads (SendGrid Inbound Parse, Mailgun
|
||||||
|
* Routes, Postmark) map into this — provider glue stays a thin transform. */
|
||||||
|
export interface EmailInboundPayload {
|
||||||
|
messageId: string; // RFC 5322 Message-ID → providerEventId (dedup on redelivery)
|
||||||
|
from: string;
|
||||||
|
fromName?: string;
|
||||||
|
to?: string[];
|
||||||
|
cc?: string[];
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
inReplyTo?: string; // Message-ID this is a reply to
|
||||||
|
references?: string[]; // full reference chain, root-first (threading)
|
||||||
|
occurredAt?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripReply = (s?: string): string => (s ?? '').replace(/^(re|fwd|fw)\s*:\s*/i, '').trim();
|
||||||
|
const stripHtml = (h?: string): string => (h ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email channel adapter. Turns a provider's inbound email into a normalized
|
||||||
|
* interaction with email-native threading: the whole reply chain collapses to one
|
||||||
|
* IIOS thread (rooted at `references[0]`/`inReplyTo`), and `messageId` dedups
|
||||||
|
* redelivery. HMAC signature for now (per-vendor schemes are a follow-up).
|
||||||
|
*/
|
||||||
|
export class EmailAdapter implements ChannelAdapter {
|
||||||
|
readonly channelType = 'EMAIL';
|
||||||
|
readonly capability: AdapterCapability = {
|
||||||
|
canReceive: true,
|
||||||
|
canSend: true,
|
||||||
|
supportsThreads: true,
|
||||||
|
requiresFollowupFetch: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean {
|
||||||
|
const sig = headers['x-iios-signature'] ?? headers['X-Iios-Signature'];
|
||||||
|
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
externalEventId(payload: unknown): string | undefined {
|
||||||
|
const id = (payload as EmailInboundPayload)?.messageId;
|
||||||
|
return typeof id === 'string' ? id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
|
||||||
|
const p = payload as EmailInboundPayload;
|
||||||
|
// A reply's thread root is the first Message-ID in its chain → same IIOS thread as the original.
|
||||||
|
const threadRoot = p.references?.[0] ?? p.inReplyTo ?? p.messageId;
|
||||||
|
return {
|
||||||
|
scope: ctx.scope,
|
||||||
|
channel: { type: 'EMAIL', externalChannelId: 'email' },
|
||||||
|
source: { handleKind: 'EXTERNAL_VISITOR', externalId: p.from, displayName: p.fromName ?? p.from },
|
||||||
|
kind: 'MESSAGE',
|
||||||
|
thread: { externalThreadId: `email:${threadRoot}`, subject: stripReply(p.subject) },
|
||||||
|
parts: [{ kind: 'TEXT', bodyText: p.text ?? stripHtml(p.html), mimeType: 'text/plain' }],
|
||||||
|
occurredAt: p.occurredAt ?? new Date().toISOString(),
|
||||||
|
providerEventId: p.messageId,
|
||||||
|
metadata: { to: p.to, cc: p.cc, messageId: p.messageId, inReplyTo: p.inReplyTo, references: p.references, html: p.html },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(cmd: OutboundCommandInput): Promise<SendResult> {
|
||||||
|
return SandboxSink.simulate(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { hmacSign } from './hmac';
|
import { hmacSign } from './hmac';
|
||||||
import type { WebhookPayload } from './webhook-adapter';
|
import type { WebhookPayload } from './webhook-adapter';
|
||||||
|
import type { EmailInboundPayload } from './email-adapter';
|
||||||
|
|
||||||
/** Email-shaped provider payload (as the adapter's normalize input). */
|
/** Email-shaped WebhookPayload (legacy generic-webhook fixture). */
|
||||||
export const emailFixture: WebhookPayload = {
|
export const emailFixture: WebhookPayload = {
|
||||||
eventId: 'email-1',
|
eventId: 'email-1',
|
||||||
from: 'someone@example.com',
|
from: 'someone@example.com',
|
||||||
@@ -11,6 +12,30 @@ export const emailFixture: WebhookPayload = {
|
|||||||
text: 'Hello, I have a question.',
|
text: 'Hello, I have a question.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A first inbound email (starts a thread) for the EmailAdapter. */
|
||||||
|
export const emailInboundFixture: EmailInboundPayload = {
|
||||||
|
messageId: '<m1@example.com>',
|
||||||
|
from: 'buyer@example.com',
|
||||||
|
fromName: 'Buyer',
|
||||||
|
to: ['support@tower.example'],
|
||||||
|
subject: 'Need help with my order',
|
||||||
|
text: 'Hi, my order has not arrived yet.',
|
||||||
|
occurredAt: '2026-07-03T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A reply to `emailInboundFixture` — must land on the SAME email thread. */
|
||||||
|
export const emailReplyFixture: EmailInboundPayload = {
|
||||||
|
messageId: '<m2@example.com>',
|
||||||
|
from: 'buyer@example.com',
|
||||||
|
fromName: 'Buyer',
|
||||||
|
to: ['support@tower.example'],
|
||||||
|
subject: 'Re: Need help with my order',
|
||||||
|
text: 'Any update on this?',
|
||||||
|
inReplyTo: '<m1@example.com>',
|
||||||
|
references: ['<m1@example.com>'],
|
||||||
|
occurredAt: '2026-07-03T11:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
/** WhatsApp-shaped provider payload. */
|
/** WhatsApp-shaped provider payload. */
|
||||||
export const whatsappFixture: WebhookPayload = {
|
export const whatsappFixture: WebhookPayload = {
|
||||||
eventId: 'wa-1',
|
eventId: 'wa-1',
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from './types';
|
|||||||
export { hmacSign, hmacVerify } from './hmac';
|
export { hmacSign, hmacVerify } from './hmac';
|
||||||
export { SandboxSink } from './sandbox';
|
export { SandboxSink } from './sandbox';
|
||||||
export { WebhookAdapter, type WebhookPayload } from './webhook-adapter';
|
export { WebhookAdapter, type WebhookPayload } from './webhook-adapter';
|
||||||
export { emailFixture, whatsappFixture, signedFixture } from './fixtures';
|
export { EmailAdapter, type EmailInboundPayload } from './email-adapter';
|
||||||
|
export { emailFixture, whatsappFixture, emailInboundFixture, emailReplyFixture, signedFixture } from './fixtures';
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface ChannelAdapter {
|
|||||||
channelType: string;
|
channelType: string;
|
||||||
capability: AdapterCapability;
|
capability: AdapterCapability;
|
||||||
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean;
|
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean;
|
||||||
|
/** Provider-native id used to dedup replays/retries at the inbound edge (e.g. eventId, Message-ID). */
|
||||||
|
externalEventId?(payload: unknown): string | undefined;
|
||||||
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest;
|
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest;
|
||||||
fetch?(ref: string): Promise<unknown>;
|
fetch?(ref: string): Promise<unknown>;
|
||||||
send(cmd: OutboundCommandInput): Promise<SendResult>;
|
send(cmd: OutboundCommandInput): Promise<SendResult>;
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export class WebhookAdapter implements ChannelAdapter {
|
|||||||
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
|
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
externalEventId(payload: unknown): string | undefined {
|
||||||
|
const id = (payload as WebhookPayload)?.eventId;
|
||||||
|
return typeof id === 'string' ? id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
|
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
|
||||||
const p = payload as WebhookPayload;
|
const p = payload as WebhookPayload;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-ai-web",
|
"name": "@insignia/iios-ai-web",
|
||||||
"version": "0.0.0",
|
"version": "0.1.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": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
"exports": {
|
||||||
"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"
|
||||||
@@ -23,5 +29,8 @@
|
|||||||
"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,13 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-community-web",
|
"name": "@insignia/iios-community-web",
|
||||||
"version": "0.0.0",
|
"version": "0.1.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": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
"exports": {
|
||||||
"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"
|
||||||
@@ -23,5 +29,8 @@
|
|||||||
"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,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-contracts",
|
"name": "@insignia/iios-contracts",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"files": ["dist"],
|
"files": [
|
||||||
|
"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,6 +27,7 @@ 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,13 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-inbox-web",
|
"name": "@insignia/iios-inbox-web",
|
||||||
"version": "0.0.0",
|
"version": "0.1.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": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
"exports": {
|
||||||
"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"
|
||||||
@@ -23,5 +29,8 @@
|
|||||||
"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,13 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-kernel-client",
|
"name": "@insignia/iios-kernel-client",
|
||||||
"version": "0.0.0",
|
"version": "0.1.4",
|
||||||
"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": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
"exports": {
|
||||||
"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"
|
||||||
@@ -19,5 +25,8 @@
|
|||||||
"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 (ports the
|
* Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io
|
||||||
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck.
|
* types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens
|
||||||
* On reconnect it re-opens the current thread so subscriptions resume with no
|
* all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI
|
||||||
* lost messages (the docs' disconnect/reconnect requirement).
|
* that watches multiple conversations keeps receiving live messages after a drop.
|
||||||
*/
|
*/
|
||||||
export class MessageSocket {
|
export class MessageSocket {
|
||||||
private readonly socket: SocketLike;
|
private readonly socket: SocketLike;
|
||||||
private currentThreadId: string | null = null;
|
private readonly joined = new Set<string>();
|
||||||
|
|
||||||
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-open the active thread after a reconnect.
|
// Re-subscribe to every joined thread after a reconnect.
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', () => {
|
||||||
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId });
|
for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,27 +40,70 @@ export class MessageSocket {
|
|||||||
this.socket.disconnect();
|
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, fn);
|
this.socket.on(event as string, fn);
|
||||||
return () => this.socket.off(event, fn);
|
return () => this.socket.off(event as string, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
async openThread(threadId?: string): Promise<OpenThreadResult> {
|
async openThread(
|
||||||
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult;
|
threadId?: string,
|
||||||
this.currentThreadId = result.threadId;
|
opts?: { membership?: string; creatorRole?: string; subject?: string },
|
||||||
|
): 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(threadId: string, content: string, opts?: { contentRef?: string }): Promise<Message> {
|
async sendMessage(
|
||||||
|
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?.contentRef,
|
contentRef: opts?.attachment?.contentRef ?? opts?.contentRef,
|
||||||
|
mimeType: opts?.attachment?.mimeType,
|
||||||
|
sizeBytes: opts?.attachment?.sizeBytes,
|
||||||
|
checksumSha256: opts?.attachment?.checksumSha256,
|
||||||
|
parentInteractionId: opts?.parentInteractionId,
|
||||||
|
mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel
|
||||||
})) as Message;
|
})) 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 };
|
||||||
}
|
}
|
||||||
@@ -68,4 +111,9 @@ 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,9 +1,16 @@
|
|||||||
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 } from './types';
|
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } 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. */
|
||||||
@@ -15,7 +22,8 @@ export class RestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||||
const h: Record<string, string> = { 'content-type': 'application/json', ...extra };
|
const custom = typeof this.config.headers === 'function' ? this.config.headers() : this.config.headers;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -57,12 +65,73 @@ 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 }): Promise<Ticket> {
|
async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record<string, unknown> }): Promise<Ticket> {
|
||||||
return this.post<Ticket>('/v1/support/tickets', body);
|
return this.post<Ticket>('/v1/support/tickets', body);
|
||||||
}
|
}
|
||||||
async escalate(threadId: string, subject?: string): Promise<Ticket> {
|
async escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
|
||||||
return this.post<Ticket>('/v1/support/escalate', { threadId, subject });
|
return this.post<Ticket>('/v1/support/escalate', { threadId, subject, metadata });
|
||||||
|
}
|
||||||
|
/** 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,10 +1,30 @@
|
|||||||
|
/** A media/file part attached to a message (contentRef points at object storage). */
|
||||||
|
export interface Attachment {
|
||||||
|
contentRef: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
kind: 'image' | 'video' | 'audio' | 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */
|
||||||
|
export interface AnnotationGroup {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
users: string[]; // usernames who applied it
|
||||||
|
}
|
||||||
|
|
||||||
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */
|
/** 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;
|
||||||
}
|
}
|
||||||
@@ -26,10 +46,50 @@ 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';
|
||||||
@@ -68,6 +128,8 @@ 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 }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,4 +250,8 @@ 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,13 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-meeting-web",
|
"name": "@insignia/iios-meeting-web",
|
||||||
"version": "0.0.0",
|
"version": "0.1.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": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
"exports": {
|
||||||
"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"
|
||||||
@@ -23,5 +29,8 @@
|
|||||||
"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,13 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-message-web",
|
"name": "@insignia/iios-message-web",
|
||||||
"version": "0.0.0",
|
"version": "0.1.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": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
"exports": {
|
||||||
"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"
|
||||||
@@ -23,5 +29,8 @@
|
|||||||
"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/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# iios-service configuration contract.
|
||||||
|
# Copy to `.env` for local dev; in prod, inject via your secrets manager / orchestrator.
|
||||||
|
# 🔒 = secret (never commit a real value). ⚠️ = must be set correctly for prod safety.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Core ─────────────────────────────────────────────────────────────────────
|
||||||
|
DATABASE_URL=postgresql://iios:iios@localhost:5434/iios?schema=public # 🔒 Postgres connection
|
||||||
|
PORT=3200
|
||||||
|
# NODE_ENV=production # set by the Docker image
|
||||||
|
# Realtime fan-out across replicas (socket.io Redis adapter). REQUIRED when running
|
||||||
|
# more than one instance with live chat; omit for a single instance (in-memory adapter).
|
||||||
|
# REDIS_URL=redis://localhost:6379 # 🔒
|
||||||
|
|
||||||
|
# ── Secrets ──────────────────────────────────────────────────────────────────
|
||||||
|
# JSON map of appId → HS256 signing secret used to verify session JWTs (SessionVerifier).
|
||||||
|
APP_SECRETS={"portal-demo":"dev-secret"} # 🔒
|
||||||
|
# JSON map of channelType → inbound webhook HMAC secret (adapter signature check).
|
||||||
|
ADAPTER_SECRETS={"WEBHOOK":"dev-adapter-secret"} # 🔒
|
||||||
|
# Default scope an unauthenticated adapter webhook ingests into.
|
||||||
|
ADAPTER_APP=portal-demo
|
||||||
|
ADAPTER_ORG=org_demo
|
||||||
|
|
||||||
|
# ── ⚠️ Production-safety flags ────────────────────────────────────────────────
|
||||||
|
# Enables /v1/dev/* (token mint, webhook inject, chaos, retention sweep). MUST be unset
|
||||||
|
# or 0 in production — leaving it on exposes unauthenticated token minting.
|
||||||
|
IIOS_DEV_TOKENS=0
|
||||||
|
# Skip `prisma migrate deploy` at boot (set to 1 when a separate migration job runs).
|
||||||
|
IIOS_SKIP_MIGRATE=0
|
||||||
|
|
||||||
|
# ── Tenant isolation ─────────────────────────────────────────────────────────
|
||||||
|
IIOS_CELL_ID=cell-default # physical cell this instance serves (blast-radius partition)
|
||||||
|
|
||||||
|
# ── Background workers ───────────────────────────────────────────────────────
|
||||||
|
IIOS_RELAY_INTERVAL_MS=500 # outbox relay tick; 0 disables the timer
|
||||||
|
IIOS_OUTBOX_MAX_ATTEMPTS=5 # relay dead-letters an event after N failed publishes
|
||||||
|
IIOS_RETENTION_SWEEP_INTERVAL_MS=0 # retention sweep tick; 0 disables (run via job instead)
|
||||||
|
IIOS_RETENTION_POLICY_VERSION=v1
|
||||||
|
IIOS_RETENTION_ARCHIVE_DAYS=90 # default archive window (per-class override: _INTERNAL/_RESTRICTED/_CONFIDENTIAL/_REGULATED)
|
||||||
|
IIOS_RETENTION_DELETE_DAYS=365 # default delete (redact) window; same per-class overrides
|
||||||
|
|
||||||
|
# ── Rate limits / quotas / budgets ───────────────────────────────────────────
|
||||||
|
IIOS_OUTBOUND_LIMIT=5 # per-(channel,target) sends per window
|
||||||
|
IIOS_OUTBOUND_WINDOW_MS=60000
|
||||||
|
IIOS_TENANT_OUTBOUND_LIMIT=10000 # per-tenant egress cap per window (noisy-neighbor guard)
|
||||||
|
IIOS_TENANT_OUTBOUND_WINDOW_MS=60000
|
||||||
|
IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
|
||||||
|
|
||||||
|
# ── Capability providers (governed egress targets) ───────────────────────────
|
||||||
|
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
|
||||||
|
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
|
||||||
|
|
||||||
|
# ── Context attestation (July 12 trust layer) ──
|
||||||
|
# Audience IIOS requires on attestations addressed to it.
|
||||||
|
IIOS_ATTESTATION_AUDIENCE=iios-core
|
||||||
|
# Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Production image for @insignia/iios-service (the single IIOS backend).
|
||||||
|
# Build context MUST be the monorepo ROOT (it needs the workspace + lockfile):
|
||||||
|
# docker build -f packages/iios-service/Dockerfile -t iios-service:latest .
|
||||||
|
#
|
||||||
|
# Multi-stage: (1) build the service + its workspace deps and prune to a
|
||||||
|
# self-contained prod bundle via `pnpm deploy`; (2) a slim runtime that only
|
||||||
|
# carries that bundle. Migrations run at container start (see docker-entrypoint.sh).
|
||||||
|
|
||||||
|
ARG NODE_VERSION=22-bookworm-slim
|
||||||
|
ARG PNPM_VERSION=10.6.2
|
||||||
|
|
||||||
|
# ── build ───────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:${NODE_VERSION} AS build
|
||||||
|
ARG PNPM_VERSION
|
||||||
|
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
|
||||||
|
# openssl + ca-certificates are required by Prisma's engine on debian-slim.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||||
|
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Full install (needs devDeps to compile), then build ONLY the service + its
|
||||||
|
# workspace dependencies, generate the Prisma client, and emit a pruned prod bundle.
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
# Generate the Prisma client BEFORE compiling — nest build (tsc) needs its types,
|
||||||
|
# otherwise Prisma results are `any` and noImplicitAny fails the build.
|
||||||
|
RUN pnpm --filter @insignia/iios-service exec prisma generate \
|
||||||
|
&& pnpm --filter "@insignia/iios-service..." build \
|
||||||
|
&& pnpm --filter @insignia/iios-service --prod --legacy deploy /app
|
||||||
|
|
||||||
|
# ── runtime ──────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:${NODE_VERSION} AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Prisma engine needs openssl at runtime too.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app ./
|
||||||
|
# Regenerate the Prisma client against the final filesystem (deterministic).
|
||||||
|
RUN node_modules/.bin/prisma generate
|
||||||
|
|
||||||
|
# Run as the built-in non-root `node` user.
|
||||||
|
RUN chmod +x docker-entrypoint.sh && chown -R node:node /app
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3200
|
||||||
|
ENV PORT=3200
|
||||||
|
# migrate deploy → start; see docker-entrypoint.sh.
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Container entrypoint for iios-service.
|
||||||
|
# 1) Apply pending DB migrations (idempotent; safe to run on every boot).
|
||||||
|
# 2) Exec the server as PID 1 so signals (SIGTERM) reach Node for graceful shutdown.
|
||||||
|
#
|
||||||
|
# NOTE: at higher replica counts, prefer running `prisma migrate deploy` ONCE as a
|
||||||
|
# separate pre-deploy job/init-container and set IIOS_SKIP_MIGRATE=1 here, so N
|
||||||
|
# replicas don't race the migration on rollout.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${IIOS_SKIP_MIGRATE:-0}" != "1" ]; then
|
||||||
|
echo "[entrypoint] applying migrations (prisma migrate deploy)…"
|
||||||
|
node_modules/.bin/prisma migrate deploy
|
||||||
|
else
|
||||||
|
echo "[entrypoint] IIOS_SKIP_MIGRATE=1 — skipping migrations"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] starting iios-service on :${PORT:-3200}"
|
||||||
|
exec node dist/main.js
|
||||||
@@ -12,21 +12,29 @@
|
|||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@insignia/iios-contracts": "workspace:*",
|
"@aws-sdk/client-s3": "^3.1090.0",
|
||||||
"@insignia/iios-adapter-sdk": "workspace:*",
|
"@insignia/iios-adapter-sdk": "workspace:*",
|
||||||
|
"@insignia/iios-contracts": "workspace:*",
|
||||||
"@nestjs/common": "^11.1.27",
|
"@nestjs/common": "^11.1.27",
|
||||||
"@nestjs/core": "^11.1.27",
|
"@nestjs/core": "^11.1.27",
|
||||||
"@nestjs/platform-express": "^11.1.27",
|
"@nestjs/platform-express": "^11.1.27",
|
||||||
"@nestjs/platform-socket.io": "^11.1.27",
|
"@nestjs/platform-socket.io": "^11.1.27",
|
||||||
"@nestjs/websockets": "^11.1.27",
|
"@nestjs/websockets": "^11.1.27",
|
||||||
"@prisma/client": "^6.2.1",
|
"@prisma/client": "^6.2.1",
|
||||||
"socket.io": "^4.8.3",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"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",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"jwks-rsa": "^4.1.0",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
|
"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",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@insignia/iios-testkit": "workspace:*",
|
"@insignia/iios-testkit": "workspace:*",
|
||||||
@@ -35,7 +43,8 @@
|
|||||||
"@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",
|
||||||
"prisma": "^6.2.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "IiosInteraction" ADD COLUMN "dataClass" TEXT NOT NULL DEFAULT 'internal';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IiosRetentionPolicySnapshot" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"policyKey" TEXT NOT NULL,
|
||||||
|
"scopeSnapshotId" TEXT NOT NULL,
|
||||||
|
"targetType" TEXT NOT NULL,
|
||||||
|
"targetId" TEXT NOT NULL,
|
||||||
|
"dataClass" TEXT NOT NULL,
|
||||||
|
"retainUntil" TIMESTAMP(3),
|
||||||
|
"archiveAfter" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deleteAfter" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sourceVersion" TEXT NOT NULL DEFAULT 'v1',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "IiosRetentionPolicySnapshot_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosRetentionPolicySnapshot_status_deleteAfter_idx" ON "IiosRetentionPolicySnapshot"("status", "deleteAfter");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosRetentionPolicySnapshot_scopeSnapshotId_status_idx" ON "IiosRetentionPolicySnapshot"("scopeSnapshotId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IiosRetentionPolicySnapshot_targetType_targetId_key" ON "IiosRetentionPolicySnapshot"("targetType", "targetId");
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IiosInteractionAnnotation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"scopeId" TEXT NOT NULL,
|
||||||
|
"targetInteractionId" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT NOT NULL,
|
||||||
|
"annotationType" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "IiosInteractionAnnotation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosInteractionAnnotation_targetInteractionId_idx" ON "IiosInteractionAnnotation"("targetInteractionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IiosInteractionAnnotation_scopeId_targetInteractionId_actor_key" ON "IiosInteractionAnnotation"("scopeId", "targetInteractionId", "actorId", "annotationType", "value");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_targetInteractionId_fkey" FOREIGN KEY ("targetInteractionId") REFERENCES "IiosInteraction"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "IiosInboxItemKind" ADD VALUE 'MENTION';
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "IiosThreadParticipant" ADD COLUMN "muted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IiosNotificationSubscription" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"scopeId" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL DEFAULT 'webpush',
|
||||||
|
"endpoint" TEXT NOT NULL,
|
||||||
|
"p256dh" TEXT NOT NULL,
|
||||||
|
"auth" TEXT NOT NULL,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "IiosNotificationSubscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IiosNotificationSubscription_endpoint_key" ON "IiosNotificationSubscription"("endpoint");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosNotificationSubscription_actorId_idx" ON "IiosNotificationSubscription"("actorId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
-- Trust plane (July 12): context attestation client registry + nonce replay ledger.
|
||||||
|
CREATE TABLE "IiosClientRegistry" (
|
||||||
|
"clientId" TEXT NOT NULL,
|
||||||
|
"clientType" TEXT NOT NULL,
|
||||||
|
"ownerService" TEXT,
|
||||||
|
"allowedAppIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"attestSecret" TEXT,
|
||||||
|
"jwksUri" TEXT,
|
||||||
|
"spiffeId" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "IiosClientRegistry_pkey" PRIMARY KEY ("clientId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "IiosAttestationNonce" (
|
||||||
|
"nonce" TEXT NOT NULL,
|
||||||
|
"clientId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "IiosAttestationNonce_pkey" PRIMARY KEY ("nonce")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IiosAttestationNonce_expiresAt_idx" ON "IiosAttestationNonce"("expiresAt");
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
-- Reusable message templates (email/SMS/in-app). DB is runtime truth; platform defaults seeded
|
||||||
|
-- from repo files on boot. Versions are immutable (a change = a new higher version).
|
||||||
|
CREATE TYPE "IiosTemplateChannel" AS ENUM ('EMAIL', 'SMS', 'INTERNAL');
|
||||||
|
|
||||||
|
CREATE TABLE "IiosMessageTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"scopeId" TEXT,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"channel" "IiosTemplateChannel" NOT NULL,
|
||||||
|
"locale" TEXT NOT NULL DEFAULT 'en',
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"subject" TEXT,
|
||||||
|
"bodyHtml" TEXT,
|
||||||
|
"bodyText" TEXT,
|
||||||
|
"variables" JSONB,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "IiosMessageTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "IiosMessageTemplate"
|
||||||
|
ADD CONSTRAINT "IiosMessageTemplate_scopeId_fkey"
|
||||||
|
FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Uniqueness for SCOPED rows (scopeId NOT NULL).
|
||||||
|
CREATE UNIQUE INDEX "IiosMessageTemplate_scopeId_key_channel_locale_version_key"
|
||||||
|
ON "IiosMessageTemplate" ("scopeId", "key", "channel", "locale", "version");
|
||||||
|
|
||||||
|
-- Uniqueness for GLOBAL rows (scopeId IS NULL). Postgres treats NULL as distinct under a normal
|
||||||
|
-- UNIQUE, so the constraint above does NOT stop two platform defaults for the same key — this
|
||||||
|
-- partial index does. (Same footgun handled in the inbox idempotency migration.)
|
||||||
|
CREATE UNIQUE INDEX "IiosMessageTemplate_global_key"
|
||||||
|
ON "IiosMessageTemplate" ("key", "channel", "locale", "version")
|
||||||
|
WHERE "scopeId" IS NULL;
|
||||||
|
|
||||||
|
-- Resolution lookup: by key/channel/locale among active rows.
|
||||||
|
CREATE INDEX "IiosMessageTemplate_key_channel_locale_active_idx"
|
||||||
|
ON "IiosMessageTemplate" ("key", "channel", "locale", "active");
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
-- Template provenance on the outbound command: which template (key/version/locale) produced this
|
||||||
|
-- send, plus a hash of the rendered content. The rendered content itself already lives in `payload`;
|
||||||
|
-- these columns answer "which template version produced this send?" for replay/audit. All nullable —
|
||||||
|
-- non-templated sends leave them null.
|
||||||
|
ALTER TABLE "IiosOutboundCommand"
|
||||||
|
ADD COLUMN "templateKey" TEXT,
|
||||||
|
ADD COLUMN "templateVersion" INTEGER,
|
||||||
|
ADD COLUMN "templateLocale" TEXT,
|
||||||
|
ADD COLUMN "renderedHash" TEXT;
|
||||||
@@ -79,6 +79,7 @@ enum IiosInboxItemKind {
|
|||||||
NEEDS_REPLY
|
NEEDS_REPLY
|
||||||
NEEDS_REVIEW
|
NEEDS_REVIEW
|
||||||
NEEDS_APPROVAL
|
NEEDS_APPROVAL
|
||||||
|
MENTION
|
||||||
SUPPORT_UPDATE
|
SUPPORT_UPDATE
|
||||||
MEETING_FOLLOWUP
|
MEETING_FOLLOWUP
|
||||||
DIGEST
|
DIGEST
|
||||||
@@ -95,6 +96,12 @@ enum IiosInboxState {
|
|||||||
STALE
|
STALE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum IiosTemplateChannel {
|
||||||
|
EMAIL
|
||||||
|
SMS
|
||||||
|
INTERNAL
|
||||||
|
}
|
||||||
|
|
||||||
enum IiosTicketState {
|
enum IiosTicketState {
|
||||||
NEW
|
NEW
|
||||||
OPEN
|
OPEN
|
||||||
@@ -303,10 +310,13 @@ model IiosScope {
|
|||||||
channels IiosChannel[]
|
channels IiosChannel[]
|
||||||
threads IiosThread[]
|
threads IiosThread[]
|
||||||
interactions IiosInteraction[]
|
interactions IiosInteraction[]
|
||||||
|
annotations IiosInteractionAnnotation[]
|
||||||
inboxItems IiosInboxItem[]
|
inboxItems IiosInboxItem[]
|
||||||
supportQueues IiosSupportQueue[]
|
supportQueues IiosSupportQueue[]
|
||||||
tickets IiosTicket[]
|
tickets IiosTicket[]
|
||||||
callbacks IiosCallbackRequest[]
|
callbacks IiosCallbackRequest[]
|
||||||
|
notificationSubscriptions IiosNotificationSubscription[]
|
||||||
|
messageTemplates IiosMessageTemplate[]
|
||||||
|
|
||||||
@@index([orgId, appId, tenantId])
|
@@index([orgId, appId, tenantId])
|
||||||
}
|
}
|
||||||
@@ -348,6 +358,7 @@ model IiosActorRef {
|
|||||||
threadsCreated IiosThread[] @relation("ThreadCreatedBy")
|
threadsCreated IiosThread[] @relation("ThreadCreatedBy")
|
||||||
participations IiosThreadParticipant[]
|
participations IiosThreadParticipant[]
|
||||||
interactions IiosInteraction[]
|
interactions IiosInteraction[]
|
||||||
|
annotations IiosInteractionAnnotation[]
|
||||||
receipts IiosMessageReceipt[]
|
receipts IiosMessageReceipt[]
|
||||||
unreadCounters IiosUnreadCounter[]
|
unreadCounters IiosUnreadCounter[]
|
||||||
inboxItemsOwned IiosInboxItem[]
|
inboxItemsOwned IiosInboxItem[]
|
||||||
@@ -355,6 +366,7 @@ model IiosActorRef {
|
|||||||
ticketsRequested IiosTicket[] @relation("TicketRequester")
|
ticketsRequested IiosTicket[] @relation("TicketRequester")
|
||||||
ticketsAssigned IiosTicket[] @relation("TicketAssignee")
|
ticketsAssigned IiosTicket[] @relation("TicketAssignee")
|
||||||
callbacksRequested IiosCallbackRequest[]
|
callbacksRequested IiosCallbackRequest[]
|
||||||
|
notificationSubscriptions IiosNotificationSubscription[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A configured channel surface (PORTAL in P1).
|
/// A configured channel surface (PORTAL in P1).
|
||||||
@@ -409,12 +421,32 @@ model IiosThreadParticipant {
|
|||||||
actorId String
|
actorId String
|
||||||
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
||||||
participantRole String @default("MEMBER")
|
participantRole String @default("MEMBER")
|
||||||
|
muted Boolean @default(false)
|
||||||
joinedAt DateTime @default(now())
|
joinedAt DateTime @default(now())
|
||||||
leftAt DateTime?
|
leftAt DateTime?
|
||||||
|
|
||||||
@@id([threadId, actorId])
|
@@id([threadId, actorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A device/browser push subscription for an actor (Web Push endpoint + keys).
|
||||||
|
/// The notification engine delivers to these when the actor is absent.
|
||||||
|
model IiosNotificationSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scopeId String
|
||||||
|
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||||
|
actorId String
|
||||||
|
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
||||||
|
kind String @default("webpush")
|
||||||
|
endpoint String @unique
|
||||||
|
p256dh String
|
||||||
|
auth String
|
||||||
|
userAgent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
lastSeenAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([actorId])
|
||||||
|
}
|
||||||
|
|
||||||
/// The semantic envelope. Idempotent per (scope, idempotencyKey).
|
/// The semantic envelope. Idempotent per (scope, idempotencyKey).
|
||||||
model IiosInteraction {
|
model IiosInteraction {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -435,6 +467,7 @@ model IiosInteraction {
|
|||||||
occurredAt DateTime @default(now())
|
occurredAt DateTime @default(now())
|
||||||
receivedAt DateTime @default(now())
|
receivedAt DateTime @default(now())
|
||||||
status IiosDeliveryState @default(RECEIVED)
|
status IiosDeliveryState @default(RECEIVED)
|
||||||
|
dataClass String @default("internal") // envelope data class; drives retention window (P9)
|
||||||
policyDecisionRef String?
|
policyDecisionRef String?
|
||||||
consentReceiptRef String?
|
consentReceiptRef String?
|
||||||
crreBundleRef String?
|
crreBundleRef String?
|
||||||
@@ -443,6 +476,7 @@ model IiosInteraction {
|
|||||||
|
|
||||||
parts IiosMessagePart[]
|
parts IiosMessagePart[]
|
||||||
receipts IiosMessageReceipt[]
|
receipts IiosMessageReceipt[]
|
||||||
|
annotations IiosInteractionAnnotation[]
|
||||||
inboxItems IiosInboxItem[]
|
inboxItems IiosInboxItem[]
|
||||||
ticketsCreatedFrom IiosTicket[]
|
ticketsCreatedFrom IiosTicket[]
|
||||||
|
|
||||||
@@ -450,6 +484,26 @@ model IiosInteraction {
|
|||||||
@@index([threadId, occurredAt])
|
@@index([threadId, occurredAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A generic annotation an actor attaches to an interaction. Both `annotationType`
|
||||||
|
/// and `value` are OPAQUE, app-supplied strings the kernel stores and aggregates
|
||||||
|
/// but never interprets — e.g. the chat app writes type "reaction" / value "👍".
|
||||||
|
/// The same primitive backs pins, saves, flags, tags later. No chat vocabulary here.
|
||||||
|
model IiosInteractionAnnotation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scopeId String
|
||||||
|
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||||
|
targetInteractionId String
|
||||||
|
target IiosInteraction @relation(fields: [targetInteractionId], references: [id], onDelete: Cascade)
|
||||||
|
actorId String
|
||||||
|
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
||||||
|
annotationType String
|
||||||
|
value String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([scopeId, targetInteractionId, actorId, annotationType, value])
|
||||||
|
@@index([targetInteractionId])
|
||||||
|
}
|
||||||
|
|
||||||
model IiosMessagePart {
|
model IiosMessagePart {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
interactionId String
|
interactionId String
|
||||||
@@ -493,6 +547,27 @@ model IiosProcessedEvent {
|
|||||||
@@id([consumerName, eventId])
|
@@id([consumerName, eventId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-resource retention snapshot (P9). Absolute lifecycle timestamps frozen from the
|
||||||
|
/// resource's age + policy window; read by the retention sweep. delete = redact-in-place.
|
||||||
|
model IiosRetentionPolicySnapshot {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
policyKey String
|
||||||
|
scopeSnapshotId String
|
||||||
|
targetType String // 'interaction'
|
||||||
|
targetId String
|
||||||
|
dataClass String
|
||||||
|
retainUntil DateTime?
|
||||||
|
archiveAfter DateTime
|
||||||
|
deleteAfter DateTime
|
||||||
|
sourceVersion String @default("v1")
|
||||||
|
status String @default("ACTIVE") // ACTIVE | ARCHIVED | REDACTED
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([targetType, targetId])
|
||||||
|
@@index([status, deleteAfter])
|
||||||
|
@@index([scopeSnapshotId, status])
|
||||||
|
}
|
||||||
|
|
||||||
/// Legal/compliance hold (P9 DSR). An ACTIVE, unexpired hold over a target BLOCKS
|
/// Legal/compliance hold (P9 DSR). An ACTIVE, unexpired hold over a target BLOCKS
|
||||||
/// erasure/retention deletion until an authorised release. Never auto-released.
|
/// erasure/retention deletion until an authorised release. Never auto-released.
|
||||||
model IiosComplianceHold {
|
model IiosComplianceHold {
|
||||||
@@ -609,6 +684,33 @@ 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())
|
||||||
@@ -766,6 +868,11 @@ 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[]
|
||||||
@@ -1253,3 +1360,31 @@ 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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// P9 slice-8 DSR smoke: right-to-erasure redacts a subject's PII in place (a ticket
|
||||||
|
// subject), an active compliance hold blocks erasure until released, erasure is
|
||||||
|
// idempotent, and holds are tenant-fenced (KG-03 / KG-02). Requires the service
|
||||||
|
// running with IIOS_DEV_TOKENS=1.
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
|
||||||
|
async function devToken(userId, orgId) {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/dev/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`);
|
||||||
|
return (await r.json()).token;
|
||||||
|
}
|
||||||
|
function call(token, path, method = 'GET', body) {
|
||||||
|
return fetch(`${SERVICE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function json(token, path, method = 'GET', body) {
|
||||||
|
const r = await call(token, path, method, body);
|
||||||
|
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
const ticketSubject = async (token, id) => (await json(token, '/v1/support/tickets')).find((t) => t.id === id)?.subject;
|
||||||
|
|
||||||
|
const A = await devToken('dsr-a', 'org_dsr_A');
|
||||||
|
const B = await devToken('dsr-b', 'org_dsr_B');
|
||||||
|
const PII = 'my SSN is 123-45';
|
||||||
|
|
||||||
|
// A creates a ticket carrying PII.
|
||||||
|
const ticket = await json(A, '/v1/support/tickets', 'POST', { subject: PII });
|
||||||
|
assert((await ticketSubject(A, ticket.id)) === PII, 'ticket carries the subject PII');
|
||||||
|
|
||||||
|
// A places a compliance hold on their own subject.
|
||||||
|
const hold = await json(A, '/v1/dsr/holds', 'POST', { targetType: 'data_subject', targetId: 'self', reason: 'litigation' });
|
||||||
|
assert((await json(A, '/v1/dsr/holds')).some((h) => h.id === hold.id), 'compliance hold is listed');
|
||||||
|
|
||||||
|
// Erasure is blocked while the hold is active.
|
||||||
|
const blocked = await call(A, '/v1/dsr/erase', 'POST');
|
||||||
|
assert(blocked.status === 409, `erase blocked by the hold → 409 (got ${blocked.status})`);
|
||||||
|
assert((await ticketSubject(A, ticket.id)) === PII, 'PII still present while held');
|
||||||
|
|
||||||
|
// Release the hold → erasure now redacts the PII in place.
|
||||||
|
await json(A, `/v1/dsr/holds/${hold.id}/release`, 'POST');
|
||||||
|
const erased = await json(A, '/v1/dsr/erase', 'POST');
|
||||||
|
assert(erased.status === 'ERASED', `erase succeeds after release (status ${erased.status})`);
|
||||||
|
assert((await ticketSubject(A, ticket.id)) === '[redacted]', 'ticket subject is redacted (erased in place, not deleted)');
|
||||||
|
|
||||||
|
// Idempotent: a second erase is a no-op.
|
||||||
|
const again = await json(A, '/v1/dsr/erase', 'POST');
|
||||||
|
assert(again.status === 'ALREADY_ERASED', `re-erase is idempotent (status ${again.status})`);
|
||||||
|
|
||||||
|
// Tenant fence: tenant B never sees A's holds.
|
||||||
|
assert(!(await json(B, '/v1/dsr/holds')).some((h) => h.id === hold.id), "tenant B's hold list excludes A's hold");
|
||||||
|
|
||||||
|
console.log('\nP9 DSR smoke: PASS');
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Email adapter smoke: a signed inbound email → normalized interaction; a reply
|
||||||
|
// (In-Reply-To) lands on the SAME email thread; outbound EMAIL → SENT (sandbox).
|
||||||
|
// Requires the service running with IIOS_DEV_TOKENS=1 (relay timer on). No real network.
|
||||||
|
import 'dotenv/config';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const APP_SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
|
||||||
|
const EMAIL_SECRET = (() => {
|
||||||
|
try { return JSON.parse(process.env.ADAPTER_SECRETS ?? '{}').EMAIL ?? 'dev-adapter-secret'; } catch { return 'dev-adapter-secret'; }
|
||||||
|
})();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const token = jwt.sign({ sub: 'inspector', appId: APP_ID, orgId: `org_${APP_ID}` }, APP_SECRET, { algorithm: 'HS256', expiresIn: '1h' });
|
||||||
|
const sign = (body) => 'sha256=' + crypto.createHmac('sha256', EMAIL_SECRET).update(body).digest('hex');
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
|
||||||
|
async function postEmail(payload) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
return fetch(`${SERVICE}/v1/adapters/EMAIL/webhook`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-iios-signature': sign(body) },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function inboundList() {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/adapters/inbound`, { headers: { authorization: `Bearer ${token}` } });
|
||||||
|
if (!r.ok) throw new Error(`GET /v1/adapters/inbound ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function waitNormalized(messageId) {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const e = (await inboundList()).find((x) => x.externalEventId === messageId);
|
||||||
|
if (e && e.status === 'NORMALIZED' && e.interactionId) return e.interactionId;
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique Message-IDs per run so re-runs don't dedup against earlier ones.
|
||||||
|
const run = randomUUID().slice(0, 8);
|
||||||
|
const mid1 = `<${run}-1@smoke>`;
|
||||||
|
const mid2 = `<${run}-2@smoke>`;
|
||||||
|
|
||||||
|
// 1) A first inbound email → normalized interaction.
|
||||||
|
const r1 = await postEmail({ messageId: mid1, from: 'buyer@smoke', fromName: 'Buyer', subject: 'Need help with my order', text: 'my order is late' });
|
||||||
|
assert(r1.status === 202, 'signed email accepted (202)');
|
||||||
|
const i1 = await waitNormalized(mid1);
|
||||||
|
assert(i1, 'first email normalized into an interaction');
|
||||||
|
|
||||||
|
// 2) A reply (In-Reply-To) → same email thread.
|
||||||
|
const r2 = await postEmail({ messageId: mid2, from: 'buyer@smoke', subject: 'Re: Need help with my order', text: 'any update?', inReplyTo: mid1, references: [mid1] });
|
||||||
|
assert(r2.status === 202, 'reply email accepted (202)');
|
||||||
|
const i2 = await waitNormalized(mid2);
|
||||||
|
assert(i2, 'reply email normalized into an interaction');
|
||||||
|
|
||||||
|
const [it1, it2] = await Promise.all([
|
||||||
|
prisma.iiosInteraction.findUniqueOrThrow({ where: { id: i1 }, include: { thread: true } }),
|
||||||
|
prisma.iiosInteraction.findUniqueOrThrow({ where: { id: i2 }, include: { thread: true } }),
|
||||||
|
]);
|
||||||
|
assert(it1.threadId === it2.threadId, 'reply joined the SAME email thread as the original');
|
||||||
|
assert(it1.thread?.externalThreadRef === `email:${mid1}`, `thread rooted at the original Message-ID (${it1.thread?.externalThreadRef})`);
|
||||||
|
|
||||||
|
// 3) Bad signature → rejected.
|
||||||
|
const bad = await fetch(`${SERVICE}/v1/adapters/EMAIL/webhook`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-iios-signature': 'sha256=bad' },
|
||||||
|
body: JSON.stringify({ messageId: `<${run}-bad@smoke>`, from: 'x', text: 'x' }),
|
||||||
|
});
|
||||||
|
assert(bad.status === 401, 'bad signature rejected (401)');
|
||||||
|
|
||||||
|
// 4) Outbound EMAIL → governed egress → SENT (sandbox).
|
||||||
|
const sendRes = await fetch(`${SERVICE}/v1/adapters/EMAIL/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ target: 'buyer@smoke', payload: { subject: 'Re: Need help', text: 'thanks, looking into it' } }),
|
||||||
|
});
|
||||||
|
const cmd = await sendRes.json();
|
||||||
|
assert(cmd.status === 'SENT', `outbound email SENT via broker (status ${cmd.status})`);
|
||||||
|
|
||||||
|
console.log('\nEmail adapter smoke: PASS');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// v1.1 smoke: dev IdP login, policy-enforced DM cap / group membership, listThreads,
|
||||||
|
// and threaded replies — all over REST. Requires the service with IIOS_DEV_TOKENS=1.
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/dev/login`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return { status: r.status, body: r.ok ? await r.json() : null };
|
||||||
|
}
|
||||||
|
function call(token, path, method = 'GET', body) {
|
||||||
|
return fetch(`${SERVICE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function json(token, path, method = 'GET', body) {
|
||||||
|
const r = await call(token, path, method, body);
|
||||||
|
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) dev IdP: correct password issues a token; wrong password is rejected.
|
||||||
|
const ok = await login('alice', 'alice');
|
||||||
|
assert(ok.body?.token, 'dev IdP: alice logs in with password');
|
||||||
|
const bad = await login('alice', 'nope');
|
||||||
|
assert(bad.status === 401, 'dev IdP: wrong password → 401');
|
||||||
|
const alice = ok.body.token;
|
||||||
|
|
||||||
|
// 2) DM is capped at two (policy).
|
||||||
|
const dm = await json(alice, '/v1/threads', 'POST', { membership: 'dm' });
|
||||||
|
assert(dm.threadId, `alice created a DM (${dm.threadId})`);
|
||||||
|
const add2 = await json(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'bob' });
|
||||||
|
assert(add2.participantCount === 2, 'added bob → 2 participants');
|
||||||
|
const add3 = await call(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'carol' });
|
||||||
|
assert(add3.status === 403, `adding a 3rd to a DM → 403 policy denied (${add3.status})`);
|
||||||
|
|
||||||
|
// 3) group: creator (ADMIN) can add several.
|
||||||
|
const grp = await json(alice, '/v1/threads', 'POST', { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'bob' });
|
||||||
|
const g3 = await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'carol' });
|
||||||
|
assert(g3.participantCount === 3, 'group admin added bob + carol → 3 participants');
|
||||||
|
|
||||||
|
// 4) threaded reply round-trips.
|
||||||
|
const first = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'question?' });
|
||||||
|
const reply = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'answer', parentInteractionId: first.id });
|
||||||
|
assert(reply.parentInteractionId === first.id, 'a reply carries parentInteractionId');
|
||||||
|
|
||||||
|
// 5) listThreads shows the caller's threads with membership + last message.
|
||||||
|
const mine = await json(alice, '/v1/threads');
|
||||||
|
const g = mine.find((t) => t.threadId === grp.threadId);
|
||||||
|
assert(g && g.membership === 'group' && g.participantCount === 3, 'GET /v1/threads lists the group with membership + count');
|
||||||
|
assert(g.lastMessage === 'answer', 'thread summary carries the last message');
|
||||||
|
|
||||||
|
console.log('\nv1.1 membership + replies smoke: PASS');
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// P9 scaling smoke: cross-instance realtime fan-out via the socket.io Redis adapter.
|
||||||
|
// Alice connects to instance A, Bob to instance B (two separate processes sharing one
|
||||||
|
// Redis + DB). Alice sends on A; Bob must receive it live on B — which only works if the
|
||||||
|
// Redis adapter fans room emits across replicas. Set A_URL / B_URL (default 3201/3202).
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const A_URL = process.env.A_URL ?? 'http://localhost:3201';
|
||||||
|
const B_URL = process.env.B_URL ?? 'http://localhost:3202';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
|
||||||
|
|
||||||
|
const sign = (userId) =>
|
||||||
|
jwt.sign({ sub: userId, name: userId, appId: APP_ID, orgId: `org_${APP_ID}` }, SECRET, { algorithm: 'HS256', expiresIn: '1h' });
|
||||||
|
const connect = (url, userId) =>
|
||||||
|
io(`${url}/message`, { auth: { token: sign(userId) }, transports: ['websocket'], forceNew: true });
|
||||||
|
const once = (socket, event, ms = 8000) =>
|
||||||
|
new Promise((res, rej) => {
|
||||||
|
const t = setTimeout(() => rej(new Error(`timeout waiting for "${event}"`)), ms);
|
||||||
|
socket.once(event, (v) => { clearTimeout(t); res(v); });
|
||||||
|
});
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
|
||||||
|
const alice = connect(A_URL, 'alice-cluster');
|
||||||
|
const bob = connect(B_URL, 'bob-cluster');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([once(alice, 'connect'), once(bob, 'connect')]);
|
||||||
|
assert(true, `Alice connected to A (${A_URL}), Bob to B (${B_URL})`);
|
||||||
|
|
||||||
|
// Alice opens a thread on instance A; membership is governed so Alice ADDS Bob, who
|
||||||
|
// then opens the SAME thread on instance B.
|
||||||
|
const opened = await alice.emitWithAck('open_thread', {});
|
||||||
|
const threadId = opened.threadId;
|
||||||
|
assert(!!threadId, `Alice opened thread ${threadId} on instance A`);
|
||||||
|
await alice.emitWithAck('add_participant', { threadId, userId: 'bob-cluster' });
|
||||||
|
await bob.emitWithAck('open_thread', { threadId });
|
||||||
|
assert(true, 'Bob joined the same thread on instance B');
|
||||||
|
|
||||||
|
// Alice sends on A → Bob must receive it on B (cross-instance fan-out via Redis).
|
||||||
|
const bobReceives = once(bob, 'message');
|
||||||
|
await alice.emitWithAck('send_message', { threadId, content: 'hello across instances' });
|
||||||
|
const received = await bobReceives;
|
||||||
|
assert(received.content === 'hello across instances', `Bob received "${received.content}" on B — emitted from A ✓`);
|
||||||
|
|
||||||
|
console.log('\nP9 cross-instance realtime smoke: PASS');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('✗', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
@@ -44,10 +44,11 @@ const alice = connect('alice');
|
|||||||
const bob = connect('bob');
|
const bob = connect('bob');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Alice creates a thread; Bob joins it.
|
// Alice creates a thread; membership is governed, so Alice ADDS Bob (he can't self-join).
|
||||||
const opened = await alice.emitWithAck('open_thread', {});
|
const opened = await alice.emitWithAck('open_thread', {});
|
||||||
const threadId = opened.threadId;
|
const threadId = opened.threadId;
|
||||||
assert(!!threadId, `Alice created thread ${threadId}`);
|
assert(!!threadId, `Alice created thread ${threadId}`);
|
||||||
|
await alice.emitWithAck('add_participant', { threadId, userId: 'bob' });
|
||||||
await bob.emitWithAck('open_thread', { threadId });
|
await bob.emitWithAck('open_thread', { threadId });
|
||||||
|
|
||||||
// Alice sends; Bob should receive it live.
|
// Alice sends; Bob should receive it live.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// P9 slice-9 retention smoke: an aged interaction's snapshot passes its delete window
|
||||||
|
// and the sweep redacts the content in place (never a hard delete), visible as a
|
||||||
|
// REDACTED snapshot on /metrics. Requires the service running with IIOS_DEV_TOKENS=1
|
||||||
|
// and IIOS_RETENTION_DELETE_DAYS=0 IIOS_RETENTION_ARCHIVE_DAYS=0 (new content due at once;
|
||||||
|
// scheduled interval left OFF so only the explicit dev sweep runs).
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function devToken(userId, orgId) {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/dev/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`);
|
||||||
|
return (await r.json()).token;
|
||||||
|
}
|
||||||
|
async function api(token, path, method = 'GET', body) {
|
||||||
|
const r = await fetch(`${SERVICE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
const redactedCount = (m) => m.retention?.byStatus?.REDACTED ?? 0;
|
||||||
|
|
||||||
|
const token = await devToken('ret-a', 'org_ret_A');
|
||||||
|
|
||||||
|
const base = redactedCount(await api(token, '/metrics'));
|
||||||
|
console.log(`baseline REDACTED snapshots = ${base}`);
|
||||||
|
|
||||||
|
// Inject content and give the relay a moment to normalize it into an interaction.
|
||||||
|
await api(token, '/v1/dev/webhook/WEBHOOK', 'POST', { text: 'retention smoke content' });
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
// Sweep: 0-day windows make every interaction's snapshot immediately due → redacted.
|
||||||
|
const swept = await api(token, '/v1/dev/retention/sweep', 'POST');
|
||||||
|
assert(swept.redacted >= 1, `sweep redacted the aged interaction(s) (redacted=${swept.redacted})`);
|
||||||
|
|
||||||
|
const after = await api(token, '/metrics');
|
||||||
|
assert(redactedCount(after) > base, `/metrics shows more REDACTED snapshots (${base} → ${redactedCount(after)})`);
|
||||||
|
assert((after.retention?.total ?? 0) >= 1, 'retention.total reflects captured snapshots');
|
||||||
|
|
||||||
|
console.log('\nP9 retention smoke: PASS');
|
||||||
|
process.exit(0);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { ScopeVector } from '@insignia/iios-contracts';
|
import type { ScopeVector } from '@insignia/iios-contracts';
|
||||||
import { WebhookAdapter, type ChannelAdapter } from '@insignia/iios-adapter-sdk';
|
import { WebhookAdapter, EmailAdapter, type ChannelAdapter } from '@insignia/iios-adapter-sdk';
|
||||||
|
|
||||||
export interface AdapterConfig {
|
export interface AdapterConfig {
|
||||||
secret: string;
|
secret: string;
|
||||||
@@ -8,10 +8,9 @@ export interface AdapterConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers channel adapters + their per-channel config (secret + scope). In P5
|
* Registers channel adapters + their per-channel config (secret + scope). EMAIL uses
|
||||||
* one reference WebhookAdapter backs several channelTypes (WEBHOOK/EMAIL/WHATSAPP)
|
* the email-native EmailAdapter (RFC threading); the rest use the reference
|
||||||
* so the fixtures exercise the same anti-corruption pipeline. Secrets come from
|
* WebhookAdapter. Secrets come from ADAPTER_SECRETS (JSON map); scope defaults to demo.
|
||||||
* ADAPTER_SECRETS (JSON map); scope defaults to the demo scope.
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdapterRegistry {
|
export class AdapterRegistry {
|
||||||
@@ -29,8 +28,9 @@ export class AdapterRegistry {
|
|||||||
appId: process.env.ADAPTER_APP ?? 'portal-demo',
|
appId: process.env.ADAPTER_APP ?? 'portal-demo',
|
||||||
};
|
};
|
||||||
for (const channelType of ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']) {
|
for (const channelType of ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']) {
|
||||||
|
const adapter: ChannelAdapter = channelType === 'EMAIL' ? new EmailAdapter() : new WebhookAdapter(channelType);
|
||||||
this.adapters.set(channelType, {
|
this.adapters.set(channelType, {
|
||||||
adapter: new WebhookAdapter(channelType),
|
adapter,
|
||||||
config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope },
|
config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { DlqService } from '../outbox/dlq.service';
|
import { DlqService } from '../outbox/dlq.service';
|
||||||
import { resetDb } from '../test-utils/reset-db';
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
||||||
import { makeFakePorts } from '@insignia/iios-testkit';
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
|
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
|
||||||
import { signedFixture, emailFixture } from '@insignia/iios-adapter-sdk';
|
import { signedFixture, emailFixture, emailInboundFixture, emailReplyFixture } from '@insignia/iios-adapter-sdk';
|
||||||
import { AdapterRegistry } from './adapter.registry';
|
import { AdapterRegistry } from './adapter.registry';
|
||||||
import { InboundService } from './inbound.service';
|
import { InboundService } from './inbound.service';
|
||||||
import { OutboundService } from './outbound.service';
|
import { OutboundService } from './outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { RawEventProjector } from './raw-event.projector';
|
import { RawEventProjector } from './raw-event.projector';
|
||||||
@@ -79,9 +81,60 @@ describe('Adapter inbound (P5)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Email adapter — inbound + threading', () => {
|
||||||
|
const receiveEmail = async (payload: object) => {
|
||||||
|
const { body, headers } = signedFixture(payload, SECRET);
|
||||||
|
return inbound().receive('EMAIL', body, headers);
|
||||||
|
};
|
||||||
|
const normalizeAll = async () => {
|
||||||
|
for (const ev of await rawReceivedEvents()) await projector().onRawReceived(ev);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('a signed inbound email → a normalized interaction with the body on the email thread', async () => {
|
||||||
|
const ack = await receiveEmail(emailInboundFixture);
|
||||||
|
expect(ack.status).toBe('RECEIVED');
|
||||||
|
await normalizeAll();
|
||||||
|
|
||||||
|
const raw = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
|
||||||
|
expect(raw.status).toBe('NORMALIZED');
|
||||||
|
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: raw.interactionId! }, include: { parts: true, thread: true } });
|
||||||
|
expect(interaction.parts[0]?.bodyText).toBe(emailInboundFixture.text);
|
||||||
|
expect(interaction.thread?.externalThreadRef).toBe('email:<m1@example.com>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reply email (In-Reply-To) lands on the SAME email thread as the original', async () => {
|
||||||
|
await receiveEmail(emailInboundFixture);
|
||||||
|
await receiveEmail(emailReplyFixture);
|
||||||
|
await normalizeAll();
|
||||||
|
|
||||||
|
const interactions = await prisma.iiosInteraction.findMany({ orderBy: { occurredAt: 'asc' } });
|
||||||
|
expect(interactions).toHaveLength(2);
|
||||||
|
expect(interactions[0]!.threadId).toBe(interactions[1]!.threadId); // reply joined the thread
|
||||||
|
expect(await prisma.iiosThread.count()).toBe(1); // one email conversation
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redelivered email (same Message-ID) → one raw event, one interaction (dedup)', async () => {
|
||||||
|
const svc = inbound();
|
||||||
|
const { body, headers } = signedFixture(emailInboundFixture, SECRET);
|
||||||
|
await svc.receive('EMAIL', body, headers);
|
||||||
|
const second = await svc.receive('EMAIL', body, headers);
|
||||||
|
expect(second.deduped).toBe(true);
|
||||||
|
await normalizeAll();
|
||||||
|
expect(await prisma.iiosInboundRawEvent.count()).toBe(1);
|
||||||
|
expect(await prisma.iiosInteraction.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bad signature on EMAIL → REJECTED, nothing ingested (fail-closed)', async () => {
|
||||||
|
const { body } = signedFixture(emailInboundFixture, SECRET);
|
||||||
|
await expect(inbound().receive('EMAIL', body, { 'x-iios-signature': 'sha256=bad' })).rejects.toThrow();
|
||||||
|
expect(await prisma.iiosInboundRawEvent.count({ where: { status: 'REJECTED' } })).toBe(1);
|
||||||
|
expect(await prisma.iiosInteraction.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Adapter outbound (P5)', () => {
|
describe('Adapter outbound (P5)', () => {
|
||||||
const outbound = (limit?: number): OutboundService => {
|
const outbound = (limit?: number): OutboundService => {
|
||||||
const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
|
const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
if (limit) s.limit = limit;
|
if (limit) s.limit = limit;
|
||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
@@ -112,7 +165,7 @@ describe('Adapter outbound (P5)', () => {
|
|||||||
it('broker obligation DENY_OUTBOUND → command BLOCKED, no send (P9)', async () => {
|
it('broker obligation DENY_OUTBOUND → command BLOCKED, no send (P9)', async () => {
|
||||||
const ports = makeFakePorts();
|
const ports = makeFakePorts();
|
||||||
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'recipient opted out' }] });
|
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'recipient opted out' }] });
|
||||||
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
|
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'blk-1');
|
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'blk-1');
|
||||||
expect(cmd.status).toBe('BLOCKED');
|
expect(cmd.status).toBe('BLOCKED');
|
||||||
expect(cmd.providerRef).toBe('blocked');
|
expect(cmd.providerRef).toBe('blocked');
|
||||||
@@ -121,7 +174,7 @@ describe('Adapter outbound (P5)', () => {
|
|||||||
it('CMP allow → command records the consent receipt (P9 slice 2)', async () => {
|
it('CMP allow → command records the consent receipt (P9 slice 2)', async () => {
|
||||||
const ports = makeFakePorts();
|
const ports = makeFakePorts();
|
||||||
ports.cmp.setStatus('ALLOW');
|
ports.cmp.setStatus('ALLOW');
|
||||||
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
|
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support');
|
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support');
|
||||||
expect(cmd.status).toBe('SENT');
|
expect(cmd.status).toBe('SENT');
|
||||||
expect(cmd.consentReceiptRef).toBe('fake-receipt');
|
expect(cmd.consentReceiptRef).toBe('fake-receipt');
|
||||||
@@ -130,7 +183,7 @@ describe('Adapter outbound (P5)', () => {
|
|||||||
it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => {
|
it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => {
|
||||||
const ports = makeFakePorts();
|
const ports = makeFakePorts();
|
||||||
ports.cmp.denyPurpose('marketing');
|
ports.cmp.denyPurpose('marketing');
|
||||||
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
|
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing');
|
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing');
|
||||||
expect(cmd.status).toBe('BLOCKED');
|
expect(cmd.status).toBe('BLOCKED');
|
||||||
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
|
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
|
||||||
@@ -146,4 +199,18 @@ describe('Adapter outbound (P5)', () => {
|
|||||||
expect(a2.status).toBe('RATE_LIMITED');
|
expect(a2.status).toBe('RATE_LIMITED');
|
||||||
expect(b1.status).toBe('SENT');
|
expect(b1.status).toBe('SENT');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('idempotency ledger: same key + a different target → conflict, no second command (P9 slice 10)', async () => {
|
||||||
|
const svc = outbound();
|
||||||
|
const first = await svc.send('WEBHOOK', 'user@a', { text: 'hi' }, 'dup-key');
|
||||||
|
expect(first.status).toBe('SENT');
|
||||||
|
await expect(svc.send('WEBHOOK', 'user@b', { text: 'hi' }, 'dup-key')).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'dup-key' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opt-in: a keyless send creates a command but writes no idempotency-ledger row (P9 slice 10)', async () => {
|
||||||
|
const cmd = await outbound().send('WEBHOOK', 'user@x', { text: 'hi' });
|
||||||
|
expect(cmd.status).toBe('SENT');
|
||||||
|
expect(await prisma.iiosIdempotencyCommand.count()).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export class InboundService {
|
|||||||
const { adapter, config } = reg;
|
const { adapter, config } = reg;
|
||||||
|
|
||||||
const payload = this.safeParse(rawBody);
|
const payload = this.safeParse(rawBody);
|
||||||
const externalEventId = typeof payload?.eventId === 'string' ? payload.eventId : undefined;
|
// The adapter extracts its provider-native id (eventId, Message-ID, …) for replay dedup.
|
||||||
|
const externalEventId = adapter.externalEventId?.(payload) ?? (typeof payload?.eventId === 'string' ? payload.eventId : undefined);
|
||||||
|
|
||||||
// Fail-closed: a bad/absent signature is recorded and rejected — never ingested.
|
// Fail-closed: a bad/absent signature is recorded and rejected — never ingested.
|
||||||
if (!adapter.verifySignature(rawBody, headers, config.secret)) {
|
if (!adapter.verifySignature(rawBody, headers, config.secret)) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { recordAudit } from '../observability/audit';
|
import { recordAudit } from '../observability/audit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,55 +24,77 @@ export class OutboundService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly broker: CapabilityBroker,
|
private readonly broker: CapabilityBroker,
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async send(
|
async send(
|
||||||
channelType: string,
|
channelType: string,
|
||||||
target: string,
|
target: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
idempotencyKey: string = randomUUID(),
|
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 existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } });
|
const key = idempotencyKey ?? randomUUID();
|
||||||
if (existing) return existing;
|
const prov = {
|
||||||
|
templateKey: provenance?.templateKey ?? null,
|
||||||
|
templateVersion: provenance?.templateVersion ?? null,
|
||||||
|
templateLocale: provenance?.templateLocale ?? null,
|
||||||
|
renderedHash: provenance?.renderedHash ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried
|
||||||
|
// command returns the existing row instead of colliding on idempotencyKey @unique.
|
||||||
|
const body = async () => {
|
||||||
|
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey: key } });
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
||||||
|
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||||
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
|
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key, ...prov },
|
||||||
|
});
|
||||||
|
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
|
||||||
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey },
|
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key, ...prov },
|
||||||
});
|
});
|
||||||
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
let result;
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey },
|
try {
|
||||||
});
|
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey: key, scopeId, purpose });
|
||||||
|
} catch (err) {
|
||||||
|
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }),
|
||||||
|
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }),
|
||||||
|
]);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
let result;
|
const [updated] = await this.prisma.$transaction([
|
||||||
try {
|
this.prisma.iiosOutboundCommand.update({
|
||||||
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId, purpose });
|
where: { id: cmd.id },
|
||||||
} catch (err) {
|
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
|
||||||
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
|
}),
|
||||||
await this.prisma.$transaction([
|
this.prisma.iiosDeliveryAttempt.create({
|
||||||
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }),
|
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
|
||||||
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }),
|
}),
|
||||||
]);
|
]);
|
||||||
throw err;
|
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
|
||||||
}
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
const [updated] = await this.prisma.$transaction([
|
// Opt-in idempotency: an explicit key routes through the ledger (request-hash conflict → 409,
|
||||||
this.prisma.iiosOutboundCommand.update({
|
// response replay); a keyless send just runs with a fresh uuid (old behavior).
|
||||||
where: { id: cmd.id },
|
if (!idempotencyKey) return body();
|
||||||
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
|
return this.idempotency.run({ scopeId, commandName: 'channel.send', key: idempotencyKey, request: { channelType, target, payload, purpose } }, body);
|
||||||
}),
|
|
||||||
this.prisma.iiosDeliveryAttempt.create({
|
|
||||||
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
|
|
||||||
return updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
|
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
@@ -9,6 +10,10 @@ 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 { NotificationModule } from './notifications/notification.module';
|
||||||
import { SupportModule } from './support/support.module';
|
import { SupportModule } from './support/support.module';
|
||||||
import { AdaptersModule } from './adapters/adapters.module';
|
import { AdaptersModule } from './adapters/adapters.module';
|
||||||
import { RoutingModule } from './routing/routing.module';
|
import { RoutingModule } from './routing/routing.module';
|
||||||
@@ -16,6 +21,7 @@ import { AiModule } from './ai/ai.module';
|
|||||||
import { CalendarModule } from './calendar/calendar.module';
|
import { CalendarModule } from './calendar/calendar.module';
|
||||||
import { CapabilityModule } from './capability/capability.module';
|
import { CapabilityModule } from './capability/capability.module';
|
||||||
import { DsrModule } from './dsr/dsr.module';
|
import { DsrModule } from './dsr/dsr.module';
|
||||||
|
import { RetentionModule } from './retention/retention.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { MetricsController } from './observability/metrics.controller';
|
import { MetricsController } from './observability/metrics.controller';
|
||||||
import { DevController } from './dev/dev.controller';
|
import { DevController } from './dev/dev.controller';
|
||||||
@@ -24,6 +30,7 @@ import { DevController } from './dev/dev.controller';
|
|||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
|
AttestationModule,
|
||||||
IdentityModule,
|
IdentityModule,
|
||||||
InteractionsModule,
|
InteractionsModule,
|
||||||
IdempotencyModule,
|
IdempotencyModule,
|
||||||
@@ -32,6 +39,10 @@ import { DevController } from './dev/dev.controller';
|
|||||||
ThreadsModule,
|
ThreadsModule,
|
||||||
MessageModule,
|
MessageModule,
|
||||||
InboxModule,
|
InboxModule,
|
||||||
|
TemplateModule,
|
||||||
|
MailModule,
|
||||||
|
MediaModule,
|
||||||
|
NotificationModule,
|
||||||
SupportModule,
|
SupportModule,
|
||||||
AdaptersModule,
|
AdaptersModule,
|
||||||
RoutingModule,
|
RoutingModule,
|
||||||
@@ -39,6 +50,7 @@ import { DevController } from './dev/dev.controller';
|
|||||||
CalendarModule,
|
CalendarModule,
|
||||||
CapabilityModule,
|
CapabilityModule,
|
||||||
DsrModule,
|
DsrModule,
|
||||||
|
RetentionModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController, MetricsController, DevController],
|
controllers: [HealthController, MetricsController, DevController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ActorResolver } from '../identity/actor.resolver';
|
|||||||
import { AiJobService } from '../ai/ai.service';
|
import { AiJobService } from '../ai/ai.service';
|
||||||
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
||||||
import { OutboundService } from '../adapters/outbound.service';
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { CalendarService } from './calendar.service';
|
import { CalendarService } from './calendar.service';
|
||||||
@@ -22,7 +23,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
|
|||||||
const START = '2026-07-02T10:00:00.000Z';
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())));
|
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)));
|
||||||
|
|
||||||
async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) {
|
async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) {
|
||||||
const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees });
|
const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ActorResolver } from '../identity/actor.resolver';
|
|||||||
import { AiJobService } from '../ai/ai.service';
|
import { AiJobService } from '../ai/ai.service';
|
||||||
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
||||||
import { OutboundService } from '../adapters/outbound.service';
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { CalendarService } from './calendar.service';
|
import { CalendarService } from './calendar.service';
|
||||||
@@ -27,7 +28,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
|
|||||||
const START = '2026-07-02T10:00:00.000Z';
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())));
|
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)));
|
||||||
|
|
||||||
async function makeInteraction(text: string): Promise<string> {
|
async function makeInteraction(text: string): Promise<string> {
|
||||||
const m = new MessageService(asService, makeFakePorts(), actors);
|
const m = new MessageService(asService, makeFakePorts(), actors);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ActorResolver } from '../identity/actor.resolver';
|
|||||||
import { AiJobService } from '../ai/ai.service';
|
import { AiJobService } from '../ai/ai.service';
|
||||||
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
||||||
import { OutboundService } from '../adapters/outbound.service';
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { CalendarService } from './calendar.service';
|
import { CalendarService } from './calendar.service';
|
||||||
@@ -24,7 +25,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
|
|||||||
const START = '2026-07-02T10:00:00.000Z';
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
|
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), outbound());
|
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), outbound());
|
||||||
|
|
||||||
async function makeInteraction(text: string): Promise<string> {
|
async function makeInteraction(text: string): Promise<string> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ 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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { CapabilityProviderRegistry } from './capability.registry';
|
||||||
|
|
||||||
|
// The registry reads env in its constructor, so each case sets env → constructs a fresh registry →
|
||||||
|
// asserts → restores env.
|
||||||
|
const SMTP_KEYS = ['IIOS_SMTP_HOST', 'IIOS_SMTP_USER', 'IIOS_SMTP_PASS', 'IIOS_PROVIDER_URL_EMAIL'];
|
||||||
|
const saved: Record<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,25 +1,40 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Inject, Injectable, NotFoundException, Optional } 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 { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
|
||||||
|
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
|
||||||
|
|
||||||
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
|
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a channelType to the provider that executes egress for it. Sandbox by
|
* 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() {
|
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) {
|
||||||
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
for (const ch of DEFAULT_CHANNELS) 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}`];
|
||||||
if (url) this.register(new HttpProvider(ch, url));
|
if (!url) continue;
|
||||||
|
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
|
||||||
|
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
|
||||||
|
}
|
||||||
|
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
|
||||||
|
|
||||||
|
interface EmailPayload {
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
inReplyTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email egress provider: shapes the outbound command into an email envelope
|
||||||
|
* ({to, subject, text, html, inReplyTo}) and POSTs it to a configured send endpoint
|
||||||
|
* (a vendor mail API / SMTP relay behind a URL). Activated only when
|
||||||
|
* IIOS_PROVIDER_URL_EMAIL is set; a transport failure is surfaced as FAILED, never thrown.
|
||||||
|
*/
|
||||||
|
export class EmailProvider implements CapabilityProvider {
|
||||||
|
readonly name = 'email-http';
|
||||||
|
readonly channelTypes = ['EMAIL'];
|
||||||
|
readonly capabilities = { canSend: true };
|
||||||
|
|
||||||
|
constructor(private readonly url: string) {}
|
||||||
|
|
||||||
|
async send(req: CapabilityRequest): Promise<ProviderResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const p = (req.payload ?? {}) as EmailPayload;
|
||||||
|
const email = { to: req.target, subject: p.subject ?? '(no subject)', text: p.text, html: p.html, inReplyTo: p.inReplyTo };
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'idempotency-key': req.idempotencyKey },
|
||||||
|
body: JSON.stringify(email),
|
||||||
|
});
|
||||||
|
const latencyMs = Date.now() - started;
|
||||||
|
if (!res.ok) return { providerRef: `email-${res.status}`, outcome: 'FAILED', errorCode: `HTTP_${res.status}`, latencyMs };
|
||||||
|
return { providerRef: `email-${req.idempotencyKey}`, outcome: 'SENT', latencyMs };
|
||||||
|
} catch (err) {
|
||||||
|
return { providerRef: 'email-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { BadRequestException, Body, Controller, ForbiddenException, Headers, Param, Post } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, ForbiddenException, Get, Headers, Param, Post, UnauthorizedException } from '@nestjs/common';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { IIOS_EVENTS } from '@insignia/iios-contracts';
|
import { IIOS_EVENTS } from '@insignia/iios-contracts';
|
||||||
import { hmacSign } from '@insignia/iios-adapter-sdk';
|
import { hmacSign } from '@insignia/iios-adapter-sdk';
|
||||||
@@ -8,6 +8,7 @@ import { AdapterRegistry } from '../adapters/adapter.registry';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import { RetentionService } from '../retention/retention.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production.
|
* Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production.
|
||||||
@@ -20,25 +21,64 @@ export class DevController {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly actors: ActorResolver,
|
private readonly actors: ActorResolver,
|
||||||
private readonly session: SessionVerifier,
|
private readonly session: SessionVerifier,
|
||||||
|
private readonly retention: RetentionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('token')
|
@Post('token')
|
||||||
token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } {
|
token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } {
|
||||||
this.assertDev();
|
this.assertDev();
|
||||||
|
return { token: this.signToken(body.appId, body.userId, body.name, body.orgId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev IdP: a credentialed login that mints the SAME JWT claims a real IdP / Session
|
||||||
|
* Broker would issue (sub/name/appId/orgId). Credentials come from DEV_USERS (JSON
|
||||||
|
* `{username: password}`; defaults to a few demo users). SessionVerifier is the stable
|
||||||
|
* verify seam — swapping in a real IdP means issuing these same claims, nothing else.
|
||||||
|
*/
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() body: { username: string; password: string; appId?: string }): { token: string; userId: string } {
|
||||||
|
this.assertDev();
|
||||||
|
const appId = body.appId ?? 'portal-demo';
|
||||||
|
let users: Record<string, string>;
|
||||||
|
try {
|
||||||
|
users = JSON.parse(process.env.DEV_USERS ?? '{"alice":"alice","bob":"bob","carol":"carol"}');
|
||||||
|
} catch {
|
||||||
|
users = {};
|
||||||
|
}
|
||||||
|
const expected = users[body.username];
|
||||||
|
if (expected === undefined || expected !== body.password) throw new UnauthorizedException('invalid username or password');
|
||||||
|
return { token: this.signToken(appId, body.username, body.username), userId: body.username };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dev directory: the known dev usernames (from DEV_USERS). A real app validates
|
||||||
|
* add-by-username against its user store / the MDM port; this stands in for it. */
|
||||||
|
@Get('users')
|
||||||
|
users(): { users: string[] } {
|
||||||
|
this.assertDev();
|
||||||
|
let map: Record<string, string>;
|
||||||
|
try {
|
||||||
|
map = JSON.parse(process.env.DEV_USERS ?? '{"alice":"alice","bob":"bob","carol":"carol"}');
|
||||||
|
} catch {
|
||||||
|
map = {};
|
||||||
|
}
|
||||||
|
return { users: Object.keys(map) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private signToken(appId: string, userId: string, name?: string, orgId?: string): string {
|
||||||
let secrets: Record<string, string>;
|
let secrets: Record<string, string>;
|
||||||
try {
|
try {
|
||||||
secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
||||||
} catch {
|
} catch {
|
||||||
secrets = {};
|
secrets = {};
|
||||||
}
|
}
|
||||||
const secret = secrets[body.appId];
|
const secret = secrets[appId];
|
||||||
if (!secret) throw new BadRequestException(`unknown app: ${body.appId}`);
|
if (!secret) throw new BadRequestException(`unknown app: ${appId}`);
|
||||||
const token = jwt.sign(
|
return jwt.sign(
|
||||||
{ sub: body.userId, name: body.name ?? body.userId, appId: body.appId, orgId: body.orgId ?? `org_${body.appId}` },
|
{ sub: userId, name: name ?? userId, appId, orgId: orgId ?? `org_${appId}` },
|
||||||
secret,
|
secret,
|
||||||
{ algorithm: 'HS256', expiresIn: '2h' },
|
{ algorithm: 'HS256', expiresIn: '2h' },
|
||||||
);
|
);
|
||||||
return { token };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server signs a sample provider payload and feeds it to the inbound pipeline. */
|
/** Server signs a sample provider payload and feeds it to the inbound pipeline. */
|
||||||
@@ -96,6 +136,13 @@ export class DevController {
|
|||||||
return { eventId: id, scopeId: scope.id };
|
return { eventId: id, scopeId: scope.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run the retention sweep on demand (drives the retention smoke). */
|
||||||
|
@Post('retention/sweep')
|
||||||
|
async retentionSweep() {
|
||||||
|
this.assertDev();
|
||||||
|
return this.retention.sweep();
|
||||||
|
}
|
||||||
|
|
||||||
private principal(authorization?: string): MessagePrincipal {
|
private principal(authorization?: string): MessagePrincipal {
|
||||||
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
||||||
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,10 +71,10 @@ export class ActorResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureParticipant(threadId: string, actorId: string): Promise<void> {
|
async ensureParticipant(threadId: string, actorId: string, participantRole = 'MEMBER'): Promise<void> {
|
||||||
await this.prisma.iiosThreadParticipant.upsert({
|
await this.prisma.iiosThreadParticipant.upsert({
|
||||||
where: { threadId_actorId: { threadId, actorId } },
|
where: { threadId_actorId: { threadId, actorId } },
|
||||||
create: { threadId, actorId },
|
create: { threadId, actorId, participantRole },
|
||||||
update: {},
|
update: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface MessageSentData {
|
|||||||
interactionId: string;
|
interactionId: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
senderActorId: string;
|
senderActorId: string;
|
||||||
|
mentions?: string[]; // opaque app notify-list (userIds); the kernel never parses "@"
|
||||||
}
|
}
|
||||||
interface MessageReadData {
|
interface MessageReadData {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -57,6 +58,60 @@ export class InboxProjector implements OnModuleInit {
|
|||||||
if (p.actorId === data.senderActorId) continue;
|
if (p.actorId === data.senderActorId) continue;
|
||||||
await this.upsertNeedsReply(thread.scopeId, p.actorId, data.threadId, data.interactionId, traceId, thread.subject);
|
await this.upsertNeedsReply(thread.scopeId, p.actorId, data.threadId, data.interactionId, traceId, thread.subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Targeted mention notifications from the app-supplied opaque notify-list.
|
||||||
|
const mentions = data.mentions ?? [];
|
||||||
|
if (mentions.length > 0) {
|
||||||
|
const src = await this.prisma.iiosInteraction.findUnique({
|
||||||
|
where: { id: data.interactionId },
|
||||||
|
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
|
||||||
|
});
|
||||||
|
const senderName = src?.actor?.sourceHandle?.externalId ?? 'Someone';
|
||||||
|
const text = src?.parts[0]?.bodyText ?? undefined;
|
||||||
|
for (const userId of mentions) {
|
||||||
|
await this.createMention(thread.scopeId, userId, data.threadId, data.interactionId, data.senderActorId, senderName, text, traceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One MENTION item per (owner, source message) for a mentioned participant (never the sender). */
|
||||||
|
private async createMention(
|
||||||
|
scopeId: string,
|
||||||
|
userId: string,
|
||||||
|
threadId: string,
|
||||||
|
sourceInteractionId: string,
|
||||||
|
senderActorId: string,
|
||||||
|
senderName: string,
|
||||||
|
summary: string | undefined,
|
||||||
|
traceId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
const handle = await this.prisma.iiosSourceHandle.findUnique({
|
||||||
|
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: userId } },
|
||||||
|
});
|
||||||
|
if (!handle) return;
|
||||||
|
const actor = await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } });
|
||||||
|
if (!actor || actor.id === senderActorId) return; // resolvable, and never notify the sender
|
||||||
|
const isMember = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } });
|
||||||
|
if (!isMember) return; // only thread participants get mention items
|
||||||
|
const existing = await this.prisma.iiosInboxItem.findFirst({ where: { ownerActorId: actor.id, sourceInteractionId, kind: 'MENTION' } });
|
||||||
|
if (existing) return; // idempotent per source message
|
||||||
|
|
||||||
|
const item = await this.prisma.iiosInboxItem.create({
|
||||||
|
data: {
|
||||||
|
scopeId,
|
||||||
|
ownerActorId: actor.id,
|
||||||
|
kind: 'MENTION',
|
||||||
|
title: `${senderName} mentioned you`,
|
||||||
|
summary,
|
||||||
|
priority: 'HIGH',
|
||||||
|
threadId,
|
||||||
|
sourceInteractionId,
|
||||||
|
traceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.iiosInboxItemStateHistory.create({
|
||||||
|
data: { inboxItemId: item.id, toState: 'OPEN', reasonCode: 'mentioned' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onMessageRead(event: CloudEvent): Promise<void> {
|
async onMessageRead(event: CloudEvent): Promise<void> {
|
||||||
@@ -67,21 +122,24 @@ export class InboxProjector implements OnModuleInit {
|
|||||||
|
|
||||||
private async applyMessageRead(event: CloudEvent): Promise<void> {
|
private async applyMessageRead(event: CloudEvent): Promise<void> {
|
||||||
const data = event.data as MessageReadData;
|
const data = event.data as MessageReadData;
|
||||||
const item = await this.prisma.iiosInboxItem.findFirst({
|
// Reading the thread clears the reader's activity AND mention items for it.
|
||||||
|
const items = await this.prisma.iiosInboxItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
threadId: data.threadId,
|
threadId: data.threadId,
|
||||||
ownerActorId: data.actorId,
|
ownerActorId: data.actorId,
|
||||||
kind: 'NEEDS_REPLY',
|
kind: { in: ['NEEDS_REPLY', 'MENTION'] },
|
||||||
state: { in: ['OPEN', 'SNOOZED'] },
|
state: { in: ['OPEN', 'SNOOZED'] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!item) return;
|
if (items.length === 0) return;
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction(
|
||||||
this.prisma.iiosInboxItem.update({ where: { id: item.id }, data: { state: 'DONE' } }),
|
items.flatMap((item) => [
|
||||||
this.prisma.iiosInboxItemStateHistory.create({
|
this.prisma.iiosInboxItem.update({ where: { id: item.id }, data: { state: 'DONE' } }),
|
||||||
data: { inboxItemId: item.id, fromState: item.state, toState: 'DONE', reasonCode: 'message_read' },
|
this.prisma.iiosInboxItemStateHistory.create({
|
||||||
}),
|
data: { inboxItemId: item.id, fromState: item.state, toState: 'DONE', reasonCode: 'message_read' },
|
||||||
]);
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async upsertNeedsReply(
|
private async upsertNeedsReply(
|
||||||
|
|||||||
@@ -88,6 +88,38 @@ describe('InboxProjector (P3 work surface)', () => {
|
|||||||
expect(await itemsFor('bob')).toHaveLength(1);
|
expect(await itemsFor('bob')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('message.sent with mentions → a MENTION item for the mentioned participant only', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await m.addParticipant(threadId, alice, 'carol');
|
||||||
|
await m.send(threadId, alice, { content: 'hey @bob look' }, 'k1', undefined, undefined, ['bob']);
|
||||||
|
|
||||||
|
await projector().onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
|
||||||
|
|
||||||
|
const bobMentions = (await itemsFor('bob')).filter((i) => i.kind === 'MENTION');
|
||||||
|
expect(bobMentions).toHaveLength(1);
|
||||||
|
expect(bobMentions[0]?.state).toBe('OPEN');
|
||||||
|
expect(bobMentions[0]?.sourceInteractionId).toBeTruthy();
|
||||||
|
expect((await itemsFor('carol')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // not mentioned
|
||||||
|
expect((await itemsFor('alice')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // never the sender
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reading the thread resolves the reader’s MENTION item to DONE', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
const sent = await m.send(threadId, alice, { content: '@bob ping' }, 'k1', undefined, undefined, ['bob']);
|
||||||
|
|
||||||
|
const proj = projector();
|
||||||
|
await proj.onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
|
||||||
|
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('OPEN');
|
||||||
|
|
||||||
|
await m.markRead(threadId, bob, sent.id);
|
||||||
|
await proj.onMessageRead((await eventsOf(IIOS_EVENTS.messageRead))[0]!);
|
||||||
|
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('DONE');
|
||||||
|
});
|
||||||
|
|
||||||
it('message.read → the recipient item goes DONE', async () => {
|
it('message.read → the recipient item goes DONE', async () => {
|
||||||
const m = ms();
|
const m = ms();
|
||||||
const { threadId } = await m.openThread(null, alice);
|
const { threadId } = await m.openThread(null, alice);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsInt,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -40,6 +41,7 @@ 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,6 +178,7 @@ 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,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AiJobService } from './ai/ai.service';
|
|||||||
import { AiBudgetGuard } from './ai/ai-budget.guard';
|
import { AiBudgetGuard } from './ai/ai-budget.guard';
|
||||||
import { CalendarService } from './calendar/calendar.service';
|
import { CalendarService } from './calendar/calendar.service';
|
||||||
import { OutboundService } from './adapters/outbound.service';
|
import { OutboundService } from './adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from './idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from './capability/capability.broker';
|
import { CapabilityBroker } from './capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from './capability/capability.registry';
|
import { CapabilityProviderRegistry } from './capability/capability.registry';
|
||||||
import type { PrismaService } from './prisma/prisma.service';
|
import type { PrismaService } from './prisma/prisma.service';
|
||||||
@@ -27,7 +28,7 @@ const B: MessagePrincipal = { userId: 'b1', orgId: 'org_B', appId: 'portal-demo'
|
|||||||
const START = '2026-07-02T10:00:00.000Z';
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
|
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
|
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
|
||||||
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), outbound());
|
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), outbound());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TemplateModule } from '../templates/template.module';
|
||||||
|
import { InteractionsModule } from '../interactions/interactions.module';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
import { MailController } from './mail.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mail orchestration: render a template (TemplateModule) → deliver it either app-to-app (an EMAIL
|
||||||
|
* interaction via IngestService) or externally (TemplatedSender/SMTP) with an in-app mirror.
|
||||||
|
* SessionVerifier + ActorResolver are global.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [TemplateModule, InteractionsModule],
|
||||||
|
controllers: [MailController],
|
||||||
|
providers: [MailService],
|
||||||
|
exports: [MailService],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,30 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { traceMiddleware } from './observability/trace.middleware';
|
import { traceMiddleware } from './observability/trace.middleware';
|
||||||
|
import { RedisIoAdapter } from './realtime/redis-io.adapter';
|
||||||
|
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 }),
|
||||||
);
|
);
|
||||||
|
app.useGlobalFilters(new PolicyDeniedFilter()); // fail-closed policy denials → 403
|
||||||
app.enableCors({ origin: true, credentials: true });
|
app.enableCors({ origin: true, credentials: true });
|
||||||
|
|
||||||
|
// Multi-replica realtime: fan socket.io room emits across instances via Redis.
|
||||||
|
// Opt-in — without REDIS_URL the default in-memory adapter is used (single instance).
|
||||||
|
if (process.env.REDIS_URL) {
|
||||||
|
const redisAdapter = new RedisIoAdapter(app, process.env.REDIS_URL);
|
||||||
|
await redisAdapter.connect();
|
||||||
|
app.useWebSocketAdapter(redisAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3200);
|
const port = Number(process.env.PORT ?? 3200);
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { StoragePort } from './storage.port';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev storage: bytes on local disk under MEDIA_DIR, with a sidecar `.meta.json`
|
||||||
|
* holding the mime/size/checksum. Object keys may contain a scope subdirectory.
|
||||||
|
* A drop-in for an S3/Supabase adapter in prod.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LocalDiskStorage implements StoragePort {
|
||||||
|
private readonly root = process.env.MEDIA_DIR?.trim() || path.join(os.tmpdir(), 'iios-media');
|
||||||
|
|
||||||
|
private full(objectKey: string): string {
|
||||||
|
// Prevent path traversal: keep everything under root.
|
||||||
|
const p = path.normalize(path.join(this.root, objectKey));
|
||||||
|
if (!p.startsWith(path.normalize(this.root))) throw new Error('invalid object key');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> {
|
||||||
|
const file = this.full(objectKey);
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
const checksumSha256 = createHash('sha256').update(data).digest('hex');
|
||||||
|
await fs.writeFile(file, data);
|
||||||
|
await fs.writeFile(`${file}.meta.json`, JSON.stringify({ mime, sizeBytes: data.length, checksumSha256 }));
|
||||||
|
return { sizeBytes: data.length, checksumSha256 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> {
|
||||||
|
const file = this.full(objectKey);
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(file);
|
||||||
|
const meta = JSON.parse(await fs.readFile(`${file}.meta.json`, 'utf8')) as { mime: string };
|
||||||
|
return { data, mime: meta.mime || 'application/octet-stream', sizeBytes: data.length };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(objectKey: string): Promise<void> {
|
||||||
|
const file = this.full(objectKey);
|
||||||
|
await fs.rm(file, { force: true });
|
||||||
|
await fs.rm(`${file}.meta.json`, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Put, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
|
||||||
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
|
||||||
|
/** Types that can execute script if a browser renders them top-level — served as downloads only. */
|
||||||
|
const SCRIPTABLE_MIMES = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml']);
|
||||||
|
|
||||||
|
@Controller('v1/media')
|
||||||
|
export class MediaController {
|
||||||
|
constructor(
|
||||||
|
private readonly media: MediaService,
|
||||||
|
private readonly session: SessionVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Authorize an upload → short-lived signed PUT url + object key. */
|
||||||
|
@Post('presign-upload')
|
||||||
|
async presignUpload(@Body() body: PresignUploadDto, @Headers('authorization') auth?: string) {
|
||||||
|
return this.media.presignUpload(this.principal(auth), { mime: body.mime, sizeBytes: body.sizeBytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize a download → short-lived signed GET url. */
|
||||||
|
@Post('presign-download')
|
||||||
|
async presignDownload(@Body() body: PresignDownloadDto, @Headers('authorization') auth?: string) {
|
||||||
|
return this.media.presignDownload(this.principal(auth), body.contentRef, body.mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The presigned PUT target — token IS the auth. Reads the raw binary body stream. */
|
||||||
|
@Put('upload/:token')
|
||||||
|
async upload(@Param('token') token: string, @Req() req: Request) {
|
||||||
|
const data = await readBody(req);
|
||||||
|
if (data.length === 0) throw new BadRequestException('empty upload body');
|
||||||
|
return this.media.put(token, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The presigned GET target — token IS the auth. Streams the bytes with their mime. */
|
||||||
|
@Get('blob/:token')
|
||||||
|
async blob(@Param('token') token: string, @Res() res: Response) {
|
||||||
|
const { data, mime } = await this.media.get(token);
|
||||||
|
res.setHeader('Content-Type', mime);
|
||||||
|
// Never let the browser MIME-sniff an upload into something executable.
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
// Script-capable types must not render inline from our origin (stored-XSS) — force a download.
|
||||||
|
// Images/video/audio/pdf stay inline so the app can preview them. Note <img>/<video> still embed
|
||||||
|
// fine even with attachment disposition; only top-level navigation to the blob is affected.
|
||||||
|
if (SCRIPTABLE_MIMES.has(mime)) res.setHeader('Content-Disposition', 'attachment');
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
|
res.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private principal(auth?: string): MessagePrincipal {
|
||||||
|
const token = (auth ?? '').replace(/^Bearer\s+/i, '');
|
||||||
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||||
|
return this.session.verify(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect a request body stream into a Buffer (binary media upload; no body parser touches it). */
|
||||||
|
function readBody(req: Request): Promise<Buffer> {
|
||||||
|
const raw = (req as Request & { rawBody?: Buffer }).rawBody;
|
||||||
|
if (raw && raw.length) return Promise.resolve(raw);
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class PresignUploadDto {
|
||||||
|
@IsString() mime!: string;
|
||||||
|
@IsInt() @Min(1) @Max(26 * 1024 * 1024) sizeBytes!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PresignDownloadDto {
|
||||||
|
@IsString() contentRef!: string;
|
||||||
|
@IsOptional() @IsString() mime?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Module, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
|
import { MediaController } from './media.controller';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
import { LocalDiskStorage } from './local-disk.storage';
|
||||||
|
import { S3Storage, s3ConfigFromEnv } from './s3.storage';
|
||||||
|
import { STORAGE_PORT, type StoragePort } from './storage.port';
|
||||||
|
|
||||||
|
/** S3/MinIO when its env is set (IIOS_S3_BUCKET + keys), else local disk. Env-driven swap. */
|
||||||
|
function makeStorage(): StoragePort {
|
||||||
|
const cfg = s3ConfigFromEnv();
|
||||||
|
if (cfg) {
|
||||||
|
new Logger('MediaStorage').log(`using S3 storage (bucket "${cfg.bucket}"${cfg.endpoint ? ` @ ${cfg.endpoint}` : ''})`);
|
||||||
|
return new S3Storage(cfg);
|
||||||
|
}
|
||||||
|
return new LocalDiskStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, IdentityModule],
|
||||||
|
controllers: [MediaController],
|
||||||
|
providers: [MediaService, { provide: STORAGE_PORT, useFactory: makeStorage }],
|
||||||
|
// Exported so the capability layer can resolve email attachment bytes at send time.
|
||||||
|
exports: [STORAGE_PORT],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { DevOpaPort } from '../platform/dev-opa.port';
|
||||||
|
import { LocalDiskStorage } from './local-disk.storage';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
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;
|
||||||
|
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
|
||||||
|
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService));
|
||||||
|
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||||
|
|
||||||
|
// point storage at an isolated temp dir for the test run
|
||||||
|
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
function tokenFrom(url: string): string {
|
||||||
|
return url.split('/').pop()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MediaService (presigned local storage)', () => {
|
||||||
|
it('presign → PUT → presign-download → GET round-trips the bytes with mime + checksum', async () => {
|
||||||
|
const s = svc();
|
||||||
|
const bytes = Buffer.from('hello-image-bytes');
|
||||||
|
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
|
||||||
|
expect(objectKey).toContain('/'); // scopeId/uuid
|
||||||
|
|
||||||
|
const put = await s.put(tokenFrom(uploadUrl), bytes);
|
||||||
|
expect(put.sizeBytes).toBe(bytes.length);
|
||||||
|
expect(put.checksumSha256).toHaveLength(64);
|
||||||
|
|
||||||
|
const { url } = await s.presignDownload(alice, objectKey, 'image/png');
|
||||||
|
const got = await s.get(tokenFrom(url));
|
||||||
|
expect(got.data.equals(bytes)).toBe(true);
|
||||||
|
expect(got.mime).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an over-the-cap upload (OPA policy)', async () => {
|
||||||
|
await expect(svc().presignUpload(alice, { mime: 'image/png', sizeBytes: 26 * 1024 * 1024 })).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a disallowed file type (OPA policy)', async () => {
|
||||||
|
await expect(svc().presignUpload(alice, { mime: 'application/x-msdownload', sizeBytes: 1000 })).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a PUT larger than the presigned size is refused', async () => {
|
||||||
|
const s = svc();
|
||||||
|
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
|
||||||
|
await expect(s.put(tokenFrom(uploadUrl), Buffer.from('way too many bytes'))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered / non-media token', async () => {
|
||||||
|
await expect(svc().get('not-a-real-token')).rejects.toThrow();
|
||||||
|
// an upload token cannot be used to download
|
||||||
|
const s = svc();
|
||||||
|
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
|
||||||
|
await expect(s.get(tokenFrom(uploadUrl))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tenant-fences downloads: an object outside my scope is forbidden', async () => {
|
||||||
|
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||||
|
import { decideOrThrow } from '../platform/fail-closed';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { STORAGE_PORT, type StoragePort } from './storage.port';
|
||||||
|
|
||||||
|
interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number }
|
||||||
|
interface DownloadToken { op: 'get'; objectKey: string; mime: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presigned media. IIOS never trusts the client with storage — it authorizes an
|
||||||
|
* upload (OPA: size/type/scope), then hands back a short-lived signed URL that
|
||||||
|
* points at its OWN storage endpoints. The bytes live behind the StoragePort; the
|
||||||
|
* kernel only ever stores the object key (contentRef) on a MessagePart.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MediaService {
|
||||||
|
private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret';
|
||||||
|
private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, '');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||||
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||||
|
private readonly actors: ActorResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Authorize an upload and return a short-lived signed PUT url + the object key. */
|
||||||
|
async presignUpload(
|
||||||
|
principal: MessagePrincipal,
|
||||||
|
input: { mime: string; sizeBytes: number },
|
||||||
|
): Promise<{ objectKey: string; uploadUrl: string }> {
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
await decideOrThrow(this.ports, { action: 'iios.media.upload', scopeId: scope.id, mime: input.mime, sizeBytes: input.sizeBytes });
|
||||||
|
const objectKey = `${scope.id}/${randomUUID()}`;
|
||||||
|
const token = jwt.sign({ op: 'put', objectKey, mime: input.mime, maxBytes: input.sizeBytes } satisfies UploadToken, this.secret, { expiresIn: '5m' });
|
||||||
|
return { objectKey, uploadUrl: `${this.publicUrl}/v1/media/upload/${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store bytes for a valid, unexpired upload token (enforcing the declared size cap). */
|
||||||
|
async put(token: string, data: Buffer): Promise<{ objectKey: string; sizeBytes: number; checksumSha256: string }> {
|
||||||
|
const t = this.verify<UploadToken>(token, 'put');
|
||||||
|
if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size');
|
||||||
|
const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime);
|
||||||
|
return { objectKey: t.objectKey, sizeBytes, checksumSha256 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize a download and return a short-lived signed GET url (tenant-fenced). */
|
||||||
|
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
if (!contentRef.startsWith(`${scope.id}/`)) throw new ForbiddenException('object not in your scope');
|
||||||
|
const token = jwt.sign({ op: 'get', objectKey: contentRef, mime } satisfies DownloadToken, this.secret, { expiresIn: '1h' });
|
||||||
|
return { url: `${this.publicUrl}/v1/media/blob/${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch bytes for a valid, unexpired download token. */
|
||||||
|
async get(token: string): Promise<{ data: Buffer; mime: string; sizeBytes: number }> {
|
||||||
|
const t = this.verify<DownloadToken>(token, 'get');
|
||||||
|
const obj = await this.storage.get(t.objectKey);
|
||||||
|
if (!obj) throw new BadRequestException('object not found');
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private verify<T extends { op: string }>(token: string, op: T['op']): T {
|
||||||
|
let payload: T;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, this.secret) as T;
|
||||||
|
} catch {
|
||||||
|
throw new ForbiddenException('invalid or expired media token');
|
||||||
|
}
|
||||||
|
if (payload.op !== op) throw new ForbiddenException('wrong media token');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import { S3Storage, s3ConfigFromEnv, type S3Config, type S3Like } from './s3.storage';
|
||||||
|
|
||||||
|
const CFG: S3Config = { endpoint: 'https://minio.test', region: 'us-east-1', bucket: 'iios', accessKeyId: 'k', secretAccessKey: 's', forcePathStyle: true };
|
||||||
|
|
||||||
|
/** A recording stub S3 client; GetObject returns whatever `store[key]` holds (or a NoSuchKey error). */
|
||||||
|
function stub(store: Record<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import type { StoragePort } from './storage.port';
|
||||||
|
|
||||||
|
export interface S3Config {
|
||||||
|
endpoint?: string; // MinIO/self-hosted URL; omit for AWS
|
||||||
|
region: string;
|
||||||
|
bucket: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
forcePathStyle: boolean; // true for MinIO
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one method this adapter uses — lets tests inject a stub client (no live S3/MinIO). */
|
||||||
|
export interface S3Like {
|
||||||
|
send(command: unknown): Promise<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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Byte storage seam. The kernel stores a `contentRef` (an opaque object key) on a
|
||||||
|
* MessagePart; the actual bytes live behind this port. Dev binds LocalDiskStorage;
|
||||||
|
* prod swaps to S3/R2/Supabase Storage with zero changes to the media service or app.
|
||||||
|
*/
|
||||||
|
export interface StoragePort {
|
||||||
|
put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }>;
|
||||||
|
get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null>;
|
||||||
|
remove(objectKey: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORAGE_PORT = Symbol('STORAGE_PORT');
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { Socket } from 'socket.io';
|
||||||
|
import { MessageGateway } from './message.gateway';
|
||||||
|
import type { MessageService, MessagePrincipal } from './message.service';
|
||||||
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import type { OutboxBus } from '../outbox/outbox.bus';
|
||||||
|
import type { PresenceService } from '../notifications/presence.service';
|
||||||
|
|
||||||
|
// Realtime delegation: the browser opens the socket with a short-lived token minted for the
|
||||||
|
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
|
||||||
|
// that audience — a full REST/actor token (iios-core) or an un-scoped token can't open the stream.
|
||||||
|
|
||||||
|
const APP = 'crm-web';
|
||||||
|
const SECRET = 'dev-crm-secret';
|
||||||
|
|
||||||
|
// ─── the real SessionVerifier must expose `aud` so the gateway can enforce it ───
|
||||||
|
describe('SessionVerifier — app-token audience surfacing', () => {
|
||||||
|
let v: SessionVerifier;
|
||||||
|
beforeAll(async () => {
|
||||||
|
delete process.env.AUTH_ISSUERS;
|
||||||
|
delete process.env.SUPABASE_URL;
|
||||||
|
process.env.APP_SECRETS = JSON.stringify({ [APP]: SECRET });
|
||||||
|
v = new SessionVerifier();
|
||||||
|
await v.onModuleInit();
|
||||||
|
});
|
||||||
|
afterAll(() => { delete process.env.APP_SECRETS; });
|
||||||
|
|
||||||
|
it('surfaces the aud claim of a realtime-scoped token', () => {
|
||||||
|
const tok = jwt.sign({ appId: APP, aud: 'iios-message' }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
|
||||||
|
expect(v.verify(tok).audience).toBe('iios-message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves audience undefined for an un-scoped token (a REST/actor token)', () => {
|
||||||
|
const tok = jwt.sign({ appId: APP }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
|
||||||
|
expect(v.verify(tok).audience).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── the gateway enforces the realtime audience on connect ───
|
||||||
|
function fakeSocket(): { sock: Socket; disconnected: () => boolean } {
|
||||||
|
let disconnected = false;
|
||||||
|
const sock = {
|
||||||
|
handshake: { auth: { token: 'tok' } },
|
||||||
|
disconnect: () => { disconnected = true; },
|
||||||
|
data: undefined as unknown,
|
||||||
|
} as unknown as Socket;
|
||||||
|
return { sock, disconnected: () => disconnected };
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatewayReturning(principal: MessagePrincipal): MessageGateway {
|
||||||
|
const session = { verify: () => principal } as unknown as SessionVerifier;
|
||||||
|
return new MessageGateway(
|
||||||
|
undefined as unknown as MessageService,
|
||||||
|
session,
|
||||||
|
undefined as unknown as OutboxBus,
|
||||||
|
undefined as unknown as PresenceService,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const principal = (audience?: string): MessagePrincipal => ({ userId: 'u', appId: APP, orgId: 'org', audience });
|
||||||
|
|
||||||
|
describe('MessageGateway — realtime audience enforcement', () => {
|
||||||
|
afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; });
|
||||||
|
|
||||||
|
it('accepts any valid token when enforcement is OFF (env unset)', () => {
|
||||||
|
const { sock, disconnected } = fakeSocket();
|
||||||
|
gatewayReturning(principal(undefined)).handleConnection(sock);
|
||||||
|
expect(disconnected()).toBe(false);
|
||||||
|
expect((sock.data as { principal: MessagePrincipal }).principal.userId).toBe('u');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a token whose aud matches the required realtime audience', () => {
|
||||||
|
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
|
||||||
|
const { sock, disconnected } = fakeSocket();
|
||||||
|
gatewayReturning(principal('iios-message')).handleConnection(sock);
|
||||||
|
expect(disconnected()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a full REST/actor token (aud=iios-core) on the socket', () => {
|
||||||
|
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
|
||||||
|
const { sock, disconnected } = fakeSocket();
|
||||||
|
gatewayReturning(principal('iios-core')).handleConnection(sock);
|
||||||
|
expect(disconnected()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an un-scoped token (no aud) when enforcement is on', () => {
|
||||||
|
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
|
||||||
|
const { sock, disconnected } = fakeSocket();
|
||||||
|
gatewayReturning(principal(undefined)).handleConnection(sock);
|
||||||
|
expect(disconnected()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ConnectedSocket,
|
ConnectedSocket,
|
||||||
MessageBody,
|
MessageBody,
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
OnGatewayInit,
|
OnGatewayInit,
|
||||||
SubscribeMessage,
|
SubscribeMessage,
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
@@ -15,6 +16,7 @@ import { logJson } from '../observability/logger';
|
|||||||
import { MessageService, type MessagePrincipal } from './message.service';
|
import { MessageService, type MessagePrincipal } from './message.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
import { OutboxBus } from '../outbox/outbox.bus';
|
import { OutboxBus } from '../outbox/outbox.bus';
|
||||||
|
import { PresenceService } from '../notifications/presence.service';
|
||||||
|
|
||||||
interface SocketState {
|
interface SocketState {
|
||||||
principal: MessagePrincipal;
|
principal: MessagePrincipal;
|
||||||
@@ -27,7 +29,7 @@ interface SocketState {
|
|||||||
* propagates messages persisted elsewhere (REST/ingest) to connected clients.
|
* propagates messages persisted elsewhere (REST/ingest) to connected clients.
|
||||||
*/
|
*/
|
||||||
@WebSocketGateway({ namespace: '/message', cors: { origin: true } })
|
@WebSocketGateway({ namespace: '/message', cors: { origin: true } })
|
||||||
export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
private readonly logger = new Logger(MessageGateway.name);
|
private readonly logger = new Logger(MessageGateway.name);
|
||||||
private readonly emittedLocally = new Set<string>();
|
private readonly emittedLocally = new Set<string>();
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
|||||||
private readonly messages: MessageService,
|
private readonly messages: MessageService,
|
||||||
private readonly session: SessionVerifier,
|
private readonly session: SessionVerifier,
|
||||||
private readonly bus: OutboxBus,
|
private readonly bus: OutboxBus,
|
||||||
|
private readonly presence: PresenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -58,6 +61,14 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
|||||||
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}`);
|
||||||
@@ -65,31 +76,82 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket): void {
|
||||||
|
this.presence.clearSocket(client.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app reports which thread is in the foreground (or null when blurred) — presence. */
|
||||||
|
@SubscribeMessage('focus_thread')
|
||||||
|
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
|
||||||
|
const state = client.data as SocketState | undefined;
|
||||||
|
if (!state?.principal) return;
|
||||||
|
this.presence.setFocus(client.id, state.principal.userId, body?.threadId ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeMessage('open_thread')
|
@SubscribeMessage('open_thread')
|
||||||
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string }) {
|
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string; subject?: string }) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
const result = await this.messages.openThread(body?.threadId ?? null, principal);
|
try {
|
||||||
await client.join(result.threadId);
|
const result = await this.messages.openThread(body?.threadId ?? null, principal, {
|
||||||
return result;
|
membership: body?.membership,
|
||||||
|
creatorRole: body?.creatorRole,
|
||||||
|
subject: body?.subject,
|
||||||
|
});
|
||||||
|
await client.join(result.threadId);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
// Fail to an ACK'd error instead of throwing (which never acks → client hangs on "loading").
|
||||||
|
return { error: (err as Error).message ?? 'could not open the conversation' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('add_participant')
|
||||||
|
async addParticipant(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; userId: string; role?: string }) {
|
||||||
|
const { principal } = client.data as SocketState;
|
||||||
|
return this.messages.addParticipant(body.threadId, principal, body.userId, body.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('send_message')
|
@SubscribeMessage('send_message')
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() body: { threadId: string; content: string; contentRef?: string },
|
@MessageBody()
|
||||||
|
body: { threadId: string; content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string; parentInteractionId?: string; mentions?: string[] },
|
||||||
) {
|
) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
const msg = await this.messages.send(
|
const msg = await this.messages.send(
|
||||||
body.threadId,
|
body.threadId,
|
||||||
principal,
|
principal,
|
||||||
{ content: body.content, contentRef: body.contentRef },
|
{ content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
|
undefined,
|
||||||
|
body.parentInteractionId,
|
||||||
|
body.mentions,
|
||||||
);
|
);
|
||||||
this.emittedLocally.add(msg.id);
|
this.emittedLocally.add(msg.id);
|
||||||
this.server.to(body.threadId).emit('message', msg);
|
this.server.to(body.threadId).emit('message', msg);
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('annotate')
|
||||||
|
async annotate(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() body: { threadId: string; interactionId: string; type: string; value: string },
|
||||||
|
) {
|
||||||
|
const { principal } = client.data as SocketState;
|
||||||
|
const r = await this.messages.toggleAnnotation(body.interactionId, principal, body.type, body.value);
|
||||||
|
// Broadcast the refreshed user list for this (interaction,type,value) so every client re-renders.
|
||||||
|
this.server.to(r.threadId).emit('annotation', {
|
||||||
|
threadId: r.threadId,
|
||||||
|
interactionId: r.interactionId,
|
||||||
|
type: r.type,
|
||||||
|
value: r.value,
|
||||||
|
op: r.op,
|
||||||
|
users: r.users,
|
||||||
|
userId: principal.userId,
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeMessage('read')
|
@SubscribeMessage('read')
|
||||||
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user