33 Commits

Author SHA1 Message Date
Claude eb385707fa feat(observability): OpenTelemetry tracing + log/trace correlation
CI / build (push) Successful in 3m49s
Deploy iios-service / build-deploy (push) Successful in 4m19s
Adds OTel auto-instrumentation (http, express, prisma/pg, redis, ...) exporting
OTLP to Tempo, so iios-service requests appear as distributed traces in Grafana.

observability/tracing.ts is imported FIRST in main.ts — auto-instrumentation
patches modules as they are require()d, so anything loaded earlier is never
traced. Env-driven (OTEL_SERVICE_NAME / OTEL_EXPORTER_OTLP_ENDPOINT); with the
endpoint unset the SDK never starts, so dev and tests carry zero overhead.
Health/metrics probes are excluded — they would otherwise swamp Tempo and bury
real request traces.

The existing P9 trace middleware now adopts the ACTIVE OTel trace id, so an
http.request log line and its distributed trace share one id (Loki -> Tempo
pivot in Grafana), falling back to x-trace-id / traceparent / generated.

pnpm-lock.yaml regenerated — CI and the Dockerfile both use
'pnpm install --frozen-lockfile', which would hard-fail on an unchanged lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:14:53 +00:00
mcp-bot 0bdae453fc ci(iios): rm -rf /tmp/kp before clone (persistent runner leaves stale dir)
Deploy iios-service / build-deploy (push) Successful in 4m8s
CI / build (push) Successful in 5m7s
The dind-builder runner is host-mode/persistent, so /tmp/kp from a prior run
survives and 'git clone ... /tmp/kp' fails with 'destination path already exists
and is not an empty directory'. Clear it first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 18:14:42 +00:00
maaz519 0a8544b6fc feat(iios): notification engine — presence-gated Web Push (engine phase)
Deploy iios-service / build-deploy (push) Failing after 4m37s
CI / build (push) Successful in 5m25s
Generic notification engine: reacts to message.sent and runs three gates before
delivering — policy (DM always; group only on @mention or reply-to-you), presence
(skip if the recipient is focused on that thread), mute (per-thread). Delivery via a
swappable NotificationPort (Web Push/VAPID adapter); a 'gone' (404/410) prunes the sub.

- PresenceService + gateway `focus_thread` signal + disconnect cleanup (room membership
  != viewing, since the sidebar joins every thread room).
- IiosNotificationSubscription table + `muted` on IiosThreadParticipant (migration).
- Endpoints: GET vapid-public-key, POST/DELETE subscribe, POST threads/:id/mute|unmute;
  listThreads returns the caller's `muted`.
- DM-vs-group read from the opaque `membership` attribute in the notification POLICY only
  — kernel stays generic (grep-verified).

Tests: 13 new (3 gates + reply-to-you + prune + web-push adapter states); full suite 205
green. Verified live: module boots, subscribe stored, mute/unmute round-trips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:16:53 +05:30
maaz519 f2ef8922ce chore: remove notifications planning docs (dropping the superpowers workflow)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:59:02 +05:30
maaz519 48c2e6589c docs(plan): notifications implementation plan (10 tasks, TDD, 3 phases)
Phase 1 frontend quick win (desktop notif + tab-title), Phase 2 IIOS engine
(schema, PresenceService + focus signal, NotificationPort + Web Push, projector
with policy/presence/mute gates, endpoints, module wiring), Phase 3 SDK+app
(service worker, registerPush, focus emit, mute toggle, deep-link) + smoke/docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:52:20 +05:30
maaz519 ba2d5f4193 docs(spec): notifications design (engine in IIOS + Web Push, per-thread mute)
Approved design: IIOS notification engine (PresenceService, NotificationProjector with
policy/presence/mute gates, swappable NotificationPort → Web Push VAPID, subscription +
participant.muted data) + host-app experience (service worker, registerPush, mute toggle,
deep-link). Trigger policy: DMs always, group only on @mention or reply-to-you. Phased:
frontend quick win → IIOS engine → SDK/app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:44:49 +05:30
maaz519 e49a71feaf docs(iios): bring API guide, DEPLOYMENT, and CEO overview up to as-built
API & SDK guide:
- §5.3 Threads & Messaging fully filled in: GET/POST /v1/threads, participants,
  my-annotations; the Message shape (senderId, attachment, annotations); the full
  socket event set (open_thread/send_message/add_participant/annotate/read/typing +
  server message/receipt/typing/annotation); reactions/pins/saves as the generic
  interaction-annotation primitive; mentions → MENTION inbox item.
- §5.4 inbox MENTION kind; SDK reactions/pins/saves + listThreads/createThread;
  vocab adds MENTION, message-part kinds, attachment kind, annotation types.

DEPLOYMENT:
- Real-IdP auth env (SUPABASE_URL/AUTH_ISSUERS via JWKS, no secret) + MEDIA_SECRET.
- Media StoragePort in topology: dev local disk → prod object storage; ⚠ local disk is
  single-instance/non-durable; bytes flow client↔storage direct (scales independently).

CEO overview: refreshed counts (192 tests, 53 tables / 19 migrations) and noted P9 has
begun — real Supabase auth (first stubbed port turned real) + generic reactions/pins/
saves/mentions/media on the kernel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:48:29 +05:30
maaz519 ac7f790303 docs(iios): media section (endpoints + governed flow diagram), real-IdP auth, media env
- New §5.10 Media: presign-upload/download + upload/blob endpoints, attachment shape,
  governance notes (25MB + type allowlist via OPA, signed tokens, tenant fence), and a
  sequence diagram of the presign→upload→send→signed-download flow with the OPA gate.
- SDK: uploadMedia/mediaUrl; send() attachment/mentions.
- Auth §3: real OIDC (Supabase/AUTH_ISSUERS) JWKS verification alongside dev HS256.
- Env: MEDIA_DIR/MEDIA_SECRET/PUBLIC_URL/SUPABASE_URL/AUTH_ISSUERS; test count 192.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:38:37 +05:30
maaz519 24a87f6fb6 feat(iios): media sharing — presigned storage port + attachments on messages
Deploy iios-service / build-deploy (push) Failing after 3m35s
CI / build (push) Successful in 3m47s
Media the industry way: the DB stores a reference, bytes live behind a storage port.
- StoragePort + LocalDiskStorage (dev). MediaService presigns short-lived signed
  upload/download URLs (HS256 tokens) pointing at IIOS's own endpoints; the bytes
  never touch the kernel. Prod swaps STORAGE_PORT to S3/Supabase — same as auth.
- MediaController: presign-upload / PUT upload/:token (raw stream) / GET blob/:token /
  presign-download. Uploads are OPA-governed (iios.media.upload: 25 MB cap +
  image/video/audio/pdf/office allowlist); downloads are tenant-fenced by object key.
- send() + MessageDto carry an attachment (contentRef/mimeType/sizeBytes → generic
  MEDIA_REF/VOICE_REF/FILE_REF part; DTO exposes kind image|video|audio|file).

Tests: media.service.spec (6) — round-trip, oversize/type denied, oversized PUT
refused, tampered token rejected, tenant fence. Full suite 192 green. Verified live:
presign→upload→send→history→signed download round-trips the exact bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:15:03 +05:30
maaz519 1256664361 feat(iios): expose senderId (stable externalId) on MessageDto
CI / build (push) Successful in 4m53s
Deploy iios-service / build-deploy (push) Failing after 8m37s
The app needs a reliable "is this message mine?" signal. senderActorId worked only
with a lazily-learned actorId, which Supabase token-refresh wipes. senderId is the
sender's externalId (email/username) — always present and comparable to the session
user — so message alignment is deterministic. Verified: senderId round-trips = email.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:58:10 +05:30
maaz519 0ecc8a4ada feat(iios): multi-issuer session verifier (many IdPs/apps → isolated appId scopes)
SessionVerifier now holds a REGISTRY of trusted OIDC issuers instead of a single
Supabase project. A token is routed by its `iss` claim to that issuer's entry,
verified against that issuer's JWKS (ES256, no secret), and stamped with that
entry's appId/orgId — so two Supabase projects / IdPs map to two isolated app
scopes on one IIOS (chat vs a future support app). App A's tokens can't reach B.

- Config: `AUTH_ISSUERS` (JSON array of { url, appId, orgId? }); the single
  `SUPABASE_URL` (+ SUPABASE_APP_ID) still works as a one-entry shorthand.
- Per-issuer JWKS cache; untrusted issuer → reject; forgery (right issuer claim,
  wrong key) → reject.
- HS256 app-token path (dev/tests) unchanged.

Tests: new session.verifier.spec (4) — routes to correct appId, second issuer →
different appId, untrusted issuer rejected, cross-issuer forgery rejected.

Note: outbox `replay.spec` has a pre-existing clock-skew flake (nextAttemptAt vs
Postgres now()) that surfaces under certain suite orderings — unrelated to this
change (fails in isolation on a clean tree; passes when another spec runs first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:38:30 +05:30
maaz519 e956ad3cb9 feat(iios): verify real Supabase access tokens (ES256 via JWKS)
SessionVerifier gains a Supabase mode (set SUPABASE_URL): user SATs are ES256,
verified against the project's public JWKS — no shared secret. Keys are fetched at
startup (onModuleInit) and cached as PEM so verify() stays synchronous; a rotated
kid triggers a background refresh. Claims map to MessagePrincipal with userId=email
(stable, human-readable → mentions/directory keep working; RealMDM canonicalises
later). The legacy HS256 app-token path is unchanged (dev/tests untouched).

senderName now prefers the actor display name over the raw handle, so real names
show on bubbles when identity is an email.

Verified end-to-end against a live project: signup → real ES256 SAT → GET /v1/threads
200 → thread created + owned by the resolved email principal. Full suite 182 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:14:28 +05:30
maaz519 8c814d9b86 feat(iios): @mentions via inbox fan-out + my-annotations query (pins/saves)
Mentions ride the existing event-driven inbox — no chat parsing in the kernel:
- send() carries an OPAQUE mentions[] (userIds) into the message event; the kernel
  never parses "@". The app supplies the notify-list.
- InboxProjector fans out a MENTION inbox item (new generic inbox kind) to each
  mentioned *participant* (never the sender), idempotent per source message; reading
  the thread resolves the reader's MENTION + NEEDS_REPLY items to DONE.
- New MENTION value in the generic IiosInboxItemKind taxonomy (migration).

Pins/saves reuse the annotation primitive; new generic query powers a Saved list:
- MessageService.listMyAnnotated(principal, type) + GET /v1/threads/my-annotations
  returns the caller's annotated messages (type "save") with thread context.

Tests: 182 pass (+4: mention fan-out, resolve-on-read, saved query, opaque mentions).
Verified live: mention→inbox delivery, read→resolve, pin/save persistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 01:35:30 +05:30
maaz519 f3c4ba72b5 feat(iios): generic interaction-annotation primitive (backs emoji reactions)
Adds IiosInteractionAnnotation — an actor attaches an OPAQUE (annotationType, value)
label to an interaction. The kernel stores + aggregates them but never interprets the
strings (the chat app writes type "reaction" / value = emoji); the same primitive backs
pins/saves/flags/tags later. No chat vocabulary in kernel code — verified by grep.

- MessageService.toggleAnnotation(): governed (participant-only via new OPA rule
  iios.interaction.annotate), idempotent toggle keyed on
  (scope, interaction, actor, type, value); history DTO carries aggregated
  { type, value, users[] } groups.
- Gateway: `annotate` event → broadcasts `annotation` (refreshed user list) to the room.
- DevOpaPort: annotate allowed only for thread members (real OPA can tighten later).
- Migration add_interaction_annotations (additive; dev data untouched).

Tests: 178 pass (+3: toggle/aggregate, coexisting values, non-member denied).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 00:20:47 +05:30
maaz519 c4206f9809 fix(iios): thread subject on create, graceful open_thread error, isolated test DB
- openThread/createThread accept a generic `subject` (group name) — stored on the
  thread, echoed via listThreads; kernel never interprets it.
- Gateway open_thread now returns an ACK'd { error } on failure instead of throwing
  (which never acked → clients hung on "loading"). Non-member/missing-thread opens
  fail cleanly.
- Tests run against an isolated `iios_test` database (vitest globalSetup creates +
  migrates it; test.env overrides DATABASE_URL) so `pnpm test` can never TRUNCATE the
  dev database again. Verified: full suite green, dev DB row counts unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:39:19 +05:30
maaz519 1af10d0f4a feat(iios): message senderName + dev user directory
- Messages now carry senderName (resolved actorId → sourceHandle.externalId) so
  clients render usernames instead of raw actor cuids.
- GET /v1/dev/users exposes the dev directory (DEV_USERS keys) so a host app can
  validate add-by-username; real apps validate against their user store / MDM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:11:20 +05:30
maaz519 31f7682a46 feat(iios): include participant names in listThreads (generic)
GET /v1/threads now returns participants[] (member usernames) so a host app can
render conversation titles / member lists without a second call. Still generic —
it only lists members of threads the caller belongs to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:27:47 +05:30
maaz519 ba745bb71a feat(iios): policy-enforced membership + threaded replies + dev IdP (generic)
Enriches the platform PLANE, not the kernel:
- DevOpaPort: the dev OPA stub now evaluates a small policy table (behind the
  same opa.decide) — DM capped at 2, add-participant requires member/group-admin,
  governed self-join for membership threads. Real OPA swaps in unchanged.
- Dev IdP login (POST /v1/dev/login) issues the same JWT claims a real IdP would.
- PolicyDeniedFilter maps fail-closed denials to HTTP 403.

Generic kernel additions (no chat vocabulary — 'dm'/'group' live only as OPA
policy + an opaque thread attribute):
- MessageService.addParticipant (governed membership by userId), governed
  openThread self-join (scoped to threads with a membership attribute),
  parentInteractionId on send (reply link), and a generic listThreads.
- REST: GET /v1/threads, POST /v1/threads, POST /v1/threads/:id/participants;
  socket add_participant + membership/parentInteractionId. ensureParticipant
  gains a role.

Tests: dev-opa.port.spec + message.spec (DM cap / group admin / governed join /
listThreads / reply). smoke-membership.mjs; realtime smokes updated for governed
join. 175 unit tests + all smokes green; kernel free of dm/group literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:27:47 +05:30
kirti 2056391f9d ci: start Postgres via docker run on localhost (services: not wired on runner)
CI / build (push) Successful in 3m58s
The declarative services: Postgres was never reachable at postgres:5432 (P1001)
on this Gitea runner even with a valid healthcheck — service-name networking to
the job isn't wired here. Switch to the mdm CI pattern: host-mode runner, start
pgvector via 'docker run -p 5434:5432', connect over localhost:5434 (also the
specs' default DATABASE_URL). Clean up the container in always().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:25:30 +00:00
kirti 23f5159521 ci: unquoted --health-cmd so the Postgres service is wired to the job
CI / build (push) Failing after 1m30s
A quoted '--health-cmd "pg_isready -U iios"' in the folded options scalar
splits on spaces, so docker received an invalid healthcheck command; the
service never went healthy and the job container couldn't resolve postgres:5432
(P1001). Match go-monorepo's proven unquoted '--health-cmd pg_isready'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:21:58 +00:00
kirti 3abad4970f ci: provision pgvector Postgres + migrate for DB-backed test suites
CI / build (push) Failing after 3m23s
The integration *.spec.ts suites need a live Postgres (schema applied) at
DATABASE_URL. Run on the dind-builder runner in a job container with a
pgvector/pgvector:pg16 service (proven pattern from go-monorepo CI), point
DATABASE_URL at the service, and 'prisma migrate deploy' before pnpm test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:16:33 +00:00
kirti d5e0dacae3 ci: build workspace + generate prisma before typecheck
CI / build (push) Failing after 1m49s
pnpm -r typecheck ran before pnpm -r build, but the @insignia/* packages
publish their types only via built dist/*.d.ts. So consumers failed with
TS2307 'Cannot find module @insignia/iios-contracts' before any dist existed.
Also generate the iios-service Prisma client so its schema-derived types
resolve. Build now precedes typecheck; prisma:generate precedes both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:04:41 +00:00
mcp-bot 43e9928ce3 ci: build + GitOps-deploy iios-service to k8s-pods (ArgoCD)
CI / build (push) Failing after 40s
Deploy iios-service / build-deploy (push) Successful in 3m49s
2026-07-04 14:15:12 +00:00
maaz519 f0ea1ca553 feat(iios): email adapter dedup fix + smoke + verification
CI / build (push) Failing after 2m8s
Adds adapter-owned externalEventId extraction to the ChannelAdapter contract
(WebhookAdapter→eventId, EmailAdapter→Message-ID) and uses it in InboundService,
so email replays dedup on Message-ID (previously only payload.eventId worked).
Adds an email-dedup spec + smoke-email.mjs proving inbound → normalized, a reply
joins the same email thread, bad signature rejected, and outbound EMAIL SENT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:34:26 +05:30
maaz519 f0afca89e3 feat(iios): register EmailAdapter for EMAIL + email-shaped egress provider
AdapterRegistry now backs the EMAIL channel with the email-native EmailAdapter
(RFC threading) instead of the generic WebhookAdapter. Adds EmailProvider — an
email-envelope egress provider ({to,subject,text,html,inReplyTo}) registered for
EMAIL when IIOS_PROVIDER_URL_EMAIL is set (sandbox stays the default). Spec:
signed email → normalized interaction on the email thread; a reply joins the
same thread (one conversation); bad signature → rejected (fail-closed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:28:35 +05:30
maaz519 ae50f56404 feat(iios): EmailAdapter (email-native normalize + threading) in adapter-sdk
Adds a canonical EmailInboundPayload + EmailAdapter implementing the
ChannelAdapter contract: HMAC signature verify + normalize with email-native
threading (whole reply chain → one IIOS thread via references/inReplyTo root),
messageId as providerEventId for dedup, html→text fallback, Re:/Fwd: stripping.
Plus emailInboundFixture/emailReplyFixture and unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:25:00 +05:30
maaz519 427396653f feat(iios): socket.io Redis adapter for cross-replica realtime (P9 scaling)
Adds an opt-in RedisIoAdapter (wired in main.ts when REDIS_URL is set) so
socket.io room emits fan out across instances via Redis pub/sub — a client on
replica B now receives messages emitted by replica A. Without REDIS_URL the
in-memory adapter is kept (single-instance dev unchanged). Adds redis to
docker-compose, REDIS_URL to the env contract, and smoke-realtime-cluster.mjs
which proves cross-instance delivery fails without Redis and passes with it.
Delivery is at-least-once at N>1; clients dedupe by message id (documented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:28:11 +05:30
maaz519 a520b26398 chore(iios): production Dockerfile + migrate entrypoint + env/secrets contract
Adds a multi-stage Dockerfile for iios-service (build → pnpm deploy prune →
slim non-root runtime), a docker-entrypoint that runs `prisma migrate deploy`
then starts the server as PID 1, a .dockerignore, a fully-commented
.env.example config contract, and docs/DEPLOYMENT.md (topology, scaling,
release strategy, prod-readiness gaps). Moves prisma to dependencies so the
CLI ships in the prod bundle. Image builds, migrates, and serves /health 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:19:13 +05:30
maaz519 5bd1f646ca docs(adr): support-service → IIOS naming migration (ADR-0001)
Establishes a docs/adr convention and records the naming-migration decision:
interaction-centric Iios-prefixed vocabulary as canonical, support as a
namespaced specialization (KG-16), @insignia/iios-* package scope, and a
greenfield-and-deprecate (strangler) migration off the legacy support-service.
Includes the legacy→IIOS naming map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:48:25 +05:30
maaz519 a933e2e3af feat(iios): idempotency ledger on outbound egress (409 on key reuse) (P9)
OutboundService.send is now opt-in idempotent: an explicit idempotencyKey routes
the send through IdempotencyService.run (Slice 6), so reusing a key with a
different target/payload returns a 409 conflict and a same-key/same-request retry
replays the cached command. The inner findUnique early-return stays as a backstop
so a FAILED-then-retried send never collides on the command's unique key. Keyless
sends keep the old behavior (no ledger row).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:44:22 +05:30
maaz519 cdba0f0179 feat(iios): retention sweep dev endpoint + /metrics + smoke (P9)
POST /v1/dev/retention/sweep runs the sweep on demand; /metrics gains a global
retention section (snapshot counts by status). smoke-retention.mjs (0-day
windows) proves an aged interaction is redacted in place and surfaces as a
REDACTED snapshot on /metrics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:57:51 +05:30
maaz519 035315ef73 feat(iios): retention_policy_snapshot + sweep (archive→redact, hold-aware) (P9)
Adds IiosRetentionPolicySnapshot (per-interaction frozen lifecycle timestamps)
+ IiosInteraction.dataClass, and a RetentionService whose sweep captures
snapshots then archives (status change) and, past delete_after, redacts content
in place (reusing the Slice-8 tombstone) — never a hard delete. An active
compliance hold blocks both; every action is audited and tenant-scopeable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:50:49 +05:30
maaz519 a5cd8f0ec4 feat(iios): /v1/dsr erasure + compliance-hold REST + smoke (P9)
Tenant-scoped /v1/dsr: POST /erase (self-service right-to-erasure), POST/GET
/holds + POST /holds/:id/release. smoke-dsr.mjs proves PII present → hold
blocks erase (409) → release → erase redacts the ticket subject in place →
re-erase idempotent → cross-tenant cannot see the hold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 06:27:42 +05:30
88 changed files with 5631 additions and 180 deletions
+16
View File
@@ -0,0 +1,16 @@
# Build context hygiene — keep the image small & builds reproducible.
**/node_modules
**/dist
**/.turbo
**/.next
**/coverage
.git
.gitignore
*.log
**/*.tsbuildinfo
.env
.env.*
!.env.example
/tmp
scratchpad
docs/openapi.yaml
+37 -2
View File
@@ -7,7 +7,13 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest # Host-mode runner (docker talks to the host daemon). We publish a pgvector
# Postgres to localhost:5434 and connect over localhost — the service-name
# networking of `services:` containers is not wired to jobs on this runner,
# so we start the DB explicitly (same proven pattern as the mdm CI).
runs-on: dind-builder
env:
DATABASE_URL: postgresql://iios:iios@localhost:5434/iios?schema=public
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -17,8 +23,37 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
# pgvector Postgres for the DB-backed *.spec.ts integration suites.
- name: Start pgvector Postgres
run: |
docker rm -f iios-ci-pg-${{ github.run_id }} >/dev/null 2>&1 || true
docker run -d --name iios-ci-pg-${{ github.run_id }} \
-e POSTGRES_USER=iios -e POSTGRES_PASSWORD=iios -e POSTGRES_DB=iios \
-p 5434:5432 pgvector/pgvector:pg16
for i in $(seq 1 30); do
docker exec iios-ci-pg-${{ github.run_id }} pg_isready -U iios >/dev/null 2>&1 && break
sleep 2
done
docker exec iios-ci-pg-${{ github.run_id }} pg_isready -U iios \
|| { echo "Postgres did not become ready"; exit 1; }
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm boundary - run: pnpm boundary
- run: pnpm -r typecheck # iios-service uses Prisma: generate the client so its generated types
# (schema enums, Prisma.InputJsonValue) are available for typecheck/build.
- run: pnpm --filter @insignia/iios-service prisma:generate
# Build before typecheck: the @insignia/* workspace packages publish their
# types via built dist/*.d.ts, so consumers (iios-kernel-client, etc.)
# can only resolve them once dist exists. Typechecking first fails with
# TS2307 "Cannot find module '@insignia/iios-contracts'".
- run: pnpm -r build - run: pnpm -r build
- run: pnpm -r typecheck
# Apply the schema to the CI Postgres before the DB-backed specs run
# (reset-db.ts TRUNCATEs existing tables; migrations must exist first).
- run: pnpm --filter @insignia/iios-service exec prisma migrate deploy
- run: pnpm test - run: pnpm test
- name: Stop Postgres
if: always()
run: docker rm -f iios-ci-pg-${{ github.run_id }} >/dev/null 2>&1 || true
+53
View File
@@ -0,0 +1,53 @@
# Build iios-service and deploy it via k8s-pods (ArgoCD). Runs on the dind-builder
# host-mode runner (mounts the host docker socket). On every push to main that
# touches the service, it builds+pushes the image (SHA-tagged) and bumps the tag in
# platform-engineering/k8s-pods -> ArgoCD rolls it.
name: Deploy iios-service
on:
push:
branches: [main]
paths:
- "packages/iios-service/**"
- "packages/iios-adapter-sdk/**"
- "packages/iios-contracts/**"
- "pnpm-lock.yaml"
- ".github/workflows/deploy-iios-service.yml"
jobs:
build-deploy:
runs-on: dind-builder
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lynkedup.cloud -u mcp-bot --password-stdin
- name: Ensure docker buildx (the Dockerfile uses BuildKit --mount)
run: |
if ! docker buildx version >/dev/null 2>&1; then
mkdir -p ~/.docker/cli-plugins
curl -sSL https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx
chmod +x ~/.docker/cli-plugins/docker-buildx
fi
docker buildx create --use --name iios-ci 2>/dev/null || docker buildx use iios-ci
- name: Build and push image
run: |
IMG=git.lynkedup.cloud/platform-engineering/iios-service
TAG=$(git rev-parse --short HEAD)
echo "TAG=$TAG" >> "$GITHUB_ENV"
docker buildx build --builder iios-ci --push \
-t "$IMG:$TAG" -f packages/iios-service/Dockerfile .
- name: Bump k8s-pods image tag (ArgoCD deploys)
run: |
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
sed -i "s#image: git.lynkedup.cloud/platform-engineering/iios-service:.*#image: git.lynkedup.cloud/platform-engineering/iios-service:${TAG}#" services/iios/deployment.yaml
git config user.name "iios-ci"; git config user.email "ci@lynkedup.cloud"
if git diff --quiet; then echo "image tag unchanged"; exit 0; fi
git commit -am "ci(iios): deploy iios-service ${TAG}"
git push origin main
+13
View File
@@ -16,5 +16,18 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
# Realtime fan-out across replicas (socket.io Redis adapter). Point the service at it
# with REDIS_URL=redis://localhost:6379; without REDIS_URL the in-memory adapter is used.
redis:
image: redis:7-alpine
container_name: iios-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
volumes: volumes:
iios_postgres_data: iios_postgres_data:
+117
View File
@@ -0,0 +1,117 @@
# Deploying IIOS
IIOS deploys as **one runtime service** (`@insignia/iios-service`) plus **one datastore**
(Postgres). Everything else in the repo is a **library** (SDKs) or a **dev demo**, not a
deployed service.
| Artifact | Type | How it ships |
|---|---|---|
| `@insignia/iios-service` | **Runtime service** | Docker image (this guide) |
| `iios-contracts`, `iios-adapter-sdk`, `iios-kernel-client`, `iios-testkit` | Library | published to npm; imported by host apps |
| `iios-message-web`, `iios-inbox-web`, `iios-support-web`, `iios-ai-web`, `iios-meeting-web`, `iios-community-web` | Web SDK (library) | published to npm; **bundled into host-app frontends** (optionally one CDN widget) |
| `apps/*` (message-demo, ai-studio, route-admin…) | Dev demo | not deployed |
The service is a **modular monolith**: a single NestJS process serves the HTTP API, the
WebSocket (socket.io) gateway, the outbox relay, and the projectors together.
## Build the image
Build context is the **monorepo root** (the image needs the workspace + lockfile):
```bash
docker build -f packages/iios-service/Dockerfile -t iios-service:latest .
```
The multi-stage build compiles the service + its workspace deps, generates the Prisma
client, and prunes to a self-contained prod bundle (`pnpm deploy --legacy`). The runtime
image runs as the non-root `node` user and starts via `docker-entrypoint.sh`, which runs
`prisma migrate deploy` then `node dist/main.js`.
## Run it
```bash
docker run --rm -p 3200:3200 \
-e DATABASE_URL='postgresql://USER:PASS@HOST:5432/iios?schema=public' \
-e APP_SECRETS='{"portal-demo":"<secret>"}' \
-e IIOS_DEV_TOKENS=0 \
iios-service:latest
```
Health check: `GET /health``200`. Metrics/ops: `GET /metrics` (relay lag, projection
cursors, retention snapshot counts).
## Configuration & secrets contract
Every knob is an environment variable — see [`packages/iios-service/.env.example`](../packages/iios-service/.env.example)
for the full, commented list. Highlights:
- **Secrets** (inject from a vault, never bake into the image): `DATABASE_URL`,
`APP_SECRETS` (per-app HS256 JWT keys), `ADAPTER_SECRETS` (webhook HMAC keys),
`MEDIA_SECRET` (signs presigned media upload/download URLs).
- **Auth (real IdP):** set `SUPABASE_URL` or `AUTH_ISSUERS` to trust real OIDC access
tokens, verified against the issuer's public **JWKS** (ES256) — **no secret to store**.
`AUTH_ISSUERS` is a JSON registry `[{url, appId, orgId?}]` routing many issuers → isolated
app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests.
- **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for
prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change.
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*`
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
single most important prod-hardening flag.
- **`IIOS_CELL_ID`** tags which physical cell this instance serves — the hook for splitting
noisy/regulated tenants into isolated cells later, with no code change.
- **Worker timers** (`IIOS_RELAY_INTERVAL_MS`, `IIOS_RETENTION_SWEEP_INTERVAL_MS`) — see
scaling notes below.
## Topology
```
host apps ──HTTPS/WSS──► [ LB / ingress ] ──► iios-service ×N ──► Postgres (pgvector)
(embed SDKs) (WS + sticky) (API+relay+ │
projectors) (+ Redis, + OPA/CMP/MDM
as external ports — later)
```
- **Postgres** — managed (RDS / Cloud SQL / Neon). One logical DB, tenant-isolated by scope.
- **Redis** — **not required for a single instance.** Set **`REDIS_URL`** when you run
**multiple replicas with live chat**: the built-in [socket.io Redis adapter](../packages/iios-service/src/realtime/redis-io.adapter.ts)
fans room emits across all instances, so a client on replica B sees messages emitted by
replica A. Without `REDIS_URL` the in-memory adapter is used (single instance). Verified by
`scripts/smoke-realtime-cluster.mjs` (two instances + one Redis; cross-instance delivery
fails without Redis, passes with it).
> Delivery is **at-least-once** across instances — with N>1 the inline send-emit and the
> relay's outbox-bridge emit can both fire for the same message, so clients should **dedupe
> by message `id`** (the payload always carries one).
- **Platform ports** (OPA policy, CMP consent, MDM, CRRE, SAS) — today in-process permissive
stubs (`LocalDevPorts`). For production, point these at real external services; the service
already calls them **fail-closed**. The **session** port already verifies real OIDC tokens
(Supabase/JWKS) when configured.
- **Media storage** (`StoragePort`) — dev = **local disk** (`MEDIA_DIR`). ⚠️ Local disk is
**single-instance and non-durable**; with N>1 replicas or for persistence, bind it to shared
**object storage** (S3/R2/Supabase Storage). Bytes always flow **client ↔ storage directly**
via presigned URLs — they never transit the service — so this scales independently.
## Scaling & release strategy
**Horizontal scaling is safe** because the event core was hardened for it:
- The outbox relay claims rows with `FOR UPDATE SKIP LOCKED` → each event is relayed by
exactly one replica; its co-located projectors process it once (idempotent `claim()` +
the projection-cursor + idempotency-command ledgers give exactly-once effects).
- The **DLQ + replay** path means a bad deploy loses nothing — fix and replay.
**Rolling / blue-green deploys:**
1. **Migrations:** run `prisma migrate deploy` as a **one-off pre-deploy job**, then set
`IIOS_SKIP_MIGRATE=1` on the replicas so N pods don't race the migration. (For a single
instance, the default boot-time migration is fine.)
2. Use **expand-contract (backward-compatible) schema changes** so old and new pods can run
against the same schema during the roll — this is the prerequisite for zero-downtime.
3. Roll replicas with a `/health` readiness gate; drain WebSocket connections on `SIGTERM`.
4. **WebSocket ingress** needs sticky sessions (and the Redis socket.io adapter once N>1).
## Not yet built (prod-readiness gaps)
- **CI/CD pipeline** to build/push this image and run migrations.
- **Real platform-port services** (OPA/CMP/MDM) — today permissive stubs.
- **Secrets manager** integration (currently plain env).
- A **schema-compatibility gate** in CI to enforce expand-contract migrations.
+78 -8
View File
@@ -58,6 +58,8 @@ Use it on every call: `-H "authorization: Bearer <token>"`.
- **Different `orgId` = different tenant.** Mint two tokens with `org_A` / `org_B` to test isolation. - **Different `orgId` = different tenant.** Mint two tokens with `org_A` / `org_B` to test isolation.
- Token TTL: 2h. - Token TTL: 2h.
**Real IdP tokens (production path).** Beyond the dev HS256 tokens, `SessionVerifier` also verifies **real OIDC access tokens** (ES256) against a trusted issuer's public **JWKS** — set `SUPABASE_URL` (single issuer) or `AUTH_ISSUERS` (a registry mapping many issuers → app scopes). The verifier routes a token by its `iss` claim, so two projects/IdPs map to two isolated `appId` scopes on one service, and app A's tokens can't reach app B. No shared secret needed. The dev token path stays for tests/local.
### Error responses (what QA will see) ### Error responses (what QA will see)
| Status | When | | Status | When |
|---|---| |---|---|
@@ -101,12 +103,24 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
### 5.3 Threads & Messaging (native chat) ### 5.3 Threads & Messaging (native chat)
| Method | Path | Body / Headers | Returns | | Method | Path | Body / Headers | Returns |
|---|---|---|---| |---|---|---|---|
| GET | `/v1/threads` | — | `ThreadSummary[]` — your threads (members, unread, last message, `membership`) |
| POST | `/v1/threads` | `{membership?, creatorRole?, subject?}` | `{threadId, status, history}` (201) — `membership`/`creatorRole`/`subject` are **opaque app attributes** the kernel stores but never interprets |
| POST | `/v1/threads/:id/participants` | `{userId, role?}` | `{threadId, participantCount}` (201) — **governed** (DM cap / group-admin via OPA) |
| GET | `/v1/threads/:id/messages` | — | `{threadId, messages[]}` | | GET | `/v1/threads/:id/messages` | — | `{threadId, messages[]}` |
| POST | `/v1/threads/:id/messages` | `{content, contentRef?}` + `idempotency-key?` header | `Message` (201) | | POST | `/v1/threads/:id/messages` | `{content, contentRef?, mimeType?, sizeBytes?, checksumSha256?, parentInteractionId?}` + `idempotency-key?` header | `Message` (201) |
| GET | `/v1/threads/my-annotations` | `?type=save` | `{message, threadId, threadSubject}[]` — messages you annotated (e.g. saved) |
**Realtime (Socket.IO, namespace `/message`):** connect, then emit client→server events: A **`Message`** carries `{id, threadId, senderId, senderName, content, attachment?, parentInteractionId?, annotations[], traceId, createdAt}`. `senderId` is the sender's stable externalId (email/username) — the reliable "is this mine?" check. `attachment` = `{contentRef, mimeType, sizeBytes, kind}` (§5.10). `annotations` = `[{type, value, users[]}]` — the reaction/pin/save aggregate.
- `open_thread` `{threadId?|otherUserId}`, `send_message` `{threadId, content, idempotencyKey?}`, `read` `{threadId}`, `delivered` `{threadId}`.
- Server emits to the thread room: `message` (a Message) and `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`. **Realtime (Socket.IO, namespace `/message`).** Token verified on connect (`auth.token`), then emit client→server:
- `open_thread` `{threadId?, membership?, creatorRole?, subject?}` — opens, or **creates** when no id; a **governed join** for membership threads. Returns `{threadId, status, history}` or `{error}` (acked, never a throw — clients don't hang).
- `send_message` `{threadId, content, contentRef?, mimeType?, sizeBytes?, parentInteractionId?, mentions?}``mentions` is an **opaque userId notify-list** (the app parses `@`; the kernel never does).
- `add_participant` `{threadId, userId, role?}` · `read` `{threadId, interactionId}` · `delivered` `{…}` · `typing` `{threadId}`.
- `annotate` `{threadId, interactionId, type, value}` — toggle a **generic annotation** (chat app uses `type: reaction|pin|save`; `value` = emoji, or empty).
Server → thread room: `message` (a Message), `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`, `typing` `{threadId, userId}`, `annotation` `{interactionId, type, value, op: add|remove, users[], userId}`.
**Governance & primitives (all fail-closed via OPA).** Membership (`iios.thread.participant.add`, `iios.thread.join`), annotating (`iios.interaction.annotate` — participant-only), and send all gate on policy. **Reactions/pins/saves are one generic primitive** — an *interaction annotation* the kernel stores + aggregates as opaque `(type, value)` but never interprets (the same primitive backs pins/saves/flags/tags). **Mentions → Inbox:** `mentions[]` flows into the message event; the inbox projector fans out a `MENTION` inbox item to each mentioned participant (never the sender); reading the thread resolves it.
### 5.4 Inbox (work queue) ### 5.4 Inbox (work queue)
| Method | Path | Query / Body | Returns | | Method | Path | Query / Body | Returns |
@@ -114,6 +128,8 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
| GET | `/v1/inbox/items` | `?state=OPEN|SNOOZED|DONE|ARCHIVED|CANCELLED|STALE` | `InboxItem[]` | | GET | `/v1/inbox/items` | `?state=OPEN|SNOOZED|DONE|ARCHIVED|CANCELLED|STALE` | `InboxItem[]` |
| PATCH | `/v1/inbox/items/:id` | `{state, reason?}` | `InboxItem` | | PATCH | `/v1/inbox/items/:id` | `{state, reason?}` | `InboxItem` |
Item **kinds** include `NEEDS_REPLY` (unreplied thread activity, one per owner+thread) and `MENTION` (someone @-mentioned you; `priority: HIGH`, one per source message). Reading the thread resolves both to `DONE`.
### 5.5 Support (tickets, escalation, callbacks, queues, agents) ### 5.5 Support (tickets, escalation, callbacks, queues, agents)
| Method | Path | Body / Query | Returns | | Method | Path | Body / Query | Returns |
|---|---|---|---| |---|---|---|---|
@@ -179,6 +195,52 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
`ScheduleMeetingDto`: `{meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}`. `meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}`; consent `status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}`. **Transcript/summary is `BLOCKED` until every attendee has `GRANTED` consent.** `ScheduleMeetingDto`: `{meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}`. `meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}`; consent `status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}`. **Transcript/summary is `BLOCKED` until every attendee has `GRANTED` consent.**
### 5.10 Media (attachments)
The DB stores a **reference**; the bytes live behind a **storage port** (dev = local disk `MEDIA_DIR`; prod = swap to S3/R2/Supabase). Bytes go **client ↔ storage directly** via short-lived signed URLs — they never pass through the kernel.
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
| POST | `/v1/media/presign-upload` | Bearer | `{mime, sizeBytes}` | `{objectKey, uploadUrl}`**OPA-gated** (size/type) |
| PUT | `/v1/media/upload/:token` | token in URL | raw bytes | `{objectKey, sizeBytes, checksumSha256}` |
| POST | `/v1/media/presign-download` | Bearer | `{contentRef, mime?}` | `{url}`**tenant-fenced**, signed 1h |
| GET | `/v1/media/blob/:token` | token in URL | — | streams the bytes with their `Content-Type` |
**Attaching to a message:** `send`/`send_message` accept `{contentRef, mimeType, sizeBytes, checksumSha256?}`. The stored `Message` then carries `attachment: { contentRef, mimeType, sizeBytes, kind }` where `kind ∈ {image, video, audio, file}` (a friendly view of the generic `MEDIA_REF`/`VOICE_REF`/`FILE_REF` part the kernel writes).
**Governance (fail-closed, built in):**
- **Upload policy** `iios.media.upload`**≤ 25 MB** and an allowlist (`image/*`, `video/*`, `audio/*`, `application/pdf`, common Office/text/zip). A violation → **403** *before* any bytes are sent.
- **Signed tokens** (HS256, `MEDIA_SECRET`) — upload URL lives **5 min**, download URL **1 h**; a tampered/expired token → 403.
- **Tenant fence** — object keys are prefixed with the caller's `scopeId`; a download for an object outside your scope → 403.
**The flow (with governance):**
```mermaid
sequenceDiagram
autonumber
participant C as Client (SDK uploadMedia)
participant API as IIOS Media API
participant OPA as OPA policy
participant ST as StoragePort (disk → S3)
participant K as Message kernel
C->>API: POST /v1/media/presign-upload {mime, sizeBytes}
API->>OPA: decide iios.media.upload (≤25MB? type allowed?)
alt denied
OPA-->>C: 403 (too large / type not allowed)
else allowed
API-->>C: { objectKey, uploadUrl } (signed, 5-min)
C->>ST: PUT bytes → uploadUrl (direct, not via kernel)
ST-->>C: { sizeBytes, checksumSha256 }
C->>K: send_message { contentRef, mime, size }
K-->>C: Message { attachment }
C->>API: POST /v1/media/presign-download { contentRef }
API->>API: tenant-fence (objectKey in my scope?)
API-->>C: { url } (signed, 1-hour)
C->>ST: GET url → bytes (stream, inline render / download)
end
```
--- ---
## 6. SDK reference ## 6. SDK reference
@@ -191,7 +253,9 @@ import { RestClient } from '@insignia/iios-kernel-client';
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token }); const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });
``` ```
Methods (all return typed promises): Methods (all return typed promises):
- **Messaging:** `getThreadMessages(threadId)`, `sendMessage(threadId, content, {contentRef?, idempotencyKey?})` - **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})`
- **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates.
- **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.*
- **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})` - **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})`
- **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)` - **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)`
- **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)` - **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)`
@@ -269,7 +333,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/<name>`)
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) | | `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation | | `smoke-tenant.mjs` | cross-tenant 403 + list isolation |
Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check: `pnpm boundary`. Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check: `pnpm boundary`.
--- ---
@@ -289,7 +353,11 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
|---|---|---| |---|---|---|
| `PORT` | `3200` | HTTP port | | `PORT` | `3200` | HTTP port |
| `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres | | `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres |
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app JWT secrets (`{appId: secret}`) | | `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app HS256 JWT secrets (`{appId: secret}`) |
| `SUPABASE_URL` / `AUTH_ISSUERS` | — | trusted OIDC issuer(s) — verify real IdP tokens (ES256) via JWKS. `AUTH_ISSUERS` is a JSON array `[{url, appId, orgId?}]` mapping issuers → app scopes; `SUPABASE_URL` is the single-issuer shorthand |
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
| `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs |
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` | | `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) | | `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit | | `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
@@ -305,7 +373,9 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
- **Interaction kind:** MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION - **Interaction kind:** MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
- **Channel types:** WEBHOOK, EMAIL, WHATSAPP, PORTAL - **Channel types:** WEBHOOK, EMAIL, WHATSAPP, PORTAL
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST - **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, **MENTION**, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
- **Message part kind:** TEXT, HTML, MARKDOWN, MEDIA_REF, FILE_REF, VOICE_REF, LOCATION, STRUCTURED_JSON · **Attachment kind (DTO):** image, video, audio, file
- **Interaction annotation (app-level, opaque to the kernel):** `type` = reaction | pin | save … ; `value` = emoji (reactions) or empty
- **Ticket state:** NEW, OPEN, PENDING, RESOLVED, CLOSED · **priority:** LOW, NORMAL, HIGH, URGENT - **Ticket state:** NEW, OPEN, PENDING, RESOLVED, CLOSED · **priority:** LOW, NORMAL, HIGH, URGENT
- **Route mode:** MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · **output format:** FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · **decision:** ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED - **Route mode:** MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · **output format:** FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · **decision:** ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
- **AI job:** CLASSIFY, SUMMARIZE, EXTRACT · **artifact:** CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · **artifact status:** PROPOSED, ACCEPTED, REJECTED, SUPERSEDED - **AI job:** CLASSIFY, SUMMARIZE, EXTRACT · **artifact:** CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · **artifact status:** PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
+5 -3
View File
@@ -40,7 +40,7 @@ Today the external world (real WhatsApp, real AI models, real calendars, the rea
| **P8** | **Calendar & meetings** — schedule, attendee consent, transcript, summary, action items | `iios-meeting-web` | Support/scheduling; callback→meeting | meeting-studio (5178) | | **P8** | **Calendar & meetings** — schedule, attendee consent, transcript, summary, action items | `iios-meeting-web` | Support/scheduling; callback→meeting | meeting-studio (5178) |
| **P9** | *(next)* **Production hardening** — real providers, real policy/consent, scale, retention | — | Ops / platform | — | | **P9** | *(next)* **Production hardening** — real providers, real policy/consent, scale, retention | — | Ops / platform | — |
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (46 tables across 9 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on. All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (53 tables across 19 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
--- ---
@@ -159,7 +159,9 @@ Each product SDK is a handful of React hooks — a front-end dev wires the UI, t
| Calendar/Zoom providers | Simulated sync | P9 | | Calendar/Zoom providers | Simulated sync | P9 |
| Multi-tenant scale, retention, SLOs | Not yet | P9 | | Multi-tenant scale, retention, SLOs | Not yet | P9 |
**Proof it works:** 95 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture. **Proof it works:** 192 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, 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.
--- ---
@@ -175,4 +177,4 @@ Narrative arc for the CEO: **one engine → chat → inbox → support → chann
--- ---
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 46 Postgres tables across 9 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar). Boundary-enforced dependency law; 95 passing tests; 7 end-to-end smoke scripts.* *Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 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.*
@@ -0,0 +1,107 @@
# ADR-0001: `support-service` → IIOS naming migration
- **Status:** Accepted
- **Date:** 2026-07-03
- **Deciders:** IIOS core
- **Context tags:** naming, packaging, migration, KG-16
## Context
IIOS (Insignia Interaction OS) is the greenfield realization of a vision first
prototyped in the standalone **`support-service`** app. That prototype already stated the
core idea — *"Everything is a message: a generic Message core (conversation / message /
channel / user) is the foundation; Support (ticket / agent / callback) is a thin
specialization on top"* — but it did so with **support-centric naming** and as a
single-purpose support desk.
IIOS generalizes that same idea to an **interaction OS** of much broader scope
(messaging, inbox, routing, AI enrichment, calendar, plus support as *one*
specialization among many). During P0P9 it landed a clean, generic vocabulary:
an `Iios`-prefixed persistence layer and `@insignia/iios-*` packages.
Two problems make an explicit decision necessary:
1. **Naming drift.** The prototype and IIOS use different words for the same concepts
(`Conversation` vs `IiosThread`, `Message` vs `IiosInteraction` + `IiosMessagePart`,
`User` vs `IiosSourceHandle` + `IiosActorRef`). Without a canonical mapping, contributors
coming from the prototype reintroduce support-centric names, and host apps don't know
which SDK is authoritative.
2. **KG-16 — "support wedge evidence overgeneralized."** The critics' analysis flags the
risk of treating the `support-service` architecture as proof of *all* IIOS capabilities.
Support is a **specialization**, not the core; the naming must make that structural
(support names are namespaced *under* the generic core, never the core itself).
An unresolved sub-question also blocks packaging: the workspace scope was historically
ambiguous between `@insignia/*` and `@lynkeduppro/*`.
## Decision
**1. Canonical vocabulary is interaction-centric and `Iios`-namespaced.**
The generic core is the noun set already shipped in `@insignia/iios-service`:
`IiosScope`, `IiosSourceHandle`/`IiosActorRef`, `IiosChannel`, `IiosThread`,
`IiosInteraction`/`IiosMessagePart`. **Support is a specialization layered on top** and is
always namespaced as such (`IiosSupport*`, `IiosTicket*`, `IiosCallbackRequest`) — never
promoted to a core concept. "Conversation" and support-centric "Message-as-ticket" framing
are retired.
**2. Package scope is `@insignia/iios-*`.** This resolves the `@insignia` vs `@lynkeduppro`
ambiguity in favour of `@insignia`. All IIOS packages already follow `@insignia/iios-<name>`;
that is now the standard.
**3. Migration is greenfield-and-deprecate (strangler), not rename-in-place.**
`support-service` is **frozen legacy** — we do not rename its models or ship a new version of
it. New work happens in IIOS; host apps cut over to the `@insignia/iios-*` SDKs feature-by-
feature. `support-service` is retired once every host app it serves has migrated.
### Canonical naming map (legacy → IIOS)
| `support-service` (legacy) | IIOS (canonical) | Note |
|---|---|---|
| `Channel` | `IiosChannel` | generic ingress/egress channel |
| `User` | `IiosSourceHandle` + `IiosActorRef` | identity split: external handle vs resolved actor |
| `Conversation` | `IiosThread` | "conversation" retired; thread is generic |
| `Message` | `IiosInteraction` + `IiosMessagePart` | envelope vs content parts |
| `Attachment` | `IiosMessagePart` (`kind=MEDIA`, `contentRef`) | attachments are just parts |
| `Team` / `TeamMember` | `IiosSupportQueue` / `IiosSupportTeamMember` | **support specialization**, namespaced |
| `ConversationAssignment` | support assignment (`IiosSupport*`) | specialization, not core |
| `Ticket` / `TicketConversation` | `IiosTicket` / `IiosTicketThreadLink` | ticket links to generic threads |
| `CallbackRequest` | `IiosCallbackRequest` | |
| `Meeting` | `IiosMeeting` (+ P8 calendar models) | promoted to first-class in IIOS |
| `Notification` | `notification_outbox` (planned) | not yet built in IIOS |
| `ProcessedCommand` | `IiosProcessedEvent` + `IiosIdempotencyCommand` | consumer ledger vs command ledger |
| `KnowledgeArticle` | (deferred — AI/RAG) | out of current scope |
### Rules going forward
- **Every persisted model is `Iios`-prefixed.** No un-prefixed domain models.
- **Support (and any future vertical) is namespaced under the generic core**, never the other
way round. If a name reads as "support-only," it must carry the `IiosSupport*`/`IiosTicket*`
prefix and depend on the core — the core never depends on it (enforced by the import-boundary
check).
- **New packages are `@insignia/iios-<name>`.**
- **Legacy references** in prose cite `support-service` explicitly as *legacy*, with a pointer
to this ADR.
## Consequences
**Positive**
- One authoritative vocabulary; contributors from the prototype have a lookup table.
- KG-16 is structurally addressed: support cannot masquerade as the core because its names are
subordinate and the boundary check enforces the dependency direction.
- Packaging is unblocked (`@insignia/iios-*` is canonical).
- No risky in-place rename of a running legacy service; cutover is incremental per host app.
**Negative / costs**
- Two systems coexist during the strangler window; the legacy `support-service` must be kept
running (but frozen) until host apps migrate.
- The legacy↔IIOS mapping must be consulted when porting features; this ADR is that map.
- `Notification` / `KnowledgeArticle` have no IIOS home yet — tracked as follow-ups, not blockers.
## Alternatives considered
- **Rename `support-service` in place** to the IIOS vocabulary. Rejected: high-risk migration
of a live service + DB for a system we intend to retire anyway; no benefit over greenfield.
- **Keep both vocabularies and bridge with adapters.** Rejected: perpetuates naming drift and
the KG-16 confusion indefinitely.
- **`@lynkeduppro/*` scope.** Rejected: all IIOS packages already ship under `@insignia`;
switching scopes now is churn with no upside. (A future rebrand can supersede this ADR.)
+19
View File
@@ -0,0 +1,19 @@
# Architecture Decision Records (ADRs)
This directory records the significant, hard-to-reverse decisions behind IIOS —
the *why*, not just the *what*. Code shows what we did; ADRs explain the choice so a
future contributor doesn't relitigate it or unknowingly violate it.
## Convention
- One file per decision: `NNNN-kebab-title.md` (zero-padded, monotonically increasing).
- Each ADR has: **Status**, **Context**, **Decision**, **Consequences**, and (where useful)
**Alternatives considered**.
- Status is one of `Proposed` · `Accepted` · `Superseded by ADR-XXXX` · `Deprecated`.
- ADRs are immutable once `Accepted`. To change a decision, write a new ADR that supersedes it.
## Index
| ADR | Title | Status |
|-----|-------|--------|
| [0001](0001-support-service-to-iios-naming-migration.md) | `support-service` → IIOS naming migration | Accepted |
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { EmailAdapter, emailInboundFixture, emailReplyFixture, signedFixture } from './index';
const ctx = { scope: { orgId: 'o', appId: 'a' } };
describe('EmailAdapter', () => {
const adapter = new EmailAdapter();
it('normalizes an inbound email into an ingest request', () => {
const req = adapter.normalize(emailInboundFixture, ctx);
expect(req.channel.type).toBe('EMAIL');
expect(req.source.handleKind).toBe('EXTERNAL_VISITOR');
expect(req.source.externalId).toBe(emailInboundFixture.from);
expect(req.parts[0]?.bodyText).toBe(emailInboundFixture.text);
expect(req.providerEventId).toBe('<m1@example.com>');
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>');
expect(req.thread?.subject).toBe('Need help with my order'); // Re:/Fwd: stripped
expect((req.metadata as { messageId: string }).messageId).toBe('<m1@example.com>');
});
it('threads a reply onto the SAME email thread as the original', () => {
const first = adapter.normalize(emailInboundFixture, ctx);
const reply = adapter.normalize(emailReplyFixture, ctx);
expect(reply.thread?.externalThreadId).toBe(first.thread?.externalThreadId); // email:<m1@example.com>
expect(reply.providerEventId).toBe('<m2@example.com>'); // but its own dedup id
});
it('falls back to the reply target when there is no references chain', () => {
const req = adapter.normalize({ ...emailReplyFixture, references: undefined }, ctx);
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>'); // uses inReplyTo
});
it('derives text from html when no plain text is present', () => {
const req = adapter.normalize({ messageId: '<h@x>', from: 'x@y', html: '<p>Hello <b>there</b></p>' }, ctx);
expect(req.parts[0]?.bodyText).toBe('Hello there');
});
it('verifies an HMAC-signed body and rejects a bad/absent signature', () => {
const secret = 'sekret';
const { body, headers } = signedFixture(emailInboundFixture, secret);
expect(adapter.verifySignature(body, headers, secret)).toBe(true);
expect(adapter.verifySignature(body, { 'x-iios-signature': 'sha256=bad' }, secret)).toBe(false);
expect(adapter.verifySignature(body, {}, secret)).toBe(false);
});
});
@@ -0,0 +1,71 @@
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
import type { AdapterCapability, ChannelAdapter, NormalizeContext, OutboundCommandInput, SendResult } from './types';
import { hmacVerify } from './hmac';
import { SandboxSink } from './sandbox';
/** Canonical inbound-email shape. Vendor payloads (SendGrid Inbound Parse, Mailgun
* Routes, Postmark) map into this — provider glue stays a thin transform. */
export interface EmailInboundPayload {
messageId: string; // RFC 5322 Message-ID → providerEventId (dedup on redelivery)
from: string;
fromName?: string;
to?: string[];
cc?: string[];
subject?: string;
text?: string;
html?: string;
inReplyTo?: string; // Message-ID this is a reply to
references?: string[]; // full reference chain, root-first (threading)
occurredAt?: string;
headers?: Record<string, string>;
}
const stripReply = (s?: string): string => (s ?? '').replace(/^(re|fwd|fw)\s*:\s*/i, '').trim();
const stripHtml = (h?: string): string => (h ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
/**
* Email channel adapter. Turns a provider's inbound email into a normalized
* interaction with email-native threading: the whole reply chain collapses to one
* IIOS thread (rooted at `references[0]`/`inReplyTo`), and `messageId` dedups
* redelivery. HMAC signature for now (per-vendor schemes are a follow-up).
*/
export class EmailAdapter implements ChannelAdapter {
readonly channelType = 'EMAIL';
readonly capability: AdapterCapability = {
canReceive: true,
canSend: true,
supportsThreads: true,
requiresFollowupFetch: false,
};
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean {
const sig = headers['x-iios-signature'] ?? headers['X-Iios-Signature'];
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
}
externalEventId(payload: unknown): string | undefined {
const id = (payload as EmailInboundPayload)?.messageId;
return typeof id === 'string' ? id : undefined;
}
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
const p = payload as EmailInboundPayload;
// A reply's thread root is the first Message-ID in its chain → same IIOS thread as the original.
const threadRoot = p.references?.[0] ?? p.inReplyTo ?? p.messageId;
return {
scope: ctx.scope,
channel: { type: 'EMAIL', externalChannelId: 'email' },
source: { handleKind: 'EXTERNAL_VISITOR', externalId: p.from, displayName: p.fromName ?? p.from },
kind: 'MESSAGE',
thread: { externalThreadId: `email:${threadRoot}`, subject: stripReply(p.subject) },
parts: [{ kind: 'TEXT', bodyText: p.text ?? stripHtml(p.html), mimeType: 'text/plain' }],
occurredAt: p.occurredAt ?? new Date().toISOString(),
providerEventId: p.messageId,
metadata: { to: p.to, cc: p.cc, messageId: p.messageId, inReplyTo: p.inReplyTo, references: p.references, html: p.html },
};
}
async send(cmd: OutboundCommandInput): Promise<SendResult> {
return SandboxSink.simulate(cmd);
}
}
+26 -1
View File
@@ -1,7 +1,8 @@
import { hmacSign } from './hmac'; import { hmacSign } from './hmac';
import type { WebhookPayload } from './webhook-adapter'; import type { WebhookPayload } from './webhook-adapter';
import type { EmailInboundPayload } from './email-adapter';
/** Email-shaped provider payload (as the adapter's normalize input). */ /** Email-shaped WebhookPayload (legacy generic-webhook fixture). */
export const emailFixture: WebhookPayload = { export const emailFixture: WebhookPayload = {
eventId: 'email-1', eventId: 'email-1',
from: 'someone@example.com', from: 'someone@example.com',
@@ -11,6 +12,30 @@ export const emailFixture: WebhookPayload = {
text: 'Hello, I have a question.', text: 'Hello, I have a question.',
}; };
/** A first inbound email (starts a thread) for the EmailAdapter. */
export const emailInboundFixture: EmailInboundPayload = {
messageId: '<m1@example.com>',
from: 'buyer@example.com',
fromName: 'Buyer',
to: ['support@tower.example'],
subject: 'Need help with my order',
text: 'Hi, my order has not arrived yet.',
occurredAt: '2026-07-03T10:00:00.000Z',
};
/** A reply to `emailInboundFixture` — must land on the SAME email thread. */
export const emailReplyFixture: EmailInboundPayload = {
messageId: '<m2@example.com>',
from: 'buyer@example.com',
fromName: 'Buyer',
to: ['support@tower.example'],
subject: 'Re: Need help with my order',
text: 'Any update on this?',
inReplyTo: '<m1@example.com>',
references: ['<m1@example.com>'],
occurredAt: '2026-07-03T11:00:00.000Z',
};
/** WhatsApp-shaped provider payload. */ /** WhatsApp-shaped provider payload. */
export const whatsappFixture: WebhookPayload = { export const whatsappFixture: WebhookPayload = {
eventId: 'wa-1', eventId: 'wa-1',
+2 -1
View File
@@ -2,4 +2,5 @@ export * from './types';
export { hmacSign, hmacVerify } from './hmac'; export { hmacSign, hmacVerify } from './hmac';
export { SandboxSink } from './sandbox'; export { SandboxSink } from './sandbox';
export { WebhookAdapter, type WebhookPayload } from './webhook-adapter'; export { WebhookAdapter, type WebhookPayload } from './webhook-adapter';
export { emailFixture, whatsappFixture, signedFixture } from './fixtures'; export { EmailAdapter, type EmailInboundPayload } from './email-adapter';
export { emailFixture, whatsappFixture, emailInboundFixture, emailReplyFixture, signedFixture } from './fixtures';
+2
View File
@@ -34,6 +34,8 @@ export interface ChannelAdapter {
channelType: string; channelType: string;
capability: AdapterCapability; capability: AdapterCapability;
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean; verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean;
/** Provider-native id used to dedup replays/retries at the inbound edge (e.g. eventId, Message-ID). */
externalEventId?(payload: unknown): string | undefined;
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest; normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest;
fetch?(ref: string): Promise<unknown>; fetch?(ref: string): Promise<unknown>;
send(cmd: OutboundCommandInput): Promise<SendResult>; send(cmd: OutboundCommandInput): Promise<SendResult>;
@@ -37,6 +37,11 @@ export class WebhookAdapter implements ChannelAdapter {
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined); return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
} }
externalEventId(payload: unknown): string | undefined {
const id = (payload as WebhookPayload)?.eventId;
return typeof id === 'string' ? id : undefined;
}
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest { normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
const p = payload as WebhookPayload; const p = payload as WebhookPayload;
return { return {
+51
View File
@@ -0,0 +1,51 @@
# ─────────────────────────────────────────────────────────────────────────────
# iios-service configuration contract.
# Copy to `.env` for local dev; in prod, inject via your secrets manager / orchestrator.
# 🔒 = secret (never commit a real value). ⚠️ = must be set correctly for prod safety.
# ─────────────────────────────────────────────────────────────────────────────
# ── Core ─────────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://iios:iios@localhost:5434/iios?schema=public # 🔒 Postgres connection
PORT=3200
# NODE_ENV=production # set by the Docker image
# Realtime fan-out across replicas (socket.io Redis adapter). REQUIRED when running
# more than one instance with live chat; omit for a single instance (in-memory adapter).
# REDIS_URL=redis://localhost:6379 # 🔒
# ── Secrets ──────────────────────────────────────────────────────────────────
# JSON map of appId → HS256 signing secret used to verify session JWTs (SessionVerifier).
APP_SECRETS={"portal-demo":"dev-secret"} # 🔒
# JSON map of channelType → inbound webhook HMAC secret (adapter signature check).
ADAPTER_SECRETS={"WEBHOOK":"dev-adapter-secret"} # 🔒
# Default scope an unauthenticated adapter webhook ingests into.
ADAPTER_APP=portal-demo
ADAPTER_ORG=org_demo
# ── ⚠️ Production-safety flags ────────────────────────────────────────────────
# Enables /v1/dev/* (token mint, webhook inject, chaos, retention sweep). MUST be unset
# or 0 in production — leaving it on exposes unauthenticated token minting.
IIOS_DEV_TOKENS=0
# Skip `prisma migrate deploy` at boot (set to 1 when a separate migration job runs).
IIOS_SKIP_MIGRATE=0
# ── Tenant isolation ─────────────────────────────────────────────────────────
IIOS_CELL_ID=cell-default # physical cell this instance serves (blast-radius partition)
# ── Background workers ───────────────────────────────────────────────────────
IIOS_RELAY_INTERVAL_MS=500 # outbox relay tick; 0 disables the timer
IIOS_OUTBOX_MAX_ATTEMPTS=5 # relay dead-letters an event after N failed publishes
IIOS_RETENTION_SWEEP_INTERVAL_MS=0 # retention sweep tick; 0 disables (run via job instead)
IIOS_RETENTION_POLICY_VERSION=v1
IIOS_RETENTION_ARCHIVE_DAYS=90 # default archive window (per-class override: _INTERNAL/_RESTRICTED/_CONFIDENTIAL/_REGULATED)
IIOS_RETENTION_DELETE_DAYS=365 # default delete (redact) window; same per-class overrides
# ── Rate limits / quotas / budgets ───────────────────────────────────────────
IIOS_OUTBOUND_LIMIT=5 # per-(channel,target) sends per window
IIOS_OUTBOUND_WINDOW_MS=60000
IIOS_TENANT_OUTBOUND_LIMIT=10000 # per-tenant egress cap per window (noisy-neighbor guard)
IIOS_TENANT_OUTBOUND_WINDOW_MS=60000
IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
# ── Capability providers (governed egress targets) ───────────────────────────
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
+54
View File
@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1
# Production image for @insignia/iios-service (the single IIOS backend).
# Build context MUST be the monorepo ROOT (it needs the workspace + lockfile):
# docker build -f packages/iios-service/Dockerfile -t iios-service:latest .
#
# Multi-stage: (1) build the service + its workspace deps and prune to a
# self-contained prod bundle via `pnpm deploy`; (2) a slim runtime that only
# carries that bundle. Migrations run at container start (see docker-entrypoint.sh).
ARG NODE_VERSION=22-bookworm-slim
ARG PNPM_VERSION=10.6.2
# ── build ───────────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION} AS build
ARG PNPM_VERSION
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
# openssl + ca-certificates are required by Prisma's engine on debian-slim.
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /repo
COPY . .
# Full install (needs devDeps to compile), then build ONLY the service + its
# workspace dependencies, generate the Prisma client, and emit a pruned prod bundle.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile
# Generate the Prisma client BEFORE compiling — nest build (tsc) needs its types,
# otherwise Prisma results are `any` and noImplicitAny fails the build.
RUN pnpm --filter @insignia/iios-service exec prisma generate \
&& pnpm --filter "@insignia/iios-service..." build \
&& pnpm --filter @insignia/iios-service --prod --legacy deploy /app
# ── runtime ──────────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION} AS runtime
ENV NODE_ENV=production
# Prisma engine needs openssl at runtime too.
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app ./
# Regenerate the Prisma client against the final filesystem (deterministic).
RUN node_modules/.bin/prisma generate
# Run as the built-in non-root `node` user.
RUN chmod +x docker-entrypoint.sh && chown -R node:node /app
USER node
EXPOSE 3200
ENV PORT=3200
# migrate deploy → start; see docker-entrypoint.sh.
ENTRYPOINT ["./docker-entrypoint.sh"]
@@ -0,0 +1,19 @@
#!/bin/sh
# Container entrypoint for iios-service.
# 1) Apply pending DB migrations (idempotent; safe to run on every boot).
# 2) Exec the server as PID 1 so signals (SIGTERM) reach Node for graceful shutdown.
#
# NOTE: at higher replica counts, prefer running `prisma migrate deploy` ONCE as a
# separate pre-deploy job/init-container and set IIOS_SKIP_MIGRATE=1 here, so N
# replicas don't race the migration on rollout.
set -e
if [ "${IIOS_SKIP_MIGRATE:-0}" != "1" ]; then
echo "[entrypoint] applying migrations (prisma migrate deploy)…"
node_modules/.bin/prisma migrate deploy
else
echo "[entrypoint] IIOS_SKIP_MIGRATE=1 — skipping migrations"
fi
echo "[entrypoint] starting iios-service on :${PORT:-3200}"
exec node dist/main.js
+11 -4
View File
@@ -12,21 +12,28 @@
"prisma:studio": "prisma studio" "prisma:studio": "prisma studio"
}, },
"dependencies": { "dependencies": {
"@insignia/iios-contracts": "workspace:*",
"@insignia/iios-adapter-sdk": "workspace:*", "@insignia/iios-adapter-sdk": "workspace:*",
"@insignia/iios-contracts": "workspace:*",
"@nestjs/common": "^11.1.27", "@nestjs/common": "^11.1.27",
"@nestjs/core": "^11.1.27", "@nestjs/core": "^11.1.27",
"@nestjs/platform-express": "^11.1.27", "@nestjs/platform-express": "^11.1.27",
"@nestjs/platform-socket.io": "^11.1.27", "@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/websockets": "^11.1.27", "@nestjs/websockets": "^11.1.27",
"@prisma/client": "^6.2.1", "@prisma/client": "^6.2.1",
"socket.io": "^4.8.3", "@socket.io/redis-adapter": "^8.3.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"prisma": "^6.2.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2",
"socket.io": "^4.8.3",
"web-push": "^3.6.7",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/sdk-node": "^0.220.0",
"@opentelemetry/auto-instrumentations-node": "^0.78.0"
}, },
"devDependencies": { "devDependencies": {
"@insignia/iios-testkit": "workspace:*", "@insignia/iios-testkit": "workspace:*",
@@ -35,7 +42,7 @@
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.0.1", "@types/node": "^26.0.1",
"prisma": "^6.2.1", "@types/web-push": "^3.6.4",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
@@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "IiosInteraction" ADD COLUMN "dataClass" TEXT NOT NULL DEFAULT 'internal';
-- CreateTable
CREATE TABLE "IiosRetentionPolicySnapshot" (
"id" TEXT NOT NULL,
"policyKey" TEXT NOT NULL,
"scopeSnapshotId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"dataClass" TEXT NOT NULL,
"retainUntil" TIMESTAMP(3),
"archiveAfter" TIMESTAMP(3) NOT NULL,
"deleteAfter" TIMESTAMP(3) NOT NULL,
"sourceVersion" TEXT NOT NULL DEFAULT 'v1',
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosRetentionPolicySnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosRetentionPolicySnapshot_status_deleteAfter_idx" ON "IiosRetentionPolicySnapshot"("status", "deleteAfter");
-- CreateIndex
CREATE INDEX "IiosRetentionPolicySnapshot_scopeSnapshotId_status_idx" ON "IiosRetentionPolicySnapshot"("scopeSnapshotId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "IiosRetentionPolicySnapshot_targetType_targetId_key" ON "IiosRetentionPolicySnapshot"("targetType", "targetId");
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "IiosInteractionAnnotation" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"targetInteractionId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"annotationType" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosInteractionAnnotation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosInteractionAnnotation_targetInteractionId_idx" ON "IiosInteractionAnnotation"("targetInteractionId");
-- CreateIndex
CREATE UNIQUE INDEX "IiosInteractionAnnotation_scopeId_targetInteractionId_actor_key" ON "IiosInteractionAnnotation"("scopeId", "targetInteractionId", "actorId", "annotationType", "value");
-- AddForeignKey
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_targetInteractionId_fkey" FOREIGN KEY ("targetInteractionId") REFERENCES "IiosInteraction"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "IiosInboxItemKind" ADD VALUE 'MENTION';
@@ -0,0 +1,30 @@
-- AlterTable
ALTER TABLE "IiosThreadParticipant" ADD COLUMN "muted" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "IiosNotificationSubscription" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"kind" TEXT NOT NULL DEFAULT 'webpush',
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosNotificationSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "IiosNotificationSubscription_endpoint_key" ON "IiosNotificationSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "IiosNotificationSubscription_actorId_idx" ON "IiosNotificationSubscription"("actorId");
-- AddForeignKey
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -79,6 +79,7 @@ enum IiosInboxItemKind {
NEEDS_REPLY NEEDS_REPLY
NEEDS_REVIEW NEEDS_REVIEW
NEEDS_APPROVAL NEEDS_APPROVAL
MENTION
SUPPORT_UPDATE SUPPORT_UPDATE
MEETING_FOLLOWUP MEETING_FOLLOWUP
DIGEST DIGEST
@@ -303,10 +304,12 @@ model IiosScope {
channels IiosChannel[] channels IiosChannel[]
threads IiosThread[] threads IiosThread[]
interactions IiosInteraction[] interactions IiosInteraction[]
annotations IiosInteractionAnnotation[]
inboxItems IiosInboxItem[] inboxItems IiosInboxItem[]
supportQueues IiosSupportQueue[] supportQueues IiosSupportQueue[]
tickets IiosTicket[] tickets IiosTicket[]
callbacks IiosCallbackRequest[] callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[]
@@index([orgId, appId, tenantId]) @@index([orgId, appId, tenantId])
} }
@@ -348,6 +351,7 @@ model IiosActorRef {
threadsCreated IiosThread[] @relation("ThreadCreatedBy") threadsCreated IiosThread[] @relation("ThreadCreatedBy")
participations IiosThreadParticipant[] participations IiosThreadParticipant[]
interactions IiosInteraction[] interactions IiosInteraction[]
annotations IiosInteractionAnnotation[]
receipts IiosMessageReceipt[] receipts IiosMessageReceipt[]
unreadCounters IiosUnreadCounter[] unreadCounters IiosUnreadCounter[]
inboxItemsOwned IiosInboxItem[] inboxItemsOwned IiosInboxItem[]
@@ -355,6 +359,7 @@ model IiosActorRef {
ticketsRequested IiosTicket[] @relation("TicketRequester") ticketsRequested IiosTicket[] @relation("TicketRequester")
ticketsAssigned IiosTicket[] @relation("TicketAssignee") ticketsAssigned IiosTicket[] @relation("TicketAssignee")
callbacksRequested IiosCallbackRequest[] callbacksRequested IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[]
} }
/// A configured channel surface (PORTAL in P1). /// A configured channel surface (PORTAL in P1).
@@ -409,12 +414,32 @@ model IiosThreadParticipant {
actorId String actorId String
actor IiosActorRef @relation(fields: [actorId], references: [id]) actor IiosActorRef @relation(fields: [actorId], references: [id])
participantRole String @default("MEMBER") participantRole String @default("MEMBER")
muted Boolean @default(false)
joinedAt DateTime @default(now()) joinedAt DateTime @default(now())
leftAt DateTime? leftAt DateTime?
@@id([threadId, actorId]) @@id([threadId, actorId])
} }
/// A device/browser push subscription for an actor (Web Push endpoint + keys).
/// The notification engine delivers to these when the actor is absent.
model IiosNotificationSubscription {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
actorId String
actor IiosActorRef @relation(fields: [actorId], references: [id])
kind String @default("webpush")
endpoint String @unique
p256dh String
auth String
userAgent String?
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
@@index([actorId])
}
/// The semantic envelope. Idempotent per (scope, idempotencyKey). /// The semantic envelope. Idempotent per (scope, idempotencyKey).
model IiosInteraction { model IiosInteraction {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -435,6 +460,7 @@ model IiosInteraction {
occurredAt DateTime @default(now()) occurredAt DateTime @default(now())
receivedAt DateTime @default(now()) receivedAt DateTime @default(now())
status IiosDeliveryState @default(RECEIVED) status IiosDeliveryState @default(RECEIVED)
dataClass String @default("internal") // envelope data class; drives retention window (P9)
policyDecisionRef String? policyDecisionRef String?
consentReceiptRef String? consentReceiptRef String?
crreBundleRef String? crreBundleRef String?
@@ -443,6 +469,7 @@ model IiosInteraction {
parts IiosMessagePart[] parts IiosMessagePart[]
receipts IiosMessageReceipt[] receipts IiosMessageReceipt[]
annotations IiosInteractionAnnotation[]
inboxItems IiosInboxItem[] inboxItems IiosInboxItem[]
ticketsCreatedFrom IiosTicket[] ticketsCreatedFrom IiosTicket[]
@@ -450,6 +477,26 @@ model IiosInteraction {
@@index([threadId, occurredAt]) @@index([threadId, occurredAt])
} }
/// A generic annotation an actor attaches to an interaction. Both `annotationType`
/// and `value` are OPAQUE, app-supplied strings the kernel stores and aggregates
/// but never interprets — e.g. the chat app writes type "reaction" / value "👍".
/// The same primitive backs pins, saves, flags, tags later. No chat vocabulary here.
model IiosInteractionAnnotation {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
targetInteractionId String
target IiosInteraction @relation(fields: [targetInteractionId], references: [id], onDelete: Cascade)
actorId String
actor IiosActorRef @relation(fields: [actorId], references: [id])
annotationType String
value String
createdAt DateTime @default(now())
@@unique([scopeId, targetInteractionId, actorId, annotationType, value])
@@index([targetInteractionId])
}
model IiosMessagePart { model IiosMessagePart {
id String @id @default(cuid()) id String @id @default(cuid())
interactionId String interactionId String
@@ -493,6 +540,27 @@ model IiosProcessedEvent {
@@id([consumerName, eventId]) @@id([consumerName, eventId])
} }
/// Per-resource retention snapshot (P9). Absolute lifecycle timestamps frozen from the
/// resource's age + policy window; read by the retention sweep. delete = redact-in-place.
model IiosRetentionPolicySnapshot {
id String @id @default(cuid())
policyKey String
scopeSnapshotId String
targetType String // 'interaction'
targetId String
dataClass String
retainUntil DateTime?
archiveAfter DateTime
deleteAfter DateTime
sourceVersion String @default("v1")
status String @default("ACTIVE") // ACTIVE | ARCHIVED | REDACTED
createdAt DateTime @default(now())
@@unique([targetType, targetId])
@@index([status, deleteAfter])
@@index([scopeSnapshotId, status])
}
/// Legal/compliance hold (P9 DSR). An ACTIVE, unexpired hold over a target BLOCKS /// Legal/compliance hold (P9 DSR). An ACTIVE, unexpired hold over a target BLOCKS
/// erasure/retention deletion until an authorised release. Never auto-released. /// erasure/retention deletion until an authorised release. Never auto-released.
model IiosComplianceHold { model IiosComplianceHold {
@@ -0,0 +1,65 @@
// P9 slice-8 DSR smoke: right-to-erasure redacts a subject's PII in place (a ticket
// subject), an active compliance hold blocks erasure until released, erasure is
// idempotent, and holds are tenant-fenced (KG-03 / KG-02). Requires the service
// running with IIOS_DEV_TOKENS=1.
import 'dotenv/config';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const APP_ID = 'portal-demo';
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
async function devToken(userId, orgId) {
const r = await fetch(`${SERVICE}/v1/dev/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }),
});
if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`);
return (await r.json()).token;
}
function call(token, path, method = 'GET', body) {
return fetch(`${SERVICE}${path}`, {
method,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined,
});
}
async function json(token, path, method = 'GET', body) {
const r = await call(token, path, method, body);
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
return r.json();
}
const ticketSubject = async (token, id) => (await json(token, '/v1/support/tickets')).find((t) => t.id === id)?.subject;
const A = await devToken('dsr-a', 'org_dsr_A');
const B = await devToken('dsr-b', 'org_dsr_B');
const PII = 'my SSN is 123-45';
// A creates a ticket carrying PII.
const ticket = await json(A, '/v1/support/tickets', 'POST', { subject: PII });
assert((await ticketSubject(A, ticket.id)) === PII, 'ticket carries the subject PII');
// A places a compliance hold on their own subject.
const hold = await json(A, '/v1/dsr/holds', 'POST', { targetType: 'data_subject', targetId: 'self', reason: 'litigation' });
assert((await json(A, '/v1/dsr/holds')).some((h) => h.id === hold.id), 'compliance hold is listed');
// Erasure is blocked while the hold is active.
const blocked = await call(A, '/v1/dsr/erase', 'POST');
assert(blocked.status === 409, `erase blocked by the hold → 409 (got ${blocked.status})`);
assert((await ticketSubject(A, ticket.id)) === PII, 'PII still present while held');
// Release the hold → erasure now redacts the PII in place.
await json(A, `/v1/dsr/holds/${hold.id}/release`, 'POST');
const erased = await json(A, '/v1/dsr/erase', 'POST');
assert(erased.status === 'ERASED', `erase succeeds after release (status ${erased.status})`);
assert((await ticketSubject(A, ticket.id)) === '[redacted]', 'ticket subject is redacted (erased in place, not deleted)');
// Idempotent: a second erase is a no-op.
const again = await json(A, '/v1/dsr/erase', 'POST');
assert(again.status === 'ALREADY_ERASED', `re-erase is idempotent (status ${again.status})`);
// Tenant fence: tenant B never sees A's holds.
assert(!(await json(B, '/v1/dsr/holds')).some((h) => h.id === hold.id), "tenant B's hold list excludes A's hold");
console.log('\nP9 DSR smoke: PASS');
process.exit(0);
@@ -0,0 +1,88 @@
// Email adapter smoke: a signed inbound email → normalized interaction; a reply
// (In-Reply-To) lands on the SAME email thread; outbound EMAIL → SENT (sandbox).
// Requires the service running with IIOS_DEV_TOKENS=1 (relay timer on). No real network.
import 'dotenv/config';
import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const APP_ID = 'portal-demo';
const APP_SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
const EMAIL_SECRET = (() => {
try { return JSON.parse(process.env.ADAPTER_SECRETS ?? '{}').EMAIL ?? 'dev-adapter-secret'; } catch { return 'dev-adapter-secret'; }
})();
const prisma = new PrismaClient();
const token = jwt.sign({ sub: 'inspector', appId: APP_ID, orgId: `org_${APP_ID}` }, APP_SECRET, { algorithm: 'HS256', expiresIn: '1h' });
const sign = (body) => 'sha256=' + crypto.createHmac('sha256', EMAIL_SECRET).update(body).digest('hex');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
async function postEmail(payload) {
const body = JSON.stringify(payload);
return fetch(`${SERVICE}/v1/adapters/EMAIL/webhook`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-iios-signature': sign(body) },
body,
});
}
async function inboundList() {
const r = await fetch(`${SERVICE}/v1/adapters/inbound`, { headers: { authorization: `Bearer ${token}` } });
if (!r.ok) throw new Error(`GET /v1/adapters/inbound ${r.status}`);
return r.json();
}
async function waitNormalized(messageId) {
for (let i = 0; i < 40; i++) {
const e = (await inboundList()).find((x) => x.externalEventId === messageId);
if (e && e.status === 'NORMALIZED' && e.interactionId) return e.interactionId;
await sleep(300);
}
return null;
}
// Unique Message-IDs per run so re-runs don't dedup against earlier ones.
const run = randomUUID().slice(0, 8);
const mid1 = `<${run}-1@smoke>`;
const mid2 = `<${run}-2@smoke>`;
// 1) A first inbound email → normalized interaction.
const r1 = await postEmail({ messageId: mid1, from: 'buyer@smoke', fromName: 'Buyer', subject: 'Need help with my order', text: 'my order is late' });
assert(r1.status === 202, 'signed email accepted (202)');
const i1 = await waitNormalized(mid1);
assert(i1, 'first email normalized into an interaction');
// 2) A reply (In-Reply-To) → same email thread.
const r2 = await postEmail({ messageId: mid2, from: 'buyer@smoke', subject: 'Re: Need help with my order', text: 'any update?', inReplyTo: mid1, references: [mid1] });
assert(r2.status === 202, 'reply email accepted (202)');
const i2 = await waitNormalized(mid2);
assert(i2, 'reply email normalized into an interaction');
const [it1, it2] = await Promise.all([
prisma.iiosInteraction.findUniqueOrThrow({ where: { id: i1 }, include: { thread: true } }),
prisma.iiosInteraction.findUniqueOrThrow({ where: { id: i2 }, include: { thread: true } }),
]);
assert(it1.threadId === it2.threadId, 'reply joined the SAME email thread as the original');
assert(it1.thread?.externalThreadRef === `email:${mid1}`, `thread rooted at the original Message-ID (${it1.thread?.externalThreadRef})`);
// 3) Bad signature → rejected.
const bad = await fetch(`${SERVICE}/v1/adapters/EMAIL/webhook`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-iios-signature': 'sha256=bad' },
body: JSON.stringify({ messageId: `<${run}-bad@smoke>`, from: 'x', text: 'x' }),
});
assert(bad.status === 401, 'bad signature rejected (401)');
// 4) Outbound EMAIL → governed egress → SENT (sandbox).
const sendRes = await fetch(`${SERVICE}/v1/adapters/EMAIL/send`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ target: 'buyer@smoke', payload: { subject: 'Re: Need help', text: 'thanks, looking into it' } }),
});
const cmd = await sendRes.json();
assert(cmd.status === 'SENT', `outbound email SENT via broker (status ${cmd.status})`);
console.log('\nEmail adapter smoke: PASS');
await prisma.$disconnect();
process.exit(0);
@@ -0,0 +1,61 @@
// v1.1 smoke: dev IdP login, policy-enforced DM cap / group membership, listThreads,
// and threaded replies — all over REST. Requires the service with IIOS_DEV_TOKENS=1.
import 'dotenv/config';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
async function login(username, password) {
const r = await fetch(`${SERVICE}/v1/dev/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
return { status: r.status, body: r.ok ? await r.json() : null };
}
function call(token, path, method = 'GET', body) {
return fetch(`${SERVICE}${path}`, {
method,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined,
});
}
async function json(token, path, method = 'GET', body) {
const r = await call(token, path, method, body);
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
return r.json();
}
// 1) dev IdP: correct password issues a token; wrong password is rejected.
const ok = await login('alice', 'alice');
assert(ok.body?.token, 'dev IdP: alice logs in with password');
const bad = await login('alice', 'nope');
assert(bad.status === 401, 'dev IdP: wrong password → 401');
const alice = ok.body.token;
// 2) DM is capped at two (policy).
const dm = await json(alice, '/v1/threads', 'POST', { membership: 'dm' });
assert(dm.threadId, `alice created a DM (${dm.threadId})`);
const add2 = await json(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'bob' });
assert(add2.participantCount === 2, 'added bob → 2 participants');
const add3 = await call(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'carol' });
assert(add3.status === 403, `adding a 3rd to a DM → 403 policy denied (${add3.status})`);
// 3) group: creator (ADMIN) can add several.
const grp = await json(alice, '/v1/threads', 'POST', { membership: 'group', creatorRole: 'ADMIN' });
await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'bob' });
const g3 = await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'carol' });
assert(g3.participantCount === 3, 'group admin added bob + carol → 3 participants');
// 4) threaded reply round-trips.
const first = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'question?' });
const reply = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'answer', parentInteractionId: first.id });
assert(reply.parentInteractionId === first.id, 'a reply carries parentInteractionId');
// 5) listThreads shows the caller's threads with membership + last message.
const mine = await json(alice, '/v1/threads');
const g = mine.find((t) => t.threadId === grp.threadId);
assert(g && g.membership === 'group' && g.participantCount === 3, 'GET /v1/threads lists the group with membership + count');
assert(g.lastMessage === 'answer', 'thread summary carries the last message');
console.log('\nv1.1 membership + replies smoke: PASS');
process.exit(0);
@@ -0,0 +1,55 @@
// P9 scaling smoke: cross-instance realtime fan-out via the socket.io Redis adapter.
// Alice connects to instance A, Bob to instance B (two separate processes sharing one
// Redis + DB). Alice sends on A; Bob must receive it live on B — which only works if the
// Redis adapter fans room emits across replicas. Set A_URL / B_URL (default 3201/3202).
import 'dotenv/config';
import { io } from 'socket.io-client';
import jwt from 'jsonwebtoken';
const A_URL = process.env.A_URL ?? 'http://localhost:3201';
const B_URL = process.env.B_URL ?? 'http://localhost:3202';
const APP_ID = 'portal-demo';
const SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
const sign = (userId) =>
jwt.sign({ sub: userId, name: userId, appId: APP_ID, orgId: `org_${APP_ID}` }, SECRET, { algorithm: 'HS256', expiresIn: '1h' });
const connect = (url, userId) =>
io(`${url}/message`, { auth: { token: sign(userId) }, transports: ['websocket'], forceNew: true });
const once = (socket, event, ms = 8000) =>
new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(`timeout waiting for "${event}"`)), ms);
socket.once(event, (v) => { clearTimeout(t); res(v); });
});
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
const alice = connect(A_URL, 'alice-cluster');
const bob = connect(B_URL, 'bob-cluster');
try {
await Promise.all([once(alice, 'connect'), once(bob, 'connect')]);
assert(true, `Alice connected to A (${A_URL}), Bob to B (${B_URL})`);
// Alice opens a thread on instance A; membership is governed so Alice ADDS Bob, who
// then opens the SAME thread on instance B.
const opened = await alice.emitWithAck('open_thread', {});
const threadId = opened.threadId;
assert(!!threadId, `Alice opened thread ${threadId} on instance A`);
await alice.emitWithAck('add_participant', { threadId, userId: 'bob-cluster' });
await bob.emitWithAck('open_thread', { threadId });
assert(true, 'Bob joined the same thread on instance B');
// Alice sends on A → Bob must receive it on B (cross-instance fan-out via Redis).
const bobReceives = once(bob, 'message');
await alice.emitWithAck('send_message', { threadId, content: 'hello across instances' });
const received = await bobReceives;
assert(received.content === 'hello across instances', `Bob received "${received.content}" on B — emitted from A ✓`);
console.log('\nP9 cross-instance realtime smoke: PASS');
} catch (err) {
console.error('✗', err.message);
process.exit(1);
} finally {
alice.close();
bob.close();
}
process.exit(0);
@@ -44,10 +44,11 @@ const alice = connect('alice');
const bob = connect('bob'); const bob = connect('bob');
try { try {
// Alice creates a thread; Bob joins it. // Alice creates a thread; membership is governed, so Alice ADDS Bob (he can't self-join).
const opened = await alice.emitWithAck('open_thread', {}); const opened = await alice.emitWithAck('open_thread', {});
const threadId = opened.threadId; const threadId = opened.threadId;
assert(!!threadId, `Alice created thread ${threadId}`); assert(!!threadId, `Alice created thread ${threadId}`);
await alice.emitWithAck('add_participant', { threadId, userId: 'bob' });
await bob.emitWithAck('open_thread', { threadId }); await bob.emitWithAck('open_thread', { threadId });
// Alice sends; Bob should receive it live. // Alice sends; Bob should receive it live.
@@ -0,0 +1,51 @@
// P9 slice-9 retention smoke: an aged interaction's snapshot passes its delete window
// and the sweep redacts the content in place (never a hard delete), visible as a
// REDACTED snapshot on /metrics. Requires the service running with IIOS_DEV_TOKENS=1
// and IIOS_RETENTION_DELETE_DAYS=0 IIOS_RETENTION_ARCHIVE_DAYS=0 (new content due at once;
// scheduled interval left OFF so only the explicit dev sweep runs).
import 'dotenv/config';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const APP_ID = 'portal-demo';
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function devToken(userId, orgId) {
const r = await fetch(`${SERVICE}/v1/dev/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }),
});
if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`);
return (await r.json()).token;
}
async function api(token, path, method = 'GET', body) {
const r = await fetch(`${SERVICE}${path}`, {
method,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
return r.json();
}
const redactedCount = (m) => m.retention?.byStatus?.REDACTED ?? 0;
const token = await devToken('ret-a', 'org_ret_A');
const base = redactedCount(await api(token, '/metrics'));
console.log(`baseline REDACTED snapshots = ${base}`);
// Inject content and give the relay a moment to normalize it into an interaction.
await api(token, '/v1/dev/webhook/WEBHOOK', 'POST', { text: 'retention smoke content' });
await sleep(2000);
// Sweep: 0-day windows make every interaction's snapshot immediately due → redacted.
const swept = await api(token, '/v1/dev/retention/sweep', 'POST');
assert(swept.redacted >= 1, `sweep redacted the aged interaction(s) (redacted=${swept.redacted})`);
const after = await api(token, '/metrics');
assert(redactedCount(after) > base, `/metrics shows more REDACTED snapshots (${base}${redactedCount(after)})`);
assert((after.retention?.total ?? 0) >= 1, 'retention.total reflects captured snapshots');
console.log('\nP9 retention smoke: PASS');
process.exit(0);
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { ScopeVector } from '@insignia/iios-contracts'; import type { ScopeVector } from '@insignia/iios-contracts';
import { WebhookAdapter, type ChannelAdapter } from '@insignia/iios-adapter-sdk'; import { WebhookAdapter, EmailAdapter, type ChannelAdapter } from '@insignia/iios-adapter-sdk';
export interface AdapterConfig { export interface AdapterConfig {
secret: string; secret: string;
@@ -8,10 +8,9 @@ export interface AdapterConfig {
} }
/** /**
* Registers channel adapters + their per-channel config (secret + scope). In P5 * Registers channel adapters + their per-channel config (secret + scope). EMAIL uses
* one reference WebhookAdapter backs several channelTypes (WEBHOOK/EMAIL/WHATSAPP) * the email-native EmailAdapter (RFC threading); the rest use the reference
* so the fixtures exercise the same anti-corruption pipeline. Secrets come from * WebhookAdapter. Secrets come from ADAPTER_SECRETS (JSON map); scope defaults to demo.
* ADAPTER_SECRETS (JSON map); scope defaults to the demo scope.
*/ */
@Injectable() @Injectable()
export class AdapterRegistry { export class AdapterRegistry {
@@ -29,8 +28,9 @@ export class AdapterRegistry {
appId: process.env.ADAPTER_APP ?? 'portal-demo', appId: process.env.ADAPTER_APP ?? 'portal-demo',
}; };
for (const channelType of ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']) { for (const channelType of ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']) {
const adapter: ChannelAdapter = channelType === 'EMAIL' ? new EmailAdapter() : new WebhookAdapter(channelType);
this.adapters.set(channelType, { this.adapters.set(channelType, {
adapter: new WebhookAdapter(channelType), adapter,
config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope }, config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope },
}); });
} }
@@ -1,14 +1,16 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { ConflictException } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { DlqService } from '../outbox/dlq.service'; import { DlqService } from '../outbox/dlq.service';
import { resetDb } from '../test-utils/reset-db'; import { resetDb } from '../test-utils/reset-db';
import { ProjectionCursorService } from '../projection/projection-cursor.service'; import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { makeFakePorts } from '@insignia/iios-testkit'; import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts'; import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { signedFixture, emailFixture } from '@insignia/iios-adapter-sdk'; import { signedFixture, emailFixture, emailInboundFixture, emailReplyFixture } from '@insignia/iios-adapter-sdk';
import { AdapterRegistry } from './adapter.registry'; import { AdapterRegistry } from './adapter.registry';
import { InboundService } from './inbound.service'; import { InboundService } from './inbound.service';
import { OutboundService } from './outbound.service'; import { OutboundService } from './outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry'; import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { RawEventProjector } from './raw-event.projector'; import { RawEventProjector } from './raw-event.projector';
@@ -79,9 +81,60 @@ describe('Adapter inbound (P5)', () => {
}); });
}); });
describe('Email adapter — inbound + threading', () => {
const receiveEmail = async (payload: object) => {
const { body, headers } = signedFixture(payload, SECRET);
return inbound().receive('EMAIL', body, headers);
};
const normalizeAll = async () => {
for (const ev of await rawReceivedEvents()) await projector().onRawReceived(ev);
};
it('a signed inbound email → a normalized interaction with the body on the email thread', async () => {
const ack = await receiveEmail(emailInboundFixture);
expect(ack.status).toBe('RECEIVED');
await normalizeAll();
const raw = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
expect(raw.status).toBe('NORMALIZED');
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: raw.interactionId! }, include: { parts: true, thread: true } });
expect(interaction.parts[0]?.bodyText).toBe(emailInboundFixture.text);
expect(interaction.thread?.externalThreadRef).toBe('email:<m1@example.com>');
});
it('a reply email (In-Reply-To) lands on the SAME email thread as the original', async () => {
await receiveEmail(emailInboundFixture);
await receiveEmail(emailReplyFixture);
await normalizeAll();
const interactions = await prisma.iiosInteraction.findMany({ orderBy: { occurredAt: 'asc' } });
expect(interactions).toHaveLength(2);
expect(interactions[0]!.threadId).toBe(interactions[1]!.threadId); // reply joined the thread
expect(await prisma.iiosThread.count()).toBe(1); // one email conversation
});
it('redelivered email (same Message-ID) → one raw event, one interaction (dedup)', async () => {
const svc = inbound();
const { body, headers } = signedFixture(emailInboundFixture, SECRET);
await svc.receive('EMAIL', body, headers);
const second = await svc.receive('EMAIL', body, headers);
expect(second.deduped).toBe(true);
await normalizeAll();
expect(await prisma.iiosInboundRawEvent.count()).toBe(1);
expect(await prisma.iiosInteraction.count()).toBe(1);
});
it('bad signature on EMAIL → REJECTED, nothing ingested (fail-closed)', async () => {
const { body } = signedFixture(emailInboundFixture, SECRET);
await expect(inbound().receive('EMAIL', body, { 'x-iios-signature': 'sha256=bad' })).rejects.toThrow();
expect(await prisma.iiosInboundRawEvent.count({ where: { status: 'REJECTED' } })).toBe(1);
expect(await prisma.iiosInteraction.count()).toBe(0);
});
});
describe('Adapter outbound (P5)', () => { describe('Adapter outbound (P5)', () => {
const outbound = (limit?: number): OutboundService => { const outbound = (limit?: number): OutboundService => {
const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())); const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
if (limit) s.limit = limit; if (limit) s.limit = limit;
return s; return s;
}; };
@@ -112,7 +165,7 @@ describe('Adapter outbound (P5)', () => {
it('broker obligation DENY_OUTBOUND → command BLOCKED, no send (P9)', async () => { it('broker obligation DENY_OUTBOUND → command BLOCKED, no send (P9)', async () => {
const ports = makeFakePorts(); const ports = makeFakePorts();
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'recipient opted out' }] }); ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'recipient opted out' }] });
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry())); const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'blk-1'); const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'blk-1');
expect(cmd.status).toBe('BLOCKED'); expect(cmd.status).toBe('BLOCKED');
expect(cmd.providerRef).toBe('blocked'); expect(cmd.providerRef).toBe('blocked');
@@ -121,7 +174,7 @@ describe('Adapter outbound (P5)', () => {
it('CMP allow → command records the consent receipt (P9 slice 2)', async () => { it('CMP allow → command records the consent receipt (P9 slice 2)', async () => {
const ports = makeFakePorts(); const ports = makeFakePorts();
ports.cmp.setStatus('ALLOW'); ports.cmp.setStatus('ALLOW');
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry())); const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support'); const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support');
expect(cmd.status).toBe('SENT'); expect(cmd.status).toBe('SENT');
expect(cmd.consentReceiptRef).toBe('fake-receipt'); expect(cmd.consentReceiptRef).toBe('fake-receipt');
@@ -130,7 +183,7 @@ describe('Adapter outbound (P5)', () => {
it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => { it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => {
const ports = makeFakePorts(); const ports = makeFakePorts();
ports.cmp.denyPurpose('marketing'); ports.cmp.denyPurpose('marketing');
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry())); const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing'); const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing');
expect(cmd.status).toBe('BLOCKED'); expect(cmd.status).toBe('BLOCKED');
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0); expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
@@ -146,4 +199,18 @@ describe('Adapter outbound (P5)', () => {
expect(a2.status).toBe('RATE_LIMITED'); expect(a2.status).toBe('RATE_LIMITED');
expect(b1.status).toBe('SENT'); expect(b1.status).toBe('SENT');
}); });
it('idempotency ledger: same key + a different target → conflict, no second command (P9 slice 10)', async () => {
const svc = outbound();
const first = await svc.send('WEBHOOK', 'user@a', { text: 'hi' }, 'dup-key');
expect(first.status).toBe('SENT');
await expect(svc.send('WEBHOOK', 'user@b', { text: 'hi' }, 'dup-key')).rejects.toBeInstanceOf(ConflictException);
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'dup-key' } })).toBe(1);
});
it('opt-in: a keyless send creates a command but writes no idempotency-ledger row (P9 slice 10)', async () => {
const cmd = await outbound().send('WEBHOOK', 'user@x', { text: 'hi' });
expect(cmd.status).toBe('SENT');
expect(await prisma.iiosIdempotencyCommand.count()).toBe(0);
});
}); });
@@ -28,7 +28,8 @@ export class InboundService {
const { adapter, config } = reg; const { adapter, config } = reg;
const payload = this.safeParse(rawBody); const payload = this.safeParse(rawBody);
const externalEventId = typeof payload?.eventId === 'string' ? payload.eventId : undefined; // The adapter extracts its provider-native id (eventId, Message-ID, …) for replay dedup.
const externalEventId = adapter.externalEventId?.(payload) ?? (typeof payload?.eventId === 'string' ? payload.eventId : undefined);
// Fail-closed: a bad/absent signature is recorded and rejected — never ingested. // Fail-closed: a bad/absent signature is recorded and rejected — never ingested.
if (!adapter.verifySignature(rawBody, headers, config.secret)) { if (!adapter.verifySignature(rawBody, headers, config.secret)) {
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityBroker } from '../capability/capability.broker';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { recordAudit } from '../observability/audit'; import { recordAudit } from '../observability/audit';
/** /**
@@ -23,55 +24,67 @@ export class OutboundService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly broker: CapabilityBroker, private readonly broker: CapabilityBroker,
private readonly idempotency: IdempotencyService,
) {} ) {}
async send( async send(
channelType: string, channelType: string,
target: string, target: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,
idempotencyKey: string = randomUUID(), idempotencyKey?: string,
scopeId?: string, scopeId?: string,
purpose?: string, purpose?: string,
) { ) {
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } }); const key = idempotencyKey ?? randomUUID();
if (existing) return existing;
// The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried
// command returns the existing row instead of colliding on idempotencyKey @unique.
const body = async () => {
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey: key } });
if (existing) return existing;
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key },
});
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
return cmd;
}
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
const cmd = await this.prisma.iiosOutboundCommand.create({ const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey }, data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key },
}); });
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
return cmd;
}
const cmd = await this.prisma.iiosOutboundCommand.create({ let result;
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey }, try {
}); result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey: key, scopeId, purpose });
} catch (err) {
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
await this.prisma.$transaction([
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }),
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }),
]);
throw err;
}
let result; const [updated] = await this.prisma.$transaction([
try { this.prisma.iiosOutboundCommand.update({
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId, purpose }); where: { id: cmd.id },
} catch (err) { data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate. }),
await this.prisma.$transaction([ this.prisma.iiosDeliveryAttempt.create({
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }), data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }), }),
]); ]);
throw err; await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
} return updated;
};
const [updated] = await this.prisma.$transaction([ // Opt-in idempotency: an explicit key routes through the ledger (request-hash conflict → 409,
this.prisma.iiosOutboundCommand.update({ // response replay); a keyless send just runs with a fresh uuid (old behavior).
where: { id: cmd.id }, if (!idempotencyKey) return body();
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef }, return this.idempotency.run({ scopeId, commandName: 'channel.send', key: idempotencyKey, request: { channelType, target, payload, purpose } }, body);
}),
this.prisma.iiosDeliveryAttempt.create({
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
}),
]);
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
return updated;
} }
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */ /** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
+6
View File
@@ -9,6 +9,8 @@ import { OutboxModule } from './outbox/outbox.module';
import { ThreadsModule } from './threads/threads.module'; import { ThreadsModule } from './threads/threads.module';
import { MessageModule } from './messaging/message.module'; import { MessageModule } from './messaging/message.module';
import { InboxModule } from './inbox/inbox.module'; import { InboxModule } from './inbox/inbox.module';
import { MediaModule } from './media/media.module';
import { NotificationModule } from './notifications/notification.module';
import { SupportModule } from './support/support.module'; import { SupportModule } from './support/support.module';
import { AdaptersModule } from './adapters/adapters.module'; import { AdaptersModule } from './adapters/adapters.module';
import { RoutingModule } from './routing/routing.module'; import { RoutingModule } from './routing/routing.module';
@@ -16,6 +18,7 @@ import { AiModule } from './ai/ai.module';
import { CalendarModule } from './calendar/calendar.module'; import { CalendarModule } from './calendar/calendar.module';
import { CapabilityModule } from './capability/capability.module'; import { CapabilityModule } from './capability/capability.module';
import { DsrModule } from './dsr/dsr.module'; import { DsrModule } from './dsr/dsr.module';
import { RetentionModule } from './retention/retention.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { MetricsController } from './observability/metrics.controller'; import { MetricsController } from './observability/metrics.controller';
import { DevController } from './dev/dev.controller'; import { DevController } from './dev/dev.controller';
@@ -32,6 +35,8 @@ import { DevController } from './dev/dev.controller';
ThreadsModule, ThreadsModule,
MessageModule, MessageModule,
InboxModule, InboxModule,
MediaModule,
NotificationModule,
SupportModule, SupportModule,
AdaptersModule, AdaptersModule,
RoutingModule, RoutingModule,
@@ -39,6 +44,7 @@ import { DevController } from './dev/dev.controller';
CalendarModule, CalendarModule,
CapabilityModule, CapabilityModule,
DsrModule, DsrModule,
RetentionModule,
], ],
controllers: [HealthController, MetricsController, DevController], controllers: [HealthController, MetricsController, DevController],
}) })
@@ -7,6 +7,7 @@ import { ActorResolver } from '../identity/actor.resolver';
import { AiJobService } from '../ai/ai.service'; import { AiJobService } from '../ai/ai.service';
import { AiBudgetGuard } from '../ai/ai-budget.guard'; import { AiBudgetGuard } from '../ai/ai-budget.guard';
import { OutboundService } from '../adapters/outbound.service'; import { OutboundService } from '../adapters/outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry'; import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
@@ -22,7 +23,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
const START = '2026-07-02T10:00:00.000Z'; const START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()))); const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)));
async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) { async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) {
const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees }); const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees });
@@ -11,6 +11,7 @@ import { ActorResolver } from '../identity/actor.resolver';
import { AiJobService } from '../ai/ai.service'; import { AiJobService } from '../ai/ai.service';
import { AiBudgetGuard } from '../ai/ai-budget.guard'; import { AiBudgetGuard } from '../ai/ai-budget.guard';
import { OutboundService } from '../adapters/outbound.service'; import { OutboundService } from '../adapters/outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry'; import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
@@ -27,7 +28,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
const START = '2026-07-02T10:00:00.000Z'; const START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()))); const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)));
async function makeInteraction(text: string): Promise<string> { async function makeInteraction(text: string): Promise<string> {
const m = new MessageService(asService, makeFakePorts(), actors); const m = new MessageService(asService, makeFakePorts(), actors);
@@ -9,6 +9,7 @@ import { ActorResolver } from '../identity/actor.resolver';
import { AiJobService } from '../ai/ai.service'; import { AiJobService } from '../ai/ai.service';
import { AiBudgetGuard } from '../ai/ai-budget.guard'; import { AiBudgetGuard } from '../ai/ai-budget.guard';
import { OutboundService } from '../adapters/outbound.service'; import { OutboundService } from '../adapters/outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry'; import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
@@ -24,7 +25,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
const START = '2026-07-02T10:00:00.000Z'; const START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())); const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), outbound()); const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), outbound());
async function makeInteraction(text: string): Promise<string> { async function makeInteraction(text: string): Promise<string> {
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import type { CapabilityProvider } from '@insignia/iios-contracts'; import type { CapabilityProvider } from '@insignia/iios-contracts';
import { SandboxProvider } from './sandbox.provider'; import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider'; import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']; const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
@@ -19,7 +20,9 @@ export class CapabilityProviderRegistry {
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch])); for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
for (const ch of DEFAULT_CHANNELS) { for (const ch of DEFAULT_CHANNELS) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`]; const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
if (url) this.register(new HttpProvider(ch, url)); if (!url) continue;
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
} }
} }
@@ -0,0 +1,40 @@
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
interface EmailPayload {
subject?: string;
text?: string;
html?: string;
inReplyTo?: string;
}
/**
* Email egress provider: shapes the outbound command into an email envelope
* ({to, subject, text, html, inReplyTo}) and POSTs it to a configured send endpoint
* (a vendor mail API / SMTP relay behind a URL). Activated only when
* IIOS_PROVIDER_URL_EMAIL is set; a transport failure is surfaced as FAILED, never thrown.
*/
export class EmailProvider implements CapabilityProvider {
readonly name = 'email-http';
readonly channelTypes = ['EMAIL'];
readonly capabilities = { canSend: true };
constructor(private readonly url: string) {}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
const p = (req.payload ?? {}) as EmailPayload;
const email = { to: req.target, subject: p.subject ?? '(no subject)', text: p.text, html: p.html, inReplyTo: p.inReplyTo };
try {
const res = await fetch(this.url, {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': req.idempotencyKey },
body: JSON.stringify(email),
});
const latencyMs = Date.now() - started;
if (!res.ok) return { providerRef: `email-${res.status}`, outcome: 'FAILED', errorCode: `HTTP_${res.status}`, latencyMs };
return { providerRef: `email-${req.idempotencyKey}`, outcome: 'SENT', latencyMs };
} catch (err) {
return { providerRef: 'email-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
}
}
}
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { BadRequestException, Body, Controller, ForbiddenException, Headers, Param, Post } from '@nestjs/common'; import { BadRequestException, Body, Controller, ForbiddenException, Get, Headers, Param, Post, UnauthorizedException } from '@nestjs/common';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { IIOS_EVENTS } from '@insignia/iios-contracts'; import { IIOS_EVENTS } from '@insignia/iios-contracts';
import { hmacSign } from '@insignia/iios-adapter-sdk'; import { hmacSign } from '@insignia/iios-adapter-sdk';
@@ -8,6 +8,7 @@ import { AdapterRegistry } from '../adapters/adapter.registry';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import { RetentionService } from '../retention/retention.service';
/** /**
* Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production. * Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production.
@@ -20,25 +21,64 @@ export class DevController {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly actors: ActorResolver, private readonly actors: ActorResolver,
private readonly session: SessionVerifier, private readonly session: SessionVerifier,
private readonly retention: RetentionService,
) {} ) {}
@Post('token') @Post('token')
token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } { token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } {
this.assertDev(); this.assertDev();
return { token: this.signToken(body.appId, body.userId, body.name, body.orgId) };
}
/**
* Dev IdP: a credentialed login that mints the SAME JWT claims a real IdP / Session
* Broker would issue (sub/name/appId/orgId). Credentials come from DEV_USERS (JSON
* `{username: password}`; defaults to a few demo users). SessionVerifier is the stable
* verify seam — swapping in a real IdP means issuing these same claims, nothing else.
*/
@Post('login')
login(@Body() body: { username: string; password: string; appId?: string }): { token: string; userId: string } {
this.assertDev();
const appId = body.appId ?? 'portal-demo';
let users: Record<string, string>;
try {
users = JSON.parse(process.env.DEV_USERS ?? '{"alice":"alice","bob":"bob","carol":"carol"}');
} catch {
users = {};
}
const expected = users[body.username];
if (expected === undefined || expected !== body.password) throw new UnauthorizedException('invalid username or password');
return { token: this.signToken(appId, body.username, body.username), userId: body.username };
}
/** Dev directory: the known dev usernames (from DEV_USERS). A real app validates
* add-by-username against its user store / the MDM port; this stands in for it. */
@Get('users')
users(): { users: string[] } {
this.assertDev();
let map: Record<string, string>;
try {
map = JSON.parse(process.env.DEV_USERS ?? '{"alice":"alice","bob":"bob","carol":"carol"}');
} catch {
map = {};
}
return { users: Object.keys(map) };
}
private signToken(appId: string, userId: string, name?: string, orgId?: string): string {
let secrets: Record<string, string>; let secrets: Record<string, string>;
try { try {
secrets = JSON.parse(process.env.APP_SECRETS ?? '{}'); secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
} catch { } catch {
secrets = {}; secrets = {};
} }
const secret = secrets[body.appId]; const secret = secrets[appId];
if (!secret) throw new BadRequestException(`unknown app: ${body.appId}`); if (!secret) throw new BadRequestException(`unknown app: ${appId}`);
const token = jwt.sign( return jwt.sign(
{ sub: body.userId, name: body.name ?? body.userId, appId: body.appId, orgId: body.orgId ?? `org_${body.appId}` }, { sub: userId, name: name ?? userId, appId, orgId: orgId ?? `org_${appId}` },
secret, secret,
{ algorithm: 'HS256', expiresIn: '2h' }, { algorithm: 'HS256', expiresIn: '2h' },
); );
return { token };
} }
/** Server signs a sample provider payload and feeds it to the inbound pipeline. */ /** Server signs a sample provider payload and feeds it to the inbound pipeline. */
@@ -96,6 +136,13 @@ export class DevController {
return { eventId: id, scopeId: scope.id }; return { eventId: id, scopeId: scope.id };
} }
/** Run the retention sweep on demand (drives the retention smoke). */
@Post('retention/sweep')
async retentionSweep() {
this.assertDev();
return this.retention.sweep();
}
private principal(authorization?: string): MessagePrincipal { private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, ''); const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required'); if (!token) throw new BadRequestException('Authorization bearer token is required');
@@ -68,10 +68,10 @@ export class ActorResolver {
); );
} }
async ensureParticipant(threadId: string, actorId: string): Promise<void> { async ensureParticipant(threadId: string, actorId: string, participantRole = 'MEMBER'): Promise<void> {
await this.prisma.iiosThreadParticipant.upsert({ await this.prisma.iiosThreadParticipant.upsert({
where: { threadId_actorId: { threadId, actorId } }, where: { threadId_actorId: { threadId, actorId } },
create: { threadId, actorId }, create: { threadId, actorId, participantRole },
update: {}, update: {},
}); });
} }
@@ -10,6 +10,7 @@ interface MessageSentData {
interactionId: string; interactionId: string;
threadId: string; threadId: string;
senderActorId: string; senderActorId: string;
mentions?: string[]; // opaque app notify-list (userIds); the kernel never parses "@"
} }
interface MessageReadData { interface MessageReadData {
threadId: string; threadId: string;
@@ -57,6 +58,60 @@ export class InboxProjector implements OnModuleInit {
if (p.actorId === data.senderActorId) continue; if (p.actorId === data.senderActorId) continue;
await this.upsertNeedsReply(thread.scopeId, p.actorId, data.threadId, data.interactionId, traceId, thread.subject); await this.upsertNeedsReply(thread.scopeId, p.actorId, data.threadId, data.interactionId, traceId, thread.subject);
} }
// Targeted mention notifications from the app-supplied opaque notify-list.
const mentions = data.mentions ?? [];
if (mentions.length > 0) {
const src = await this.prisma.iiosInteraction.findUnique({
where: { id: data.interactionId },
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
});
const senderName = src?.actor?.sourceHandle?.externalId ?? 'Someone';
const text = src?.parts[0]?.bodyText ?? undefined;
for (const userId of mentions) {
await this.createMention(thread.scopeId, userId, data.threadId, data.interactionId, data.senderActorId, senderName, text, traceId);
}
}
}
/** One MENTION item per (owner, source message) for a mentioned participant (never the sender). */
private async createMention(
scopeId: string,
userId: string,
threadId: string,
sourceInteractionId: string,
senderActorId: string,
senderName: string,
summary: string | undefined,
traceId: string | undefined,
): Promise<void> {
const handle = await this.prisma.iiosSourceHandle.findUnique({
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: userId } },
});
if (!handle) return;
const actor = await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } });
if (!actor || actor.id === senderActorId) return; // resolvable, and never notify the sender
const isMember = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } });
if (!isMember) return; // only thread participants get mention items
const existing = await this.prisma.iiosInboxItem.findFirst({ where: { ownerActorId: actor.id, sourceInteractionId, kind: 'MENTION' } });
if (existing) return; // idempotent per source message
const item = await this.prisma.iiosInboxItem.create({
data: {
scopeId,
ownerActorId: actor.id,
kind: 'MENTION',
title: `${senderName} mentioned you`,
summary,
priority: 'HIGH',
threadId,
sourceInteractionId,
traceId,
},
});
await this.prisma.iiosInboxItemStateHistory.create({
data: { inboxItemId: item.id, toState: 'OPEN', reasonCode: 'mentioned' },
});
} }
async onMessageRead(event: CloudEvent): Promise<void> { async onMessageRead(event: CloudEvent): Promise<void> {
@@ -67,21 +122,24 @@ export class InboxProjector implements OnModuleInit {
private async applyMessageRead(event: CloudEvent): Promise<void> { private async applyMessageRead(event: CloudEvent): Promise<void> {
const data = event.data as MessageReadData; const data = event.data as MessageReadData;
const item = await this.prisma.iiosInboxItem.findFirst({ // Reading the thread clears the reader's activity AND mention items for it.
const items = await this.prisma.iiosInboxItem.findMany({
where: { where: {
threadId: data.threadId, threadId: data.threadId,
ownerActorId: data.actorId, ownerActorId: data.actorId,
kind: 'NEEDS_REPLY', kind: { in: ['NEEDS_REPLY', 'MENTION'] },
state: { in: ['OPEN', 'SNOOZED'] }, state: { in: ['OPEN', 'SNOOZED'] },
}, },
}); });
if (!item) return; if (items.length === 0) return;
await this.prisma.$transaction([ await this.prisma.$transaction(
this.prisma.iiosInboxItem.update({ where: { id: item.id }, data: { state: 'DONE' } }), items.flatMap((item) => [
this.prisma.iiosInboxItemStateHistory.create({ this.prisma.iiosInboxItem.update({ where: { id: item.id }, data: { state: 'DONE' } }),
data: { inboxItemId: item.id, fromState: item.state, toState: 'DONE', reasonCode: 'message_read' }, this.prisma.iiosInboxItemStateHistory.create({
}), data: { inboxItemId: item.id, fromState: item.state, toState: 'DONE', reasonCode: 'message_read' },
]); }),
]),
);
} }
private async upsertNeedsReply( private async upsertNeedsReply(
@@ -88,6 +88,38 @@ describe('InboxProjector (P3 work surface)', () => {
expect(await itemsFor('bob')).toHaveLength(1); expect(await itemsFor('bob')).toHaveLength(1);
}); });
it('message.sent with mentions → a MENTION item for the mentioned participant only', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await m.addParticipant(threadId, alice, 'carol');
await m.send(threadId, alice, { content: 'hey @bob look' }, 'k1', undefined, undefined, ['bob']);
await projector().onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
const bobMentions = (await itemsFor('bob')).filter((i) => i.kind === 'MENTION');
expect(bobMentions).toHaveLength(1);
expect(bobMentions[0]?.state).toBe('OPEN');
expect(bobMentions[0]?.sourceInteractionId).toBeTruthy();
expect((await itemsFor('carol')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // not mentioned
expect((await itemsFor('alice')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // never the sender
});
it('reading the thread resolves the readers MENTION item to DONE', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
const sent = await m.send(threadId, alice, { content: '@bob ping' }, 'k1', undefined, undefined, ['bob']);
const proj = projector();
await proj.onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('OPEN');
await m.markRead(threadId, bob, sent.id);
await proj.onMessageRead((await eventsOf(IIOS_EVENTS.messageRead))[0]!);
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('DONE');
});
it('message.read → the recipient item goes DONE', async () => { it('message.read → the recipient item goes DONE', async () => {
const m = ms(); const m = ms();
const { threadId } = await m.openThread(null, alice); const { threadId } = await m.openThread(null, alice);
+2 -1
View File
@@ -12,6 +12,7 @@ import { AiJobService } from './ai/ai.service';
import { AiBudgetGuard } from './ai/ai-budget.guard'; import { AiBudgetGuard } from './ai/ai-budget.guard';
import { CalendarService } from './calendar/calendar.service'; import { CalendarService } from './calendar/calendar.service';
import { OutboundService } from './adapters/outbound.service'; import { OutboundService } from './adapters/outbound.service';
import { IdempotencyService } from './idempotency/idempotency.service';
import { CapabilityBroker } from './capability/capability.broker'; import { CapabilityBroker } from './capability/capability.broker';
import { CapabilityProviderRegistry } from './capability/capability.registry'; import { CapabilityProviderRegistry } from './capability/capability.registry';
import type { PrismaService } from './prisma/prisma.service'; import type { PrismaService } from './prisma/prisma.service';
@@ -27,7 +28,7 @@ const B: MessagePrincipal = { userId: 'b1', orgId: 'org_B', appId: 'portal-demo'
const START = '2026-07-02T10:00:00.000Z'; const START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())); const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors); const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), outbound()); const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), outbound());
+13
View File
@@ -1,9 +1,12 @@
import './observability/tracing'; // MUST be first — auto-instrumentation patches modules on require
import 'dotenv/config'; import 'dotenv/config';
import 'reflect-metadata'; import 'reflect-metadata';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { traceMiddleware } from './observability/trace.middleware'; import { traceMiddleware } from './observability/trace.middleware';
import { RedisIoAdapter } from './realtime/redis-io.adapter';
import { PolicyDeniedFilter } from './platform/policy-denied.filter';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { rawBody: true }); const app = await NestFactory.create(AppModule, { rawBody: true });
@@ -11,7 +14,17 @@ async function bootstrap(): Promise<void> {
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
); );
app.useGlobalFilters(new PolicyDeniedFilter()); // fail-closed policy denials → 403
app.enableCors({ origin: true, credentials: true }); app.enableCors({ origin: true, credentials: true });
// Multi-replica realtime: fan socket.io room emits across instances via Redis.
// Opt-in — without REDIS_URL the default in-memory adapter is used (single instance).
if (process.env.REDIS_URL) {
const redisAdapter = new RedisIoAdapter(app, process.env.REDIS_URL);
await redisAdapter.connect();
app.useWebSocketAdapter(redisAdapter);
}
const port = Number(process.env.PORT ?? 3200); const port = Number(process.env.PORT ?? 3200);
await app.listen(port); await app.listen(port);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -0,0 +1,49 @@
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { Injectable } from '@nestjs/common';
import type { StoragePort } from './storage.port';
/**
* Dev storage: bytes on local disk under MEDIA_DIR, with a sidecar `.meta.json`
* holding the mime/size/checksum. Object keys may contain a scope subdirectory.
* A drop-in for an S3/Supabase adapter in prod.
*/
@Injectable()
export class LocalDiskStorage implements StoragePort {
private readonly root = process.env.MEDIA_DIR?.trim() || path.join(os.tmpdir(), 'iios-media');
private full(objectKey: string): string {
// Prevent path traversal: keep everything under root.
const p = path.normalize(path.join(this.root, objectKey));
if (!p.startsWith(path.normalize(this.root))) throw new Error('invalid object key');
return p;
}
async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> {
const file = this.full(objectKey);
await fs.mkdir(path.dirname(file), { recursive: true });
const checksumSha256 = createHash('sha256').update(data).digest('hex');
await fs.writeFile(file, data);
await fs.writeFile(`${file}.meta.json`, JSON.stringify({ mime, sizeBytes: data.length, checksumSha256 }));
return { sizeBytes: data.length, checksumSha256 };
}
async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> {
const file = this.full(objectKey);
try {
const data = await fs.readFile(file);
const meta = JSON.parse(await fs.readFile(`${file}.meta.json`, 'utf8')) as { mime: string };
return { data, mime: meta.mime || 'application/octet-stream', sizeBytes: data.length };
} catch {
return null;
}
}
async remove(objectKey: string): Promise<void> {
const file = this.full(objectKey);
await fs.rm(file, { force: true });
await fs.rm(`${file}.meta.json`, { force: true });
}
}
@@ -0,0 +1,61 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Put, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { MediaService } from './media.service';
import { SessionVerifier } from '../platform/session.verifier';
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
import type { MessagePrincipal } from '../identity/actor.resolver';
@Controller('v1/media')
export class MediaController {
constructor(
private readonly media: MediaService,
private readonly session: SessionVerifier,
) {}
/** Authorize an upload → short-lived signed PUT url + object key. */
@Post('presign-upload')
async presignUpload(@Body() body: PresignUploadDto, @Headers('authorization') auth?: string) {
return this.media.presignUpload(this.principal(auth), { mime: body.mime, sizeBytes: body.sizeBytes });
}
/** Authorize a download → short-lived signed GET url. */
@Post('presign-download')
async presignDownload(@Body() body: PresignDownloadDto, @Headers('authorization') auth?: string) {
return this.media.presignDownload(this.principal(auth), body.contentRef, body.mime);
}
/** The presigned PUT target — token IS the auth. Reads the raw binary body stream. */
@Put('upload/:token')
async upload(@Param('token') token: string, @Req() req: Request) {
const data = await readBody(req);
if (data.length === 0) throw new BadRequestException('empty upload body');
return this.media.put(token, data);
}
/** The presigned GET target — token IS the auth. Streams the bytes with their mime. */
@Get('blob/:token')
async blob(@Param('token') token: string, @Res() res: Response) {
const { data, mime } = await this.media.get(token);
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.send(data);
}
private principal(auth?: string): MessagePrincipal {
const token = (auth ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
/** Collect a request body stream into a Buffer (binary media upload; no body parser touches it). */
function readBody(req: Request): Promise<Buffer> {
const raw = (req as Request & { rawBody?: Buffer }).rawBody;
if (raw && raw.length) return Promise.resolve(raw);
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
@@ -0,0 +1,11 @@
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PresignUploadDto {
@IsString() mime!: string;
@IsInt() @Min(1) @Max(26 * 1024 * 1024) sizeBytes!: number;
}
export class PresignDownloadDto {
@IsString() contentRef!: string;
@IsOptional() @IsString() mime?: string;
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { IdentityModule } from '../identity/identity.module';
import { MediaController } from './media.controller';
import { MediaService } from './media.service';
import { LocalDiskStorage } from './local-disk.storage';
import { STORAGE_PORT } from './storage.port';
@Module({
imports: [PlatformModule, IdentityModule],
controllers: [MediaController],
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter.
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }],
})
export class MediaModule {}
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { DevOpaPort } from '../platform/dev-opa.port';
import { LocalDiskStorage } from './local-disk.storage';
import { MediaService } from './media.service';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService));
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
// point storage at an isolated temp dir for the test run
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
function tokenFrom(url: string): string {
return url.split('/').pop()!;
}
describe('MediaService (presigned local storage)', () => {
it('presign → PUT → presign-download → GET round-trips the bytes with mime + checksum', async () => {
const s = svc();
const bytes = Buffer.from('hello-image-bytes');
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
expect(objectKey).toContain('/'); // scopeId/uuid
const put = await s.put(tokenFrom(uploadUrl), bytes);
expect(put.sizeBytes).toBe(bytes.length);
expect(put.checksumSha256).toHaveLength(64);
const { url } = await s.presignDownload(alice, objectKey, 'image/png');
const got = await s.get(tokenFrom(url));
expect(got.data.equals(bytes)).toBe(true);
expect(got.mime).toBe('image/png');
});
it('rejects an over-the-cap upload (OPA policy)', async () => {
await expect(svc().presignUpload(alice, { mime: 'image/png', sizeBytes: 26 * 1024 * 1024 })).rejects.toBeInstanceOf(PolicyDeniedError);
});
it('rejects a disallowed file type (OPA policy)', async () => {
await expect(svc().presignUpload(alice, { mime: 'application/x-msdownload', sizeBytes: 1000 })).rejects.toBeInstanceOf(PolicyDeniedError);
});
it('a PUT larger than the presigned size is refused', async () => {
const s = svc();
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
await expect(s.put(tokenFrom(uploadUrl), Buffer.from('way too many bytes'))).rejects.toThrow();
});
it('rejects a tampered / non-media token', async () => {
await expect(svc().get('not-a-real-token')).rejects.toThrow();
// an upload token cannot be used to download
const s = svc();
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
await expect(s.get(tokenFrom(uploadUrl))).rejects.toThrow();
});
it('tenant-fences downloads: an object outside my scope is forbidden', async () => {
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
});
});
@@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { STORAGE_PORT, type StoragePort } from './storage.port';
interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number }
interface DownloadToken { op: 'get'; objectKey: string; mime: string }
/**
* Presigned media. IIOS never trusts the client with storage it authorizes an
* upload (OPA: size/type/scope), then hands back a short-lived signed URL that
* points at its OWN storage endpoints. The bytes live behind the StoragePort; the
* kernel only ever stores the object key (contentRef) on a MessagePart.
*/
@Injectable()
export class MediaService {
private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret';
private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, '');
constructor(
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
private readonly actors: ActorResolver,
) {}
/** Authorize an upload and return a short-lived signed PUT url + the object key. */
async presignUpload(
principal: MessagePrincipal,
input: { mime: string; sizeBytes: number },
): Promise<{ objectKey: string; uploadUrl: string }> {
const scope = await this.actors.resolveScope(principal);
await decideOrThrow(this.ports, { action: 'iios.media.upload', scopeId: scope.id, mime: input.mime, sizeBytes: input.sizeBytes });
const objectKey = `${scope.id}/${randomUUID()}`;
const token = jwt.sign({ op: 'put', objectKey, mime: input.mime, maxBytes: input.sizeBytes } satisfies UploadToken, this.secret, { expiresIn: '5m' });
return { objectKey, uploadUrl: `${this.publicUrl}/v1/media/upload/${token}` };
}
/** Store bytes for a valid, unexpired upload token (enforcing the declared size cap). */
async put(token: string, data: Buffer): Promise<{ objectKey: string; sizeBytes: number; checksumSha256: string }> {
const t = this.verify<UploadToken>(token, 'put');
if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size');
const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime);
return { objectKey: t.objectKey, sizeBytes, checksumSha256 };
}
/** Authorize a download and return a short-lived signed GET url (tenant-fenced). */
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
const scope = await this.actors.resolveScope(principal);
if (!contentRef.startsWith(`${scope.id}/`)) throw new ForbiddenException('object not in your scope');
const token = jwt.sign({ op: 'get', objectKey: contentRef, mime } satisfies DownloadToken, this.secret, { expiresIn: '1h' });
return { url: `${this.publicUrl}/v1/media/blob/${token}` };
}
/** Fetch bytes for a valid, unexpired download token. */
async get(token: string): Promise<{ data: Buffer; mime: string; sizeBytes: number }> {
const t = this.verify<DownloadToken>(token, 'get');
const obj = await this.storage.get(t.objectKey);
if (!obj) throw new BadRequestException('object not found');
return obj;
}
private verify<T extends { op: string }>(token: string, op: T['op']): T {
let payload: T;
try {
payload = jwt.verify(token, this.secret) as T;
} catch {
throw new ForbiddenException('invalid or expired media token');
}
if (payload.op !== op) throw new ForbiddenException('wrong media token');
return payload;
}
}
@@ -0,0 +1,12 @@
/**
* Byte storage seam. The kernel stores a `contentRef` (an opaque object key) on a
* MessagePart; the actual bytes live behind this port. Dev binds LocalDiskStorage;
* prod swaps to S3/R2/Supabase Storage with zero changes to the media service or app.
*/
export interface StoragePort {
put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }>;
get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null>;
remove(objectKey: string): Promise<void>;
}
export const STORAGE_PORT = Symbol('STORAGE_PORT');
@@ -4,6 +4,7 @@ import {
ConnectedSocket, ConnectedSocket,
MessageBody, MessageBody,
OnGatewayConnection, OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit, OnGatewayInit,
SubscribeMessage, SubscribeMessage,
WebSocketGateway, WebSocketGateway,
@@ -15,6 +16,7 @@ import { logJson } from '../observability/logger';
import { MessageService, type MessagePrincipal } from './message.service'; import { MessageService, type MessagePrincipal } from './message.service';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import { OutboxBus } from '../outbox/outbox.bus'; import { OutboxBus } from '../outbox/outbox.bus';
import { PresenceService } from '../notifications/presence.service';
interface SocketState { interface SocketState {
principal: MessagePrincipal; principal: MessagePrincipal;
@@ -27,7 +29,7 @@ interface SocketState {
* propagates messages persisted elsewhere (REST/ingest) to connected clients. * propagates messages persisted elsewhere (REST/ingest) to connected clients.
*/ */
@WebSocketGateway({ namespace: '/message', cors: { origin: true } }) @WebSocketGateway({ namespace: '/message', cors: { origin: true } })
export class MessageGateway implements OnGatewayInit, OnGatewayConnection { export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(MessageGateway.name); private readonly logger = new Logger(MessageGateway.name);
private readonly emittedLocally = new Set<string>(); private readonly emittedLocally = new Set<string>();
@@ -37,6 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
private readonly messages: MessageService, private readonly messages: MessageService,
private readonly session: SessionVerifier, private readonly session: SessionVerifier,
private readonly bus: OutboxBus, private readonly bus: OutboxBus,
private readonly presence: PresenceService,
) {} ) {}
afterInit(): void { afterInit(): void {
@@ -65,31 +68,82 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
} }
} }
handleDisconnect(client: Socket): void {
this.presence.clearSocket(client.id);
}
/** The app reports which thread is in the foreground (or null when blurred) — presence. */
@SubscribeMessage('focus_thread')
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
const state = client.data as SocketState | undefined;
if (!state?.principal) return;
this.presence.setFocus(client.id, state.principal.userId, body?.threadId ?? null);
}
@SubscribeMessage('open_thread') @SubscribeMessage('open_thread')
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string }) { async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string; subject?: string }) {
const { principal } = client.data as SocketState; const { principal } = client.data as SocketState;
const result = await this.messages.openThread(body?.threadId ?? null, principal); try {
await client.join(result.threadId); const result = await this.messages.openThread(body?.threadId ?? null, principal, {
return result; membership: body?.membership,
creatorRole: body?.creatorRole,
subject: body?.subject,
});
await client.join(result.threadId);
return result;
} catch (err) {
// Fail to an ACK'd error instead of throwing (which never acks → client hangs on "loading").
return { error: (err as Error).message ?? 'could not open the conversation' };
}
}
@SubscribeMessage('add_participant')
async addParticipant(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; userId: string; role?: string }) {
const { principal } = client.data as SocketState;
return this.messages.addParticipant(body.threadId, principal, body.userId, body.role);
} }
@SubscribeMessage('send_message') @SubscribeMessage('send_message')
async sendMessage( async sendMessage(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,
@MessageBody() body: { threadId: string; content: string; contentRef?: string }, @MessageBody()
body: { threadId: string; content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string; parentInteractionId?: string; mentions?: string[] },
) { ) {
const { principal } = client.data as SocketState; const { principal } = client.data as SocketState;
const msg = await this.messages.send( const msg = await this.messages.send(
body.threadId, body.threadId,
principal, principal,
{ content: body.content, contentRef: body.contentRef }, { content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
randomUUID(), randomUUID(),
undefined,
body.parentInteractionId,
body.mentions,
); );
this.emittedLocally.add(msg.id); this.emittedLocally.add(msg.id);
this.server.to(body.threadId).emit('message', msg); this.server.to(body.threadId).emit('message', msg);
return msg; return msg;
} }
@SubscribeMessage('annotate')
async annotate(
@ConnectedSocket() client: Socket,
@MessageBody() body: { threadId: string; interactionId: string; type: string; value: string },
) {
const { principal } = client.data as SocketState;
const r = await this.messages.toggleAnnotation(body.interactionId, principal, body.type, body.value);
// Broadcast the refreshed user list for this (interaction,type,value) so every client re-renders.
this.server.to(r.threadId).emit('annotation', {
threadId: r.threadId,
interactionId: r.interactionId,
type: r.type,
value: r.value,
op: r.op,
users: r.users,
userId: principal.userId,
});
return r;
}
@SubscribeMessage('read') @SubscribeMessage('read')
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) { async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
const { principal } = client.data as SocketState; const { principal } = client.data as SocketState;
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { MessageService } from './message.service'; import { MessageService } from './message.service';
import { MessageGateway } from './message.gateway'; import { MessageGateway } from './message.gateway';
import { OutboxModule } from '../outbox/outbox.module'; import { OutboxModule } from '../outbox/outbox.module';
import { PresenceService } from '../notifications/presence.service';
@Module({ @Module({
imports: [OutboxModule], imports: [OutboxModule],
providers: [MessageService, MessageGateway], providers: [MessageService, MessageGateway, PresenceService],
exports: [MessageService], exports: [MessageService, PresenceService],
}) })
export class MessageModule {} export class MessageModule {}
@@ -9,16 +9,55 @@ import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver
export type { MessagePrincipal }; export type { MessagePrincipal };
/** A generic annotation aggregate on a message (opaque type/value + who applied it). */
export interface AnnotationDto {
type: string;
value: string;
users: string[];
}
/** A media attachment on a message (the app renders it by `kind`). */
export interface AttachmentDto {
contentRef: string;
mimeType: string;
sizeBytes: number;
kind: 'image' | 'video' | 'audio' | 'file';
}
function mediaKind(mime: string): AttachmentDto['kind'] {
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
return 'file';
}
export interface MessageDto { export interface MessageDto {
id: string; id: string;
threadId: string; threadId: string;
senderActorId: string; senderActorId: string;
senderId: string; // the sender's stable externalId (email/username) — reliable "is this mine?"
senderName: string;
content: string; content: string;
contentRef?: string; contentRef?: string;
attachment?: AttachmentDto;
parentInteractionId?: string;
annotations: AnnotationDto[];
traceId: string; traceId: string;
createdAt: Date; createdAt: Date;
} }
export interface ThreadSummary {
threadId: string;
subject: string | null;
membership?: string;
participants: string[];
participantCount: number;
unread: number;
muted?: boolean;
lastMessage?: string;
lastAt?: Date;
}
export interface OpenThreadResult { export interface OpenThreadResult {
threadId: string; threadId: string;
status: string; status: string;
@@ -39,16 +78,29 @@ export class MessageService {
private readonly actors: ActorResolver, private readonly actors: ActorResolver,
) {} ) {}
/** Open an existing thread, or create one when no id is given. Joins as participant. */ /**
async openThread(threadId: string | null, principal: MessagePrincipal): Promise<OpenThreadResult> { * Open an existing thread, or create one when no id is given. On create, an optional
* generic `membership` attribute is stamped on the thread's metadata (the app's DM/group
* hint the kernel never branches on it; policy does), and the creator joins as ADMIN
* 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> {
if (!threadId) { if (!threadId) {
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal }); await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
const scope = await this.actors.resolveScope(principal); const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal); const actor = await this.actors.resolveActor(scope.id, principal);
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app).
const thread = await this.prisma.iiosThread.create({ const thread = await this.prisma.iiosThread.create({
data: { scopeId: scope.id, createdByActorId: actor.id }, data: {
scopeId: scope.id,
createdByActorId: actor.id,
subject: opts?.subject?.trim() || undefined,
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined,
},
}); });
await this.actors.ensureParticipant(thread.id, actor.id); await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
return { threadId: thread.id, status: thread.status, history: [] }; return { threadId: thread.id, status: thread.status, history: [] };
} }
@@ -56,16 +108,122 @@ export class MessageService {
if (!thread) throw new NotFoundException('thread not found'); if (!thread) throw new NotFoundException('thread not found');
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId }); await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
const actor = await this.actors.resolveActor(thread.scopeId, principal); const actor = await this.actors.resolveActor(thread.scopeId, principal);
const alreadyMember =
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null;
// Join is governed ONLY for threads that opted into a membership model (chat dm/group);
// support/inbox/generic threads (no membership attr) keep open-join, unchanged.
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, { action: 'iios.thread.join', threadId, scopeId: thread.scopeId, membership, alreadyMember });
await this.actors.ensureParticipant(threadId, actor.id); await this.actors.ensureParticipant(threadId, actor.id);
return { threadId, status: thread.status, history: await this.history(threadId) }; return { threadId, status: thread.status, history: await this.history(threadId) };
} }
/**
* Governed thread membership (a member adds another user by userId). Fail-closed via
* policy: the DM cap / group-admin rule is enforced by OPA (dev stub now, real later);
* the kernel only supplies generic context and adds the participant. The target's actor
* is resolved-or-created so you can add someone who hasn't logged in yet.
*/
async addParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string, role = 'MEMBER'): Promise<{ threadId: string; participantCount: number }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, {
action: 'iios.thread.participant.add',
threadId,
scopeId: thread.scopeId,
membership,
participantCount,
callerRole: callerP?.participantRole,
targetUserId,
role,
});
const scope = await this.actors.resolveScope(principal);
const target = await this.actors.resolveActor(scope.id, {
userId: targetUserId,
appId: principal.appId,
orgId: principal.orgId,
tenantId: principal.tenantId,
displayName: targetUserId,
});
await this.actors.ensureParticipant(threadId, target.id, role);
return { threadId, participantCount: participantCount + 1 };
}
/** Toggle the caller's per-thread notification mute flag. */
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.prisma.iiosThreadParticipant.update({
where: { threadId_actorId: { threadId, actorId: actor.id } },
data: { muted },
});
return { threadId, muted };
}
/** Generic "my threads": every thread the caller participates in, with last message + unread. */
async listThreads(principal: MessagePrincipal): Promise<ThreadSummary[]> {
const scope = await this.actors.findScope(principal);
if (!scope) return [];
const actor = await this.actors.resolveActor(scope.id, principal);
const memberships = await this.prisma.iiosThreadParticipant.findMany({ where: { actorId: actor.id }, select: { threadId: true, muted: true } });
const threadIds = memberships.map((m) => m.threadId);
if (threadIds.length === 0) return [];
const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted]));
const [threads, 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({
where: { threadId: { in: threadIds } },
include: { actor: { include: { sourceHandle: true } } },
}),
]);
const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
const membersBy = new Map<string, string[]>();
for (const p of allParts) {
const name = p.actor?.sourceHandle?.externalId ?? p.actor?.displayName ?? p.actorId;
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,
};
}),
);
return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0));
}
async send( async send(
threadId: string, threadId: string,
principal: MessagePrincipal, principal: MessagePrincipal,
body: { content: string; contentRef?: string }, body: { content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string },
idempotencyKey: string, idempotencyKey: string,
traceId: string = randomUUID(), traceId: string = randomUUID(),
parentInteractionId?: string,
mentions?: string[],
): Promise<MessageDto> { ): Promise<MessageDto> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found'); if (!thread) throw new NotFoundException('thread not found');
@@ -75,10 +233,15 @@ export class MessageService {
const actor = await this.actors.resolveActor(thread.scopeId, principal); const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.actors.ensureParticipant(threadId, actor.id); await this.actors.ensureParticipant(threadId, actor.id);
// A reply links to its parent — but only if the parent is in the same thread (else ignored).
const parentRef = parentInteractionId
? (await this.prisma.iiosInteraction.findFirst({ where: { id: parentInteractionId, threadId }, select: { id: true } }))?.id
: undefined;
// Idempotency: a repeat key returns the existing message (no re-increment). // Idempotency: a repeat key returns the existing message (no re-increment).
const existing = await this.prisma.iiosInteraction.findUnique({ const existing = await this.prisma.iiosInteraction.findUnique({
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } }, where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
include: { parts: { orderBy: { partIndex: 'asc' } } }, include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
}); });
if (existing) return this.toDto(existing, threadId); if (existing) return this.toDto(existing, threadId);
@@ -93,6 +256,7 @@ export class MessageService {
idempotencyKey, idempotencyKey,
status: 'NORMALIZED', status: 'NORMALIZED',
traceId, traceId,
parentInteractionId: parentRef,
}, },
}); });
@@ -100,7 +264,18 @@ export class MessageService {
{ interactionId: created.id, partIndex: 0, kind: 'TEXT', bodyText: body.content }, { interactionId: created.id, partIndex: 0, kind: 'TEXT', bodyText: body.content },
]; ];
if (body.contentRef) { if (body.contentRef) {
parts.push({ interactionId: created.id, partIndex: 1, kind: 'FILE_REF', contentRef: body.contentRef }); const mime = body.mimeType ?? '';
// Generic part kind from mime — the kernel stores media as opaque parts.
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
parts.push({
interactionId: created.id,
partIndex: 1,
kind,
contentRef: body.contentRef,
mimeType: body.mimeType,
sizeBytes: body.sizeBytes != null ? BigInt(body.sizeBytes) : undefined,
checksumSha256: body.checksumSha256,
});
} }
await tx.iiosMessagePart.createMany({ data: parts }); await tx.iiosMessagePart.createMany({ data: parts });
@@ -114,7 +289,9 @@ export class MessageService {
datacontenttype: 'application/json', datacontenttype: 'application/json',
traceparent: `00-${traceId.replace(/-/g, '')}-0000000000000000-01`, traceparent: `00-${traceId.replace(/-/g, '')}-0000000000000000-01`,
insignia: { scopeSnapshotId: thread.scopeId, correlationId: traceId, idempotencyKey, dataClass: 'internal' }, insignia: { scopeSnapshotId: thread.scopeId, correlationId: traceId, idempotencyKey, dataClass: 'internal' },
data: { interactionId: created.id, threadId, senderActorId: actor.id }, // `mentions` is an OPAQUE app-supplied notify-list (userIds) — the kernel never parses
// "@"; the inbox projector generically fans out a notification to those actors.
data: { interactionId: created.id, threadId, senderActorId: actor.id, mentions: mentions ?? [] },
}; };
await tx.iiosOutboxEvent.create({ await tx.iiosOutboxEvent.create({
data: { data: {
@@ -139,7 +316,7 @@ export class MessageService {
return tx.iiosInteraction.findUniqueOrThrow({ return tx.iiosInteraction.findUniqueOrThrow({
where: { id: created.id }, where: { id: created.id },
include: { parts: { orderBy: { partIndex: 'asc' } } }, include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
}); });
}); });
@@ -148,7 +325,7 @@ export class MessageService {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const winner = await this.prisma.iiosInteraction.findUniqueOrThrow({ const winner = await this.prisma.iiosInteraction.findUniqueOrThrow({
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } }, where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
include: { parts: { orderBy: { partIndex: 'asc' } } }, include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
}); });
return this.toDto(winner, threadId); return this.toDto(winner, threadId);
} }
@@ -220,7 +397,7 @@ export class MessageService {
async getMessageById(id: string, principal?: MessagePrincipal): Promise<MessageDto | null> { async getMessageById(id: string, principal?: MessagePrincipal): Promise<MessageDto | null> {
const i = await this.prisma.iiosInteraction.findUnique({ const i = await this.prisma.iiosInteraction.findUnique({
where: { id }, where: { id },
include: { parts: { orderBy: { partIndex: 'asc' } } }, include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
}); });
if (!i || !i.threadId) return null; if (!i || !i.threadId) return null;
if (principal) await this.actors.assertOwns(principal, i.scopeId); // tenant fence (KG-02) when called on behalf of a caller if (principal) await this.actors.assertOwns(principal, i.scopeId); // tenant fence (KG-02) when called on behalf of a caller
@@ -231,25 +408,162 @@ export class MessageService {
const interactions = await this.prisma.iiosInteraction.findMany({ const interactions = await this.prisma.iiosInteraction.findMany({
where: { threadId }, where: { threadId },
orderBy: { occurredAt: 'asc' }, orderBy: { occurredAt: 'asc' },
include: { parts: { orderBy: { partIndex: 'asc' } } }, include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
}); });
return interactions.map((i) => this.toDto(i, threadId)); const annotations = await this.annotationsByInteraction(interactions.map((i) => i.id));
return interactions.map((i) => this.toDto(i, threadId, annotations.get(i.id) ?? []));
}
/**
* Toggle a generic annotation (actor attaches/removes an opaque label on an interaction).
* `annotationType`/`value` are app-supplied and never interpreted here (the chat app uses
* type "reaction" + an emoji value). Governed: only a thread participant may annotate.
* Returns the refreshed user list for that (type,value) so callers can broadcast it.
*/
async toggleAnnotation(
interactionId: string,
principal: MessagePrincipal,
annotationType: string,
value: string,
): Promise<{ interactionId: string; threadId: string; type: string; value: string; op: 'add' | 'remove'; users: string[] }> {
const target = await this.prisma.iiosInteraction.findUnique({
where: { id: interactionId },
select: { id: true, threadId: true, scopeId: true },
});
if (!target || !target.threadId) throw new NotFoundException('message not found');
const actor = await this.actors.resolveActor(target.scopeId, principal);
const isMember =
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId: target.threadId, actorId: actor.id } } })) !== null;
await decideOrThrow(this.ports, { action: 'iios.interaction.annotate', threadId: target.threadId, scopeId: target.scopeId, isMember });
const key = {
scopeId_targetInteractionId_actorId_annotationType_value: {
scopeId: target.scopeId,
targetInteractionId: interactionId,
actorId: actor.id,
annotationType,
value,
},
};
const existing = await this.prisma.iiosInteractionAnnotation.findUnique({ where: key });
let op: 'add' | 'remove';
if (existing) {
await this.prisma.iiosInteractionAnnotation.delete({ where: { id: existing.id } });
op = 'remove';
} else {
await this.prisma.iiosInteractionAnnotation.create({
data: { scopeId: target.scopeId, targetInteractionId: interactionId, actorId: actor.id, annotationType, value },
});
op = 'add';
}
const users =
(await this.annotationsByInteraction([interactionId])).get(interactionId)?.find((a) => a.type === annotationType && a.value === value)?.users ?? [];
return { interactionId, threadId: target.threadId, type: annotationType, value, op, users };
}
/**
* Generic "my annotated interactions" every message the caller annotated with a given
* type, newest first, with thread context. The app uses type "save" for a personal
* bookmarks list (and could use "pin" for a cross-thread pinned view). Generic: the kernel
* never interprets the type string.
*/
async listMyAnnotated(
principal: MessagePrincipal,
annotationType: string,
): Promise<Array<{ message: MessageDto; threadId: string; threadSubject: string | null }>> {
const scope = await this.actors.findScope(principal);
if (!scope) return [];
const actor = await this.actors.resolveActor(scope.id, principal);
const anns = await this.prisma.iiosInteractionAnnotation.findMany({
where: { actorId: actor.id, annotationType },
orderBy: { createdAt: 'desc' },
select: { targetInteractionId: true },
});
const ids = anns.map((a) => a.targetInteractionId);
if (ids.length === 0) return [];
const [interactions, agg] = await Promise.all([
this.prisma.iiosInteraction.findMany({
where: { id: { in: ids } },
include: {
parts: { orderBy: { partIndex: 'asc' } },
actor: { include: { sourceHandle: true } },
thread: { select: { subject: true } },
},
}),
this.annotationsByInteraction(ids),
]);
const byId = new Map(interactions.map((i) => [i.id, i]));
return ids
.map((id) => byId.get(id))
.filter((i): i is NonNullable<typeof i> => Boolean(i && i.threadId))
.map((i) => ({
message: this.toDto(i, i.threadId as string, agg.get(i.id) ?? []),
threadId: i.threadId as string,
threadSubject: i.thread?.subject ?? null,
}));
} }
// ─── helpers ──────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────
/** Aggregate annotations for the given interactions into { type, value, users[] } groups. */
private async annotationsByInteraction(interactionIds: string[]): Promise<Map<string, AnnotationDto[]>> {
const map = new Map<string, AnnotationDto[]>();
if (interactionIds.length === 0) return map;
const rows = await this.prisma.iiosInteractionAnnotation.findMany({
where: { targetInteractionId: { in: interactionIds } },
include: { actor: { include: { sourceHandle: true } } },
orderBy: { createdAt: 'asc' },
});
for (const r of rows) {
const user = r.actor?.sourceHandle?.externalId ?? r.actor?.displayName ?? r.actorId;
const list = map.get(r.targetInteractionId) ?? [];
let entry = list.find((e) => e.type === r.annotationType && e.value === r.value);
if (!entry) {
entry = { type: r.annotationType, value: r.value, users: [] };
list.push(entry);
}
entry.users.push(user);
map.set(r.targetInteractionId, list);
}
for (const list of map.values()) for (const e of list) e.users.sort(); // deterministic order
return map;
}
private toDto( private toDto(
interaction: { id: string; actorId: string | null; traceId: string | null; occurredAt: Date; parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }> }, interaction: {
id: string;
actorId: string | null;
actor?: { displayName: string | null; sourceHandle: { externalId: string } | null } | null;
parentInteractionId?: string | null;
traceId: string | null;
occurredAt: Date;
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null; mimeType?: string | null; sizeBytes?: bigint | null }>;
},
threadId: string, threadId: string,
annotations: AnnotationDto[] = [],
): MessageDto { ): MessageDto {
const text = interaction.parts.find((p) => p.kind === 'TEXT'); const text = interaction.parts.find((p) => p.kind === 'TEXT');
const file = interaction.parts.find((p) => p.contentRef); const file = interaction.parts.find((p) => p.contentRef);
const attachment: AttachmentDto | undefined = file?.contentRef
? {
contentRef: file.contentRef,
mimeType: file.mimeType ?? 'application/octet-stream',
sizeBytes: file.sizeBytes != null ? Number(file.sizeBytes) : 0,
kind: mediaKind(file.mimeType ?? ''),
}
: undefined;
return { return {
id: interaction.id, id: interaction.id,
threadId, threadId,
senderActorId: interaction.actorId ?? '', senderActorId: interaction.actorId ?? '',
senderId: interaction.actor?.sourceHandle?.externalId ?? '',
senderName: interaction.actor?.displayName ?? interaction.actor?.sourceHandle?.externalId ?? 'unknown',
content: text?.bodyText ?? '', content: text?.bodyText ?? '',
contentRef: file?.contentRef ?? undefined, contentRef: file?.contentRef ?? undefined,
attachment,
parentInteractionId: interaction.parentInteractionId ?? undefined,
annotations,
traceId: interaction.traceId ?? '', traceId: interaction.traceId ?? '',
createdAt: interaction.occurredAt, createdAt: interaction.occurredAt,
}; };
@@ -1,15 +1,20 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts';
import { resetDb } from '../test-utils/reset-db'; import { resetDb } from '../test-utils/reset-db';
import { makeFakePorts } from '@insignia/iios-testkit'; import { makeFakePorts } from '@insignia/iios-testkit';
import { MessageService, type MessagePrincipal } from './message.service'; import { MessageService, type MessagePrincipal } from './message.service';
import { ActorResolver } from '../identity/actor.resolver'; import { ActorResolver } from '../identity/actor.resolver';
import { DevOpaPort } from '../platform/dev-opa.port';
import type { PrismaService } from '../prisma/prisma.service'; import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } }); const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService; const asService = prisma as unknown as PrismaService;
const svc = () => new MessageService(asService, makeFakePorts(), new ActorResolver(asService)); const svc = () => new MessageService(asService, makeFakePorts(), new ActorResolver(asService));
// A service whose OPA actually enforces the membership rules (real dev policy plane).
const gov = () =>
new MessageService(asService, { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts, new ActorResolver(asService));
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' }; const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' }; const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
@@ -101,3 +106,124 @@ describe('MessageService (P2 native messaging)', () => {
expect(ce.insignia.correlationId).toBe(msg.traceId); expect(ce.insignia.correlationId).toBe(msg.traceId);
}); });
}); });
describe('Governed membership + replies (v1.1, policy-enforced)', () => {
it('a direct message is capped at two people (OPA policy)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
expect((await s.addParticipant(threadId, alice, 'bob')).participantCount).toBe(2);
await expect(s.addParticipant(threadId, alice, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError);
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(2); // unchanged
});
it('group: the creator is ADMIN and can add; a plain member cannot', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob'); // admin adds a member
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(2);
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
});
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await expect(s.openThread(threadId, bob)).rejects.toBeInstanceOf(PolicyDeniedError);
await s.addParticipant(threadId, alice, 'bob');
expect((await s.openThread(threadId, bob)).threadId).toBe(threadId);
});
it('listThreads returns the callers threads with membership, count, last message + unread', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob');
await s.send(threadId, alice, { content: 'hello team' }, 'k1');
const mine = await s.listThreads(alice);
expect(mine).toHaveLength(1);
expect(mine[0]).toMatchObject({ membership: 'group', participantCount: 2, lastMessage: 'hello team' });
expect((await s.listThreads(bob))[0]?.unread).toBe(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' });
const first = await s.send(threadId, alice, { content: 'question?' }, 'k1');
const reply = await s.send(threadId, alice, { content: 'answer' }, 'k2', undefined, first.id);
expect(reply.parentInteractionId).toBe(first.id);
const { threadId: other } = await s.openThread(null, alice, { membership: 'dm' });
const cross = await s.send(other, alice, { content: 'x' }, 'k3', undefined, first.id);
expect(cross.parentInteractionId).toBeUndefined(); // parent not in this thread
});
});
describe('Interaction annotations (generic reactions primitive)', () => {
it('toggleAnnotation adds then removes (toggle) and aggregates users into history', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob');
await s.openThread(threadId, bob);
const msg = await s.send(threadId, alice, { content: 'ship it' }, 'k1');
const add = await s.toggleAnnotation(msg.id, bob, 'reaction', '👍');
expect(add.op).toBe('add');
expect(add.users).toEqual(['bob']);
const add2 = await s.toggleAnnotation(msg.id, alice, 'reaction', '👍'); // alice also 👍
expect(add2.op).toBe('add');
expect(add2.users).toEqual(['alice', 'bob']); // sorted, deterministic
const rem = await s.toggleAnnotation(msg.id, bob, 'reaction', '👍'); // bob toggles off
expect(rem.op).toBe('remove');
expect(rem.users).toEqual(['alice']);
const hist = await s.history(threadId);
const m = hist.find((x) => x.id === msg.id)!;
expect(m.annotations).toEqual([{ type: 'reaction', value: '👍', users: ['alice'] }]);
});
it('different values coexist for the same actor (👍 and 🎉 both stick)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
const msg = await s.send(threadId, alice, { content: 'hi' }, 'k1');
await s.toggleAnnotation(msg.id, alice, 'reaction', '👍');
await s.toggleAnnotation(msg.id, alice, 'reaction', '🎉');
const m = (await s.history(threadId)).find((x) => x.id === msg.id)!;
expect(m.annotations).toEqual(
expect.arrayContaining([
{ type: 'reaction', value: '👍', users: ['alice'] },
{ type: 'reaction', value: '🎉', users: ['alice'] },
]),
);
});
it('a non-member cannot annotate (governed by policy)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
const msg = await s.send(threadId, alice, { content: 'secret' }, 'k1');
await expect(s.toggleAnnotation(msg.id, bob, 'reaction', '👍')).rejects.toBeInstanceOf(PolicyDeniedError);
});
it('listMyAnnotated returns the callers saved messages with thread context; others see none', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
await s.addParticipant(threadId, alice, 'bob');
const msg = await s.send(threadId, alice, { content: 'save this' }, 'k1');
await s.toggleAnnotation(msg.id, alice, 'save', ''); // save is a personal annotation
const saved = await s.listMyAnnotated(alice, 'save');
expect(saved).toHaveLength(1);
expect(saved[0]).toMatchObject({ threadId, threadSubject: 'Design' });
expect(saved[0]?.message.content).toBe('save this');
expect(await s.listMyAnnotated(bob, 'save')).toHaveLength(0); // bob saved nothing
});
it('send carries an OPAQUE mentions[] into the message event (kernel never parses @)', async () => {
const s = svc();
const { threadId } = await s.openThread(null, alice);
await s.openThread(threadId, bob);
await s.send(threadId, alice, { content: 'hi @bob' }, 'k1', undefined, undefined, ['bob']);
const ev = await prisma.iiosOutboxEvent.findFirstOrThrow({ where: { eventType: 'com.insignia.iios.message.sent.v1' } });
const ce = ev.cloudEvent as { data: { mentions?: string[] } };
expect(ce.data.mentions).toEqual(['bob']);
});
});
@@ -0,0 +1,46 @@
import { BadRequestException, Body, Controller, Delete, Get, Headers, Post } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { SessionVerifier } from '../platform/session.verifier';
import { SubscribeDto, UnsubscribeDto } from './notification.dto';
@Controller('v1/notifications')
export class NotificationController {
constructor(
private readonly prisma: PrismaService,
private readonly actors: ActorResolver,
private readonly session: SessionVerifier,
) {}
/** The public VAPID key the client needs to create a push subscription. */
@Get('vapid-public-key')
vapidKey(): { key: string } {
return { key: process.env.VAPID_PUBLIC_KEY ?? '' };
}
/** Store (or refresh) a push subscription for the caller. */
@Post('subscribe')
async subscribe(@Body() body: SubscribeDto, @Headers('authorization') auth?: string): Promise<{ ok: true }> {
const principal = this.principal(auth);
const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal);
await this.prisma.iiosNotificationSubscription.upsert({
where: { endpoint: body.endpoint },
create: { scopeId: scope.id, actorId: actor.id, kind: body.kind ?? 'webpush', endpoint: body.endpoint, p256dh: body.keys.p256dh, auth: body.keys.auth, userAgent: body.userAgent },
update: { actorId: actor.id, p256dh: body.keys.p256dh, auth: body.keys.auth, lastSeenAt: new Date() },
});
return { ok: true };
}
@Delete('subscribe')
async unsubscribe(@Body() body: UnsubscribeDto): Promise<{ ok: true }> {
await this.prisma.iiosNotificationSubscription.deleteMany({ where: { endpoint: body.endpoint } });
return { ok: true };
}
private principal(auth?: string): MessagePrincipal {
const token = (auth ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,12 @@
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
export class SubscribeDto {
@IsOptional() @IsIn(['webpush']) kind?: string;
@IsString() endpoint!: string;
@IsObject() keys!: { p256dh: string; auth: string };
@IsOptional() @IsString() userAgent?: string;
}
export class UnsubscribeDto {
@IsString() endpoint!: string;
}
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { OutboxModule } from '../outbox/outbox.module';
import { MessageModule } from '../messaging/message.module';
import { NotificationController } from './notification.controller';
import { NotificationProjector } from './notification.projector';
import { WebPushDelivery } from './web-push.delivery';
import { NOTIFICATION_PORT } from './notification.port';
@Module({
imports: [OutboxModule, MessageModule], // OutboxModule → bus/dlq; MessageModule → PresenceService. Prisma/Projection/Identity are @Global.
controllers: [NotificationController],
providers: [
NotificationProjector,
{
// Dev binds Web Push (VAPID); prod can swap this to an email/FCM adapter.
provide: NOTIFICATION_PORT,
useFactory: () =>
new WebPushDelivery(
process.env.VAPID_PUBLIC_KEY
? { publicKey: process.env.VAPID_PUBLIC_KEY, privateKey: process.env.VAPID_PRIVATE_KEY ?? '', subject: process.env.VAPID_SUBJECT ?? 'mailto:dev@insignia' }
: undefined,
),
},
],
})
export class NotificationModule {}
@@ -0,0 +1,18 @@
/** The delivery seam. Dev = Web Push (VAPID); prod can swap to email/FCM/APNs. */
export interface PushSub {
endpoint: string;
p256dh: string;
auth: string;
}
export interface NotificationPayload {
title: string;
body: string;
data: { threadId: string; interactionId?: string };
}
export interface NotificationPort {
deliver(sub: PushSub, payload: NotificationPayload): Promise<'sent' | 'gone' | 'failed'>;
}
export const NOTIFICATION_PORT = Symbol('NOTIFICATION_PORT');
@@ -0,0 +1,131 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
import { DlqService } from '../outbox/dlq.service';
import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { PresenceService } from './presence.service';
import { NotificationProjector } from './notification.projector';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const actors = new ActorResolver(asService);
const ms = () => new MessageService(asService, makeFakePorts(), actors);
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
async function actorIdFor(userId: string): Promise<string> {
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: userId } });
return (await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } })).id;
}
async function seedSub(userId: string): Promise<string> {
const actorId = await actorIdFor(userId);
const scope = await prisma.iiosScope.findFirstOrThrow();
await prisma.iiosNotificationSubscription.create({ data: { scopeId: scope.id, actorId, kind: 'webpush', endpoint: `https://push/${userId}`, p256dh: 'k', auth: 'a' } });
return actorId;
}
async function sentEvents(): Promise<CloudEvent[]> {
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
}
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
const deliver = vi.fn().mockResolvedValue(deliverResult);
const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
return { proj, deliver, presence };
}
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('NotificationProjector', () => {
it('DM: notifies the recipient (absent), never the sender', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
});
it('group without a mention: does NOT notify', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
it('group WITH a mention: notifies the mentioned member', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
expect(deliver).toHaveBeenCalledOnce();
});
it('group reply-to-you: notifies the parent author even without a mention', async () => {
const m = ms();
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
const parent = await m.send(threadId, bob, { content: 'question?' }, 'k1'); // bob authors the parent
await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
const { proj, deliver } = makeProjector();
const evs = await sentEvents();
await proj.onMessageSent(evs[evs.length - 1]!); // project the reply
expect(deliver).toHaveBeenCalledOnce();
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
});
it('presence gate: a recipient viewing the thread is NOT notified', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
await m.send(threadId, alice, { content: 'hi' }, 'k1');
const presence = new PresenceService();
presence.setFocus('sockB', 'bob', threadId);
const { proj, deliver } = makeProjector(presence);
await proj.onMessageSent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
it('mute gate: a muted recipient is NOT notified', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
await m.addParticipant(threadId, alice, 'bob');
const bobActorId = await seedSub('bob');
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
await m.send(threadId, alice, { content: 'hi' }, 'k1');
const { proj, deliver } = makeProjector();
await proj.onMessageSent((await sentEvents())[0]!);
expect(deliver).not.toHaveBeenCalled();
});
it('prunes a subscription that returns "gone"', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
await m.addParticipant(threadId, alice, 'bob');
await seedSub('bob');
await m.send(threadId, alice, { content: 'hi' }, 'k1');
const { proj } = makeProjector(new PresenceService(), 'gone');
await proj.onMessageSent((await sentEvents())[0]!);
expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
});
});
@@ -0,0 +1,114 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
import { OutboxBus } from '../outbox/outbox.bus';
import { DlqService } from '../outbox/dlq.service';
import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { PresenceService } from './presence.service';
import { NOTIFICATION_PORT, type NotificationPort } from './notification.port';
interface MsgData {
interactionId: string;
threadId: string;
senderActorId: string;
mentions?: string[];
}
/**
* Turns `message.sent` into push notifications for absent recipients. Three gates:
* 1. policy DM always; group only if @mentioned or a reply to that recipient
* 2. presence skip if the recipient is currently focused on that thread
* 3. mute skip if the recipient muted the thread
* Then dispatches to each of the recipient's subscriptions; a 'gone' result prunes it.
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group is read
* from the opaque `membership` thread attribute here in the notification *policy*, not the kernel.
*/
@Injectable()
export class NotificationProjector implements OnModuleInit {
private readonly consumer = 'notification-projector';
constructor(
private readonly prisma: PrismaService,
private readonly bus: OutboxBus,
private readonly dlq: DlqService,
private readonly cursor: ProjectionCursorService,
private readonly presence: PresenceService,
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
) {}
onModuleInit(): void {
this.dlq.registerHandler(this.consumer, (e) => this.onMessageSent(e));
this.bus.on(IIOS_EVENTS.messageSent, (p) =>
void this.onMessageSent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
);
}
async onMessageSent(event: CloudEvent): Promise<void> {
if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance
await this.apply(event);
await this.cursor.advance(this.consumer, event);
}
private async apply(event: CloudEvent): Promise<void> {
const data = event.data as MsgData;
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
if (!thread) return;
const membership = (thread.metadata as { membership?: string } | null)?.membership;
const interaction = await this.prisma.iiosInteraction.findUnique({
where: { id: data.interactionId },
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
});
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
const mentions = data.mentions ?? [];
// Parent author (for reply-to-you) — one lookup.
let parentAuthorActorId: string | null = null;
if (interaction?.parentInteractionId) {
const parent = await this.prisma.iiosInteraction.findUnique({
where: { id: interaction.parentInteractionId },
select: { actorId: true },
});
parentAuthorActorId = parent?.actorId ?? null;
}
const participants = await this.prisma.iiosThreadParticipant.findMany({
where: { threadId: data.threadId },
include: { actor: { include: { sourceHandle: true } } },
});
for (const p of participants) {
if (p.actorId === data.senderActorId) continue; // never the sender
const userId = p.actor?.sourceHandle?.externalId;
// gate 1 — policy
const mentioned = userId != null && mentions.includes(userId);
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
if (!(membership === 'dm' || mentioned || repliedToMe)) continue;
// gate 2 — presence
if (userId && this.presence.isViewing(userId, data.threadId)) continue;
// gate 3 — mute
if (p.muted) continue;
const subs = await this.prisma.iiosNotificationSubscription.findMany({ where: { actorId: p.actorId } });
for (const s of subs) {
const result = await this.port.deliver(
{ endpoint: s.endpoint, p256dh: s.p256dh, auth: s.auth },
{ title: senderName, body, data: { threadId: data.threadId, interactionId: data.interactionId } },
);
if (result === 'gone') await this.prisma.iiosNotificationSubscription.delete({ where: { id: s.id } });
}
}
}
private async claim(eventId: string): Promise<boolean> {
const res = await this.prisma.iiosProcessedEvent.createMany({
data: [{ consumerName: this.consumer, eventId }],
skipDuplicates: true,
});
return res.count > 0;
}
}
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { PresenceService } from './presence.service';
describe('PresenceService', () => {
it('reports viewing only for the focused thread, and clears on disconnect', () => {
const p = new PresenceService();
expect(p.isViewing('alice', 'T1')).toBe(false);
p.setFocus('sock1', 'alice', 'T1');
expect(p.isViewing('alice', 'T1')).toBe(true);
expect(p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
p.setFocus('sock1', 'alice', 'T2'); // moved focus
expect(p.isViewing('alice', 'T1')).toBe(false);
expect(p.isViewing('alice', 'T2')).toBe(true);
p.clearSocket('sock1');
expect(p.isViewing('alice', 'T2')).toBe(false);
});
it('any of the actors sockets counts as viewing', () => {
const p = new PresenceService();
p.setFocus('sockA', 'bob', 'T9');
p.setFocus('sockB', 'bob', null); // a second tab, no focus
expect(p.isViewing('bob', 'T9')).toBe(true);
});
});
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
/**
* In-memory focus tracker (single instance). Keyed on userId (the stable externalId the
* gateway has as principal.userId). NOTE: room membership viewing the sidebar joins
* every thread room for live updates, so presence uses an explicit `focus_thread` signal.
* Prod (multi-replica): back this with Redis.
*/
@Injectable()
export class PresenceService {
private readonly focus = new Map<string, { userId: string; threadId: string | null }>(); // socketId → focus
setFocus(socketId: string, userId: string, threadId: string | null): void {
this.focus.set(socketId, { userId, threadId });
}
clearSocket(socketId: string): void {
this.focus.delete(socketId);
}
isViewing(userId: string, threadId: string): boolean {
for (const f of this.focus.values()) if (f.userId === userId && f.threadId === threadId) return true;
return false;
}
}
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from 'vitest';
import { WebPushDelivery } from './web-push.delivery';
const sub = { endpoint: 'https://push/x', p256dh: 'k', auth: 'a' };
const payload = { title: 'Bob', body: 'hi', data: { threadId: 'T1' } };
const vapid = { publicKey: 'p', privateKey: 'k', subject: 'mailto:x' };
describe('WebPushDelivery', () => {
it('returns "sent" on success', async () => {
const send = vi.fn().mockResolvedValue({ statusCode: 201 });
const d = new WebPushDelivery(vapid, send as never);
expect(await d.deliver(sub, payload)).toBe('sent');
expect(send).toHaveBeenCalledOnce();
});
it('returns "gone" on 404/410 so the caller prunes', async () => {
const d410 = new WebPushDelivery(vapid, vi.fn().mockRejectedValue({ statusCode: 410 }) as never);
expect(await d410.deliver(sub, payload)).toBe('gone');
const d404 = new WebPushDelivery(vapid, vi.fn().mockRejectedValue({ statusCode: 404 }) as never);
expect(await d404.deliver(sub, payload)).toBe('gone');
});
it('returns "failed" on other errors', async () => {
const d = new WebPushDelivery(vapid, vi.fn().mockRejectedValue({ statusCode: 500 }) as never);
expect(await d.deliver(sub, payload)).toBe('failed');
});
it('is disabled (returns "failed", never calls send) without VAPID keys', async () => {
const send = vi.fn();
const d = new WebPushDelivery(undefined, send as never);
expect(await d.deliver(sub, payload)).toBe('failed');
expect(send).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import webpush from 'web-push';
import type { NotificationPayload, NotificationPort, PushSub } from './notification.port';
type SendFn = typeof webpush.sendNotification;
export interface Vapid {
publicKey: string;
privateKey: string;
subject: string;
}
/** Web Push (VAPID) delivery. A dead subscription (404/410) → 'gone' so the caller prunes it. */
@Injectable()
export class WebPushDelivery implements NotificationPort {
private readonly vapid?: Vapid;
constructor(
vapid?: Vapid,
private readonly send: SendFn = webpush.sendNotification,
) {
// Store VAPID; pass it per-send (not global setVapidDetails) so construction never
// validates keys — keeps the adapter unit-testable with a mocked transport.
this.vapid = vapid?.publicKey ? vapid : undefined;
}
async deliver(sub: PushSub, payload: NotificationPayload): Promise<'sent' | 'gone' | 'failed'> {
if (!this.vapid) return 'failed'; // push disabled (no VAPID keys)
try {
await this.send(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
{ vapidDetails: this.vapid },
);
return 'sent';
} catch (e) {
const code = (e as { statusCode?: number }).statusCode;
return code === 404 || code === 410 ? 'gone' : 'failed';
}
}
}
@@ -11,6 +11,7 @@ import { RouteService } from '../routing/route.service';
import { AiJobService } from '../ai/ai.service'; import { AiJobService } from '../ai/ai.service';
import { AiBudgetGuard } from '../ai/ai-budget.guard'; import { AiBudgetGuard } from '../ai/ai-budget.guard';
import { OutboundService } from '../adapters/outbound.service'; import { OutboundService } from '../adapters/outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry'; import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { runWithTrace } from './trace-context'; import { runWithTrace } from './trace-context';
@@ -23,7 +24,7 @@ const actors = new ActorResolver(asService);
const user: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo' }; const user: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo' };
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())); const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors); const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> { async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> {
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import { ProjectionCursorService } from '../projection/projection-cursor.service'; import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { RetentionService } from '../retention/retention.service';
/** /**
* Tenant-scoped metrics (P9 observability, JSON). Returns the CALLER's tenant * Tenant-scoped metrics (P9 observability, JSON). Returns the CALLER's tenant
@@ -17,6 +18,7 @@ export class MetricsController {
private readonly actors: ActorResolver, private readonly actors: ActorResolver,
private readonly session: SessionVerifier, private readonly session: SessionVerifier,
private readonly cursors: ProjectionCursorService, private readonly cursors: ProjectionCursorService,
private readonly retention: RetentionService,
) {} ) {}
@Get() @Get()
@@ -59,6 +61,7 @@ export class MetricsController {
tenant, tenant,
relay: { outboxPending, oldestPendingMs }, relay: { outboxPending, oldestPendingMs },
projections, projections,
retention: await this.retention.summary(),
}; };
} }
@@ -1,4 +1,5 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import { trace } from '@opentelemetry/api';
import { runWithTrace, newTraceId, traceIdFromTraceparent } from './trace-context'; import { runWithTrace, newTraceId, traceIdFromTraceparent } from './trace-context';
import { logJson } from './logger'; import { logJson } from './logger';
@@ -9,8 +10,13 @@ import { logJson } from './logger';
* the response finishes (method, path, status, ms). Wired via app.use() in main.ts. * the response finishes (method, path, status, ms). Wired via app.use() in main.ts.
*/ */
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void { export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
// Prefer the ACTIVE OpenTelemetry trace id: that is what makes this log line
// joinable to its distributed trace in Grafana (Loki -> Tempo). Falls back to
// the inbound header, then traceparent, then a generated id, so a correlation
// id is always present even when tracing is disabled.
const otelTraceId = trace.getActiveSpan()?.spanContext().traceId;
const headerTrace = (req.headers['x-trace-id'] as string | undefined)?.trim(); const headerTrace = (req.headers['x-trace-id'] as string | undefined)?.trim();
const traceId = headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId(); const traceId = otelTraceId || headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
res.setHeader('x-trace-id', traceId); res.setHeader('x-trace-id', traceId);
const startedAt = Date.now(); const startedAt = Date.now();
runWithTrace(traceId, () => { runWithTrace(traceId, () => {
@@ -0,0 +1,50 @@
/**
* OpenTelemetry bootstrap. MUST be imported before anything else in main.ts
* auto-instrumentation works by patching modules (http, express, pg, redis, )
* as they are require()d, so any module loaded before this runs is never traced.
*
* Env-driven on purpose: the OTel SDK reads OTEL_SERVICE_NAME,
* OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL itself, so the
* collector target is a deployment concern rather than a code change. When
* OTEL_EXPORTER_OTLP_ENDPOINT is unset the SDK never starts, so local dev and
* tests run with zero tracing overhead and no exporter errors.
*
* Traces land in Tempo and are viewable in Grafana. The existing P9 trace
* middleware adopts the active OTel trace id, so an `http.request` log line and
* its distributed trace share one id (Loki -> Tempo pivot).
*/
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (endpoint) {
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations({
// Noisy and low value: every file read becomes a span.
'@opentelemetry/instrumentation-fs': { enabled: false },
// k8s probes hit /health constantly; tracing them would swamp Tempo
// and bury the real request traces.
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const url = req.url ?? '';
return ['/health', '/ready', '/healthz', '/readyz', '/metrics'].some((p) =>
url.startsWith(p),
);
},
},
}),
],
});
sdk.start();
const shutdown = (): void => {
// Flush buffered spans before exit, else the last requests before a
// rollout are lost.
void sdk.shutdown().finally(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { DevOpaPort } from './dev-opa.port';
const opa = new DevOpaPort();
describe('DevOpaPort (dev policy plane — membership rules)', () => {
it('allows every existing action by default (thread create/read, message send)', async () => {
for (const action of ['iios.thread.create', 'iios.thread.read', 'iios.message.send', undefined]) {
expect((await opa.decide({ action })).allow).toBe(true);
}
});
it('DM is capped at two: adding a 2nd is allowed, a 3rd is denied', async () => {
const add = (participantCount: number) =>
opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount });
expect((await add(1)).allow).toBe(true); // 1 → adding the 2nd
const third = await add(2); // 2 → adding a 3rd
expect(third.allow).toBe(false);
expect(third.obligations[0]?.reason).toMatch(/two people/);
});
it('adding a participant requires the caller be a member', async () => {
const d = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'NONE', participantCount: 3 });
expect(d.allow).toBe(false);
expect(d.obligations[0]?.reason).toMatch(/member/);
});
it('group add/remove requires ADMIN', async () => {
const asMember = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'MEMBER', participantCount: 3 });
expect(asMember.allow).toBe(false);
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
const asAdmin = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'ADMIN', participantCount: 3 });
expect(asAdmin.allow).toBe(true);
});
it('self-join is governed only on membership threads', async () => {
// generic / support thread (no membership attr) → open join, unchanged
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
// a membership (chat) thread → existing member allowed, stranger denied
expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: true })).allow).toBe(true);
const stranger = await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false });
expect(stranger.allow).toBe(false);
expect(stranger.obligations[0]?.reason).toMatch(/not a member/);
});
});
@@ -0,0 +1,66 @@
import type { PolicyDecision } from '@insignia/iios-contracts';
/**
* Dev stand-in for the real OPA policy plane. It evaluates a small **policy table**
* against the generic `{ action, ...context }` the kernel passes to `opa.decide`.
* Everything defaults to ALLOW (so existing actions are unchanged); only the rules
* below deny. This is the *policy plane*, NOT the kernel the chat meaning of "dm"
* vs "group" lives here as policy, and a real OPA drops in behind the same interface.
*/
export interface OpaInput {
action?: string;
membership?: string; // app-set generic thread attribute: 'dm' | 'group'
participantCount?: number;
callerRole?: string; // 'MEMBER' | 'ADMIN'
alreadyMember?: boolean;
isMember?: boolean;
mime?: string;
sizeBytes?: number;
[k: string]: unknown;
}
const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
const MEDIA_ALLOWED = /^(image|video|audio)\//;
const MEDIA_ALLOWED_DOCS = new Set([
'application/pdf', 'text/plain', 'application/zip',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]);
const allow = (): PolicyDecision => ({ decisionRef: 'dev-allow', allow: true, obligations: [], ttlSeconds: 60 });
const deny = (reason: string): PolicyDecision => ({ decisionRef: 'dev-deny', allow: false, obligations: [{ kind: 'AUDIT', reason }], ttlSeconds: 0 });
export class DevOpaPort {
async decide(input: unknown): Promise<PolicyDecision> {
const i = (input ?? {}) as OpaInput;
switch (i.action) {
case 'iios.thread.participant.add': {
const count = i.participantCount ?? 0;
if (i.membership === 'dm' && count >= 2) return deny('a direct message is limited to two people');
if (i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can add participants');
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
return allow();
}
case 'iios.thread.join': {
// Only threads that opted into a membership model (chat dm/group) are governed;
// generic/support threads (no membership attr) keep open-join. For a governed
// thread, new members must be added first (governed add-participant).
if (!i.membership) return allow();
return i.alreadyMember ? allow() : deny('you are not a member of this thread');
}
case 'iios.interaction.annotate': {
// Annotating (e.g. reacting to) a message requires being a participant of its thread.
return i.isMember ? allow() : deny('you are not a member of this thread');
}
case 'iios.media.upload': {
const mime = i.mime ?? '';
if ((i.sizeBytes ?? 0) > MEDIA_MAX_BYTES) return deny('file too large (max 25 MB)');
if (!MEDIA_ALLOWED.test(mime) && !MEDIA_ALLOWED_DOCS.has(mime)) return deny(`file type not allowed: ${mime || 'unknown'}`);
return allow();
}
default:
return allow();
}
}
}
@@ -1,10 +1,10 @@
import type { import type {
IiosPlatformPorts, IiosPlatformPorts,
PlatformPrincipal, PlatformPrincipal,
PolicyDecision,
ContextDecisionBundle, ContextDecisionBundle,
SourceHandleResolution, SourceHandleResolution,
} from '@insignia/iios-contracts'; } from '@insignia/iios-contracts';
import { DevOpaPort } from './dev-opa.port';
/** DI token for the platform ports (Session/OPA/CMP/MDM/CRRE/SAS/Capability). */ /** DI token for the platform ports (Session/OPA/CMP/MDM/CRRE/SAS/Capability). */
export const PLATFORM_PORTS = Symbol('PLATFORM_PORTS'); export const PLATFORM_PORTS = Symbol('PLATFORM_PORTS');
@@ -22,11 +22,8 @@ export class LocalDevPorts implements IiosPlatformPorts {
return { principalRef: 'local-dev', orgId: 'org_demo', appId: 'portal-demo', assurance: 'service' }; return { principalRef: 'local-dev', orgId: 'org_demo', appId: 'portal-demo', assurance: 'service' };
}, },
}; };
opa = { // Dev policy plane: default-allow + the membership rules (real OPA swaps in here).
async decide(_input: unknown): Promise<PolicyDecision> { opa = new DevOpaPort();
return { decisionRef: 'local-allow', allow: true, obligations: [], ttlSeconds: 60 };
},
};
cmp = { cmp = {
async checkPurpose(_input: unknown): Promise<{ receiptRef?: string; status: 'ALLOW' | 'DENY' | 'NOT_REQUIRED' }> { async checkPurpose(_input: unknown): Promise<{ receiptRef?: string; status: 'ALLOW' | 'DENY' | 'NOT_REQUIRED' }> {
return { status: 'NOT_REQUIRED' }; return { status: 'NOT_REQUIRED' };
@@ -0,0 +1,16 @@
import { ArgumentsHost, Catch, HttpStatus, type ExceptionFilter } from '@nestjs/common';
import { PolicyDeniedError } from '@insignia/iios-contracts';
import type { Response } from 'express';
/**
* Maps a fail-closed `PolicyDeniedError` (from `decideOrThrow`) to HTTP 403 instead of a
* generic 500, so callers can tell "policy denied" (with the reason) from a server fault.
* Applies to every policy-gated REST endpoint (e.g. the DM-cap / membership rules).
*/
@Catch(PolicyDeniedError)
export class PolicyDeniedFilter implements ExceptionFilter {
catch(err: PolicyDeniedError, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
res.status(HttpStatus.FORBIDDEN).json({ statusCode: 403, code: err.code, message: err.message });
}
}
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { SessionVerifier } from './session.verifier';
// Two independent fake IdPs (each its own EC signing key + kid).
function makeIssuer(kid: string) {
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const jwk = { ...(publicKey.export({ format: 'jwk' }) as Record<string, unknown>), kid, use: 'sig', alg: 'ES256' };
return { privateKey, jwks: { keys: [jwk] } };
}
const chat = makeIssuer('chat-kid');
const support = makeIssuer('support-kid');
const CHAT_URL = 'https://chat-proj.example.co';
const SUPPORT_URL = 'https://support-proj.example.co';
function sat(priv: KeyObject, url: string, kid: string, email: string, name: string): string {
return jwt.sign(
{ email, user_metadata: { full_name: name }, role: 'authenticated' },
priv,
{ algorithm: 'ES256', issuer: `${url}/auth/v1`, audience: 'authenticated', subject: 'uuid-x', keyid: kid, expiresIn: '1h' },
);
}
describe('SessionVerifier — multi-issuer (OIDC/JWKS registry)', () => {
let verifier: SessionVerifier;
beforeAll(async () => {
process.env.AUTH_ISSUERS = JSON.stringify([
{ url: CHAT_URL, appId: 'portal-demo' },
{ url: SUPPORT_URL, appId: 'support-app' },
]);
// Serve each issuer's JWKS from its own well-known URL.
vi.stubGlobal(
'fetch',
vi.fn(async (u: string) => {
const s = String(u);
const body = s.includes('chat-proj') ? chat.jwks : s.includes('support-proj') ? support.jwks : { keys: [] };
return { ok: true, json: async () => body } as unknown as Response;
}),
);
verifier = new SessionVerifier();
await verifier.onModuleInit();
});
afterAll(() => {
vi.unstubAllGlobals();
delete process.env.AUTH_ISSUERS;
});
it('routes a token to the appId of its own issuer', () => {
expect(verifier.verify(sat(chat.privateKey, CHAT_URL, 'chat-kid', 'Alice@x.com', 'Alice'))).toMatchObject({
userId: 'alice@x.com',
appId: 'portal-demo',
displayName: 'Alice',
});
});
it('a second issuer maps to a DIFFERENT appId — isolated scope', () => {
expect(verifier.verify(sat(support.privateKey, SUPPORT_URL, 'support-kid', 'Bob@x.com', 'Bob'))).toMatchObject({
userId: 'bob@x.com',
appId: 'support-app',
});
});
it('rejects a token from an untrusted issuer', () => {
const rogue = makeIssuer('rogue-kid');
expect(() => verifier.verify(sat(rogue.privateKey, 'https://evil.example.co', 'rogue-kid', 'e@x.com', 'E'))).toThrow();
});
it('rejects a forgery: signed by issuer B but claiming issuer A + As kid', () => {
const forged = sat(support.privateKey, CHAT_URL, 'chat-kid', 'x@x.com', 'X'); // wrong key for the claimed issuer
expect(() => verifier.verify(forged)).toThrow();
});
});
@@ -1,30 +1,148 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { createPublicKey } from 'node:crypto';
import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import type { MessagePrincipal } from '../messaging/message.service'; import type { MessagePrincipal } from '../messaging/message.service';
/** One trusted OIDC issuer (a Supabase project / IdP) → the app scope it maps to. */
interface IssuerEntry {
issuer: string; // the token `iss` claim, e.g. https://<ref>.supabase.co/auth/v1
jwksUrl: string;
appId: string;
orgId: string;
audience: string;
kidToPem: Map<string, string>;
}
/** /**
* The real `session` port for P2: verifies a host app's HS256 token (reuses the * The `session` port. Verifies whatever token a caller presents:
* support-service AppTokenVerifier pattern). Per-app secrets come from the *
* APP_SECRETS env (JSON map keyed by appId). Expected claims: { sub, name?, * 1. OIDC / JWKS (real IdPs) a REGISTRY of trusted issuers (env `AUTH_ISSUERS`,
* appId, orgId?, tenantId? }. * or the single `SUPABASE_URL` shorthand). A token is routed by its `iss` claim
* to that issuer's entry, verified against that issuer's public JWKS (ES256, no
* secret), and stamped with that entry's `appId`/`orgId`. So two projects/IdPs
* map to two isolated app scopes on one IIOS app A's tokens can't reach app B.
*
* 2. App token (dev / HS256) legacy per-app secrets from `APP_SECRETS`, keyed by
* the `appId` claim. Unchanged; used by the dev IdP + tests.
*
* A Session Broker would later collapse case 1 to a single issuer (the Broker's PAT).
*/ */
@Injectable() @Injectable()
export class SessionVerifier { export class SessionVerifier implements OnModuleInit {
private readonly secrets: Record<string, string>; private readonly appSecrets: Record<string, string>;
private readonly issuers = new Map<string, IssuerEntry>(); // keyed by issuer string
constructor() { constructor() {
try { try {
this.secrets = JSON.parse(process.env.APP_SECRETS ?? '{}'); this.appSecrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
} catch { } catch {
this.secrets = {}; this.appSecrets = {};
}
for (const cfg of this.readIssuerConfig()) {
const url = this.normalize(cfg.url);
const appId = cfg.appId?.trim() || 'portal-demo';
const issuer = cfg.issuer?.trim() || `${url}/auth/v1`;
const jwksUrl = cfg.jwksUrl?.trim() || `${url}/auth/v1/.well-known/jwks.json`;
this.issuers.set(issuer, {
issuer,
jwksUrl,
appId,
orgId: cfg.orgId?.trim() || `org_${appId}`,
audience: cfg.audience?.trim() || 'authenticated',
kidToPem: new Map(),
});
}
}
async onModuleInit(): Promise<void> {
await Promise.all([...this.issuers.values()].map((e) => this.refreshJwks(e)));
}
/** Assemble the issuer registry from AUTH_ISSUERS (multi) or SUPABASE_URL (single, back-compat). */
private readIssuerConfig(): Array<{ url: string; appId?: string; orgId?: string; issuer?: string; jwksUrl?: string; audience?: string }> {
const out: Array<{ url: string; appId?: string; orgId?: string; issuer?: string; jwksUrl?: string; audience?: string }> = [];
try {
const raw = process.env.AUTH_ISSUERS;
if (raw) {
const parsed = JSON.parse(raw) as Array<{ url: string; appId?: string; orgId?: string; issuer?: string; jwksUrl?: string; audience?: string }>;
if (Array.isArray(parsed)) out.push(...parsed.filter((e) => e && (e.url || e.issuer)));
}
} catch {
/* ignore malformed AUTH_ISSUERS */
}
const single = process.env.SUPABASE_URL?.trim();
if (single && !out.length) {
out.push({ url: single, appId: process.env.SUPABASE_APP_ID?.trim(), orgId: process.env.SUPABASE_ORG_ID?.trim() });
}
return out;
}
/** Fetch one issuer's JWKS and cache each key as PEM (so verify() stays synchronous). */
private async refreshJwks(entry: IssuerEntry): Promise<void> {
try {
const res = await fetch(entry.jwksUrl);
if (!res.ok) return;
const { keys } = (await res.json()) as { keys: Array<Record<string, unknown>> };
const next = new Map<string, string>();
for (const jwk of keys ?? []) {
const kid = jwk.kid as string | undefined;
if (!kid) continue;
try {
next.set(kid, createPublicKey({ key: jwk as never, format: 'jwk' }).export({ type: 'spki', format: 'pem' }) as string);
} catch {
/* skip a key we can't import */
}
}
if (next.size) entry.kidToPem = next;
} catch {
/* keep whatever keys we already have */
} }
} }
verify(token: string): MessagePrincipal { verify(token: string): MessagePrincipal {
const decoded = jwt.decode(token) as jwt.JwtPayload | null; const decoded = jwt.decode(token, { complete: true }) as { header?: { alg?: string; kid?: string }; payload?: jwt.JwtPayload } | null;
const appId = decoded?.appId ? String(decoded.appId) : undefined; if (!decoded?.payload) throw new UnauthorizedException('invalid token');
if (this.issuers.size && decoded.header?.alg === 'ES256') {
const iss = decoded.payload.iss ? String(decoded.payload.iss) : '';
const entry = this.issuers.get(iss);
if (!entry) throw new UnauthorizedException(`untrusted issuer: ${iss || '(none)'}`);
return this.verifyOidc(token, decoded.header.kid, entry);
}
return this.verifyAppToken(token, decoded.payload);
}
/** Verify an ES256 SAT against its issuer's JWKS key, mapping to that issuer's app scope. */
private verifyOidc(token: string, kid: string | undefined, entry: IssuerEntry): MessagePrincipal {
const pem = kid ? entry.kidToPem.get(kid) : undefined;
if (!pem) {
void this.refreshJwks(entry); // rotated / not loaded — pull fresh for next time
throw new UnauthorizedException('unknown signing key — retry');
}
let payload: jwt.JwtPayload;
try {
payload = jwt.verify(token, pem, { algorithms: ['ES256'], issuer: entry.issuer, audience: entry.audience }) as jwt.JwtPayload;
} catch {
throw new UnauthorizedException('invalid token');
}
const email = payload.email ? String(payload.email).toLowerCase() : undefined;
const meta = (payload.user_metadata ?? {}) as { full_name?: string; name?: string };
// userId = email (stable + human-readable → mentions/directory work); RealMDM canonicalises `sub` later.
const userId = email ?? String(payload.sub);
return {
userId,
appId: entry.appId,
orgId: entry.orgId,
tenantId: undefined,
displayName: meta.full_name ?? meta.name ?? email ?? userId,
};
}
/** Legacy per-app HS256 token (dev IdP / host apps). */
private verifyAppToken(token: string, decodedPayload: jwt.JwtPayload): MessagePrincipal {
const appId = decodedPayload.appId ? String(decodedPayload.appId) : undefined;
if (!appId) throw new UnauthorizedException('missing appId claim'); if (!appId) throw new UnauthorizedException('missing appId claim');
const secret = this.secrets[appId]; const secret = this.appSecrets[appId];
if (!secret) throw new UnauthorizedException(`unknown app: ${appId}`); if (!secret) throw new UnauthorizedException(`unknown app: ${appId}`);
let payload: jwt.JwtPayload; let payload: jwt.JwtPayload;
@@ -43,4 +161,9 @@ export class SessionVerifier {
displayName: payload.name ? String(payload.name) : undefined, displayName: payload.name ? String(payload.name) : undefined,
}; };
} }
/** Accept a project URL in any shape (…/rest/v1, …/auth/v1, trailing slash). */
private normalize(url: string): string {
return url.trim().replace(/\/+$/, '').replace(/\/(rest|auth)\/v1$/, '');
}
} }
@@ -0,0 +1,46 @@
import { Logger, type INestApplicationContext } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';
import type { Server, ServerOptions } from 'socket.io';
/**
* Socket.io adapter backed by Redis pub/sub (P9 scaling). With the default in-memory
* adapter, `server.to(room).emit()` only reaches sockets on the SAME instance; behind a
* load balancer a client on replica B never sees a message emitted by replica A. This
* adapter fans room emits across every replica through Redis, so realtime works at N>1.
* Wired only when REDIS_URL is set (single-instance dev keeps the in-memory adapter).
*/
export class RedisIoAdapter extends IoAdapter {
private readonly logger = new Logger(RedisIoAdapter.name);
private adapterConstructor?: ReturnType<typeof createAdapter>;
private clients: Redis[] = [];
constructor(
app: INestApplicationContext,
private readonly url: string,
) {
super(app);
}
/** Establish the pub/sub clients + the socket.io Redis adapter factory. */
async connect(): Promise<void> {
const pubClient = new Redis(this.url, { maxRetriesPerRequest: null });
const subClient = pubClient.duplicate();
this.clients = [pubClient, subClient];
pubClient.on('error', (err) => this.logger.error(`redis pub error: ${err.message}`));
subClient.on('error', (err) => this.logger.error(`redis sub error: ${err.message}`));
this.adapterConstructor = createAdapter(pubClient, subClient);
this.logger.log('socket.io Redis adapter connected — realtime fans out across replicas');
}
createIOServer(port: number, options?: ServerOptions): Server {
const server = super.createIOServer(port, options) as Server;
if (this.adapterConstructor) server.adapter(this.adapterConstructor);
return server;
}
async dispose(): Promise<void> {
await Promise.all(this.clients.map((c) => c.quit().catch(() => undefined)));
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { RetentionService } from './retention.service';
/** Data-retention sweep, global so the dev controller + /metrics can reach it. */
@Global()
@Module({
providers: [RetentionService],
exports: [RetentionService],
})
export class RetentionModule {}
@@ -0,0 +1,119 @@
import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { ActorResolver } from '../identity/actor.resolver';
import { IngestService } from '../interactions/ingest.service';
import { RetentionService } from './retention.service';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const actors = new ActorResolver(asService);
const svc = () => new RetentionService(asService);
async function ingest(text: string, orgId = 'org_demo'): Promise<{ interactionId: string; scopeId: string }> {
const res = await new IngestService(asService, makeFakePorts(), actors).ingest(
{
scope: { orgId, appId: 'portal-demo' },
channel: { type: 'WEBHOOK', externalChannelId: 'webhook' },
source: { handleKind: 'EXTERNAL_VISITOR', externalId: 'ext-1' },
kind: 'MESSAGE',
thread: { externalThreadId: `wh-${randomUUID().slice(0, 6)}` },
parts: [{ kind: 'TEXT', bodyText: text }],
occurredAt: new Date().toISOString(),
providerEventId: `pe-${randomUUID()}`,
},
`ing-${randomUUID()}`,
);
const it = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId } });
return { interactionId: res.interactionId, scopeId: it.scopeId };
}
const past = new Date(Date.now() - 86_400_000);
const future = new Date(Date.now() + 86_400_000);
const setSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) =>
prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'interaction', targetId } }, data });
const bodyOf = async (interactionId: string) => (await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } })).bodyText;
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('RetentionService (P9 — retention sweep)', () => {
it('ensureSnapshots captures one ACTIVE snapshot per interaction with archive < delete', async () => {
const { interactionId } = await ingest('hello');
await svc().ensureSnapshots();
const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'interaction', targetId: interactionId } } });
expect(snap.status).toBe('ACTIVE');
expect(snap.dataClass).toBe('internal');
expect(snap.archiveAfter.getTime()).toBeLessThan(snap.deleteAfter.getTime());
});
it('redacts an interaction whose delete window has passed', async () => {
const { interactionId } = await ingest('my secret');
await svc().ensureSnapshots();
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past });
const res = await svc().applySweep();
expect(res.redacted).toBe(1);
expect(await bodyOf(interactionId)).toBe('[redacted]');
expect((await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: interactionId } })).status).toBe('REDACTED');
expect((await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'interaction', targetId: interactionId } } })).status).toBe('REDACTED');
expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted' } })).toBe(1);
});
it('archives (not deletes) when only the archive window has passed, then deletes later', async () => {
const { interactionId } = await ingest('still needed');
await svc().ensureSnapshots();
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: future });
let res = await svc().applySweep();
expect(res).toMatchObject({ archived: 1, redacted: 0 });
expect(await bodyOf(interactionId)).toBe('still needed'); // content untouched by archive
await setSnapshot(interactionId, { deleteAfter: past });
res = await svc().applySweep();
expect(res.redacted).toBe(1);
expect(await bodyOf(interactionId)).toBe('[redacted]');
});
it('an active compliance hold blocks the sweep until released', async () => {
const { interactionId, scopeId } = await ingest('held content');
await svc().ensureSnapshots();
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past });
const hold = await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'message', targetId: interactionId, holdReason: 'legal' } });
let res = await svc().applySweep();
expect(res).toMatchObject({ redacted: 0, skippedHeld: 1 });
expect(await bodyOf(interactionId)).toBe('held content');
await prisma.iiosComplianceHold.update({ where: { id: hold.id }, data: { status: 'RELEASED' } });
res = await svc().applySweep();
expect(res.redacted).toBe(1);
expect(await bodyOf(interactionId)).toBe('[redacted]');
});
it('is idempotent — a REDACTED snapshot is terminal', async () => {
const { interactionId } = await ingest('x');
await svc().ensureSnapshots();
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past });
await svc().applySweep();
expect(await svc().applySweep()).toMatchObject({ archived: 0, redacted: 0, skippedHeld: 0 });
});
it('sweep is tenant-scoped', async () => {
const a = await ingest('tenant a', 'org_A');
const b = await ingest('tenant b', 'org_B');
await svc().ensureSnapshots();
await setSnapshot(a.interactionId, { archiveAfter: past, deleteAfter: past });
await setSnapshot(b.interactionId, { archiveAfter: past, deleteAfter: past });
const res = await svc().applySweep(a.scopeId);
expect(res.redacted).toBe(1);
expect(await bodyOf(a.interactionId)).toBe('[redacted]');
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
});
});
@@ -0,0 +1,127 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { recordAudit } from '../observability/audit';
const DAY_MS = 86_400_000;
export interface SweepResult {
archived: number;
redacted: number;
skippedHeld: number;
}
/**
* Data retention (P9). Each interaction gets a per-resource retention snapshot with
* frozen archive/delete timestamps; a scheduled sweep archives (status change) then
* deletes = redacts-in-place (reusing the DSR tombstone semantics) once a resource ages
* past its window. An active compliance hold blocks both. Never a hard delete.
*/
@Injectable()
export class RetentionService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RetentionService.name);
private timer?: ReturnType<typeof setInterval>;
constructor(private readonly prisma: PrismaService) {}
onModuleInit(): void {
const ms = Number(process.env.IIOS_RETENTION_SWEEP_INTERVAL_MS ?? 0);
if (ms > 0) {
this.timer = setInterval(() => {
void this.sweep().catch((err) => this.logger.warn(`retention sweep failed: ${(err as Error).message}`));
}, ms);
}
}
onModuleDestroy(): void {
if (this.timer) clearInterval(this.timer);
}
private windowDays(dataClass: string, kind: 'ARCHIVE' | 'DELETE'): number {
const cls = process.env[`IIOS_RETENTION_${kind}_DAYS_${dataClass.toUpperCase()}`];
const glob = process.env[`IIOS_RETENTION_${kind}_DAYS`];
return Number(cls ?? glob ?? (kind === 'ARCHIVE' ? 90 : 365));
}
/** Lazily capture a per-resource snapshot for any interaction that lacks one. */
async ensureSnapshots(scopeId?: string): Promise<number> {
const interactions = await this.prisma.iiosInteraction.findMany({
where: scopeId ? { scopeId } : {},
select: { id: true, scopeId: true, dataClass: true, receivedAt: true },
});
let created = 0;
for (const i of interactions) {
const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({
where: { targetType_targetId: { targetType: 'interaction', targetId: i.id } },
});
if (exists) continue;
const base = i.receivedAt.getTime();
await this.prisma.iiosRetentionPolicySnapshot.create({
data: {
policyKey: `default:${i.dataClass}:v1`,
scopeSnapshotId: i.scopeId,
targetType: 'interaction',
targetId: i.id,
dataClass: i.dataClass,
archiveAfter: new Date(base + this.windowDays(i.dataClass, 'ARCHIVE') * DAY_MS),
deleteAfter: new Date(base + this.windowDays(i.dataClass, 'DELETE') * DAY_MS),
sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1',
},
});
created++;
}
return created;
}
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
async applySweep(scopeId?: string): Promise<SweepResult> {
const now = new Date();
const due = await this.prisma.iiosRetentionPolicySnapshot.findMany({
where: { status: { in: ['ACTIVE', 'ARCHIVED'] }, archiveAfter: { lte: now }, ...(scopeId ? { scopeSnapshotId: scopeId } : {}) },
});
const result: SweepResult = { archived: 0, redacted: 0, skippedHeld: 0 };
for (const s of due) {
const held = await this.prisma.iiosComplianceHold.findFirst({
where: { targetId: s.targetId, status: 'ACTIVE', OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
});
if (held) {
result.skippedHeld++;
continue;
}
if (s.deleteAfter <= now) {
await this.prisma.$transaction([
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }),
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }),
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
]);
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
result.redacted++;
} else if (s.status === 'ACTIVE') {
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
await recordAudit(this.prisma, { action: 'retention.archived', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
result.archived++;
}
}
return result;
}
/** Full sweep: capture missing snapshots, then act on due ones. */
async sweep(scopeId?: string): Promise<SweepResult> {
await this.ensureSnapshots(scopeId);
return this.applySweep(scopeId);
}
/** Snapshot lifecycle counts for /metrics (global ops). */
async summary(): Promise<{ total: number; byStatus: Record<string, number> }> {
const groups = await this.prisma.iiosRetentionPolicySnapshot.groupBy({ by: ['status'], _count: true });
const byStatus: Record<string, number> = {};
let total = 0;
for (const g of groups) {
byStatus[g.status] = g._count;
total += g._count;
}
return { total, byStatus };
}
}
@@ -13,6 +13,7 @@ import { RouteService } from './route.service';
import { RouteProjector } from './route.projector'; import { RouteProjector } from './route.projector';
import { IngestService } from '../interactions/ingest.service'; import { IngestService } from '../interactions/ingest.service';
import { OutboundService } from '../adapters/outbound.service'; import { OutboundService } from '../adapters/outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry'; import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { OutboxBus } from '../outbox/outbox.bus'; import { OutboxBus } from '../outbox/outbox.bus';
@@ -106,7 +107,7 @@ describe('Route simulator (P6, preview-first)', () => {
describe('RouteService approve/deny → execute (P6)', () => { describe('RouteService approve/deny → execute (P6)', () => {
const admin: MessagePrincipal = { userId: 'admin', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Admin' }; const admin: MessagePrincipal = { userId: 'admin', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Admin' };
const svc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())), actors); const svc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)), actors);
it('approving a REVIEW decision executes the forward to the sandbox', async () => { it('approving a REVIEW decision executes the forward to the sandbox', async () => {
const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
@@ -160,7 +161,7 @@ describe('RouteService approve/deny → execute (P6)', () => {
}); });
describe('RouteProjector — AUTOMATIC forwarding (P6)', () => { describe('RouteProjector — AUTOMATIC forwarding (P6)', () => {
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())), actors); const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)), actors);
const projector = () => new RouteProjector(asService, new OutboxBus(), new DlqService(asService), routeSvc(), new ProjectionCursorService(asService)); const projector = () => new RouteProjector(asService, new OutboxBus(), new DlqService(asService), routeSvc(), new ProjectionCursorService(asService));
// Ingest an inbound (channel-bearing) interaction — routable, unlike native DMs. // Ingest an inbound (channel-bearing) interaction — routable, unlike native DMs.
@@ -8,7 +8,7 @@ import type { PrismaClient } from '@prisma/client';
export async function resetDb(prisma: PrismaClient): Promise<void> { export async function resetDb(prisma: PrismaClient): Promise<void> {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`TRUNCATE TABLE `TRUNCATE TABLE
"IiosComplianceHold","IiosProjectionCursor","IiosIdempotencyCommand","IiosDlqItem","IiosAuditLink", "IiosRetentionPolicySnapshot","IiosComplianceHold","IiosProjectionCursor","IiosIdempotencyCommand","IiosDlqItem","IiosAuditLink",
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest", "IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow", "IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef", "IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
@@ -1,6 +1,10 @@
import { IsOptional, IsString } from 'class-validator'; import { IsInt, IsOptional, IsString } from 'class-validator';
export class SendMessageDto { export class SendMessageDto {
@IsString() content!: string; @IsString() content!: string;
@IsOptional() @IsString() contentRef?: string; @IsOptional() @IsString() contentRef?: string;
@IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
@IsOptional() @IsString() checksumSha256?: string;
@IsOptional() @IsString() parentInteractionId?: string;
} }
@@ -8,6 +8,7 @@ import {
HttpCode, HttpCode,
Param, Param,
Post, Post,
Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ThreadsService } from './threads.service'; import { ThreadsService } from './threads.service';
import { MessageService } from '../messaging/message.service'; import { MessageService } from '../messaging/message.service';
@@ -22,11 +23,50 @@ export class ThreadsController {
private readonly session: SessionVerifier, private readonly session: SessionVerifier,
) {} ) {}
/** Generic "my threads" — every thread the caller participates in (last message + unread). */
@Get()
async listThreads(@Headers('authorization') auth?: string) {
return this.messages.listThreads(this.principal(auth));
}
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
@Get('my-annotations')
async myAnnotations(@Headers('authorization') auth?: string, @Query('type') type = 'save') {
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. */
@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 });
}
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
@Post(':id/participants')
@HttpCode(201)
async addParticipant(@Param('id') id: string, @Body() body: { userId: string; role?: string }, @Headers('authorization') auth?: string) {
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
}
@Get(':id/messages') @Get(':id/messages')
async listMessages(@Param('id') id: string) { async listMessages(@Param('id') id: string) {
return this.threads.getMessages(id); return this.threads.getMessages(id);
} }
/** Mute / unmute notifications from this thread for the caller. */
@Post(':id/mute')
@HttpCode(200)
async mute(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.muteThread(id, this.principal(auth), true);
}
@Post(':id/unmute')
@HttpCode(200)
async unmute(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.muteThread(id, this.principal(auth), false);
}
/** REST/polling fallback for native send (same write as the socket path). */ /** REST/polling fallback for native send (same write as the socket path). */
@Post(':id/messages') @Post(':id/messages')
@HttpCode(201) @HttpCode(201)
@@ -36,14 +76,19 @@ export class ThreadsController {
@Headers('authorization') authorization?: string, @Headers('authorization') authorization?: string,
@Headers('idempotency-key') idempotencyKey?: string, @Headers('idempotency-key') idempotencyKey?: string,
) { ) {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
const principal = this.session.verify(token);
return this.messages.send( return this.messages.send(
id, id,
principal, this.principal(authorization),
{ content: body.content, contentRef: body.contentRef }, { content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
idempotencyKey ?? randomUUID(), idempotencyKey ?? randomUUID(),
undefined,
body.parentInteractionId,
); );
} }
private principal(authorization?: string) {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
} }
+1997 -38
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
import { execSync } from 'node:child_process';
// Tests TRUNCATE between cases, so they MUST NOT touch the dev database. Run them
// against an isolated `iios_test` DB (created + migrated here, once, before the suite).
const TEST_URL = 'postgresql://iios:iios@localhost:5434/iios_test?schema=public';
export default function setup() {
try {
execSync(`docker exec iios-db psql -U iios -d postgres -c "CREATE DATABASE iios_test"`, { stdio: 'pipe' });
} catch (e) {
const msg = String(e.stderr ?? e.stdout ?? e);
if (!/already exists/i.test(msg)) {
console.warn(`[vitest] could not create iios_test (is the iios-db container up?): ${msg.slice(0, 160)}`);
}
}
execSync('pnpm --filter @insignia/iios-service exec prisma migrate deploy', {
stdio: 'inherit',
env: { ...process.env, DATABASE_URL: TEST_URL },
});
}
+7 -3
View File
@@ -15,9 +15,13 @@ export default defineConfig({
}, },
test: { test: {
include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'], include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'],
// DB-backed specs share one Postgres and TRUNCATE between tests. Run every // DB-backed specs TRUNCATE between tests, so they run against an ISOLATED
// file in a single worker process, sequentially, so there is no cross-file // `iios_test` database (created + migrated by the global setup) — never the dev
// race on the shared database. // DB. This env overrides any DATABASE_URL from the shell.
env: { DATABASE_URL: 'postgresql://iios:iios@localhost:5434/iios_test?schema=public' },
globalSetup: ['./scripts/vitest-global-setup.mjs'],
// Run every file in a single worker process, sequentially, so there is no
// cross-file race on the shared database.
fileParallelism: false, fileParallelism: false,
sequence: { concurrent: false }, sequence: { concurrent: false },
pool: 'forks', pool: 'forks',