19 Commits

Author SHA1 Message Date
maaz519 8ce647552a feat(templates): T3 render() service (resolve+render, inline, version pin)
TemplateService.render(source, vars, opts) → { content, provenance }. Stored
key → resolve+render; inline → render without a DB row (key/version null);
explicit version pins. Provenance = {templateKey, version, locale, sha256(content)}.
10 tests (svc + repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:25:27 +05:30
maaz519 b3afd44d00 feat(templates): T2 IiosMessageTemplate table + resolve()
Schema + migration for the template source table, and TemplateRepository.resolve:
highest active version for (key,channel,locale), scoped override preferred over
the global (scopeId NULL) default.

Migration adds a PARTIAL unique index WHERE scopeId IS NULL so two platform
defaults for the same key can't coexist (Postgres treats NULL as distinct under
a plain UNIQUE — the scoped constraint alone wouldn't catch it). 6 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:23:44 +05:30
maaz519 8768af96c1 feat(templates): T1 pure Handlebars renderer
renderTemplate(raw, vars) → {subject, html, text}. HTML body auto-escaped
(customer names into email = XSS risk); subject/text verbatim. Throws on a
missing declared variable — never send a half-rendered receipt. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:21:09 +05:30
maaz519 cea0a27118 docs(templates): email/message template module plan
Design + 8-task TDD plan for the IIOS template module. Self-reviewed:
provenance-write path, nullable-scope unique index, PII minimization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:18:26 +05:30
maaz519 6dc9e4ffee feat: reject socket-scoped tokens on REST (the other half of realtime delegation)
The delegated socket token (aud=IIOS_REALTIME_AUDIENCE) was only narrow in one direction: the
gateway accepts ONLY that audience, but REST never checked the actor token's audience — so a
leaked browser socket token still drove privileged REST, i.e. a full actor token with extra steps.

RealtimeTokenRestGuard closes it, registered GLOBALLY (APP_GUARD) because every REST route is a
target — only 2 of 15 controllers sit behind ContextAttestationGuard, so inbox/media/interactions/
ai/... would otherwise stay open.

It is a decode-only REJECT filter, never an authenticator:
- does not verify signatures (controllers still call SessionVerifier); stripping `aud` to bypass it
  invalidates the signature downstream,
- no bearer -> pass through (health, metrics, HMAC adapter webhooks),
- ws context -> pass through (the gateway enforces the mirror rule),
- unset IIOS_REALTIME_AUDIENCE -> no-op, the same switch that turns on the socket half.

So one env now enables the whole boundary: socket accepts only iios-message, REST refuses it.
8 tests; typecheck + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:29:59 +05:30
maaz519 3298401772 feat: enforce realtime delegation audience on the message socket
The socket used to accept any valid token, so a full REST/actor token could open the
live stream. Now it can require a narrowly-scoped delegated token (aud=iios-message),
so a leaked socket token can't drive privileged REST, and vice-versa.

- MessagePrincipal gains `audience`, surfaced from both verify paths (OIDC aud, and the
  app-token `aud` claim).
- message.gateway: when IIOS_REALTIME_AUDIENCE is set, handleConnection accepts only a
  token whose aud matches (opt-in, like IIOS_REQUIRE_ATTESTATION; unset = no change).
- spec: verifier surfaces aud; gateway accepts iios-message, rejects iios-core / no-aud
  when enforcing, passes through when off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:56:19 +05:30
maaz519 e39caa3c80 feat: verify context attestations via ES256/JWKS + rename client to appshell-crm
Proof #3 now supports the production asymmetric path, not just the dev shared secret.

- context-attestation.ts: PublicKeyResolverPort seam; verify() picks ES256 (JWKS by
  kid) when the client has a jwksUri, else HS256 (dev). signDevAttestationES256 helper.
- attestation-stores.ts: JwksPublicKeyResolver (jwks-rsa, one cached client per URI,
  refetch on rotation, fail-closed to NO_KEY).
- attestation.module.ts: inject the resolver into the verifier; dev-seed the client as
  clientType APPSHELL (it is the CRM browser-flow parent per the July-12 notes).
- rename the registered client crm-support-widget -> appshell-crm (the name reflects the
  attesting parent, not a widget).
- specs: ES256 (valid via JWKS, wrong key -> BAD_SIGNATURE, unknown kid -> NO_KEY, no
  resolver -> NO_KEY) + two stolen-token gate cases (wrong app binding -> APP_MISMATCH,
  wrong audience -> WRONG_AUDIENCE).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:56:10 +05:30
maaz519 85a78eb21e feat(iios-kernel-client): per-request header factory → 0.1.4
RestConfig.headers now accepts a function, invoked once per request, so a caller can
mint a FRESH context attestation (new single-use nonce) each call. A static header was
replay-rejected on the 2nd request of a multi-call operation. Published 0.1.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:26:33 +05:30
maaz519 3e66c7a5db perf(iios): listThreads fetches last-message-per-thread in one query
Replaced the per-thread findFirst inside Promise.all (N parallel queries) with a
single Postgres DISTINCT ON query. A caller with many threads no longer fans out
and exhausts the connection pool (the conversation.list 500 under accumulated data).
message.spec 16/16 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:28:37 +05:30
maaz519 9e0411bfe6 feat(iios-kernel-client): RestClient custom headers passthrough → 0.1.3
Adds optional RestConfig.headers, merged into every request, so a server-side
caller (be-crm's glue) can carry X-Context-Attestation (the July 12 proof #3)
alongside the bearer token. Backward compatible. Published 0.1.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:03:45 +05:30
maaz519 f2d590b04d feat(iios): wire the three-proof gate into the request path (§1b)
Applies the context-attestation verifier at the request boundary via a guard, so a
stolen/forged/replayed attestation is rejected before IIOS acts — while staying safe
to roll out.

- ContextAttestationGuard: if an X-Context-Attestation header is present, verify it
  (its app_id must match the actor token's app_id) → 403 on any failure; if absent,
  allow only when IIOS_REQUIRE_ATTESTATION != 1 (dev/zero-trust), else 403. The flag is
  read per-request and defaults OFF, so existing callers keep working until AppShell/
  be-crm start forwarding attestations.
- AttestationModule (@Global): provides the verifier + Prisma stores + guard, and
  dev-seeds the crm-support-widget client so locally minted attestations verify.
- Guard applied to ThreadsController + SupportController.
- Tests (5 pass, no DB): not-required-allows, required-rejects, valid, forged, replayed.

Workload/mTLS (proof #2) stays a mesh concern. Next: SDK header passthrough + demote
be-crm IiosClient to forward an attestation, then flip the flag to prove end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:49:40 +05:30
maaz519 dd6a4cd4fa feat(iios): context-attestation verifier — the July 12 stolen-token proof (§1)
A valid actor token is no longer enough. This adds the trust-layer core so IIOS
can require a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.)
before a privileged request — defeating a hacked app replaying a stolen token.

- prisma: IiosClientRegistry (registered clients allowed to attest, per app) +
  IiosAttestationNonce (single-use replay ledger). Migration applied.
- ContextAttestationVerifier + ports (ClientRegistryPort, NonceStorePort) + a dev
  signer. Verifies: registered+active client, signature, aud=iios, app_id matches
  the token AND is allowed for the client, freshness, single-use nonce. Fail-closed.
- Prisma-backed stores; nonce reserve is atomic via the unique PK (P2002 = replay).
- Tests (9 pass): happy path + the five rejection cases the CEO named
  (unknown client, wrong audience, expired, replayed nonce, app-mismatch) +
  forged-signature + disabled-client; a DB atomicity test (skips if engine offline).

Decoupled from the request path on purpose — wiring it into the messaging guard
(the three-proof gate) + demoting be-crm to an attestation forwarder is the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:11:04 +05:30
maaz519 77dd5bac82 fix(iios): use extended query parser so nested metadata filters parse
NestJS 11 runs Express 5, whose default 'simple' query parser ignores nested
params like ?metadata[key]=value — so GET /v1/threads silently dropped the
metadata filter, causing e.g. an app's "reuse existing thread for this customer"
check to match ANY thread. Set the qs-based 'extended' parser.

Verified: filtering by a non-existent metadata value now returns 0 (was returning
all); the be-crm support smoke goes 12/12 (was 10/12 once a prior thread existed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:33:28 +05:30
maaz519 b918a21083 feat(iios): generic opaque metadata + manual ticket assign (glue prereqs)
Generic kernel primitives so app backends (e.g. a CRM support glue) can link
their domain to interactions WITHOUT the kernel learning any app vocabulary:

- thread: accept an opaque `metadata` bag on create (merged with membership),
  echo it in listThreads, and filter listThreads by `?metadata[key]=value`
  (equality on the opaque bag — the kernel never interprets the keys).
- support: accept opaque `metadata` on createTicket/escalate (thread bag
  inherited); add SupportService.assignTo — a policy-gated manual assignment
  of a ticket to a target actor (POST /v1/support/tickets/:id/assignee).
- SDK @insignia/iios-kernel-client 0.1.2: createThread(metadata),
  listThreads({metadata}) filter, createTicket(metadata), escalate(metadata),
  assignTicket; ThreadSummary/Ticket expose the opaque bag.

Generic-safety gate holds: no crm/customer/lead in kernel code (opaque values
only). Tests: +2 (metadata create/filter, ticket metadata + assignTo); 31 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:39:30 +05:30
maaz519 8c70b6d31f feat(iios-kernel-client): reconcile to chat-web surface, publish 0.1.1
Brings the shared SDK up to the fuller client chat-web had vendored, so
frontends consume one source of truth instead of copying it:

- types: Message gains senderId/senderName/attachment/parentInteractionId/
  annotations; add Attachment, AnnotationGroup, AnnotationEvent, ThreadSummary,
  SavedItem, LoginResult; MessageEvents gains `annotation`; SocketLike gains
  connected + optional timeout.
- MessageSocket: onConnected, openThread(opts), richer sendMessage(opts)
  (attachment/mentions/parentInteractionId, back-compatible with contentRef),
  pin/save/react via annotate, focus, reconnect-re-subscribe-all.
- RestClient: login, listUsers, listThreads, createThread, listSaved,
  addParticipant.

Bump 0.1.0 -> 0.1.1. Whole monorepo still builds (Message change is additive
for iios-message-web/iios-support-web/demos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:49:25 +05:30
maaz519 64c498a97a build: publish @insignia/iios-* SDKs to the Gitea package registry
- .npmrc routes the @insignia scope to https://git.lynkedup.cloud/api/packages/insignia/npm/
  (auth via ${GITEA_TOKEN} env — no secret committed).
- The 9 frontend SDK packages (contracts, kernel-client, adapter-sdk, *-web) are now
  publishable: private dropped, version 0.1.0, publishConfig pinned to Gitea. iios-service
  and iios-testkit stay private (pnpm publish skips them).
- Root `release` / `release:dry` scripts; a Gitea Actions workflow publishes on a v* tag.
- PUBLISHING.md documents publish + consumer (.npmrc) setup.

Verified: dry-run packs cleanly and workspace:* deps resolve to 0.1.0 in the tarball.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:13:55 +05:30
maaz519 5abee1b5b7 docs: add repo-local project memory under .claude/memory/
Versioned, per-topic project memory (index + entries): IIOS overview, the generic-safety
rule, run/test + the replay.spec flake workaround, recent features (Supabase auth,
reactions/pins/saves, mentions, media, notifications), the chat-web consumer, and workflow
conventions. CLAUDE.md points to it as the accumulating-notes companion to the canonical rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:55:38 +05:30
maaz519 58ebaf1982 docs: add CLAUDE.md — project rules for future Claude Code sessions
Captures what IIOS is, the #1 generic-safety guardrail (no chat vocabulary in the
kernel; meaning lives in OPA policy + opaque attributes + the app), the architecture
(kernel + platform ports + fail-closed + outbox/projectors), tech stack, run/test
commands (isolated iios_test DB, the replay.spec flake note), conventions (commit
email, IIOS_DEV_TOKENS off in prod), and current build state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:48:10 +05:30
maaz519 6fc03fa4c3 docs(iios): notifications — API guide §5.11 (endpoints + engine diagram), env, counts
- API guide §5.11 Notifications: vapid-public-key / subscribe / unsubscribe / thread
  mute endpoints, the focus_thread presence signal, the three gates, and the engine
  flow diagram; SDK reference (registerPush/muteThread/focus); VAPID env; tests 205.
- DEPLOYMENT: VAPID env + the in-memory-presence → Redis (multi-replica) caveat.
- CEO overview: counts (205 tests, 54 tables / 20 migrations) + notifications in the
  "P9 has begun" note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:45:11 +05:30
67 changed files with 2767 additions and 1988 deletions
+12
View File
@@ -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
+28
View File
@@ -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]].
+19
View File
@@ -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.
+23
View File
@@ -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]].
+31
View File
@@ -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 P0P8 (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]].
+30
View File
@@ -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`.
+27
View File
@@ -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 }}
@@ -42,7 +42,6 @@ jobs:
- name: Bump k8s-pods image tag (ArgoCD deploys)
run: |
rm -rf /tmp/kp # dind-builder is a persistent host: clear any stale checkout from a prior run
git clone --depth 1 -b main \
"https://mcp-bot:${{ secrets.K8S_PODS_TOKEN }}@git.lynkedup.cloud/platform-engineering/k8s-pods.git" /tmp/kp
cd /tmp/kp
+6
View File
@@ -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}
+122
View File
@@ -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)
P0P8: 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.
+53
View File
@@ -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.
+4
View File
@@ -54,6 +54,10 @@ for the full, commented list. Highlights:
app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests.
- **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for
prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change.
- **Notifications (Web Push):** set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT`
to enable push (unset → the engine no-ops). ⚠️ Presence (the "are you viewing this thread?"
gate) is **in-memory / single-instance** today — with N>1 replicas, back `PresenceService`
with Redis so the projector on one replica sees focus from another.
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*`
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
single most important prod-hardening flag.
+51 -1
View File
@@ -243,6 +243,54 @@ sequenceDiagram
---
### 5.11 Notifications (push, presence-gated)
The engine reacts to `message.sent` and pushes to **absent** recipients through a swappable
**NotificationPort** (Web Push/VAPID now; email/FCM later). The DB holds only push
subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays `IiosInboxItem`.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/v1/notifications/vapid-public-key` | — | `{key}` — the public VAPID key the client needs to subscribe |
| POST | `/v1/notifications/subscribe` | `{kind?, endpoint, keys:{p256dh, auth}, userAgent?}` | `{ok:true}` — store/refresh the caller's push subscription |
| DELETE | `/v1/notifications/subscribe` | `{endpoint}` | `{ok:true}` |
| POST | `/v1/threads/:id/mute` · `/unmute` | — | `{threadId, muted}` — per-thread notification mute for the caller |
**Presence (socket):** the app emits `focus_thread` `{threadId | null}` on the `/message`
namespace whenever the foreground conversation changes (or the tab blurs). This is how the
engine knows you're *viewing* a thread — **room membership ≠ viewing**, because the sidebar
joins every thread room for live updates. `GET /v1/threads` returns each thread's `muted`.
**The three gates (fail-closed, in the notification policy — not the kernel):**
1. **Policy** — DM always; a group message only if you were `@`-mentioned **or** it's a reply to you.
2. **Presence** — skip if you're currently focused on that thread (you saw it live).
3. **Mute** — skip if you muted the thread.
A dead subscription (Web Push `404/410`) is pruned. DM-vs-group is read from the opaque
`membership` thread attribute here in the *notification policy*; the kernel never branches on it.
```mermaid
flowchart TB
SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"]
PROJ --> LOOP{{"for each recipient (never the sender)"}}
LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"}
G1 -->|no| X1["skip"]
G1 -->|yes| G2{"PRESENCE: focused on this thread?"}
G2 -->|yes| X2["skip — seen live"]
G2 -->|no| G3{"MUTE: thread muted?"}
G3 -->|yes| X3["skip"]
G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"]
PORT --> WP["Web Push (VAPID)"]
WP -->|sent| OUT["→ browser push service → service worker → OS notification"]
WP -->|"gone (404/410)"| PRUNE["prune dead subscription"]
```
**Client (SDK layer, `lib/notifications.ts` → belongs in `@insignia/iios-kernel-client`):**
`registerPush()` (permission → register service worker → `PushManager.subscribe` with the
VAPID key → POST the subscription), `muteThread(threadId, muted)`, and `MessageSocket.focus(threadId)`.
A service worker renders the OS notification on `push` and deep-links to the thread on click.
---
## 6. SDK reference
Two layers: a low-level `RestClient` (+ socket) and per-domain React hook packages.
@@ -256,6 +304,7 @@ Methods (all return typed promises):
- **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})`
- **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates.
- **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.*
- **Notifications:** `registerPush()` (service worker + `PushManager.subscribe` + POST subscription), `muteThread(threadId, muted)`, `MessageSocket.focus(threadId)` (presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering.
- **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})`
- **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)`
- **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)`
@@ -333,7 +382,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/<name>`)
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation |
Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check: `pnpm boundary`.
Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`.
---
@@ -358,6 +407,7 @@ Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check
| `MEDIA_DIR` | `<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/*` |
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
+3 -3
View File
@@ -159,9 +159,9 @@ Each product SDK is a handful of React hooks — a front-end dev wires the UI, t
| Calendar/Zoom providers | Simulated sync | P9 |
| Multi-tenant scale, retention, SLOs | Not yet | P9 |
**Proof it works:** 192 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
**Proof it works:** 205 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, and media sharing** (images/video/audio/docs on a swappable storage port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off.
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, media sharing** (images/video/audio/docs on a swappable storage port), and **presence-gated push notifications** (Web Push via a swappable notification port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off.
---
@@ -177,4 +177,4 @@ Narrative arc for the CEO: **one engine → chat → inbox → support → chann
---
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 53 Postgres tables across 19 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media). Boundary-enforced dependency law; 192 passing tests; 8+ end-to-end smoke scripts. First real-provider swap live: Supabase auth (JWKS-verified) + a media storage port.*
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 54 Postgres tables across 20 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media → notifications). Boundary-enforced dependency law; 205 passing tests; 8+ end-to-end smoke scripts. First real-provider swaps live: Supabase auth (JWKS-verified), a media storage port, and Web Push notifications.*
+277
View File
@@ -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 T1T7 — 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).
+113
View File
@@ -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.*
+3 -1
View File
@@ -7,7 +7,9 @@
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"test": "vitest run",
"boundary": "node scripts/check-import-boundary.mjs"
"boundary": "node scripts/check-import-boundary.mjs",
"release:dry": "pnpm -r build && pnpm -r publish --dry-run --no-git-checks",
"release": "pnpm -r build && pnpm -r publish --no-git-checks"
},
"devDependencies": {
"@types/node": "^26.0.1",
+7 -3
View File
@@ -1,15 +1,19 @@
{
"name": "@insignia/iios-adapter-sdk",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@insignia/iios-contracts": "workspace:*"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-ai-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-community-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+7 -3
View File
@@ -1,12 +1,16 @@
{
"name": "@insignia/iios-contracts",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-inbox-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-kernel-client",
"version": "0.0.0",
"private": true,
"version": "0.1.4",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -19,5 +25,8 @@
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
@@ -8,14 +8,14 @@ export interface MessageSocketConfig {
}
/**
* Framework-agnostic facade over the `/message` Socket.io namespace (ports the
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck.
* On reconnect it re-opens the current thread so subscriptions resume with no
* lost messages (the docs' disconnect/reconnect requirement).
* Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io
* types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens
* all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI
* that watches multiple conversations keeps receiving live messages after a drop.
*/
export class MessageSocket {
private readonly socket: SocketLike;
private currentThreadId: string | null = null;
private readonly joined = new Set<string>();
constructor(config: MessageSocketConfig, socket?: SocketLike) {
this.socket =
@@ -26,9 +26,9 @@ export class MessageSocket {
autoConnect: config.autoConnect ?? true,
}) as unknown as SocketLike);
// Re-open the active thread after a reconnect.
// Re-subscribe to every joined thread after a reconnect.
this.socket.on('connect', () => {
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId });
for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
});
}
@@ -40,27 +40,70 @@ export class MessageSocket {
this.socket.disconnect();
}
/** Run `handler` on every (re)connect, and immediately if already connected. */
onConnected(handler: () => void): () => void {
this.socket.on('connect', handler);
if (this.socket.connected) handler();
return () => this.socket.off('connect', handler);
}
/** Subscribe to a server event; returns an unsubscribe fn. */
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
const fn = handler as (...args: unknown[]) => void;
this.socket.on(event, fn);
return () => this.socket.off(event, fn);
this.socket.on(event as string, fn);
return () => this.socket.off(event as string, fn);
}
async openThread(threadId?: string): Promise<OpenThreadResult> {
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult;
this.currentThreadId = result.threadId;
async openThread(
threadId?: string,
opts?: { membership?: string; creatorRole?: string; subject?: string },
): Promise<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;
}
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', {
threadId,
content,
contentRef: opts?.contentRef,
contentRef: opts?.attachment?.contentRef ?? opts?.contentRef,
mimeType: opts?.attachment?.mimeType,
sizeBytes: opts?.attachment?.sizeBytes,
checksumSha256: opts?.attachment?.checksumSha256,
parentInteractionId: opts?.parentInteractionId,
mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel
})) as Message;
}
/** Pin a message in the thread (shared, generic annotation type "pin"). */
async pin(threadId: string, interactionId: string): Promise<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 }> {
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
}
@@ -68,4 +111,9 @@ export class MessageSocket {
typing(threadId: string): void {
this.socket.emit('typing', { threadId });
}
/** Tell the server which thread is in the foreground (or null when blurred) — drives presence. */
focus(threadId: string | null): void {
this.socket.emit('focus_thread', { threadId });
}
}
+74 -5
View File
@@ -1,9 +1,16 @@
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types';
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
export interface RestConfig {
serviceUrl: string;
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. */
@@ -15,7 +22,8 @@ export class RestClient {
}
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}`;
return h;
}
@@ -57,12 +65,73 @@ export class RestClient {
return (await r.json()) as InboxItem;
}
// ─── auth + threads (app surface) ─────────────────────────────
/** Dev IdP login (POST /v1/dev/login). A real IdP issues the same JWT — this is the swap point. */
async login(username: string, password: string): Promise<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 ──────────────────────────────────────────────────
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);
}
async escalate(threadId: string, subject?: string): Promise<Ticket> {
return this.post<Ticket>('/v1/support/escalate', { threadId, subject });
async escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
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[]> {
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
+66
View File
@@ -1,10 +1,30 @@
/** A media/file part attached to a message (contentRef points at object storage). */
export interface Attachment {
contentRef: string;
mimeType: string;
sizeBytes: number;
kind: 'image' | 'video' | 'audio' | 'file';
}
/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */
export interface AnnotationGroup {
type: string;
value: string;
users: string[]; // usernames who applied it
}
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */
export interface Message {
id: string;
threadId: string;
senderActorId: string;
senderId: string; // sender's email/username — reliable "is this mine?" check
senderName: string;
content: string;
contentRef?: string;
attachment?: Attachment;
parentInteractionId?: string;
annotations?: AnnotationGroup[];
traceId?: string;
createdAt: string;
}
@@ -26,10 +46,50 @@ export interface TypingEvent {
userId: string;
}
/** Broadcast when someone toggles an annotation — carries the refreshed user list. */
export interface AnnotationEvent {
threadId: string;
interactionId: string;
type: string;
value: string;
op: 'add' | 'remove';
users: string[];
userId: string;
}
export interface MessageEvents {
message: (m: Message) => void;
receipt: (e: ReceiptEvent) => void;
typing: (e: TypingEvent) => void;
annotation: (e: AnnotationEvent) => void;
}
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
export interface ThreadSummary {
threadId: string;
subject: string | null;
membership?: string; // 'dm' | 'group' (opaque app attribute)
/** 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';
@@ -68,6 +128,8 @@ export interface Ticket {
assignedActorId?: string | null;
createdAt: string;
updatedAt: string;
/** Opaque, app-supplied attribute bag on the ticket — echoed verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
threadLinks?: Array<{ threadId: string; relationKind: string }>;
}
@@ -188,4 +250,8 @@ export interface SocketLike {
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
connect(): unknown;
disconnect(): unknown;
/** True while the underlying transport is connected (socket.io exposes this). */
connected?: boolean;
/** Per-call ack timeout (socket.io). Optional so fakes can omit it. */
timeout?(ms: number): { emitWithAck(event: string, ...args: unknown[]): Promise<unknown> };
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-meeting-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-message-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+10
View File
@@ -49,3 +49,13 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
# ── Capability providers (governed egress targets) ───────────────────────────
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
# ── Context attestation (July 12 trust layer) ──
# Audience IIOS requires on attestations addressed to it.
IIOS_ATTESTATION_AUDIENCE=iios-core
# Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the
# 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
+3 -4
View File
@@ -24,16 +24,15 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^16.4.7",
"handlebars": "^4.7.9",
"ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0",
"prisma": "^6.2.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"socket.io": "^4.8.3",
"web-push": "^3.6.7",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/sdk-node": "^0.220.0",
"@opentelemetry/auto-instrumentations-node": "^0.78.0"
"web-push": "^3.6.7"
},
"devDependencies": {
"@insignia/iios-testkit": "workspace:*",
@@ -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");
@@ -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");
@@ -96,6 +96,12 @@ enum IiosInboxState {
STALE
}
enum IiosTemplateChannel {
EMAIL
SMS
INTERNAL
}
enum IiosTicketState {
NEW
OPEN
@@ -310,6 +316,7 @@ model IiosScope {
tickets IiosTicket[]
callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[]
messageTemplates IiosMessageTemplate[]
@@index([orgId, appId, tenantId])
}
@@ -677,6 +684,33 @@ model IiosInboxItem {
@@index([ownerActorId, state, priority])
}
/// A reusable message template (email/SMS/in-app). DB is runtime truth; platform defaults are
/// seeded from repo files on boot. Versions are immutable — a change writes a new (higher) version,
/// never edits in place, so a rendered send can always be traced to the exact source it used.
/// scopeId NULL = a platform default; set = a tenant scope's override of the same key. Resolution
/// prefers the scoped row, else the global default. (The global-uniqueness of NULL-scope rows is
/// enforced by a partial unique index added in the migration — Postgres treats NULL as distinct.)
model IiosMessageTemplate {
id String @id @default(cuid())
scopeId String?
scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade)
key String
channel IiosTemplateChannel
locale String @default("en")
version Int @default(1)
subject String?
bodyHtml String?
bodyText String?
/// Declared variable names — render throws if a declared var is missing (fail loud).
variables Json?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scopeId, key, channel, locale, version])
@@index([key, channel, locale, active])
}
/// Audit trail of inbox-item state transitions.
model IiosInboxItemStateHistory {
id String @id @default(cuid())
@@ -1321,3 +1355,31 @@ model IiosDlqItem {
@@unique([consumerName, sourceId])
@@index([scopeId, status])
}
// ─── Trust plane (July 12) — context attestation ──────────────────
// Registered clients/BFFs/adapters allowed to call IIOS and attest context on
// behalf of an app. A valid actor token is NOT enough; the caller must present a
// context attestation signed by one of these registered clients.
model IiosClientRegistry {
clientId String @id
clientType String // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
ownerService String?
allowedAppIds String[] // which app_ids this client may attest for
attestSecret String? // dev: shared HS256 signing key (AppShell's key stand-in)
jwksUri String? // prod: verify the attestation signature via JWKS
spiffeId String? // prod: workload identity for mTLS proof
status String @default("ACTIVE") // ACTIVE | DISABLED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Single-use nonce ledger: once a context attestation's nonce is seen it can
// never be replayed. Reserve-if-absent (unique PK) makes the check atomic.
model IiosAttestationNonce {
nonce String @id
clientId String
expiresAt DateTime
firstSeenAt DateTime @default(now())
@@index([expiresAt])
}
+2
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { PlatformModule } from './platform/platform.module';
import { AttestationModule } from './platform/attestation.module';
import { IdentityModule } from './identity/identity.module';
import { InteractionsModule } from './interactions/interactions.module';
import { IdempotencyModule } from './idempotency/idempotency.module';
@@ -27,6 +28,7 @@ import { DevController } from './dev/dev.controller';
imports: [
PrismaModule,
PlatformModule,
AttestationModule,
IdentityModule,
InteractionsModule,
IdempotencyModule,
@@ -8,6 +8,9 @@ export interface MessagePrincipal {
appId: string;
tenantId?: string;
displayName?: string;
/** The token's audience (`aud`). Used to enforce realtime delegation: a socket-scoped
* token (`iios-message`) must not be a full REST/actor token (`iios-core`), and vice-versa. */
audience?: string;
}
/**
+4 -1
View File
@@ -1,4 +1,3 @@
import './observability/tracing'; // MUST be first — auto-instrumentation patches modules on require
import 'dotenv/config';
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
@@ -10,6 +9,10 @@ import { PolicyDeniedFilter } from './platform/policy-denied.filter';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { rawBody: true });
// Express 5 defaults to the 'simple' query parser, which ignores nested params like
// ?metadata[key]=value — so generic metadata filters would be silently dropped. Use the
// qs-based 'extended' parser so those parse into a nested object.
app.getHttpAdapter().getInstance().set('query parser', 'extended');
app.use(traceMiddleware); // request-scoped trace context + x-trace-id header (P9)
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
@@ -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);
});
});
@@ -61,6 +61,14 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
try {
const token = String(client.handshake.auth?.token ?? '');
const principal = this.session.verify(token);
// Realtime delegation (least privilege): when IIOS_REALTIME_AUDIENCE is set, the socket
// accepts ONLY a token minted for that audience (a short-lived `iios-message` delegate) —
// a full REST/actor token (`iios-core`) or an un-scoped token cannot open the stream. So a
// leaked socket token can't drive privileged REST, and vice-versa. Unset → no enforcement.
const required = process.env.IIOS_REALTIME_AUDIENCE?.trim();
if (required && principal.audience !== required) {
throw new Error(`realtime audience "${principal.audience ?? '(none)'}" != required "${required}"`);
}
client.data = { principal } satisfies SocketState;
} catch (err) {
this.logger.warn(`rejecting socket: ${(err as Error).message}`);
@@ -50,6 +50,8 @@ export interface ThreadSummary {
threadId: string;
subject: string | null;
membership?: string;
/** The thread's opaque, app-supplied attribute bag — echoed back verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
participants: string[];
participantCount: number;
unread: number;
@@ -85,19 +87,21 @@ export class MessageService {
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may
* re-open it (policy `iios.thread.join`) — new members enter via addParticipant.
*/
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> {
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<OpenThreadResult> {
if (!threadId) {
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal);
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app).
// `membership`/`creatorRole`/`subject`/`metadata` are generic, app-supplied thread attributes —
// the kernel stores/echoes them as an opaque bag but never branches on their meaning (that lives
// in policy + the app). `membership` is folded into the same bag for back-compat.
const merged = { ...(opts?.metadata ?? {}), ...(opts?.membership ? { membership: opts.membership } : {}) };
const thread = await this.prisma.iiosThread.create({
data: {
scopeId: scope.id,
createdByActorId: actor.id,
subject: opts?.subject?.trim() || undefined,
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined,
metadata: Object.keys(merged).length > 0 ? (merged as Prisma.InputJsonValue) : undefined,
},
});
await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
@@ -167,8 +171,12 @@ export class MessageService {
return { threadId, muted };
}
/** Generic "my threads": every thread the caller participates in, with last message + unread. */
async listThreads(principal: MessagePrincipal): Promise<ThreadSummary[]> {
/**
* Generic "my threads": every thread the caller participates in, with last message + unread.
* An optional `filter.metadata` narrows to threads whose opaque attribute bag matches ALL of the
* given key/values (an equality match on the JSON bag — the kernel does not interpret the keys).
*/
async listThreads(principal: MessagePrincipal, filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
const scope = await this.actors.findScope(principal);
if (!scope) return [];
const actor = await this.actors.resolveActor(scope.id, principal);
@@ -177,7 +185,7 @@ export class MessageService {
if (threadIds.length === 0) return [];
const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted]));
const [threads, unreads, allParts] = await Promise.all([
const [allThreads, unreads, allParts] = await Promise.all([
this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }),
this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }),
this.prisma.iiosThreadParticipant.findMany({
@@ -185,6 +193,14 @@ export class MessageService {
include: { actor: { include: { sourceHandle: true } } },
}),
]);
// Opaque equality filter on the metadata bag (every requested key must match).
const metaFilter = filter?.metadata;
const threads = metaFilter
? allThreads.filter((t) => {
const bag = (t.metadata as Record<string, unknown> | null) ?? {};
return Object.entries(metaFilter).every(([k, v]) => bag[k] === v);
})
: allThreads;
const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
const membersBy = new Map<string, string[]>();
for (const p of allParts) {
@@ -192,27 +208,41 @@ export class MessageService {
membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]);
}
const summaries = await Promise.all(
threads.map(async (t) => {
const last = await this.prisma.iiosInteraction.findFirst({
where: { threadId: t.id },
orderBy: { occurredAt: 'desc' },
include: { parts: { where: { kind: 'TEXT' }, take: 1 } },
});
const members = membersBy.get(t.id) ?? [];
return {
threadId: t.id,
subject: t.subject,
membership: (t.metadata as { membership?: string } | null)?.membership,
participants: members,
participantCount: members.length,
unread: unreadBy.get(t.id) ?? 0,
muted: mutedBy.get(t.id) ?? false,
lastMessage: last?.parts[0]?.bodyText ?? undefined,
lastAt: last?.occurredAt,
};
}),
);
// Latest message per thread in ONE query (Postgres DISTINCT ON) instead of one findFirst
// per thread — a caller with many threads no longer fans out N parallel queries and
// exhausts the connection pool.
const lastRows =
threads.length === 0
? []
: await this.prisma.$queryRaw<Array<{ threadId: string; occurredAt: Date; lastMessage: string | null }>>(Prisma.sql`
SELECT DISTINCT ON (i."threadId")
i."threadId" AS "threadId",
i."occurredAt" AS "occurredAt",
(SELECT p."bodyText" FROM "IiosMessagePart" p
WHERE p."interactionId" = i.id AND p.kind::text = 'TEXT'
ORDER BY p."partIndex" ASC LIMIT 1) AS "lastMessage"
FROM "IiosInteraction" i
WHERE i."threadId" IN (${Prisma.join(threads.map((t) => t.id))})
ORDER BY i."threadId", i."occurredAt" DESC
`);
const lastBy = new Map(lastRows.map((r) => [r.threadId, r]));
const summaries = threads.map((t) => {
const last = lastBy.get(t.id);
const members = membersBy.get(t.id) ?? [];
return {
threadId: t.id,
subject: t.subject,
membership: (t.metadata as { membership?: string } | null)?.membership,
metadata: (t.metadata as Record<string, unknown> | null) ?? null,
participants: members,
participantCount: members.length,
unread: unreadBy.get(t.id) ?? 0,
muted: mutedBy.get(t.id) ?? false,
lastMessage: last?.lastMessage ?? undefined,
lastAt: last?.occurredAt,
};
});
return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0));
}
@@ -143,6 +143,23 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect((await s.listThreads(bob))[0]?.unread).toBe(1);
});
it('opaque metadata: stored on create, echoed in listThreads, and a metadata filter narrows the list', async () => {
const s = svc();
// Two threads with different opaque app attributes — the kernel never interprets these values.
await s.openThread(null, alice, { metadata: { source: 'crm-support', crmCustomerId: 'cust_1' } });
await s.openThread(null, alice, { metadata: { source: 'other' } });
const all = await s.listThreads(alice);
expect(all).toHaveLength(2);
const support = all.find((t) => (t.metadata as { source?: string } | null)?.source === 'crm-support');
expect(support?.metadata).toMatchObject({ source: 'crm-support', crmCustomerId: 'cust_1' });
// Equality filter on the opaque bag returns only the matching thread.
const filtered = await s.listThreads(alice, { metadata: { source: 'crm-support' } });
expect(filtered).toHaveLength(1);
expect(filtered[0]?.metadata).toMatchObject({ crmCustomerId: 'cust_1' });
});
it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
@@ -1,5 +1,4 @@
import type { Request, Response, NextFunction } from 'express';
import { trace } from '@opentelemetry/api';
import { runWithTrace, newTraceId, traceIdFromTraceparent } from './trace-context';
import { logJson } from './logger';
@@ -10,13 +9,8 @@ import { logJson } from './logger';
* the response finishes (method, path, status, ms). Wired via app.use() in main.ts.
*/
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
// Prefer the ACTIVE OpenTelemetry trace id: that is what makes this log line
// joinable to its distributed trace in Grafana (Loki -> Tempo). Falls back to
// the inbound header, then traceparent, then a generated id, so a correlation
// id is always present even when tracing is disabled.
const otelTraceId = trace.getActiveSpan()?.spanContext().traceId;
const headerTrace = (req.headers['x-trace-id'] as string | undefined)?.trim();
const traceId = otelTraceId || headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
const traceId = headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
res.setHeader('x-trace-id', traceId);
const startedAt = Date.now();
runWithTrace(traceId, () => {
@@ -1,50 +0,0 @@
/**
* OpenTelemetry bootstrap. MUST be imported before anything else in main.ts —
* auto-instrumentation works by patching modules (http, express, pg, redis, …)
* as they are require()d, so any module loaded before this runs is never traced.
*
* Env-driven on purpose: the OTel SDK reads OTEL_SERVICE_NAME,
* OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL itself, so the
* collector target is a deployment concern rather than a code change. When
* OTEL_EXPORTER_OTLP_ENDPOINT is unset the SDK never starts, so local dev and
* tests run with zero tracing overhead and no exporter errors.
*
* Traces land in Tempo and are viewable in Grafana. The existing P9 trace
* middleware adopts the active OTel trace id, so an `http.request` log line and
* its distributed trace share one id (Loki -> Tempo pivot).
*/
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (endpoint) {
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations({
// Noisy and low value: every file read becomes a span.
'@opentelemetry/instrumentation-fs': { enabled: false },
// k8s probes hit /health constantly; tracing them would swamp Tempo
// and bury the real request traces.
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const url = req.url ?? '';
return ['/health', '/ready', '/healthz', '/readyz', '/metrics'].some((p) =>
url.startsWith(p),
);
},
},
}),
],
});
sdk.start();
const shutdown = (): void => {
// Flush buffered spans before exit, else the last requests before a
// rollout are lost.
void sdk.shutdown().finally(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { JwksClient } from 'jwks-rsa';
import { PrismaService } from '../prisma/prisma.service';
import type {
ClientRegistryEntry,
ClientRegistryPort,
NonceStorePort,
PublicKeyResolverPort,
} from './context-attestation';
/** Client registry backed by Postgres (IiosClientRegistry). */
@Injectable()
export class PrismaClientRegistry implements ClientRegistryPort {
constructor(private readonly prisma: PrismaService) {}
async find(clientId: string): Promise<ClientRegistryEntry | null> {
const r = await this.prisma.iiosClientRegistry.findUnique({ where: { clientId } });
if (!r) return null;
return {
clientId: r.clientId,
clientType: r.clientType,
allowedAppIds: r.allowedAppIds,
attestSecret: r.attestSecret ?? undefined,
jwksUri: r.jwksUri ?? undefined,
status: r.status === 'ACTIVE' ? 'ACTIVE' : 'DISABLED',
};
}
}
/**
* Nonce ledger backed by Postgres (IiosAttestationNonce). Reserve-if-absent is atomic via the
* unique primary key: a duplicate insert (P2002) means the nonce was already used → replay.
*/
@Injectable()
export class PrismaNonceStore implements NonceStorePort {
constructor(private readonly prisma: PrismaService) {}
async reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean> {
try {
await this.prisma.iiosAttestationNonce.create({ data: input });
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') return false; // already seen
throw e;
}
}
}
/**
* Resolves a client's public signing key from its JWKS (prod ES256 path). Keeps ONE JwksClient
* per jwksUri — the client caches keys and refetches on a cache miss (so key rotation is picked up
* without a restart). Returns null on any failure so the verifier fails closed with NO_KEY.
*/
@Injectable()
export class JwksPublicKeyResolver implements PublicKeyResolverPort {
private readonly clients = new Map<string, JwksClient>();
private clientFor(jwksUri: string): JwksClient {
let c = this.clients.get(jwksUri);
if (!c) {
c = new JwksClient({ jwksUri, cache: true, cacheMaxEntries: 8, cacheMaxAge: 10 * 60_000, rateLimit: true, jwksRequestsPerMinute: 12 });
this.clients.set(jwksUri, c);
}
return c;
}
async resolve(jwksUri: string, kid: string): Promise<string | null> {
try {
const key = await this.clientFor(jwksUri).getSigningKey(kid);
return key.getPublicKey(); // PEM (SPKI) — works for EC (ES256) and RSA keys
} catch {
return null; // unknown kid / unreachable JWKS / malformed key → fail closed
}
}
}
@@ -0,0 +1,45 @@
import { Global, Module, type OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ContextAttestationVerifier } from './context-attestation';
import { PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver } from './attestation-stores';
import { ContextAttestationGuard } from './context-attestation.guard';
/**
* Wires the context-attestation trust layer into DI and exposes the guard globally so any
* controller can enforce the three-proof gate. Also dev-seeds a registered client so locally
* minted attestations verify (prod registers clients out-of-band).
*/
@Global()
@Module({
providers: [
PrismaClientRegistry,
PrismaNonceStore,
JwksPublicKeyResolver,
{
provide: ContextAttestationVerifier,
useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore, keys: JwksPublicKeyResolver) =>
new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }, keys),
inject: [PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver],
},
ContextAttestationGuard,
],
exports: [ContextAttestationVerifier, ContextAttestationGuard],
})
export class AttestationModule implements OnModuleInit {
constructor(private readonly prisma: PrismaService) {}
/** Dev seed: register the CRM support client so an attestation signed with the shared dev
* secret verifies locally. Gated by IIOS_DEV_TOKENS + IIOS_ATTESTATION_DEV_SECRET; best-effort. */
async onModuleInit(): Promise<void> {
if (process.env.IIOS_DEV_TOKENS !== '1') return;
const secret = process.env.IIOS_ATTESTATION_DEV_SECRET;
if (!secret) return;
await this.prisma.iiosClientRegistry
.upsert({
where: { clientId: 'appshell-crm' },
create: { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' },
update: { clientType: 'APPSHELL', attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' },
})
.catch(() => undefined);
}
}
@@ -0,0 +1,96 @@
import { describe, it, expect, afterEach } from 'vitest';
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
import { ContextAttestationGuard } from './context-attestation.guard';
import {
ContextAttestationVerifier,
signDevAttestation,
type ClientRegistryPort,
type NonceStorePort,
} from './context-attestation';
import type { SessionVerifier } from './session.verifier';
const SECRET = 'appshell-dev-signing-key';
// SessionVerifier stand-in: the token always resolves to app "crm-web".
const fakeSession = {
verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }),
} as unknown as SessionVerifier;
const registryFor = (): ClientRegistryPort => ({
find: async (id) =>
id === 'appshell-crm'
? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' }
: null,
});
const freshNonces = (): NonceStorePort => {
const seen = new Set<string>();
return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) };
};
function makeGuard(): ContextAttestationGuard {
const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' });
return new ContextAttestationGuard(fakeSession, verifier);
}
// A guard whose actor token resolves to `tokenApp` — used to test attestation↔token app binding.
function makeGuardForApp(tokenApp: string): ContextAttestationGuard {
const session = {
verify: () => ({ userId: 'agent_1', appId: tokenApp, orgId: 'org', tenantId: 'tnt', displayName: 'A' }),
} as unknown as SessionVerifier;
const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' });
return new ContextAttestationGuard(session, verifier);
}
function ctxWith(headers: Record<string, string>): ExecutionContext {
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext;
}
function att(over: Record<string, unknown> = {}, secret = SECRET): string {
return signDevAttestation(
{ issuer: 'appshell.crm', audience: 'iios-core', clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over },
secret,
);
}
describe('ContextAttestationGuard (three-proof gate on the request path)', () => {
afterEach(() => { delete process.env.IIOS_REQUIRE_ATTESTATION; });
it('allows a request with no attestation when NOT required (dev / zero-trust)', async () => {
expect(await makeGuard().canActivate(ctxWith({}))).toBe(true);
});
it('rejects a request with no attestation when IIOS_REQUIRE_ATTESTATION=1', async () => {
process.env.IIOS_REQUIRE_ATTESTATION = '1';
await expect(makeGuard().canActivate(ctxWith({}))).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows a valid attestation bound to the token app', async () => {
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att() };
expect(await makeGuard().canActivate(ctxWith(headers))).toBe(true);
});
it('rejects a forged attestation (wrong signing key) — stolen-context defense', async () => {
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_forge' }, 'attacker-key') };
await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects a replayed attestation (same nonce twice)', async () => {
const guard = makeGuard();
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_replay' }) };
expect(await guard.canActivate(ctxWith(headers))).toBe(true);
await expect(guard.canActivate(ctxWith({ ...headers }))).rejects.toBeInstanceOf(ForbiddenException);
});
// ── doc rejection matrix: "valid token + wrong client/context → reject" ──
it('rejects a valid attestation whose app binding != the actor token app (stolen context reused by another app)', async () => {
// Attestation is validly signed for app crm-web, but the presented actor token is for a different app.
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_appmix' }) };
await expect(makeGuardForApp('other-app').canActivate(ctxWith(headers))).rejects.toThrow(/APP_MISMATCH/);
});
// ── doc rejection matrix: "valid token + wrong audience → reject before business logic" ──
it('rejects an attestation minted for another service (audience != iios-core) replayed at IIOS', async () => {
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_aud', audience: 'some-other-service' }) };
await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toThrow(/WRONG_AUDIENCE/);
});
});
@@ -0,0 +1,52 @@
import { type CanActivate, type ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { SessionVerifier } from './session.verifier';
import { ContextAttestationVerifier } from './context-attestation';
/**
* The July 12 three-proof gate on the request path. Proof #1 (a valid actor token) is done by
* the controllers; proof #2 (workload/mTLS) is the mesh's job in prod. This guard adds proof #3:
* a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.).
*
* Behaviour (safe rollout):
* • attestation header present → verify it; the attestation's app_id must match the actor
* token's app_id. Any failure → 403.
* • attestation header absent → allowed ONLY when not required (dev / a zero-trust standalone
* caller that IIOS checks fully itself). With `IIOS_REQUIRE_ATTESTATION=1`, absence is a 403.
*
* The requirement is read per-request so it can be toggled without restarts and stays OFF by
* default — existing callers that don't send an attestation keep working until the rollout flips.
*/
@Injectable()
export class ContextAttestationGuard implements CanActivate {
constructor(
private readonly session: SessionVerifier,
private readonly attest: ContextAttestationVerifier,
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const required = process.env.IIOS_REQUIRE_ATTESTATION === '1';
const req = ctx.switchToHttp().getRequest<Request>();
const raw = req.headers['x-context-attestation'];
const attestation = Array.isArray(raw) ? raw[0] : raw;
if (!attestation) {
if (required) throw new ForbiddenException('context attestation required');
return true; // dev / zero-trust: the actor token is still enforced downstream
}
// Bind the attestation to the token's app_id, so a stolen attestation for another app fails.
const auth = req.headers['authorization'] ?? '';
const token = String(auth).replace(/^Bearer\s+/i, '');
let appId: string;
try {
appId = this.session.verify(token).appId ?? '';
} catch {
throw new ForbiddenException('invalid actor token');
}
const result = await this.attest.verify(attestation, appId);
if (!result.ok) throw new ForbiddenException(`context attestation rejected: ${result.reason}`);
return true;
}
}
@@ -0,0 +1,193 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { generateKeyPairSync } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import {
ContextAttestationVerifier,
signDevAttestation,
signDevAttestationES256,
type ClientRegistryEntry,
type ClientRegistryPort,
type NonceStorePort,
type PublicKeyResolverPort,
} from './context-attestation';
import { PrismaNonceStore } from './attestation-stores';
import type { PrismaService } from '../prisma/prisma.service';
const APPSHELL_SECRET = 'appshell-dev-signing-key';
const AUD = 'iios-core';
const NOW = new Date('2026-07-13T12:00:00Z');
// ─── in-memory fakes (fast, deterministic — no DB needed) ─────────
function fakeRegistry(over: Partial<ClientRegistryEntry> = {}): ClientRegistryPort {
const entry: ClientRegistryEntry = {
clientId: 'appshell-crm',
clientType: 'SUPPORT_BFF',
allowedAppIds: ['crm-web'],
attestSecret: APPSHELL_SECRET,
status: 'ACTIVE',
...over,
};
return { find: async (id) => (id === entry.clientId ? entry : null) };
}
function fakeNonces(): NonceStorePort {
const seen = new Set<string>();
return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) };
}
const verifier = (reg: ClientRegistryPort = fakeRegistry(), nonces: NonceStorePort = fakeNonces()) =>
new ContextAttestationVerifier(reg, nonces, { audience: AUD });
/** A well-formed AppShell attestation, overridable per test. */
function attest(over: Record<string, unknown> = {}, secret = APPSHELL_SECRET, at = NOW): string {
return signDevAttestation(
{ issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: 'n_default', ...over },
secret,
at,
);
}
describe('ContextAttestationVerifier (July 12 trust proof)', () => {
it('accepts a valid attestation from a registered client for the matching app', async () => {
const res = await verifier().verify(attest({ nonce: 'n_ok' }), 'crm-web', NOW);
expect(res.ok).toBe(true);
if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm');
});
// ── the five rejection cases the CEO named ──
it('rejects an unknown / unregistered client (wrong client)', async () => {
const res = await verifier().verify(attest({ clientId: 'hacker-app', nonce: 'n1' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'UNKNOWN_CLIENT' });
});
it('rejects the wrong audience (token minted for another service)', async () => {
const res = await verifier().verify(attest({ audience: 'some-other-service', nonce: 'n2' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' });
});
it('rejects an expired attestation', async () => {
// signed 10 min ago with a 5 min TTL → already expired at NOW
const stale = attest({ nonce: 'n3', ttlSeconds: 300 }, APPSHELL_SECRET, new Date(NOW.getTime() - 600_000));
const res = await verifier().verify(stale, 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'EXPIRED' });
});
it('rejects a replayed nonce (same attestation used twice)', async () => {
const v = verifier();
const token = attest({ nonce: 'n_replay' });
const first = await v.verify(token, 'crm-web', NOW);
const second = await v.verify(token, 'crm-web', NOW);
expect(first.ok).toBe(true);
expect(second).toMatchObject({ ok: false, reason: 'REPLAY' });
});
it('rejects app mismatch: token app != attestation app', async () => {
const res = await verifier().verify(attest({ appId: 'crm-web', nonce: 'n4' }), 'some-other-app', NOW);
expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
});
// ── extra hardening ──
it('rejects a forged signature (signed with the wrong key)', async () => {
const res = await verifier().verify(attest({ nonce: 'n5' }, 'attacker-key'), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' });
});
it('rejects a disabled client', async () => {
const res = await verifier(fakeRegistry({ status: 'DISABLED' })).verify(attest({ nonce: 'n6' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'CLIENT_DISABLED' });
});
it('rejects an app the client is not allowed to attest for', async () => {
const reg = fakeRegistry({ allowedAppIds: ['other-app'] });
const res = await verifier(reg).verify(attest({ appId: 'crm-web', nonce: 'n7' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
});
});
// ─── prod asymmetric path: ES256 verified via the client's JWKS ───
describe('ContextAttestationVerifier — JWKS / ES256 (production signing path)', () => {
const JWKS = 'https://appshell.example/.well-known/jwks.json';
const KID = 'appshell-key-1';
// A real EC P-256 keypair (what AppShell would hold; only the public half is published as JWKS).
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
const pubPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
const { privateKey: otherPriv } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const otherPrivPem = otherPriv.export({ type: 'pkcs8', format: 'pem' }) as string;
// Registry entry that verifies via JWKS (no shared secret) + a resolver that maps KID → pubkey.
const jwksRegistry: ClientRegistryPort = {
find: async (id) =>
id === 'appshell-crm'
? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], jwksUri: JWKS, status: 'ACTIVE' }
: null,
};
const resolver: PublicKeyResolverPort = { resolve: async (_uri, kid) => (kid === KID ? pubPem : null) };
const v = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD }, resolver);
// No 4th arg → no key resolver wired at all (distinct from passing undefined, which hits the default).
const vNoResolver = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD });
function es256(over: Record<string, unknown> = {}, priv = privPem, kid = KID): string {
return signDevAttestationES256(
{ issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over },
priv,
kid,
NOW,
);
}
it('accepts an ES256 attestation whose signature verifies against the JWKS', async () => {
const res = await v().verify(es256({ nonce: 'es_ok' }), 'crm-web', NOW);
expect(res.ok).toBe(true);
if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm');
});
it('rejects an ES256 attestation signed with a DIFFERENT private key (forged)', async () => {
const res = await v().verify(es256({ nonce: 'es_forged' }, otherPrivPem), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' });
});
it('rejects when the JWT header carries an unknown kid (no matching JWKS key)', async () => {
const res = await v().verify(es256({ nonce: 'es_kid' }, privPem, 'rotated-away-kid'), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' });
});
it('rejects when a JWKS client is configured but no key resolver is wired', async () => {
const res = await vNoResolver().verify(es256({ nonce: 'es_nores' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' });
});
it('still enforces audience / app / replay on the ES256 path', async () => {
const vv = v();
expect(await vv.verify(es256({ nonce: 'es_aud', audience: 'other' }), 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' });
expect(await vv.verify(es256({ nonce: 'es_app' }), 'some-other-app', NOW)).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
const tok = es256({ nonce: 'es_replay' });
expect((await vv.verify(tok, 'crm-web', NOW)).ok).toBe(true);
expect(await vv.verify(tok, 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'REPLAY' });
});
});
// ─── DB-level atomicity of the nonce ledger ───────────────────────
describe('PrismaNonceStore (atomic replay defense)', () => {
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const store = new PrismaNonceStore(prisma as unknown as PrismaService);
let dbUp = false;
beforeAll(async () => {
try { await prisma.$connect(); dbUp = true; } catch { dbUp = false; }
});
afterAll(async () => { if (dbUp) await prisma.$disconnect(); });
it('reserves a nonce once; a second reserve of the same nonce is rejected', async (ctx) => {
if (!dbUp) return ctx.skip(); // DB unreachable in this environment — the in-memory REPLAY test covers the logic
const nonce = `n_db_${NOW.getTime()}_${Math.floor(Math.random() * 1e9)}`;
const expiresAt = new Date(NOW.getTime() + 300_000);
const first = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt });
const second = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt });
expect(first).toBe(true);
expect(second).toBe(false);
await prisma.iiosAttestationNonce.delete({ where: { nonce } }).catch(() => undefined);
});
});
@@ -0,0 +1,190 @@
import jwt from 'jsonwebtoken';
/**
* The July 12 "third proof". A cryptographically valid actor token is NOT enough — a hacked
* app can replay a stolen one. Before IIOS processes a privileged request it must also verify
* a CONTEXT ATTESTATION: a short-lived, single-use, signed statement from an authorized parent
* (AppShell for CRM, the adapter registry for channels, Calendar for calendar) that says
* "this request, this client, this app, this context is genuinely mine."
*
* This module is the verifier + its two ports (client registry, nonce ledger) + a dev signer.
* It is intentionally decoupled from the request path so it can be unit-tested in isolation and
* wired into the messaging guard as a separate, gated step.
*/
/** The subset of AppShell's context-attestation envelope that IIOS verifies. */
export interface ContextAttestation {
attestationId?: string;
issuer: string;
issuerType?: string; // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
audience: string; // must equal the configured IIOS audience
clientId: string; // must be a registered, ACTIVE client
appId: string; // must match the actor token's app_id AND be allowed for this client
scope?: { orgId?: string; appId?: string; tenantId?: string; buId?: string };
nonce: string; // single-use
iat?: number;
exp?: number; // short-lived
}
/** A registered caller allowed to attest context on behalf of one or more apps. */
export interface ClientRegistryEntry {
clientId: string;
clientType: string;
allowedAppIds: string[];
attestSecret?: string; // dev: shared HS256 signing key (AppShell's key stand-in)
jwksUri?: string; // prod: verify the attestation signature asymmetrically (ES256) via this JWKS
status: 'ACTIVE' | 'DISABLED';
}
export interface ClientRegistryPort {
find(clientId: string): Promise<ClientRegistryEntry | null>;
}
export interface NonceStorePort {
/** Atomically reserve a nonce. Returns true if fresh, false if it was already seen (replay). */
reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean>;
}
/**
* Resolves the PUBLIC key for a JWKS uri + key id, for the prod asymmetric (ES256) path.
* A port so the verifier stays network-free and unit-testable — the real impl fetches +
* caches the client's JWKS; tests inject a fake that returns a known key.
*/
export interface PublicKeyResolverPort {
/** Return the signing key (PEM) for `kid` at `jwksUri`, or null if it can't be resolved. */
resolve(jwksUri: string, kid: string): Promise<string | null>;
}
export type AttestationReason =
| 'MALFORMED'
| 'UNKNOWN_CLIENT'
| 'CLIENT_DISABLED'
| 'NO_KEY'
| 'BAD_SIGNATURE'
| 'WRONG_AUDIENCE'
| 'APP_MISMATCH'
| 'EXPIRED'
| 'REPLAY';
export type AttestationResult =
| { ok: true; attestation: ContextAttestation }
| { ok: false; reason: AttestationReason; detail: string };
export interface AttestationConfig {
/** The audience IIOS requires on attestations addressed to it (e.g. 'iios-core'). */
audience: string;
}
const deny = (reason: AttestationReason, detail: string): AttestationResult => ({ ok: false, reason, detail });
export class ContextAttestationVerifier {
constructor(
private readonly registry: ClientRegistryPort,
private readonly nonces: NonceStorePort,
private readonly config: AttestationConfig,
/** Optional — required only for clients that verify via a JWKS (prod ES256). */
private readonly keys?: PublicKeyResolverPort,
) {}
/**
* Verify an attestation against an already-verified actor token. Fail-closed: any doubt → deny.
* @param attestationJwt the signed attestation the parent issued
* @param tokenAppId the `app_id` from the verified actor token (Level-1 proof)
* @param now current time — injected so tests are deterministic
*/
async verify(attestationJwt: string, tokenAppId: string, now: Date = new Date()): Promise<AttestationResult> {
// 1. Decode WITHOUT verifying, only to learn who claims to have signed it.
const claimed = jwt.decode(attestationJwt, { json: true }) as (ContextAttestation & jwt.JwtPayload) | null;
if (!claimed || typeof claimed !== 'object' || !claimed.clientId || !claimed.nonce) {
return deny('MALFORMED', 'attestation missing clientId/nonce');
}
// 2. The claimed client must be registered and ACTIVE.
const client = await this.registry.find(claimed.clientId);
if (!client) return deny('UNKNOWN_CLIENT', `client "${claimed.clientId}" is not registered`);
if (client.status !== 'ACTIVE') return deny('CLIENT_DISABLED', `client "${claimed.clientId}" is ${client.status}`);
// 3. Verify the signature with the client's key. A client verifies EITHER asymmetrically via
// its published JWKS (prod: AppShell signs ES256 with a private key) OR with a shared HS256
// secret (dev stand-in). ignoreExpiration so we can return a precise EXPIRED (step 6) rather
// than BAD_SIGNATURE.
const key = await this.resolveVerificationKey(attestationJwt, client);
if (!key.ok) return deny(key.reason, key.detail);
let att: ContextAttestation & jwt.JwtPayload;
try {
att = jwt.verify(attestationJwt, key.pem, { algorithms: [key.alg], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload;
} catch (e) {
return deny('BAD_SIGNATURE', (e as Error).message);
}
// 4. Audience must be IIOS — a token for another service can't be replayed at IIOS.
if (att.audience !== this.config.audience) return deny('WRONG_AUDIENCE', `audience "${att.audience}" != "${this.config.audience}"`);
// 5. app binding: the attestation's app must match the token's app AND be allowed for this client.
if (att.appId !== tokenAppId) return deny('APP_MISMATCH', `attestation app "${att.appId}" != token app "${tokenAppId}"`);
if (!client.allowedAppIds.includes(att.appId)) return deny('APP_MISMATCH', `client "${client.clientId}" not allowed for app "${att.appId}"`);
// 6. Freshness — attestations are short-lived.
const expMs = (att.exp ?? 0) * 1000;
if (!expMs || expMs <= now.getTime()) return deny('EXPIRED', 'attestation expired or missing exp');
// 7. Single-use — reserve the nonce. A replayed (stolen) attestation loses here. Fail-closed.
const fresh = await this.nonces.reserve({ nonce: att.nonce, clientId: client.clientId, expiresAt: new Date(expMs) });
if (!fresh) return deny('REPLAY', `nonce "${att.nonce}" already used`);
return { ok: true, attestation: att };
}
/**
* Pick the algorithm + verification key for a client. JWKS (ES256) wins when configured:
* read the `kid` from the JWT header and resolve the public key from the client's JWKS.
* Otherwise fall back to the shared HS256 dev secret. Either way returns a precise NO_KEY
* reason when no usable key can be obtained (fail-closed).
*/
private async resolveVerificationKey(
attestationJwt: string,
client: ClientRegistryEntry,
): Promise<{ ok: true; pem: string; alg: 'ES256' | 'HS256' } | { ok: false; reason: AttestationReason; detail: string }> {
if (client.jwksUri) {
if (!this.keys) return { ok: false, reason: 'NO_KEY', detail: `client "${client.clientId}" uses JWKS but no key resolver is wired` };
const decoded = jwt.decode(attestationJwt, { complete: true });
const kid = decoded && typeof decoded === 'object' ? (decoded.header?.kid as string | undefined) : undefined;
if (!kid) return { ok: false, reason: 'NO_KEY', detail: 'attestation header missing kid (required for JWKS)' };
const pem = await this.keys.resolve(client.jwksUri, kid).catch(() => null);
if (!pem) return { ok: false, reason: 'NO_KEY', detail: `no JWKS key for kid "${kid}"` };
return { ok: true, pem, alg: 'ES256' };
}
if (client.attestSecret) return { ok: true, pem: client.attestSecret, alg: 'HS256' };
return { ok: false, reason: 'NO_KEY', detail: `no verification key configured for "${client.clientId}"` };
}
}
/**
* DEV/TEST helper: mint a signed attestation the way AppShell would (HS256 stand-in for its
* signing key). Production AppShell signs asymmetrically and IIOS verifies via the client's JWKS.
*/
export function signDevAttestation(
claims: Omit<ContextAttestation, 'iat' | 'exp'> & { ttlSeconds?: number },
secret: string,
now: Date = new Date(),
): string {
const { ttlSeconds = 300, ...rest } = claims;
const iat = Math.floor(now.getTime() / 1000);
return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, secret, { algorithm: 'HS256' });
}
/**
* DEV/TEST helper: mint an attestation signed ASYMMETRICALLY (ES256), the way production AppShell
* does. `privateKeyPem` is a PKCS#8 EC private key; `kid` is stamped into the JWT header so the
* verifier can pick the matching public key from the client's JWKS.
*/
export function signDevAttestationES256(
claims: Omit<ContextAttestation, 'iat' | 'exp'> & { ttlSeconds?: number },
privateKeyPem: string,
kid: string,
now: Date = new Date(),
): string {
const { ttlSeconds = 300, ...rest } = claims;
const iat = Math.floor(now.getTime() / 1000);
return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, privateKeyPem, { algorithm: 'ES256', keyid: kid });
}
@@ -1,10 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { PLATFORM_PORTS, LocalDevPorts } from './platform-ports';
import { RealtimeTokenRestGuard } from './realtime-token.guard';
/** Binds the platform ports (P1: the permissive in-service default). */
/**
* Binds the platform ports (P1: the permissive in-service default), and registers the
* realtime-delegation REST guard globally a socket-scoped token must not drive REST on ANY
* route, so it can't be per-controller (see realtime-token.guard).
*/
@Global()
@Module({
providers: [{ provide: PLATFORM_PORTS, useClass: LocalDevPorts }],
providers: [
{ provide: PLATFORM_PORTS, useClass: LocalDevPorts },
{ provide: APP_GUARD, useClass: RealtimeTokenRestGuard },
],
exports: [PLATFORM_PORTS],
})
export class PlatformModule {}
@@ -0,0 +1,68 @@
import { describe, it, expect, afterEach } from 'vitest';
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import { RealtimeTokenRestGuard } from './realtime-token.guard';
// Realtime delegation, REST half: a socket-scoped token (aud=iios-message) opens the /message
// socket and NOTHING else. The gateway enforces the mirror rule; this guard is the REST side.
const SECRET = 'dev-crm-secret';
const token = (payload: Record<string, unknown>) => jwt.sign(payload, SECRET, { algorithm: 'HS256', expiresIn: '5m' });
/** An HTTP ExecutionContext carrying the given authorization header. */
function httpCtx(authorization?: string): ExecutionContext {
return {
getType: () => 'http',
switchToHttp: () => ({ getRequest: () => ({ headers: authorization ? { authorization } : {} }) }),
} as unknown as ExecutionContext;
}
const wsCtx = () => ({ getType: () => 'ws' }) as unknown as ExecutionContext;
const guard = new RealtimeTokenRestGuard();
describe('RealtimeTokenRestGuard (socket tokens must not drive REST)', () => {
afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; });
it('rejects a socket-scoped token (aud=iios-message) on REST', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web', aud: 'iios-message' })}`);
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
it('allows a normal actor token (no aud)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web' })}`))).toBe(true);
});
it('allows a REST-audience token (aud=iios-core)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-core' })}`))).toBe(true);
});
it('rejects when the socket audience is one of several in an aud array', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: ['iios-core', 'iios-message'] })}`);
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
it('is a no-op when IIOS_REALTIME_AUDIENCE is unset (same switch as the socket half)', () => {
const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-message' })}`);
expect(guard.canActivate(ctx)).toBe(true);
});
it('passes through unauthenticated routes (health, metrics, HMAC adapter webhooks)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx())).toBe(true);
expect(guard.canActivate(httpCtx('Hmac abc123'))).toBe(true); // non-bearer scheme
});
it('never applies to the socket itself (ws context)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(wsCtx())).toBe(true);
});
it('passes a malformed bearer through (auth is the controller\'s job, not this filter\'s)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx('Bearer not-a-jwt'))).toBe(true);
});
});
@@ -0,0 +1,43 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import type { Request } from 'express';
/**
* Realtime delegation the REST half.
*
* The browser's socket token is deliberately narrow: `aud = IIOS_REALTIME_AUDIENCE` (e.g.
* iios-message), minutes-long, and good for the /message socket ONLY. The gateway enforces the
* mirror of this rule (it accepts ONLY that audience). Without this guard the narrowing is
* one-directional: REST doesn't check the actor token's audience, so a leaked socket token would
* still drive privileged REST i.e. it would be a full actor token with extra steps.
*
* Applied GLOBALLY (APP_GUARD) on purpose: every REST route is a target, not just the ones behind
* ContextAttestationGuard (inbox, media, interactions, ai, would otherwise stay open).
*
* It is a decode-only REJECT filter, never an authenticator:
* - it does not verify signatures (each controller still calls SessionVerifier) cheap, and it
* can't be bypassed by stripping `aud`, because that invalidates the signature downstream;
* - no bearer token pass through (health, metrics, HMAC adapter webhooks are not its business);
* - unset IIOS_REALTIME_AUDIENCE no-op (same switch that turns on the socket half).
*/
@Injectable()
export class RealtimeTokenRestGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (context.getType() !== 'http') return true; // the socket enforces its own (mirror) rule
const socketAudience = process.env.IIOS_REALTIME_AUDIENCE?.trim();
if (!socketAudience) return true;
const auth = context.switchToHttp().getRequest<Request>().headers['authorization'];
const raw = Array.isArray(auth) ? auth[0] : auth;
if (typeof raw !== 'string' || !/^Bearer\s+/i.test(raw)) return true;
const decoded = jwt.decode(raw.replace(/^Bearer\s+/i, ''), { json: true });
const aud = decoded?.aud;
const isSocketToken = aud === socketAudience || (Array.isArray(aud) && aud.includes(socketAudience));
if (isSocketToken) {
throw new ForbiddenException(`socket-scoped token (aud="${socketAudience}") cannot be used for REST`);
}
return true;
}
}
@@ -135,6 +135,7 @@ export class SessionVerifier implements OnModuleInit {
orgId: entry.orgId,
tenantId: undefined,
displayName: meta.full_name ?? meta.name ?? email ?? userId,
audience: typeof payload.aud === 'string' ? payload.aud : entry.audience,
};
}
@@ -159,6 +160,7 @@ export class SessionVerifier implements OnModuleInit {
orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`,
tenantId: payload.tenantId ? String(payload.tenantId) : undefined,
displayName: payload.name ? String(payload.name) : undefined,
audience: typeof payload.aud === 'string' ? payload.aud : undefined,
};
}
@@ -1,10 +1,12 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { IiosTicketState } from '@prisma/client';
import { SupportService } from './support.service';
import { AssignmentService } from './assignment.service';
import { SessionVerifier } from '../platform/session.verifier';
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
import type { MessagePrincipal } from '../identity/actor.resolver';
import {
AssignTicketDto,
AvailabilityDto,
CallbackDto,
CreateQueueDto,
@@ -14,6 +16,7 @@ import {
} from './support.dto';
@Controller('v1/support')
@UseGuards(ContextAttestationGuard)
export class SupportController {
constructor(
private readonly support: SupportService,
@@ -32,7 +35,7 @@ export class SupportController {
@Post('escalate')
async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) {
return this.support.escalate(body.threadId, this.principal(auth), body.subject);
return this.support.escalate(body.threadId, this.principal(auth), body.subject, body.metadata);
}
@Get('tickets')
@@ -45,6 +48,12 @@ export class SupportController {
return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason);
}
/** Manually assign a ticket to a specific actor (by userId) — generic assignment override. */
@Post('tickets/:id/assignee')
async assign(@Param('id') id: string, @Body() body: AssignTicketDto, @Headers('authorization') auth?: string) {
return this.support.assignTo(id, this.principal(auth), body.userId);
}
@Post('callbacks')
async callback(
@Body() body: CallbackDto,
@@ -1,4 +1,4 @@
import { IsIn, IsOptional, IsString } from 'class-validator';
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const;
const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const;
@@ -7,11 +7,18 @@ export class CreateTicketDto {
@IsString() subject!: string;
@IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number];
@IsOptional() @IsString() threadId?: string;
/** Opaque, app-supplied attribute bag — stored on the ticket, never interpreted by the kernel. */
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class EscalateDto {
@IsString() threadId!: string;
@IsOptional() @IsString() subject?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class AssignTicketDto {
@IsString() userId!: string;
}
export class PatchTicketDto {
@@ -35,7 +35,7 @@ export class SupportService {
async createTicket(
principal: MessagePrincipal,
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string },
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string; metadata?: Record<string, unknown> },
idempotencyKey?: string,
) {
await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal });
@@ -63,6 +63,8 @@ export class SupportService {
subject: input.subject,
priority: input.priority ?? 'P3',
traceId,
// Opaque, app-supplied attribute bag (e.g. a caller's external ref) — stored, never interpreted.
metadata: input.metadata ? (input.metadata as Prisma.InputJsonValue) : undefined,
},
});
await tx.iiosTicketStateHistory.create({
@@ -99,11 +101,17 @@ export class SupportService {
return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body);
}
/** Escalate a chat thread to support: create a ticket linked to that thread. */
async escalate(threadId: string, principal: MessagePrincipal, subject?: string) {
/** Escalate a chat thread to support: create a ticket linked to that thread. `metadata` is opaque. */
async escalate(threadId: string, principal: MessagePrincipal, subject?: string, metadata?: Record<string, unknown>) {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
return this.createTicket(principal, { subject: subject ?? thread.subject ?? 'Support request', threadId });
// Inherit the thread's opaque bag so the ticket carries the same app context, unless overridden.
const inherited = { ...((thread.metadata as Record<string, unknown> | null) ?? {}), ...(metadata ?? {}) };
return this.createTicket(principal, {
subject: subject ?? thread.subject ?? 'Support request',
threadId,
metadata: Object.keys(inherited).length > 0 ? inherited : undefined,
});
}
async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise<void> {
@@ -120,6 +128,53 @@ export class SupportService {
.catch(() => undefined);
}
/**
* Manually assign a ticket to a specific actor (by userId) a generic override of the
* event-driven auto-assignment. The target is resolved-or-created as an actor in the ticket's
* scope (so you can assign someone who hasn't logged in yet) and joined to the linked thread(s)
* so they can reply over the socket. Fail-closed via policy `iios.support.ticket.assign`.
*/
async assignTo(ticketId: string, principal: MessagePrincipal, targetUserId: string) {
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId }, include: { threadLinks: true } });
if (!ticket) throw new NotFoundException('ticket not found');
await decideOrThrow(this.ports, { action: 'iios.support.ticket.assign', ticketId, scopeId: ticket.scopeId, targetUserId });
const target = await this.actors.resolveActor(ticket.scopeId, {
userId: targetUserId,
appId: principal.appId,
orgId: principal.orgId,
tenantId: principal.tenantId,
displayName: targetUserId,
});
const toState: IiosTicketState = ticket.state === 'NEW' ? 'OPEN' : ticket.state;
const event: CloudEvent = {
specversion: '1.0',
id: `evt_assign_${ticketId}_${target.id}`,
type: IIOS_EVENTS.ticketStateChanged,
source: `iios/support/${ticket.scopeId}`,
subject: `ticket/${ticketId}`,
time: new Date().toISOString(),
datacontenttype: 'application/json',
insignia: { scopeSnapshotId: ticket.scopeId, correlationId: ticket.traceId ?? undefined, idempotencyKey: `assign:${ticketId}:${target.id}`, dataClass: 'internal' },
data: { ticketId, fromState: ticket.state, toState, assignedActorId: target.id },
};
await this.prisma.$transaction([
this.prisma.iiosTicket.update({ where: { id: ticketId }, data: { assignedActorId: target.id, state: toState } }),
this.prisma.iiosTicketStateHistory.create({ data: { ticketId, fromState: ticket.state, toState, actorId: target.id, reasonCode: 'assigned' } }),
this.prisma.iiosOutboxEvent.create({
data: {
aggregateType: 'ticket',
aggregateId: ticketId,
eventType: IIOS_EVENTS.ticketStateChanged,
cloudEvent: event as unknown as Prisma.InputJsonValue,
partitionKey: `${ticket.scopeId}:${ticketId}`,
},
}),
]);
for (const link of ticket.threadLinks) await this.actors.ensureParticipant(link.threadId, target.id);
return this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
}
async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) {
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
if (!ticket) throw new NotFoundException('ticket not found');
@@ -57,6 +57,19 @@ describe('SupportService (P4)', () => {
expect(links[0]?.threadId).toBe(threadId);
});
it('stores an opaque metadata bag on the ticket; assignTo assigns to a target actor and opens it', async () => {
const t = await support().createTicket(cust, { subject: 'help', metadata: { crmCustomerId: 'cust_1', source: 'crm-support' } });
expect(t.metadata).toMatchObject({ crmCustomerId: 'cust_1', source: 'crm-support' });
expect(t.state).toBe('NEW');
const assigned = await support().assignTo(t.id, cust, 'agent_1');
expect(assigned?.state).toBe('OPEN');
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'agent_1' } });
const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } });
expect(assigned?.assignedActorId).toBe(actor.id);
expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id, reasonCode: 'assigned' } })).toBe(1);
});
it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => {
const t = await support().createTicket(cust, { subject: 's' });
await support().transition(t.id, cust, 'OPEN');
@@ -0,0 +1,16 @@
import type { IiosTemplateChannel } from '@prisma/client';
/** What to render: a STORED template (by key, optionally pinned to a version) or INLINE content. */
export type TemplateSource =
| { key: string; version?: number }
| { inline: { subject?: string; html?: string; text?: string; variables?: string[] } };
export interface RenderOptions {
channel: IiosTemplateChannel;
locale?: string;
scopeId?: string;
}
export function isInlineSource(s: TemplateSource): s is { inline: { subject?: string; html?: string; text?: string; variables?: string[] } } {
return 'inline' in s;
}
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { renderTemplate, MissingTemplateVariableError } from './template.renderer';
describe('renderTemplate (pure)', () => {
it('substitutes variables into subject / html / text', () => {
const out = renderTemplate(
{ subject: 'Receipt for {{firstName}}', bodyHtml: '<p>Hi {{firstName}}</p>', bodyText: 'Hi {{firstName}}', variables: ['firstName'] },
{ firstName: 'Dana' },
);
expect(out.subject).toBe('Receipt for Dana');
expect(out.html).toBe('<p>Hi Dana</p>');
expect(out.text).toBe('Hi Dana');
});
// The load-bearing safety property: a customer-supplied name goes into an HTML email body.
it('escapes HTML in the html body, but leaves subject/text verbatim (XSS)', () => {
const out = renderTemplate(
{ subject: 'Hi {{firstName}}', bodyHtml: '<p>{{firstName}}</p>', bodyText: '{{firstName}}', variables: ['firstName'] },
{ firstName: '<script>alert(1)</script>' },
);
expect(out.html).toBe('<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
expect(out.html).not.toContain('<script>');
expect(out.text).toBe('<script>alert(1)</script>'); // plaintext is not HTML → not escaped
});
it('supports {{#if}} and {{#each}}', () => {
const out = renderTemplate(
{ bodyText: '{{#if companyName}}Company: {{companyName}}\n{{/if}}{{#each items}}- {{this}}\n{{/each}}', variables: [] },
{ companyName: 'Acme', items: ['a', 'b'] },
);
expect(out.text).toBe('Company: Acme\n- a\n- b\n');
});
it('throws when a declared variable is missing (fail loud)', () => {
expect(() => renderTemplate({ bodyText: 'Hi {{firstName}}', variables: ['firstName'] }, {})).toThrow(MissingTemplateVariableError);
});
it('renders only the parts that are present', () => {
const out = renderTemplate({ bodyText: 'code {{code}}', variables: ['code'] }, { code: '123' });
expect(out.text).toBe('code 123');
expect(out.subject).toBeUndefined();
expect(out.html).toBeUndefined();
});
});
@@ -0,0 +1,46 @@
import Handlebars from 'handlebars';
/** The raw, unrendered strings of a template (from the DB row or an inline source). */
export interface RawTemplate {
subject?: string | null;
bodyHtml?: string | null;
bodyText?: string | null;
/** Declared variable names. Every one MUST be supplied at render time (fail loud). */
variables?: string[] | null;
}
export interface RenderedContent {
subject?: string;
html?: string;
text?: string;
}
/** A declared variable was not supplied — we refuse to send a half-rendered "Hi ," message. */
export class MissingTemplateVariableError extends Error {
constructor(public readonly missing: string[]) {
super(`missing required template variable(s): ${missing.join(', ')}`);
this.name = 'MissingTemplateVariableError';
}
}
/**
* Pure render: interpolate `vars` into a template's strings. HTML output is auto-escaped (values
* like a customer name go into an email body XSS risk); subject and plaintext are NOT escaped.
* No I/O the caller supplies the raw template. Throws if any declared variable is absent.
*/
export function renderTemplate(tpl: RawTemplate, vars: Record<string, unknown>): RenderedContent {
const declared = tpl.variables ?? [];
const missing = declared.filter((name) => vars[name] === undefined || vars[name] === null);
if (missing.length > 0) throw new MissingTemplateVariableError(missing);
const out: RenderedContent = {};
if (tpl.subject != null) out.subject = compile(tpl.subject, false)(vars);
if (tpl.bodyHtml != null) out.html = compile(tpl.bodyHtml, true)(vars);
if (tpl.bodyText != null) out.text = compile(tpl.bodyText, false)(vars);
return out;
}
/** `escape=true` → HTML-escape interpolated values (for the html body); false → verbatim. */
function compile(src: string, escape: boolean): Handlebars.TemplateDelegate {
return Handlebars.compile(src, { noEscape: !escape, strict: false });
}
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { TemplateNotFoundError, TemplateRepository } from './template.repository';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const repo = new TemplateRepository(prisma as unknown as PrismaService);
async function scope(): Promise<string> {
const s = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } });
return s.id;
}
async function seed(data: Partial<Parameters<typeof prisma.iiosMessageTemplate.create>[0]['data']> & { key: string }) {
return prisma.iiosMessageTemplate.create({
data: { channel: 'EMAIL', locale: 'en', version: 1, bodyText: 't', ...data } as never,
});
}
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('TemplateRepository.resolve', () => {
it('returns the global default when no scope override exists', async () => {
await seed({ key: 'welcome', bodyText: 'global' });
const t = await repo.resolve('welcome', 'EMAIL', 'en', await scope());
expect(t.bodyText).toBe('global');
expect(t.scopeId).toBeNull();
});
it('prefers the scoped override over the global default', async () => {
const scopeId = await scope();
await seed({ key: 'welcome', bodyText: 'global' }); // scopeId NULL
await seed({ key: 'welcome', bodyText: 'scoped', scopeId }); // override
const t = await repo.resolve('welcome', 'EMAIL', 'en', scopeId);
expect(t.bodyText).toBe('scoped');
expect(t.scopeId).toBe(scopeId);
});
it('returns the highest active version', async () => {
await seed({ key: 'welcome', version: 1, bodyText: 'v1' });
await seed({ key: 'welcome', version: 3, bodyText: 'v3' });
await seed({ key: 'welcome', version: 2, bodyText: 'v2' });
const t = await repo.resolve('welcome', 'EMAIL', 'en');
expect(t.version).toBe(3);
});
it('ignores inactive versions (a retracted v3 falls back to v2)', async () => {
await seed({ key: 'welcome', version: 2, bodyText: 'v2' });
await seed({ key: 'welcome', version: 3, bodyText: 'v3', active: false });
const t = await repo.resolve('welcome', 'EMAIL', 'en');
expect(t.version).toBe(2);
});
it('throws TemplateNotFoundError for an unknown key', async () => {
await expect(repo.resolve('nope', 'EMAIL', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError);
});
it('does not cross channels (an EMAIL template is not an SMS template)', async () => {
await seed({ key: 'welcome', channel: 'EMAIL' });
await expect(repo.resolve('welcome', 'SMS', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError);
});
});
@@ -0,0 +1,49 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IiosMessageTemplate, IiosTemplateChannel } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export class TemplateNotFoundError extends NotFoundException {
constructor(key: string, channel: string, locale: string) {
super(`no active template for key="${key}" channel=${channel} locale=${locale}`);
this.name = 'TemplateNotFoundError';
}
}
@Injectable()
export class TemplateRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* Resolve the template to use: the highest active version for (key, channel, locale), preferring
* a row owned by `scopeId` (a tenant override) and falling back to the global default (scopeId
* NULL). Throws if neither exists.
*/
async resolve(
key: string,
channel: IiosTemplateChannel,
locale = 'en',
scopeId?: string,
version?: number,
): Promise<IiosMessageTemplate> {
// Scoped override first, then the global default — for both the pinned and highest-active paths.
if (scopeId) {
const scoped = await this.pick({ key, channel, locale, scopeId }, version);
if (scoped) return scoped;
}
const global = await this.pick({ key, channel, locale, scopeId: null }, version);
if (global) return global;
throw new TemplateNotFoundError(key, channel, locale);
}
/** A pinned `version` fetches that exact version (the caller was explicit); otherwise the highest
* active version a retracted (inactive) newest version falls back to the last good one. */
private pick(
where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null },
version?: number,
) {
return this.prisma.iiosMessageTemplate.findFirst({
where: version != null ? { ...where, version } : { ...where, active: true },
orderBy: { version: 'desc' },
});
}
}
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { TemplateRepository } from './template.repository';
import { TemplateService } from './template.service';
import { TemplateNotFoundError } from './template.repository';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const svc = new TemplateService(new TemplateRepository(prisma as unknown as PrismaService));
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('TemplateService.render', () => {
it('resolves a stored template and renders it, with provenance', async () => {
await prisma.iiosMessageTemplate.create({
data: { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'Hi {{firstName}}', bodyHtml: '<p>{{firstName}}</p>', variables: ['firstName'] },
});
const { content, provenance } = await svc.render({ key: 'welcome' }, { firstName: 'Dana' }, { channel: 'EMAIL' });
expect(content.subject).toBe('Hi Dana');
expect(content.html).toBe('<p>Dana</p>');
expect(provenance).toMatchObject({ templateKey: 'welcome', templateVersion: 2, templateLocale: 'en' });
expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/);
});
it('renders an inline source without a DB row (key/version null)', async () => {
const { content, provenance } = await svc.render(
{ inline: { subject: 'Ad-hoc {{x}}', html: '<b>{{x}}</b>', variables: ['x'] } },
{ x: 'Y' },
{ channel: 'EMAIL' },
);
expect(content.subject).toBe('Ad-hoc Y');
expect(content.html).toBe('<b>Y</b>');
expect(provenance.templateKey).toBeNull();
expect(provenance.templateVersion).toBeNull();
expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/);
});
it('pins to an explicit version when asked', async () => {
await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 1, bodyText: 'v1' } });
await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 2, bodyText: 'v2' } });
const latest = await svc.render({ key: 'k' }, {}, { channel: 'EMAIL' });
const pinned = await svc.render({ key: 'k', version: 1 }, {}, { channel: 'EMAIL' });
expect(latest.content.text).toBe('v2');
expect(pinned.content.text).toBe('v1');
expect(pinned.provenance.templateVersion).toBe(1);
});
it('throws for an unknown stored key', async () => {
await expect(svc.render({ key: 'missing' }, {}, { channel: 'EMAIL' })).rejects.toBeInstanceOf(TemplateNotFoundError);
});
});
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { TemplateRepository } from './template.repository';
import { renderTemplate, type RawTemplate, type RenderedContent } from './template.renderer';
import { isInlineSource, type RenderOptions, type TemplateSource } from './template.model';
/** The provenance of a rendered send — carried onto the outbound command for replay/audit. */
export interface RenderProvenance {
templateKey: string | null; // null for inline sources
templateVersion: number | null;
templateLocale: string | null;
renderedHash: string; // sha256 of the rendered content
}
export interface RenderResult {
content: RenderedContent;
provenance: RenderProvenance;
}
@Injectable()
export class TemplateService {
constructor(private readonly repo: TemplateRepository) {}
/**
* Resolve (if a stored key) and render a template with `vars`, returning the content plus the
* provenance to record on the send. Inline sources skip the DB and carry null key/version the
* caller owns inline content, so no declared-variable contract is enforced for it.
*/
async render(source: TemplateSource, vars: Record<string, unknown>, opts: RenderOptions): Promise<RenderResult> {
const locale = opts.locale ?? 'en';
if (isInlineSource(source)) {
const raw: RawTemplate = {
subject: source.inline.subject,
bodyHtml: source.inline.html,
bodyText: source.inline.text,
variables: source.inline.variables ?? [],
};
const content = renderTemplate(raw, vars);
return { content, provenance: this.provenance(content, null, null, null) };
}
const tpl = await this.repo.resolve(source.key, opts.channel, locale, opts.scopeId, source.version);
const content = renderTemplate(
{ subject: tpl.subject, bodyHtml: tpl.bodyHtml, bodyText: tpl.bodyText, variables: (tpl.variables as string[] | null) ?? [] },
vars,
);
return { content, provenance: this.provenance(content, tpl.key, tpl.version, tpl.locale) };
}
private provenance(content: RenderedContent, key: string | null, version: number | null, locale: string | null): RenderProvenance {
const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex');
return { templateKey: key, templateVersion: version, templateLocale: locale, renderedHash: hash };
}
}
@@ -9,13 +9,16 @@ import {
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ThreadsService } from './threads.service';
import { MessageService } from '../messaging/message.service';
import { SessionVerifier } from '../platform/session.verifier';
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
import { SendMessageDto } from './send-message.dto';
@Controller('v1/threads')
@UseGuards(ContextAttestationGuard)
export class ThreadsController {
constructor(
private readonly threads: ThreadsService,
@@ -23,10 +26,14 @@ export class ThreadsController {
private readonly session: SessionVerifier,
) {}
/** Generic "my threads" — every thread the caller participates in (last message + unread). */
/**
* Generic "my threads" every thread the caller participates in (last message + unread).
* `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key).
*/
@Get()
async listThreads(@Headers('authorization') auth?: string) {
return this.messages.listThreads(this.principal(auth));
async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
}
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
@@ -35,11 +42,11 @@ export class ThreadsController {
return this.messages.listMyAnnotated(this.principal(auth), type);
}
/** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */
/** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
@Post()
@HttpCode(201)
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) {
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject });
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }, @Headers('authorization') auth?: string) {
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata });
}
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-support-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -24,5 +30,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+104 -1824
View File
File diff suppressed because it is too large Load Diff