38 Commits

Author SHA1 Message Date
maaz519 00d22be714 Merge pull request 'feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads' (#5) from feat/s3-storage into dev
Reviewed-on: #5
2026-07-20 09:01:56 +00:00
maaz519 b4104b9769 feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads
- media upload policy now allows text/html, text/markdown, text/x-markdown,
  text/csv (in addition to images/av, pdf, txt, zip, office docs)
- blob endpoint adds X-Content-Type-Options: nosniff, and forces
  Content-Disposition: attachment for script-capable types (html, xhtml, svg,
  xml) so an uploaded file can't render/execute inline from the IIOS origin
  (stored-XSS). Images/video/audio/pdf still serve inline for preview.
- dev-opa test covering the allowed types + unknown/oversize denials

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:30:25 +05:30
maaz519 32aa04503d Merge pull request 'Feat/s3 storage' (#4) from feat/s3-storage into dev
Reviewed-on: #4
2026-07-18 12:28:22 +00:00
maaz519 c8c2d0811b Merge remote-tracking branch 'origin/dev' into feat/s3-storage 2026-07-18 17:57:21 +05:30
maaz519 ce33834d56 feat(threads): governed group settings — rename, list members, remove participant
- MessageService.renameThread / removeParticipant / listParticipants,
  each fail-closed via OPA (kernel stays generic — no dm/group branching)
- dev OPA: iios.thread.update + iios.thread.participant.remove rules
  (group requires ADMIN; ungoverned threads unchanged)
- REST: PATCH /v1/threads/:id, GET + DELETE /v1/threads/:id/participants
- tests: admin renames/removes, member is denied, member list carries roles
  (message.spec + dev-opa.port.spec)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:34:28 +05:30
maaz519 b164ef945c feat(mail): in-app attachments on internal mail + surface media parts in thread reads
- MailInternalDto accepts attachments[]; deposit() stores each as a media
  part (image/video→MEDIA_REF, audio→VOICE_REF, else FILE_REF)
- ingest now persists part sizeBytes; contract + DTO carry it
- threads.getMessages returns mimeType + sizeBytes so mail readers can
  render inline images / file chips
- test: internal mail stores an image attachment as a MEDIA_REF part

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:24:49 +05:30
maaz519 18498ee9fa Merge pull request 'feat(mail): tag mail threads source=crm-mail (list separately from chat)' (#3) from feat/s3-storage into dev
Reviewed-on: #3
2026-07-18 11:12:35 +00:00
maaz519 40c98522dc feat(mail): tag mail threads source=crm-mail (list separately from chat)
ingest() puts metadata on the interaction, not the thread, and listThreads filters
THREAD metadata — so MailService now tags the thread directly after deposit
(source=crm-mail). Lets the CRM list mail threads apart from Messenger chat
(source=crm-messenger). 7 tests. NOTE: requires an IIOS redeploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:30:28 +05:30
maaz519 50f9b3213d Merge pull request 'Feat/s3 storage' (#1) from feat/s3-storage into dev
Reviewed-on: #1
2026-07-18 09:06:39 +00:00
maaz519 0c6650f3c6 feat(media): S3/MinIO storage adapter (StoragePort)
Drop-in S3-compatible backend for object storage (media + email attachments) —
the swap the StoragePort seam was designed for. MinIO/self-hosted/R2/Supabase all
work via endpoint + path-style.

- S3Storage implements StoragePort (put/get/remove); sha256 computed locally on put
  (matches LocalDiskStorage); a missing object reads back as null, not an error.
- Env-driven binding in MediaModule: IIOS_S3_BUCKET + keys set → S3Storage, else
  LocalDiskStorage. forcePathStyle defaults true (MinIO); endpoint omitted → AWS.
- S3 client is injectable so tests run with no live server.

6 unit tests (config parse, put size/sha, get round-trip, NoSuchKey→null, remove).
Full suite 300/300, boundary + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:26:53 +05:30
maaz519 d28c873d40 feat(mail): email attachments over SMTP
Email can now carry attachments (e.g. an invoice PDF). The kernel already stored
attachments as message parts + the media StoragePort holds the bytes; the gap was
the email envelope.

- EMAIL payload carries attachment REFS ({filename, contentRef, mimeType}), not
  bytes — the ledger + T8 PII redaction stay small; bytes are fetched at send time.
- SmtpProvider takes an AttachmentResolver; resolves each ref via storage and attaches
  (nodemailer). FAILS CLOSED if a declared attachment can't be resolved (or no resolver
  is wired) — never send a receipt/invoice missing its file; a FAILED command retries.
- CapabilityProviderRegistry injects the resolver from STORAGE_PORT (@Optional);
  MediaModule exports STORAGE_PORT, CapabilityModule imports MediaModule. No cycle.
- TemplatedSender + MailService + the /v1/mail/send DTO pass attachments through.

Verified: 6 new unit tests (attach, fail-closed x2, plain-unaffected, resolver,
pass-through) + a REAL Ethereal SMTP send WITH a PDF attachment (SENT). Full suite
294/294, boundary + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:14:20 +05:30
maaz519 9b075f46f9 feat(mail): inbox mirror + INTERNAL (app-to-app) delivery
MailService renders a template then deposits it as an EMAIL interaction on a
per-email thread (thread model: one thread per email), reusing IngestService.
Because ingest adds no participants and a thread is only visible to its
participants, it ensureParticipant()s BOTH sender and recipient — so the mirror
is actually visible.

- postInternal: app-to-app mail, no SMTP → recipient's in-app inbox.
- sendExternalWithMirror: SMTP send (TemplatedSender) + mirror an interaction
  ONLY for a registered recipient (pre-registration sends are email-only — no
  inbox exists yet). Idempotent across both the send and the mirror.
- Writes Interactions, NEVER InboxItems (the projector owns those — KG-15).
- POST /v1/mail/internal, POST /v1/mail/send. MailModule in AppModule.

Verified over HTTP: internal mail → recipient SEES the thread in their inbox;
external send → command + mirror; no-source/no-auth 400. 7 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:54:32 +05:30
maaz519 bba5fae061 docs(mail): inbox mirror + INTERNAL delivery plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:49:19 +05:30
maaz519 74cca2d534 feat(smtp): SMTP egress provider (nodemailer) for the EMAIL channel
Makes external email actually leave the building (was sandbox-only). SmtpProvider
implements CapabilityProvider; env-activated (accounts@ primary, ceo@ fallback);
transporter injected for tests.

- send() maps target+payload -> {from,to,subject,html,text,inReplyTo,references};
  providerRef = nodemailer's real Message-ID (so replies thread via In-Reply-To).
- Fallback ONLY on pre-acceptance failures (connect/auth/timeout) — a post-acceptance
  error is terminal, so a message the server already took can't be double-delivered.
- Never throws — transport failure -> FAILED, per the adapter doctrine.
- Registry precedence via registration order: SMTP > HTTP relay > sandbox for EMAIL.

Verified: 13 unit tests (config/envelope/fallback/precedence) + a REAL SMTP round-trip
against nodemailer Ethereal (SENT, genuine Message-ID). Full suite 281/281, boundary+build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:31:04 +05:30
maaz519 7d7c75915a docs(smtp): SMTP provider plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:25:37 +05:30
maaz519 65023ce404 feat(templates): T8 PII redaction of outbound commands (fast-follow)
An email/SMS command holds PII: the recipient address (target) and the rendered
body (payload). RetentionService now snapshots outbound commands and, once aged,
redacts target->'[redacted]' and payload->{redacted:true} in place while KEEPING
the template provenance (key/version/locale/hash) — so 'which template version did
we send?' stays answerable after the PII is gone.

- ensureOutboundSnapshots: one snapshot per command, dataClass 'outbound',
  archiveAfter==deleteAfter (PII goes straight to redact, no archive phase).
  Nullable command scope uses an 'unscoped' tag so the global sweep still reaches it.
- applySweep redact branch switches on targetType; compliance holds honored; audit
  'retention.redacted' resourceType 'outbound_command'.

Closes lever #2 of the PII-minimization plan. 3 new + 6 regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:56:22 +05:30
maaz519 b44f795ba4 feat(templates): T6 controller + DTOs + module wiring
POST /v1/templates/send (render→queue) and /preview (render only). OPA-gated in
the sender: iios.template.send, and iios.template.send.inline for ad-hoc HTML
(arbitrary markup to a customer is riskier than a reviewed stored template).
Scope resolved from the caller's principal. TemplateModule registered in AppModule.

Verified over HTTP against a local boot: preview + send of the seeded
payment.receipt (SENT via sandbox, provenance persisted), XSS name escaped,
idempotent replay → 1 row, unknown key 404, bad channel/no-source/no-auth 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:39:04 +05:30
maaz519 37407cb0af feat(templates): T5 boot seeder + default seeds
TemplateSeeder (OnModuleInit) seeds repo file defaults as global (scopeId NULL)
templates if absent. Idempotent per (key,channel,locale,version): re-boot inserts
nothing; a bumped version adds a new row and keeps the old for audit. Seeds:
welcome (email), payment.receipt (email+sms), onboarding.reminder (email) —
placeholder copy for marketing to replace. 3 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:33:12 +05:30
maaz519 10f3545a25 feat(templates): T4 sendTemplated() + outbound provenance
- IiosOutboundCommand gains templateKey/version/locale/renderedHash (migration).
- OutboundService.send accepts an optional opaque `provenance` and writes it into
  the command on BOTH the PENDING and RATE_LIMITED create paths — the only way to
  stamp provenance by construction, since send() creates the row itself (review
  finding). OutboundService still neither renders nor resolves templates.
- TemplatedSender.sendTemplated: render -> OutboundService.send -> provenance, one
  entry point. EMAIL payload = {subject,html,text}; SMS = {text}. Idempotent per key.

Tests: 4 sender + 16 adapters regression green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:30:19 +05:30
maaz519 8ce647552a feat(templates): T3 render() service (resolve+render, inline, version pin)
TemplateService.render(source, vars, opts) → { content, provenance }. Stored
key → resolve+render; inline → render without a DB row (key/version null);
explicit version pins. Provenance = {templateKey, version, locale, sha256(content)}.
10 tests (svc + repo).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:45:11 +05:30
102 changed files with 5086 additions and 2004 deletions
+12
View File
@@ -0,0 +1,12 @@
# IIOS Project Memory — Index
Repo-local, versioned project memory. The full canonical rules are in the repo root
**`CLAUDE.md`**; these files are granular, per-topic memories that accumulate across sessions.
Read the relevant ones before working in that area; add/update files as the project evolves.
- [project_iios_overview.md](project_iios_overview.md) — what IIOS is (generic interaction OS; kernel + specializations + platform ports)
- [feedback_generic_safety.md](feedback_generic_safety.md) — THE #1 rule: no chat/domain vocabulary in the kernel
- [reference_run_and_test.md](reference_run_and_test.md) — run command, isolated `iios_test` DB, and the `replay.spec` flake workaround
- [project_recent_features.md](project_recent_features.md) — the "real-providers" era: Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications
- [reference_chat_web_consumer.md](reference_chat_web_consumer.md) — chat-web is the reference app driving IIOS feature work
- [feedback_workflow.md](feedback_workflow.md) — commits (email + co-author), IIOS_DEV_TOKENS off in prod, TDD, build-before-restart
+28
View File
@@ -0,0 +1,28 @@
---
name: feedback_generic_safety
description: THE #1 locked rule — the kernel must never hardcode chat/domain vocabulary
metadata:
type: feedback
---
**The kernel must stay generic.** No `'dm'` / `'group'` / `'reaction'` / `'emoji'` / `'mention'`
literals in kernel or messaging *logic*. If a reviewer asked "is this a chat backend now?" the
answer must stay **no**.
**Why:** IIOS is a generic interaction OS; chat is one consumer. Baking chat meaning into the
kernel destroys reuse (support/community/meetings share the same core).
**How to apply:** domain meaning lives in exactly three places, never the kernel —
1. **OPA policy** (`DevOpaPort` → real OPA): DM-cap, group-admin, governed-join, media limits,
notification triggers.
2. **Opaque attributes** the kernel stores but never interprets: `thread.metadata.membership`
(`dm`/`group`), interaction annotations (opaque `annotationType`+`value` → app writes
`reaction`/`pin`/`save`), `mentions[]` (opaque userId notify-list; kernel never parses `@`).
3. **The app** (chat-web): rendering + product semantics.
Reading an opaque attr inside a **policy/notification gate** (`membership === 'dm'` in
`DevOpaPort` or `notification.projector.ts`) is OK — that file IS the policy plane. Everywhere
else, keep generic. Verify before committing:
`grep -rniE "'dm'|'group'|reaction|emoji" packages/iios-service/src | grep -v spec` — hits only
in policy/notification/app-facing layers or comments. Also run `pnpm boundary`.
Related: [[project_iios_overview]].
+19
View File
@@ -0,0 +1,19 @@
---
name: feedback_workflow
description: Working conventions — commits, prod flags, TDD, rebuild-before-restart
metadata:
type: feedback
---
- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email
**`maaz@insigniaconsultancy.com`**, co-author every commit with Claude. The user commits
directly to `main` in this project (fine); branch only if asked.
- **⚠ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token
minting, chaos, retention sweep). The single most important prod-hardening flag.
- **TDD** — write the failing spec first; verify it fails; implement; verify it passes.
- **Rebuild before restart** — after backend changes, `nest build` then restart from `dist`;
`lsof -ti :3200 | xargs kill -9` first or the old build keeps serving (stale-dist bites).
- **New kernel capability = a generic primitive only** — add domain meaning in OPA policy + the
app, never the kernel. See [[feedback_generic_safety]].
- The user prefers **direct, fast iteration** (implement → verify end-to-end → commit), not
heavyweight multi-agent/spec ceremony. Include "how to test" in summaries; report failures honestly.
+23
View File
@@ -0,0 +1,23 @@
---
name: project_iios_overview
description: What IIOS is — a generic multi-tenant interaction OS; kernel + specializations + swappable platform ports
metadata:
type: project
---
IIOS (Insignia Interaction OS) = one NestJS service (`@insignia/iios-service`) + SDKs, in a
pnpm monorepo (`packages/*`). Every interaction (chat message, ticket, routed post, AI
suggestion, meeting) is the **same kernel object** inside a **tenant scope** (`IiosScope`:
org/app/tenant/…), behind the **same fail-closed gates**, emitting the **same audit trail**.
Products (messaging, inbox, support, routing, AI, calendar, media, notifications) are thin
**specializations** on top of a tiny kernel — never the reverse (`pnpm boundary` enforces it).
Platform seams are **ports** (`IiosPlatformPorts`, DI token `PLATFORM_PORTS`; dev = permissive
`LocalDevPorts`): session, opa, cmp (consent), mdm, sas, capability, plus `StoragePort` (media)
and `NotificationPort` (push). **Dev stubs swap to real adapters with zero consumer changes.**
Every op passes `decideOrThrow(ports, {action,…})` fail-closed. Events go through a
transactional outbox → `OutboxBus` → idempotent projectors (inbox, notifications).
See the repo `CLAUDE.md` and `docs/IIOS_API_AND_SDK_GUIDE.md` (as-built reference) for detail.
Related: [[feedback_generic_safety]].
+31
View File
@@ -0,0 +1,31 @@
---
name: project_recent_features
description: The "real-providers" era — Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications
metadata:
type: project
---
Beyond P0P8 (kernel → messaging → inbox → support → adapters → routing → AI →
calendar/meetings), recent work (mostly driven by the chat app, the start of P9 "real
providers"):
- **Real Supabase auth** — `SessionVerifier` verifies real OIDC tokens against issuer JWKS
(ES256, no secret), a **multi-issuer registry** routed by the `iss` claim → per-issuer `appId`
scope (`AUTH_ISSUERS` JSON, or `SUPABASE_URL` single-issuer shorthand). `userId = email`.
Legacy HS256 app-token path (`APP_SECRETS`) stays for dev/tests.
- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive (opaque
`annotationType`+`value`); socket `annotate` event → `annotation` broadcast;
`GET /v1/threads/my-annotations?type=save`.
- **@mentions → Inbox** — `send(... mentions[])` (opaque userId list) → `InboxProjector` fans out
a `MENTION` inbox item to mentioned participants; reading resolves it.
- **Media** — `StoragePort` (dev = local disk `MEDIA_DIR`; prod swap to S3/Supabase), presigned
upload/download (signed HS256 tokens, OPA-gated size/type, tenant-fenced), `attachment` on
`MessageDto`; parts use generic `MEDIA_REF/VOICE_REF/FILE_REF`.
- **Notifications** — presence-gated Web Push: `NotificationProjector` runs 3 gates
(policy=DM/mention/reply-to-you · presence=`focus_thread` signal · per-thread `muted`) →
swappable `NotificationPort` (Web Push/VAPID); dead sub (410) pruned. Presence is in-memory
(single-instance) → Redis for multi-replica.
- `senderId` (stable externalId) on `MessageDto` for reliable "is this mine?".
~205 tests. Keep `docs/IIOS_API_AND_SDK_GUIDE.md` current when adding endpoints.
Related: [[reference_chat_web_consumer]], [[feedback_generic_safety]].
@@ -0,0 +1,20 @@
---
name: reference_chat_web_consumer
description: chat-web is the reference consumer app that drives IIOS feature work
metadata:
type: reference
---
**chat-web** (separate repo, `~/Documents/insignia-work/chat-web`) is the reference app on IIOS —
a 1:1 + group chat UI (Vite + React + TanStack Router/Query + socket.io-client + supabase-js).
It's frontend-only; IIOS provides identity, threads, messages, realtime, reactions, mentions,
media, notifications.
Feature work usually spans **both repos**: a generic primitive/port in iios + the app UI in
chat-web. The layer split we follow: **service** owns storage/auth/governance, the **SDK layer**
(`chat-web/src/lib/*`, mirrors `@insignia/iios-kernel-client`) owns client plumbing
(e.g. `uploadMedia`/`mediaUrl`, `registerPush`), the **app** owns rendering.
chat-web uses real Supabase login (`VITE_SUPABASE_URL` + anon key in its `.env`); identity =
email. Run it with `pnpm dev`. Commit both repos with git email `maaz@insigniaconsultancy.com`.
Related: [[project_recent_features]].
+30
View File
@@ -0,0 +1,30 @@
---
name: reference_run_and_test
description: How to run the service + the test-DB isolation and the replay.spec flake workaround
metadata:
type: reference
---
**Infra:** Postgres in docker `iios-db` on **:5434** (db `iios`), Redis on :6379. If Docker is
down: `open -a OrbStack` then `docker start iios-db`.
**Run** (dev auth via Supabase; media + push enabled):
```
pnpm --filter @insignia/iios-service exec nest build # rebuild after backend changes
SUPABASE_URL=https://<ref>.supabase.co REDIS_URL=redis://localhost:6379 PORT=3200 \
APP_SECRETS='{"portal-demo":"dev-secret"}' MEDIA_DIR=/tmp/iios-media \
VAPID_PUBLIC_KEY=… VAPID_PRIVATE_KEY=… VAPID_SUBJECT=mailto:dev@insignia \
node packages/iios-service/dist/main.js # :3200 ; GET /health
```
Restarting: `lsof -ti :3200 | xargs kill -9` first (stale instance → EADDRINUSE / old build served).
**Tests:** `pnpm test` (Vitest) runs against an **isolated `iios_test` DB** (globalSetup creates
+ migrates it; `DATABASE_URL` overridden) — it **never wipes the dev `iios` DB**. TDD: spec next
to code; DB specs use `resetDb()`.
**⚠ Known flake — `outbox/replay.spec`:** clock/ordering-sensitive, pre-existing. If it fails in a
full run, it's stale `iios_test` state, NOT a regression. Fix:
`docker exec iios-db psql -U iios -d postgres -c "DROP DATABASE IF EXISTS iios_test WITH (FORCE)"`
then re-run. Prove it's not yours by stashing changes and re-running.
`pnpm boundary` = import-boundary check (must stay OK). Smokes: `packages/iios-service/scripts/smoke-*.mjs`.
+27
View File
@@ -0,0 +1,27 @@
name: publish-sdks
# Publish the @insignia/iios-* SDK packages to the Gitea npm registry on a version tag.
# Requires: Gitea Actions enabled + a runner, and a repo secret GITEA_PUBLISH_TOKEN
# (a token with `write:package` scope for the `insignia` org).
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm -r build
- run: pnpm -r publish --no-git-checks
env:
# maps to ${GITEA_TOKEN} in .npmrc; private packages (service, testkit) are skipped
GITEA_TOKEN: ${{ secrets.GITEA_PUBLISH_TOKEN }}
@@ -42,7 +42,6 @@ jobs:
- name: Bump k8s-pods image tag (ArgoCD deploys) - name: Bump k8s-pods image tag (ArgoCD deploys)
run: | run: |
rm -rf /tmp/kp # dind-builder is a persistent host: clear any stale checkout from a prior run
git clone --depth 1 -b main \ git clone --depth 1 -b main \
"https://mcp-bot:${{ secrets.K8S_PODS_TOKEN }}@git.lynkedup.cloud/platform-engineering/k8s-pods.git" /tmp/kp "https://mcp-bot:${{ secrets.K8S_PODS_TOKEN }}@git.lynkedup.cloud/platform-engineering/k8s-pods.git" /tmp/kp
cd /tmp/kp cd /tmp/kp
+6
View File
@@ -0,0 +1,6 @@
# @insignia SDK packages publish to / install from the Gitea package registry.
# Auth comes from the GITEA_TOKEN env var (never commit the token itself).
# publish → token needs the `write:package` scope
# install → token needs `read:package`
@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/
//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN}
+122
View File
@@ -0,0 +1,122 @@
# IIOS — Claude Code Rules
> **Project memory:** granular, versioned per-topic notes live in **`.claude/memory/`** — read
> `.claude/memory/MEMORY.md` (the index) and the relevant entries before working in an area, and
> add/update memories as the project evolves. This file is the canonical rules; those are the
> accumulating notes.
## What IIOS is
**IIOS (Insignia Interaction OS)** is a **generic, multi-tenant "interaction OS"** — one
NestJS service (`@insignia/iios-service`) + a family of SDKs. Every interaction (a chat
message, a support ticket, a routed post, an AI suggestion, a meeting) is the **same kernel
object** inside a **tenant scope**, behind the **same fail-closed gates** (policy/consent),
emitting the **same audit trail**. Products (messaging, support, community, AI, meetings) are
thin **specializations** on top of the kernel — never the other way around.
`chat-web` (separate repo) is the reference consumer app.
## THE #1 LOCKED RULE — generic-safety (read this before touching the kernel)
**The kernel must never hardcode chat/domain vocabulary.** No `'dm'` / `'group'` /
`'reaction'` / `'emoji'` / `'mention'` literals in kernel or messaging *logic*. If a reviewer
asked *"is this a chat backend now?"* the answer must stay **no**.
Domain meaning lives in **three** places, never the kernel:
1. **OPA policy** (the policy plane — `DevOpaPort` now, real OPA later). The DM-cap,
group-admin, governed-join, media-limit, and notification-trigger rules live here.
2. **Opaque thread/interaction attributes** the kernel stores but never interprets:
`thread.metadata.membership` (`'dm'|'group'`), interaction annotations (opaque
`annotationType` + `value` → the app writes `reaction`/`pin`/`save`), `mentions[]` (an
opaque userId notify-list the kernel fans out; it never parses `@`).
3. **The app** (chat-web) — rendering + product semantics.
Reading an opaque attribute inside a **policy/notification gate** (e.g. `membership === 'dm'`
in `DevOpaPort` or `notification.projector.ts`) is allowed — that file *is* the policy plane,
not the kernel. Everywhere else, keep it generic. Verify with a grep before committing:
`grep -rniE "'dm'|'group'|reaction|emoji" packages/iios-service/src | grep -v spec` — hits
should only be in the policy/notification/app-facing layers or comments.
`pnpm boundary` enforces the layer dependency law (specializations import the kernel, never
the reverse). Run it; don't break it.
## Architecture
- **Kernel primitives** (generic): `IiosScope` (six-vector: org/app/tenant/bu/…), `IiosSourceHandle`
(externalId = userId; stays `UNVERIFIED` until MDM resolves → `canonicalEntityId`), `IiosActorRef`,
`IiosThread` (subject/metadata), `IiosThreadParticipant`, `IiosInteraction` (+ `parentInteractionId`
reply link), `IiosMessagePart` (media as `contentRef`), `IiosInteractionAnnotation` (generic).
- **Platform ports** (`IiosPlatformPorts`, DI token `PLATFORM_PORTS`; dev = `LocalDevPorts`):
session, opa, cmp (consent), mdm, sas, capability — plus a `StoragePort` (media) and
`NotificationPort` (push). **Dev stubs → real adapters with zero consumer changes.** Every
op passes `decideOrThrow(ports, {action,…})` **fail-closed**.
- **Session (auth):** `SessionVerifier` verifies (a) real OIDC tokens (Supabase/`AUTH_ISSUERS`)
against the issuer JWKS (ES256, no secret), routed by `iss` → per-issuer `appId` scope; or
(b) legacy dev HS256 app tokens (`APP_SECRETS`, keyed by `appId`). `userId = email` for OIDC.
- **Events:** transactional outbox → `OutboxBus`**projectors** (inbox, notifications).
Projectors are **idempotent** (`claim()` on `IiosProcessedEvent` + projection cursor).
Delivery is at-least-once → clients dedupe by message `id`.
- **Scope isolation:** every row is tagged by `scopeId` (org+app+tenant). By-id ops call
`assertOwns` (tenant fence → 403). Always `select`/scope Prisma queries.
## Tech stack
NestJS 11 · Prisma 6 / PostgreSQL 16 (docker `iios-db` on **:5434**, db `iios`) · Redis
(socket.io adapter, multi-replica) · socket.io (`/message` namespace) · Vitest · pnpm
monorepo (`packages/*`). `iios-service` is a **modular monolith** (HTTP + WS + relay +
projectors in one process).
## Running & testing
```bash
docker start iios-db # Postgres :5434 (OrbStack; `open -a OrbStack` if down)
pnpm --filter @insignia/iios-service exec nest build
# run (dev auth via Supabase; media + notifications enabled):
SUPABASE_URL=https://<ref>.supabase.co REDIS_URL=redis://localhost:6379 PORT=3200 \
APP_SECRETS='{"portal-demo":"dev-secret"}' MEDIA_DIR=/tmp/iios-media \
VAPID_PUBLIC_KEY=VAPID_PRIVATE_KEY=VAPID_SUBJECT=mailto:dev@insignia \
node packages/iios-service/dist/main.js # → :3200 ; GET /health
```
- **`pnpm test`** — Vitest. Runs against an **isolated `iios_test` DB** (globalSetup creates +
migrates it; `DATABASE_URL` overridden). **It never wipes the dev `iios` DB.** ~205 tests.
- **TDD**: write the failing spec first (see `*.spec.ts` next to the code). DB specs use
`resetDb()` + real Postgres.
- **Flaky `outbox/replay.spec`**: it's clock/ordering-sensitive and pre-existing. If it fails
in a full run, `docker exec iios-db psql -U iios -d postgres -c "DROP DATABASE IF EXISTS iios_test WITH (FORCE)"`
then re-run — it's stale test-DB state, not a regression.
- **`pnpm boundary`** — import-boundary check (must stay OK).
- Smokes: `packages/iios-service/scripts/smoke-*.mjs` (run against a live service).
## Conventions
- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email
**`maaz@insigniaconsultancy.com`**, and co-author every commit with Claude. Branch off `main`
before committing if asked; otherwise the session has committed directly to `main`.
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token
minting). Single most important prod flag.
- New kernel capability = a **generic primitive** only (see the #1 rule). Add domain meaning in
policy + app.
- Prisma: always `select` to avoid leaking `passwordHash`/PII; org/tenant scope every `where`.
## What's built (state)
P0P8: kernel → messaging → inbox → support → adapters → routing → AI → calendar/meetings.
Recent (the "P9 real-providers" era, mostly driven by the chat app):
- **Real Supabase auth** — multi-issuer JWKS verification (`SessionVerifier`); first stubbed
port turned real.
- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive
(opaque type/value), `annotate` socket event, `GET /v1/threads/my-annotations`.
- **@mentions → Inbox** — `mentions[]` on send → `MENTION` inbox item (projector).
- **Media** — `StoragePort` (dev local disk → prod S3/Supabase), presigned upload/download,
attachment on `MessageDto`.
- **Notifications** — presence-gated Web Push: `NotificationProjector` (policy/presence/mute
gates) + swappable `NotificationPort`, `focus_thread` presence signal, per-thread mute.
- `senderId` (stable externalId) on messages for reliable "is this mine?".
## Docs (as-built)
- `docs/IIOS_API_AND_SDK_GUIDE.md` — the **as-built REST/socket/SDK reference** (endpoints,
shapes, env, vocab). Keep it current when adding endpoints.
- `docs/DEPLOYMENT.md` — deploy/topology/env/scaling.
- `docs/IIOS_OVERVIEW_FOR_CEO.md` — plain-language capability tour.
+53
View File
@@ -0,0 +1,53 @@
# Publishing the IIOS SDKs (Gitea package registry)
The `@insignia/iios-*` **frontend SDKs** publish to our self-hosted Gitea npm registry:
```
https://git.lynkedup.cloud/api/packages/insignia/npm/
```
**Published (public in the registry):** `iios-contracts`, `iios-kernel-client`, `iios-adapter-sdk`,
and the React hook packages `iios-message-web`, `iios-inbox-web`, `iios-support-web`,
`iios-ai-web`, `iios-community-web`, `iios-meeting-web`.
**Kept private (never published):** `iios-service` (the deployed backend) and `iios-testkit` (dev fakes).
> be-crm does **not** consume these — it talks to IIOS over REST. The SDKs are a **frontend** concern
> (chat-web, the CRM support UI, mobile).
## One-time: get a token
Gitea → **Settings → Applications → Generate Token**:
- to **publish**: scope `write:package`
- to **install** (private packages): scope `read:package`
Export it (never commit it):
```bash
export GITEA_TOKEN=<your-gitea-token>
```
The repo `.npmrc` already routes the `@insignia` scope to Gitea and reads `${GITEA_TOKEN}`.
## Publish
```bash
pnpm release:dry # build all + pack (no upload) — verify the 9 packages pack cleanly
pnpm release # build all + publish the non-private packages to Gitea
```
`pnpm -r publish` automatically **skips** `private` packages, so only the 9 SDKs go out.
Versions are **immutable** — bump before re-publishing (edit `version`, or adopt `changesets`).
Or push a tag and let CI do it (see `.gitea/workflows/publish-sdks.yml`; needs Gitea Actions +
a runner + the `GITEA_PUBLISH_TOKEN` secret):
```bash
git tag v0.1.0 && git push origin v0.1.0
```
## Consume (in chat-web / the CRM front-end)
Add an `.npmrc` to the consuming repo:
```ini
@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/
//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN}
```
Then:
```bash
export GITEA_TOKEN=<read-token>
pnpm add @insignia/iios-kernel-client @insignia/iios-contracts
```
This replaces the **vendored** client that chat-web copies today — one source of truth for all frontends.
+4
View File
@@ -54,6 +54,10 @@ for the full, commented list. Highlights:
app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests. app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests.
- **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for - **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for
prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change. prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change.
- **Notifications (Web Push):** set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT`
to enable push (unset → the engine no-ops). ⚠️ Presence (the "are you viewing this thread?"
gate) is **in-memory / single-instance** today — with N>1 replicas, back `PresenceService`
with Redis so the projector on one replica sees focus from another.
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*` - **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*`
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the (unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
single most important prod-hardening flag. single most important prod-hardening flag.
+51 -1
View File
@@ -243,6 +243,54 @@ sequenceDiagram
--- ---
### 5.11 Notifications (push, presence-gated)
The engine reacts to `message.sent` and pushes to **absent** recipients through a swappable
**NotificationPort** (Web Push/VAPID now; email/FCM later). The DB holds only push
subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays `IiosInboxItem`.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/v1/notifications/vapid-public-key` | — | `{key}` — the public VAPID key the client needs to subscribe |
| POST | `/v1/notifications/subscribe` | `{kind?, endpoint, keys:{p256dh, auth}, userAgent?}` | `{ok:true}` — store/refresh the caller's push subscription |
| DELETE | `/v1/notifications/subscribe` | `{endpoint}` | `{ok:true}` |
| POST | `/v1/threads/:id/mute` · `/unmute` | — | `{threadId, muted}` — per-thread notification mute for the caller |
**Presence (socket):** the app emits `focus_thread` `{threadId | null}` on the `/message`
namespace whenever the foreground conversation changes (or the tab blurs). This is how the
engine knows you're *viewing* a thread — **room membership ≠ viewing**, because the sidebar
joins every thread room for live updates. `GET /v1/threads` returns each thread's `muted`.
**The three gates (fail-closed, in the notification policy — not the kernel):**
1. **Policy** — DM always; a group message only if you were `@`-mentioned **or** it's a reply to you.
2. **Presence** — skip if you're currently focused on that thread (you saw it live).
3. **Mute** — skip if you muted the thread.
A dead subscription (Web Push `404/410`) is pruned. DM-vs-group is read from the opaque
`membership` thread attribute here in the *notification policy*; the kernel never branches on it.
```mermaid
flowchart TB
SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"]
PROJ --> LOOP{{"for each recipient (never the sender)"}}
LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"}
G1 -->|no| X1["skip"]
G1 -->|yes| G2{"PRESENCE: focused on this thread?"}
G2 -->|yes| X2["skip — seen live"]
G2 -->|no| G3{"MUTE: thread muted?"}
G3 -->|yes| X3["skip"]
G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"]
PORT --> WP["Web Push (VAPID)"]
WP -->|sent| OUT["→ browser push service → service worker → OS notification"]
WP -->|"gone (404/410)"| PRUNE["prune dead subscription"]
```
**Client (SDK layer, `lib/notifications.ts` → belongs in `@insignia/iios-kernel-client`):**
`registerPush()` (permission → register service worker → `PushManager.subscribe` with the
VAPID key → POST the subscription), `muteThread(threadId, muted)`, and `MessageSocket.focus(threadId)`.
A service worker renders the OS notification on `push` and deep-links to the thread on click.
---
## 6. SDK reference ## 6. SDK reference
Two layers: a low-level `RestClient` (+ socket) and per-domain React hook packages. Two layers: a low-level `RestClient` (+ socket) and per-domain React hook packages.
@@ -256,6 +304,7 @@ Methods (all return typed promises):
- **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})` - **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})`
- **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates. - **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates.
- **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.* - **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.*
- **Notifications:** `registerPush()` (service worker + `PushManager.subscribe` + POST subscription), `muteThread(threadId, muted)`, `MessageSocket.focus(threadId)` (presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering.
- **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})` - **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})`
- **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)` - **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)`
- **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)` - **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)`
@@ -333,7 +382,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/<name>`)
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) | | `smoke-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` (192 tests). Import-boundary check: `pnpm boundary`. Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`.
--- ---
@@ -358,6 +407,7 @@ Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) | | `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs | | `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
| `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs | | `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs |
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | — | Web Push (notifications). Unset → push disabled (engine no-ops). Generate once: `node -e "console.log(require('web-push').generateVAPIDKeys())"` |
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` | | `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) | | `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit | | `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
+3 -3
View File
@@ -159,9 +159,9 @@ Each product SDK is a handful of React hooks — a front-end dev wires the UI, t
| Calendar/Zoom providers | Simulated sync | P9 | | 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:** 192 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture. **Proof it works:** 205 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, and media sharing** (images/video/audio/docs on a swappable storage port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off. **P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, media sharing** (images/video/audio/docs on a swappable storage port), and **presence-gated push notifications** (Web Push via a swappable notification port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off.
--- ---
@@ -177,4 +177,4 @@ Narrative arc for the CEO: **one engine → chat → inbox → support → chann
--- ---
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 53 Postgres tables across 19 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media). Boundary-enforced dependency law; 192 passing tests; 8+ end-to-end smoke scripts. First real-provider swap live: Supabase auth (JWKS-verified) + a media storage port.* *Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 54 Postgres tables across 20 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media → notifications). Boundary-enforced dependency law; 205 passing tests; 8+ end-to-end smoke scripts. First real-provider swaps live: Supabase auth (JWKS-verified), a media storage port, and Web Push notifications.*
+67
View File
@@ -0,0 +1,67 @@
# Email Attachments — Implementation Plan
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · Extends the SMTP provider + mail plumbing.
## Goal
Let an external email carry attachments (e.g. an invoice PDF). The kernel already STORES attachments
as message parts (`contentRef` + mime + size); the media `StoragePort` holds the bytes. The one gap is
the **email envelope**`SmtpProvider` (and the payload) don't carry attachments. Close that.
## Design (locked)
- **Payload carries REFS, not bytes:** the EMAIL payload gains
`attachments?: [{ filename, contentRef, mimeType? }]`. Refs keep the outbound-command ledger small
(and keep T8's PII redaction cheap) — bytes are fetched at send time.
- **Resolver seam:** `AttachmentResolver = (contentRef) => Promise<{ filename?; content: Buffer; contentType? } | null>`.
`SmtpProvider` takes an optional resolver; on send it resolves each ref and attaches
(nodemailer `attachments: [{ filename, content, contentType }]`).
- **Fail closed on a missing attachment:** if a declared attachment can't be resolved, the send is
`FAILED` (so it retries) — NOT sent without it. A receipt/invoice missing its file is worse than a
retry. (No resolver wired at all + attachments present → also FAILED, same reasoning.)
- **Wiring:** `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`;
`CapabilityProviderRegistry` `@Optional() @Inject(STORAGE_PORT)` → builds the resolver from
`storage.get(contentRef)` → passes it to `SmtpProvider`. `@Optional` so contexts without storage
still boot (attachments simply can't resolve → FAILED if any are declared).
- **Scope:** SMTP path only. The HTTP relay `EmailProvider` attachment support is a separate follow-up
(it would base64 the bytes into the relay POST). INTERNAL/mirror attachments already work via message
parts and are not this plan.
## Files
```
src/capability/smtp.provider.ts # attachments in EmailPayload + resolve+attach in send()
src/capability/smtp.provider.spec.ts # attach resolved bytes; missing → FAILED
src/capability/capability.registry.ts # inject STORAGE_PORT → resolver → SmtpProvider
src/capability/capability.module.ts # import MediaModule
src/media/media.module.ts # export STORAGE_PORT
src/templates/templated-sender.ts # accept + pass `attachments`
src/mail/mail.service.ts # accept + pass `attachments` (external send)
```
## Tasks (TDD)
**T1 — SmtpProvider attaches / fails closed**
- Inject a stub resolver. Tests: two refs → nodemailer `attachments` has both (filename + content +
contentType); a ref the resolver returns `null` for → outcome `FAILED`, nothing sent; no attachments
in payload → unchanged (plain send still SENT).
**T2 — registry wires the resolver from STORAGE_PORT**
- `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; registry injects it
`@Optional`. Test: with a fake storage bound, `forChannel('EMAIL')` SMTP resolves an attachment;
without storage, the registry still constructs (attachments would FAIL, but boot is fine).
**T3 — pass-through: TemplatedSender + MailService**
- `sendTemplated`/`sendExternalWithMirror` accept `attachments` and place them in the EMAIL payload.
Tests: the outbound command's payload carries the attachment refs.
**T4 — gate + real send**
- Full suite + boundary + build. Manual: a real Ethereal send with a small attachment → SENT, and the
Ethereal message shows the attachment.
## Risks
- **Fail-closed is deliberate** — don't silently send an invoice email without the invoice.
- **Payload holds refs, not bytes** — so the ledger and T8 redaction stay small; the resolver reads
bytes only at send time.
- **`@Optional` storage** — a context without `STORAGE_PORT` boots fine but can't send attachments;
that's correct (fail-closed), not a silent drop.
+277
View File
@@ -0,0 +1,277 @@
# Email / Message Template Module — Implementation Plan
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Target:** first templates sending by Friday go-live.
## Goal
A reusable template module so **any** message the system sends — welcome, payment receipt,
onboarding reminders, drip — is produced from a stored (or ad-hoc) template with variables filled
in, then handed to the **existing** outbound pipeline. One render path, one send path, provenance
recorded for every send.
From the July 16 meeting: *"template का एक पूरा module बनाना है… template में value भरोगे, और वो
outbound क्यू में डाल दोगे।"*
## What already exists (the substrate — do NOT rebuild)
- `OutboundService.send(channelType, target, payload, idempotencyKey?, scopeId?, purpose?)`
idempotency + per-target/per-tenant rate limits + delivery ledger (`IiosOutboundCommand`).
- `CapabilityBroker` — policy gate + obligations + provider selection for egress.
- `EMAIL` is a registered channel; `EmailProvider` exists (**HTTP**, not SMTP — see Out of Scope).
- `EMAIL` is a first-class `IiosInteractionKind`; `IiosMessagePartKind` has `HTML`/`TEXT`.
- `IiosActorKind` includes `SERVICE`/`BOT` (system sender is first-class).
- `InboxModule` uses `OnModuleInit` — copy that pattern for the template seeder.
The module adds **content/rendering** in front of this. It never talks to a provider directly.
## Design decisions (locked)
| # | Decision | Rationale |
|---|---|---|
| 1 | **Channel-generic**: one template per `(key, channel)`; channels `EMAIL` / `SMS` / `INTERNAL` | Vivek wants the same confirmation on email *and* SMS; INTERNAL = app-to-app, no SMTP |
| 2 | **Seed-from-files, DB-is-truth**: templates are files in the repo, seeded into the DB on boot if absent | Copy is version-controlled + code-reviewed; a later admin UI can edit the DB with no deploy; Friday needs no UI |
| 3 | **Handlebars** rendering | Auto-escapes HTML (customer names go into email → XSS risk), logic-less, no code execution, one dep |
| 4 | **Global default + scope override**: `scopeId` nullable — `NULL` = platform default, set = tenant override; resolve scoped-first-else-global | Seeds cleanly at boot (IIOS scopes are created lazily, so a boot seeder has no scope to seed into); leaves room for white-label |
| 5 | **Provenance on `IiosOutboundCommand`** (4 columns), not a new `template_snapshot` table | Rendered content is already in `payload`; only provenance is missing. Honours the SOT's intent at 4 columns |
| 6 | **`TemplateSource` = stored `{key,version?}` OR `{inline:{subject,html,text}}`** | Marketing hands over finished HTML (*"Maaz, HTML भेज सकते हैं"*); inline still renders vars + records provenance |
| 7 | **Integration pattern B**: pure `render()` + thin `sendTemplated()` composer | Single entry point for callers; provenance guaranteed by construction; `OutboundService` stays content-agnostic |
| 8 | **Caller = a service** (be-crm's system token); **recipient = a `target` address, never a login** | System mail has no user on the sending side; the recipient may have no account yet |
## Caller & auth model
Every send is authenticated as the **calling app/service** (e.g. be-crm via its `APP_SECRETS`
entry), verified by `SessionVerifier` like every other IIOS endpoint. The recipient is a plain
`target` string — **not** an IIOS principal and **not** required to be logged in or registered.
Sending a welcome email to an anonymous payer is the normal case: the app is the sender, the
address is data. `scopeId` for the send is derived from the caller's principal (`org/app/tenant`).
## Data model
### New table — `IiosMessageTemplate`
```prisma
enum IiosTemplateChannel {
EMAIL
SMS
INTERNAL
}
/// The template SOURCE. DB is runtime truth; platform defaults are seeded from repo files on boot.
/// Versions are immutable: a change writes a new (higher) version, never edits in place.
model IiosMessageTemplate {
id String @id @default(cuid())
/// NULL = platform default (seeded). Set = a tenant scope's override of the same key.
scopeId String?
scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade)
key String // "welcome", "payment.receipt", "onboarding.reminder"
channel IiosTemplateChannel
locale String @default("en")
version Int @default(1)
subject String? // EMAIL only
bodyHtml String? // EMAIL / INTERNAL
bodyText String? // SMS, and EMAIL plaintext fallback
/// Declared variable names — render throws if a declared var is missing (fail loud).
variables Json?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scopeId, key, channel, locale, version])
@@index([key, channel, locale, active])
}
```
`IiosScope` needs the back-relation `messageTemplates IiosMessageTemplate[]`.
**Resolution:** `resolve(key, channel, locale, scopeId?)` returns the highest-`version` `active`
row, preferring `scopeId = <caller scope>` and falling back to `scopeId IS NULL`. No match → 404.
### Provenance columns on `IiosOutboundCommand`
```prisma
templateKey String?
templateVersion Int?
templateLocale String?
renderedHash String? // sha256 of the rendered content — replay/audit
```
All nullable: non-templated sends (if any) leave them null.
**How provenance is written (review finding):** `OutboundService.send` **creates the command row
itself** (both the RATE_LIMITED and PENDING paths), so a caller cannot stamp provenance after the
fact without a racy update. Therefore `send()` gains one optional trailing arg —
`provenance?: { templateKey; templateVersion; templateLocale; renderedHash }` — written into the
same `create()` in both paths. `OutboundService` stays content-agnostic: it does not render or
resolve templates, it only persists four opaque strings it is handed. This is what makes
decision 7's "provenance guaranteed by construction" true. **This is a change to an existing file**
(`adapters/outbound.service.ts`) and its spec — call it out in the PR.
### Migrations (hand-written, non-destructive)
1. `add_message_template` — the enum + table + `IiosScope` back-relation. **Plus a partial unique
index for global rows (review finding):** the `@@unique([scopeId, key, channel, locale, version])`
does **not** prevent duplicate *platform-default* rows, because Postgres treats `NULL` scopeId as
distinct (`NULL != NULL`) — the same footgun handled in the inbox idempotency migration. Add:
`CREATE UNIQUE INDEX "IiosMessageTemplate_global_key" ON "IiosMessageTemplate"("key","channel","locale","version") WHERE "scopeId" IS NULL;`
so a double-seed or race cannot create two defaults for the same key.
2. `add_outbound_template_provenance` — the 4 nullable columns on `IiosOutboundCommand`.
## Module structure
```
packages/iios-service/src/templates/
template.channel.ts // IiosTemplateChannel re-export/helpers if needed
template.model.ts // TemplateSource, RenderedContent, CreateSendInput types
template.repository.ts // resolve(): scoped ?? global, highest active version
template.renderer.ts // Handlebars compile+render; escaping; missing-var throw
template.service.ts // render(source, vars) — pure; resolve + renderer
templated-sender.ts // sendTemplated(): render -> OutboundService.send -> stamp provenance
template.seeder.ts // OnModuleInit: seed file defaults into DB if (key,channel,locale,version) absent
template.controller.ts // POST /v1/templates/send, POST /v1/templates/preview
template.dto.ts // SendTemplateDto, PreviewTemplateDto (class-validator)
template.module.ts
seeds/
welcome.email.ts // { key, channel, locale, version, subject, html, text, variables }
payment-receipt.email.ts
payment-receipt.sms.ts
onboarding-reminder.email.ts
templates.spec.ts // TDD, real Postgres (localhost:5434), mirrors inbox.spec.ts
```
**Scope boundary:** `render()` is channel-generic (it can render an `INTERNAL` template to
`{subject,html,text}`). `sendTemplated()` covers **external** channels (`EMAIL`/`SMS`) via
`OutboundService`. `INTERNAL` *delivery* (render → create an in-app `Interaction`, no SMTP) reuses
`render()` but is wired by the messaging/mirror spec — this module never imports the messaging layer.
## Contract
```ts
type TemplateSource =
| { key: string; version?: number }
| { inline: { subject?: string; html?: string; text?: string } };
interface RenderedContent { subject?: string; html?: string; text?: string }
// template.renderer.ts — PURE (no I/O): given a template's raw strings + vars, produce content.
// template.service.ts render() — resolves the template from the DB (I/O), then calls the pure renderer.
// For an inline source there is no DB row, so declared-variable validation is skipped — inline
// content is the caller's responsibility; only stored templates enforce their declared `variables`.
render(source: TemplateSource, vars: Record<string, unknown>, opts: { channel: IiosTemplateChannel; locale?: string; scopeId?: string }): Promise<RenderedContent>
// templated-sender.ts — external egress
sendTemplated(input: {
source: TemplateSource;
channel: 'EMAIL' | 'SMS';
target: string; // email address / phone
vars: Record<string, unknown>;
scopeId?: string;
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
purpose?: string;
}): Promise<IiosOutboundCommand>
```
**HTTP** (`SessionVerifier`-auth'd; `scopeId` from the caller's principal):
- `POST /v1/templates/send``sendTemplated`. OPA action `iios.template.send`;
inline source additionally gated on `iios.template.send.inline` (arbitrary HTML to customers).
- `POST /v1/templates/preview``render` only, returns `RenderedContent`. No send. For marketing/QA.
## Task-by-task (TDD)
Each task: write the failing test → run it (confirm red) → implement → run (green) → commit.
**T1 — Renderer (`template.renderer.ts`)**
- Tests: substitutes `{{firstName}}`; **escapes `<script>` in a name**; `{{#if}}`/`{{#each}}`;
a declared-but-missing variable throws; renders `bodyHtml` and `bodyText` independently.
- Impl: Handlebars, `noEscape:false`; validate declared `variables` present.
**T2 — Migrations + repository (`template.repository.ts`)**
- Migration 1 (table+enum). Regenerate client.
- Tests: `resolve` returns highest active version; **scoped overrides global**; unknown key → NotFound;
inactive versions ignored.
**T3 — `render()` service tying resolve+renderer, incl. inline source**
- Tests: stored `{key}` resolves+renders; `{inline}` renders without a DB row; wrong channel → 404.
**T4 — Provenance: `OutboundService.send` param + migration + `sendTemplated()`**
- Migration 2 (4 columns).
- Modify `OutboundService.send` to accept the optional `provenance` arg and write it into the command
`create()` on both the RATE_LIMITED and PENDING paths. Extend `outbound.service.spec.ts`: a send
with provenance persists all four fields; a send without leaves them null (no regression).
- Then `sendTemplated()`. Tests: composes render→`OutboundService.send`; **stamps
`templateKey/version/locale/renderedHash`** on the command; **idempotent per key** (replay → one
`IiosOutboundCommand`); inline → `templateKey` null, `renderedHash` set.
**T5 — Seeder (`template.seeder.ts`, `seeds/*`)**
- Tests: boot seeds the file defaults as `scopeId NULL`; **re-seed is idempotent** (no dupes);
a bumped file version inserts a new row, leaves the old.
**T6 — Controller + DTOs**
- Tests (HTTP, boot against sandbox provider so no real mail): `POST /send` renders+queues;
unknown key → 404; bad body → 400; missing auth → 400/401; `POST /preview` returns content, sends nothing.
**T7 — Whole-suite gate**
- `vitest run` (all packages), `npm run boundary`, `npm run build` all green.
- Manual: boot locally, `POST /v1/templates/send` with the `welcome` seed via sandbox, inspect the
`IiosOutboundCommand` row for payload + provenance.
**T8 — PII redaction of outbound commands (fast-follow; lever #2)**
- Extend `RetentionService` so its sweep also redacts aged `IiosOutboundCommand` rows: raw `target`
and rendered `payload` → redacted, while `templateKey/version/renderedHash` + `scopeId` are kept
for audit. Reuse the existing redact-in-place pattern (currently applied to `iiosMessagePart`).
- Tests: a command past its window has `target`/`payload` redacted but provenance intact; a command
under compliance hold is skipped; audit row `retention.redacted` written.
- Independent of T1T7 — can land immediately after the module without blocking Friday.
## Error handling
- Unknown key/channel/locale → `NotFoundException` (404).
- Declared variable missing → throw (400) — never send a half-rendered receipt.
- Transport failure → surfaced as `FAILED` on the command by `OutboundService`, never thrown to caller.
- Idempotent replay (same key) → returns the existing command; never double-sends.
## Out of scope (separate specs — this module unblocks them)
1. **`SmtpProvider`** (nodemailer, `accounts@`/`ceo@lynkeduppro.com`) in IIOS's capability registry.
Today's `EmailProvider` is HTTP; SMTP is a new provider flipped on by env. Without it, sends land
in the sandbox.
2. **`POST /webhooks/stripe` in be-crm** — verify Stripe signature → mint a **service token**
call `POST /v1/templates/send` (welcome + receipt), keyed on the Stripe object id. This is the
"backend" the frontend-only payment site lacks.
3. **Inbox mirror** (post-registration): render an email → also create an `Interaction(kind=EMAIL)`
so it appears in the customer's in-app inbox. `INTERNAL` delivery lives here. Mirror only *after*
registration (no inbox exists before).
## PII minimization
Sending email means IIOS must *touch* the recipient's address (you can't mail without it) and the
rendered body holds their name — so `IiosOutboundCommand.target`/`payload` become PII. You cannot
avoid IIOS touching it; you avoid it **accumulating** and being **cheaply reachable**. Three levers,
by impact:
1. **Rotate the `Qwerty@a2` signing secret — precondition for prod, highest impact, not code.**
The stored PII is only dangerous because any leaked token can be brute-forced back to the secret,
letting an attacker forge a token and read the outbound table. A strong random secret leaves the
PII in place but **unreachable by forgery** — 90% of the risk closed by one env change. **Gate:
do not point the real SMTP provider at production until this is rotated.**
2. **Redact the raw address + body after delivery (fast-follow task — see T8).**
IIOS keeps `templateKey/version/renderedHash` + the opaque `scopeId` for audit, but the raw
`target` and rendered `payload` are redacted-in-place once past their retention window. The
`RetentionService` already does exactly this for interaction message parts (`bodyText →
'[redacted]'`); it just doesn't cover `IiosOutboundCommand` yet. Reuse the same sweep.
3. **Tokenized recipient (future, when MDM ships).** The clean model is: callers pass a `userId`,
IIOS resolves the address from MDM *at send time* and never persists it. Not available today
(MDM isn't deployed; `externalId` is a UUID with no email). **Design accommodation now:** keep
`target: string` for Friday, but treat it as an opaque "where to send" so it can later become
`{ userId }` resolved via MDM without changing the `sendTemplated` contract shape.
**Rejected shortcut:** having be-crm send SMTP directly so IIOS never sees the address. It "avoids"
the PII but breaks the inbox mirror and the single-send-path (IIOS would have no record to copy into
the inbox) — trading a solvable security problem for a broken feature.
## Other risks
- **Friday, no test env:** first real sends go to paying customers from an unproven path. Insist on a
sandbox target / 100%-off test coupon before wiring the real SMTP provider (compounds with lever #1
above — don't send real mail from prod until the secret is rotated *and* a safe test target exists).
+113
View File
@@ -0,0 +1,113 @@
# Inbox Mirror + INTERNAL Delivery — Implementation Plan
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** every message the app
sends appears in the customer's in-app inbox; and users can send app-to-app "mail" with no SMTP.
## Goal
Two capabilities on one mechanism:
1. **Mirror** — when an external EMAIL is sent, also record it as an in-app interaction so the
customer sees a copy in their CRM inbox. Vivek: *"जो भी communication…उसकी एक copy inbox में चाहिए ही चाहिए."*
2. **INTERNAL delivery** — a user sends a mail-style message (subject + body) to another user with
**no SMTP**; it lands only in the recipient's in-app inbox. Vivek: *"app-to-app…without smtp."*
Both reduce to the same primitive: **render a template → create an `Interaction(kind=EMAIL)` with
subject + HTML + TEXT parts on a thread.** External additionally does the SMTP send (already built).
## Architectural guardrail (carried from the earlier inbox work)
This is the **mail-style inbox (a projection over `Interaction`s)** — NOT the `InboxItem` work-surface.
- An email/message becomes an `Interaction(kind=EMAIL)` on a thread. The inbox view lists interactions.
- An `InboxItem` is created ONLY when the projector decides action is needed (NEEDS_REPLY/MENTION) —
that's the existing projector, unchanged. **We do not write InboxItems here.** Mixing them is the
KG-15 "inbox fatigue" failure.
## What already exists (reuse, do NOT rebuild)
- `IngestService.ingest(req, idempotencyKey)` — the generic create-an-interaction entry: resolves
source handle → actor → channel → thread, writes `Interaction` (kind from `req.kind`) + parts +
outbox event, idempotent per (scope, idempotencyKey). Inbound email already uses it to make
`EMAIL` interactions with HTML/TEXT parts — **the exact model for the outbound mirror.**
- `TemplateService.render()` (exported) → `{subject, html, text}`.
- `TemplatedSender.sendTemplated()` → SMTP egress (built).
- `IiosMessagePartKind` has `HTML` + `TEXT`; `IiosInteractionKind` has `EMAIL`.
## Design (locked)
- **New `MailService`** (new `src/mail/` module) orchestrates `TemplateService` + `IngestService` +
`TemplatedSender` + `ActorResolver`. Templates/outbound stay unaware of each other.
- `postInternal(...)` — render → `ingest()` an `EMAIL` interaction on a per-email thread. No SMTP.
- `sendExternalWithMirror(...)` — render → `TemplatedSender.sendTemplated()` (SMTP) → **and** mirror
via `ingest()` **iff the recipient is a registered user** (timing rule below).
- **Visibility (resolved review finding):** `ingest()` creates the interaction + thread but adds NO
participants, and `listThreads` shows only threads where the caller is a participant. So after each
ingest the MailService `ensureParticipant`s **both** the sender's actor and the recipient's actor
(`ActorResolver.resolveActor``ensureParticipant`). Without this the mirror is invisible.
- **`ingest()` returns `threadId`** — used directly to add the two participants.
- **Rendered content → parts:** part 0 `HTML` (bodyHtml), part 1 `TEXT` (bodyText); `subject` → the
thread subject (email threads share a subject). Attachments are the separate attachments plan.
- **Idempotency:** the ingest idempotencyKey = the send's key (e.g. `mirror:<stripe_session>`), so a
retried send never doubles the inbox copy.
- **Reply/threading:** `parentInteractionId` for in-thread replies (already modeled); a mirrored
email's `inReplyTo` maps to the parent interaction.
## The timing rule (locked, from the meeting)
**Mirror only AFTER the recipient is registered.** The welcome/receipt go out *before* registration —
there is no in-app inbox to mirror into yet. So `sendExternalWithMirror` mirrors only when the target
resolves to a registered actor; pre-registration sends are email-only. Vivek: *"just time app pe
register kar liya, uske baad se jitna communication…uske inbox mein chahiye."*
## Thread model (DECIDED: one thread per email)
**Each send is its own thread / inbox entry; a reply threads onto it.** Matches email semantics and
pairs with the reply (`parentInteractionId`) feature. Implementation: the ingest `externalThreadId`
is **derived from the send's idempotency key**, so a retried send reuses the same thread (no dupe)
while distinct emails get distinct threads. A reply posts onto the parent's thread.
## Files
```
src/mail/mail.service.ts # new — postInternal, sendExternalWithMirror
src/mail/mail.service.spec.ts # new — DB-backed
src/mail/mail.module.ts # new — imports TemplateModule + AdaptersModule + interactions
src/mail/mail.controller.ts # new? — OR extend template.controller with a `deliverInternal` route
```
(Whether INTERNAL gets its own HTTP route or rides the template controller is a small call made at build time.)
## Task-by-task (TDD) — pending the thread-model decision
**T1 — `renderToParts()` helper**: `{subject,html,text}``IngestInteractionRequest.parts` +
thread subject. Test: HTML+TEXT parts produced; empty parts omitted.
**T2 — `postInternal()`**: render an INTERNAL template → `ingest()` an `EMAIL` interaction on the
thread between sender + recipient (thread model per the decision). Test: interaction created with
kind EMAIL + parts; idempotent per key; lands on the recipient's thread.
**T3 — `sendExternalWithMirror()`**: render → `sendTemplated` (SMTP/sandbox) → mirror `ingest()`
**only if** the recipient resolves to a registered actor. Test: registered → one outbound command +
one mirror interaction; unregistered → outbound only, no mirror; idempotent (replay → no dupes).
**T4 — controller/module wiring + HTTP verify** (route for INTERNAL send; mirror invoked from the
external send path). Boot + drive over HTTP against the sandbox.
**T5 — gate**: full suite + boundary + build; manual: send external → confirm a mirror interaction
appears on the recipient's thread.
## Out of scope (follow-ons)
- **Frontend mail-inbox view** — surfacing `EMAIL` interactions as a mail-style inbox in the CRM
(the current CRM inbox is the InboxItem work-surface; the mail view is separate UI).
- **Attachments** (separate plan). **Stripe webhook** (be-crm) — the trigger.
## Risks
- **Don't write InboxItems here** (KG-15). Interactions only; the projector owns InboxItems.
- **Idempotency must cover BOTH** the SMTP send and the mirror ingest, or a retried webhook doubles
the inbox copy. Same key threaded through both.
- **Unregistered recipients:** resolving "is this a registered user?" must be cheap and correct, or a
pre-registration send could either error or wrongly mirror into a non-existent inbox.
- **Review finding — recipient participation:** `ingest()` resolves and attaches the *source* actor.
For the interaction to appear in the *recipient's* inbox, the **recipient must be a thread
participant.** T2/T3 must ensure this — either by making the thread's participant set include the
recipient at create time, or an explicit `ensureParticipant` after ingest. A mirror the recipient
isn't a participant of is invisible — silent failure. Cover it with an assertion in the tests
("recipient can list the thread / the interaction shows in their inbox query").
+113
View File
@@ -0,0 +1,113 @@
# Insignia Platform — Live State (from the mesh-verify probe, 2026-07-10)
> Distilled from the authenticated `mesh-verify.lynkedup.cloud` dashboard (`/api/results` +
> `/api/journey`). This is the **real** platform IIOS is meant to plug into — service inventory,
> the identity/session/governance flow, and the exact contracts to wire IIOS's platform ports.
> Tokens redacted (the raw JSON dumps contain live SAT/PAT/refresh tokens — do not commit them).
## Cluster / mesh
- **Cluster:** `lynkedup-tech` (NYC2 / DigitalOcean). **Istio** (istio-envoy) + **SPIFFE/SPIRE**,
trust domain **`spiffe://insignia.tech`** (SVIDs like `spiffe://insignia.tech/ns/sre/sa/default`,
`.../sa/realmdm-sas`). mTLS **PERMISSIVE**. Reached by ClusterIP DNS.
- **Namespaces:** `sre` (MDM, OPA, misc), `cmp` (consent platform), `insignia` (identity/session/
app-facing services), `istio-system`.
- Public edges: `*.lynkedup.cloud` (behind oauth2-proxy → Keycloak).
## The identity/session/governance flow (7 steps — the doctrine)
> **Separate authorities:** Session Broker *authenticates*, OPA *authorizes*, CMP decides *purpose*,
> RealMDM *resolves identity*. Purpose-proof ≠ authorization. The PAT carries the external `sub`
> only as a **SHA-256 hash**, never raw; `canonical_person_id` is null until MDM VERIFIES (MDM never
> blocks login).
1. **Anonymous consent (CMP Edge)** — browser CMP SDK → `POST /edge/v1/cache-policy` → EdDSA-signed
cache-category manifest; a ConsentReceipt goes to CMP over gRPC. Purpose proof only.
2. **External login (Supabase)****SAT** (ES256 JWT). Claims: `iss` (project `/auth/v1`), opaque
UUID `sub`, `aud=authenticated`, `email`, `user_metadata` (full_name, avatar_url), `aal`, `amr`.
Proves the *session*, not the person/permission. Verified via Supabase **JWKS** (kid-selected).
3. **Session Broker exchange SAT → PAT**`POST /v1/sessions/exchange` (Bearer SAT +
`X-Client-Authorization` = BFF Keycloak client-creds, aud=session-broker). Broker verifies the
SAT, calls the MDM bridge, resolves scope from **memberships**, mints the **PAT** (~5 min).
3b. **Workload identity (SPIFFE/mTLS)** — each meshed pod gets an X.509-SVID; OPA receives **both**
the user (`principal`) and the caller (`caller.spiffe_id`) — different layers, never merged.
4. **MDM Auth-Subject Bridge**`POST /v1/auth-subjects/resolve {issuer, subject}` → `{platform_
principal_id, canonical_person_id (null until VERIFIED), link_state (PENDING/VERIFIED),
link_version, match_method}`. Keyed on **iss+sub** (never email). Idempotent.
5. **OPA decision** — `POST /v1/decisions` → `{decision_id, allow, reason_codes, obligations,
policy_version}`. Obligations = masks / row-filters / denied fields / audit level / ttl. A
decision, not a 50-line entitlement JWT — the PEP MUST enforce every obligation.
6. **AppShell assembles the ACE** — combines PAT + OPA obligations + CMP consent into an App
Context Envelope (HttpOnly cookie): `capabilities[]` (policy-derived), `ui_obligations`
(hide/mask/step_up), `consent`. **No tokens, no raw PII in the browser.**
7. **CRM renders** — applies OPA `row_filter` (SQL WHERE) + `mask_fields` + `deny_fields`
server-side; rows reference `canonical_person_id`, not email.
## Service inventory + real endpoints
**Identity / session (`insignia` ns):**
- **Session Broker** `session-broker.insignia:80` — `POST /v1/sessions/exchange` (SAT→PAT).
- **Memberships** `memberships.insignia` (public `insignia-memberships.lynkedup.cloud`) —
`POST /v1/internal/resolve`, `GET /v1/memberships` (Bearer PAT) → scope tuple + `allowed[]`.
- **AppShell BFF** `appshell-bff.insignia:80` (public `insignia-appshell.lynkedup.cloud`) —
`GET /apps/crm-web/bootstrap` (Bearer PAT) → ACE.
- **Profile** `profile.insignia:80` — `GET /v1/profile`, `GET /v1/stats`. Backed by **sqlite3**
(`/data/profiles.db`, PVC `insignia-profile-data`, WAL, persistent, single-replica RWO).
- **CRM** `crm.insignia:80` — `GET /v1/leads?view=list` (applies obligations).
- **Policy Gateway** `policy-gateway.insignia:80` — `POST /v1/decisions`, `POST /v1/decisions/batch`.
**MDM (`sre` ns):** `mdm-kernel.sre:80` (`/v1/auth-subjects/resolve`, `/healthz`, `/readyz`; auth =
Keycloak service token **aud=realmdm**) · `mdm-ai.sre:80` · `mdm-sas.sre:9090` (**gRPC** — tokenization/SAS).
**OPA / policy (`sre` ns):** `opa.sre:8181` (`/health`, `GET /v1/data` = live policy tree;
default-deny) · `realmdm-opa.sre:8181` · `opal-server.sre:7002` (OPAL policy distribution).
**CMP (`cmp` ns), backed by Postgres + NATS + Redis:** `cmp-core:8080` (real `CheckConsent` gRPC;
service token aud=cmp) · `cmp-admin:8086` · `cmp-evidence:8081` · `cmp-sync:8085` ·
`cmp-tollgate:8082` (NATS JetStream gating) · `cmp-media:8083` · `cmp-worker:8084`. Plus
`insignia-consent-edge.insignia` (`POST /edge/v1/cache-policy`) and `insignia-consent-adapter.insignia`
(`POST /v1/consent/evaluate {purpose}` → `{permitted, legal_basis, state, consent_epoch}`).
**Other (`sre` ns):** `poi-api:5000` · `roof:8002` (YOLO segmentation) · `commit:8081` ·
`presign:8080` · `egs:8090` · `artifact-retrieval:8082` (was 503 on probe day) · `cmp-docs`.
## The two contracts IIOS must match
### PAT (Platform Access Token) — what IIOS should verify
`iss = https://identity.insignia.internal` · **ES256** (EC P-256), verify via the broker **JWKS**
(`.../.well-known/jwks.json`, kid-selected — **no shared secret**) · `aud` includes the app (e.g.
`crm-web`) · `lifetime ~300s`. Claims:
```
sub = platform_principal_id app_id tenant_id org_id bu_id
region environment role aal amr auth_source
external_subject_hash = sha256:… (NOT the raw external sub)
canonical_person_id (null until VERIFIED)
session_epoch · policy_epoch · consent_epoch (stale-detection)
sid (platform_session_id) · typ = platform-access+jwt
```
### OPA decision — `POST http://policy-gateway.insignia.svc.cluster.local/v1/decisions`
Request `{ input: { principal{platform_principal_id, canonical_person_id, auth_source, aal, roles,
memberships[]}, caller{spiffe_id}, resource{type, id, tenant_id, classification[]}, action,
context{purpose, device_trust, consent_receipt_ids[], network_zone, time} } }`
Response `{ decision_id, allow, reason_codes[], obligations{ row_filter, allow_fields[], mask_fields{},
deny_fields[], audit, decision_ttl_seconds }, policy_version }`.
## What this means for wiring IIOS's ports
1. **Auth — verify the PAT, not the Supabase SAT.** IIOS today verifies the Supabase SAT directly
(a dev shortcut). In the real platform the **Session Broker** does SAT→PAT; a platform workload
verifies the **PAT**. The PAT already carries the full scope tuple + `platform_principal_id`, so
`MessagePrincipal` maps ~1:1: `userId = platform_principal_id` (→ `canonical_person_id` once
VERIFIED), `appId = app_id`, `orgId = org_id`, `tenantId = tenant_id`, `+ buId`. Wire it by adding
the broker as an `AUTH_ISSUERS` entry (iss `https://identity.insignia.internal`, ES256, its JWKS).
2. **OPA — point `OpaPort` at the Policy Gateway** (`POST /v1/decisions`). Build `{principal, caller.
spiffe_id, resource, action, context}` from the PAT + the op; `decideOrThrow` maps `allow` → proceed
and **must enforce the obligations** (masks/row-filter/deny). The gateway's input is richer than
IIOS's current `{action,…}` — that's the adapter's job to assemble.
3. **MDM — usually don't call it.** The PAT already carries `platform_principal_id`/`canonical_person_id`
(the broker resolved at login). Only call `/v1/auth-subjects/resolve` if IIOS is the identity-exchange
edge (it isn't — the Broker is).
4. **CMP — consent gate** via `insignia-consent-adapter /v1/consent/evaluate {purpose}` when processing
content for AI/analytics/marketing.
5. **SAS — tokenization/masking** is `mdm-sas` (gRPC) — the "tokenize sensitive parts before storage"
requirement.
*Source: two API payloads captured 2026-07-10 (`result.json` = probes, `journey.json` = the CRM
first-vertical-slice journey). Re-capture from the authenticated dashboard to refresh.*
+139
View File
@@ -0,0 +1,139 @@
# SMTP Provider — Implementation Plan
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** make external email *actually leave the building* (welcome / receipt), the critical path for Friday.
## Goal
Add an `SmtpProvider` so the `EMAIL` channel delivers via real SMTP (`accounts@lynkeduppro.com`,
fallback `ceo@lynkeduppro.com`) instead of the sandbox. The template module already renders and
queues to the `EMAIL` channel; this is the one piece between "queued (SENT via sandbox)" and "the
customer receives it." From the meeting: *"जो पहला जा रहा है, वो SMTP से जा रहा है, क्योंकि हमें तुरंत चाहिए."*
## What already exists (do NOT rebuild)
- `CapabilityProvider { name, channelTypes, capabilities, send(req) }` — the seam.
- `CapabilityProviderRegistry` binds a provider per channel: **sandbox by default**; `EmailProvider`
(HTTP) when `IIOS_PROVIDER_URL_EMAIL` is set. Unknown channels fail closed.
- `OutboundService.send``CapabilityBroker` (policy + obligations) → the bound provider. Idempotency,
rate limits, ledger, provenance all upstream — untouched.
- `req.payload` for EMAIL is `{ subject, text, html, inReplyTo }` (from `TemplatedSender`).
## Design decisions (locked)
| # | Decision | Why |
|---|---|---|
| 1 | New `SmtpProvider implements CapabilityProvider`, `channelTypes=['EMAIL']`, via **nodemailer** | The established provider pattern; nodemailer is the standard SMTP client |
| 2 | **Env-driven activation**, like `IIOS_PROVIDER_URL_EMAIL` | Off by default (sandbox); flip on by setting SMTP env — no code change to enable |
| 3 | **Registry precedence for EMAIL: SMTP > HTTP > sandbox** | SMTP is the intended prod path; HTTP relay stays available; sandbox is the safe default |
| 4 | **Transporter is injected** (constructor takes a `Transporter` or a factory) | SMTP is untestable against a live server in CI; inject a stub/`jsonTransport` to assert the envelope |
| 5 | **Optional fallback sender** (`accounts@` primary → `ceo@` on failure) | The meeting's fallback: if the primary mailbox send fails, retry once via the fallback identity |
| 6 | **Never throw** — a transport error returns `{ outcome: 'FAILED', errorCode }` | Adapter doctrine; the command is marked FAILED, the caller isn't broken |
## Config (env)
```
IIOS_SMTP_HOST=smtp.<mail-host> # e.g. smtp.gmail.com (Google Workspace)
IIOS_SMTP_PORT=587
IIOS_SMTP_SECURE=false # true for 465, false for 587/STARTTLS
IIOS_SMTP_USER=accounts@lynkeduppro.com
IIOS_SMTP_PASS=<app password> # Workspace App Password, NOT the account password
IIOS_SMTP_FROM="LynkedUp Pro <accounts@lynkeduppro.com>" # defaults to USER
# optional fallback identity used only if the primary send FAILS
IIOS_SMTP_FALLBACK_USER=ceo@lynkeduppro.com
IIOS_SMTP_FALLBACK_PASS=<app password>
IIOS_SMTP_FALLBACK_FROM="Justin Johnson <ceo@lynkeduppro.com>"
```
Activation rule: `SmtpProvider` is bound for `EMAIL` iff `IIOS_SMTP_HOST` + `IIOS_SMTP_USER` +
`IIOS_SMTP_PASS` are all set. Fallback transporter built only if the `_FALLBACK_*` trio is set.
## Files
```
src/capability/smtp.provider.ts # new — the provider
src/capability/smtp.provider.spec.ts # new — injected-transport tests
src/capability/capability.registry.ts # modify — bind SMTP for EMAIL when configured (precedence)
package.json # add nodemailer + @types/nodemailer
```
## Contract
```ts
interface SmtpIdentity { host: string; port: number; secure: boolean; user: string; pass: string; from: string }
class SmtpProvider implements CapabilityProvider {
readonly name = 'smtp';
readonly channelTypes = ['EMAIL'];
readonly capabilities = { canSend: true };
// `makeTransport` is injectable so tests pass a stub / nodemailer jsonTransport.
constructor(primary: SmtpIdentity, fallback?: SmtpIdentity, makeTransport?: (id: SmtpIdentity) => Transporter) {}
async send(req: CapabilityRequest): Promise<ProviderResult>;
}
```
`send()` builds the mail from `req.target` (recipient) + `req.payload`:
```
{ from, to: req.target, subject, text, html,
inReplyTo?, references?, // threading, from payload.inReplyTo
messageId } // generated; returned as providerRef so replies can thread
```
Primary transporter sends; on throw, if a fallback identity exists, retry once via it; still failing
`FAILED`. Success → `{ outcome: 'SENT', providerRef: messageId, latencyMs }`.
**Review findings folded in:**
- **`providerRef` = nodemailer's returned `info.messageId`**, not a hand-generated id — nodemailer
stamps the real `Message-ID` it sent, which is what a reply's `In-Reply-To` will actually match.
- **Fallback only on PRE-acceptance failures** (connection refused, auth failure, timeout) — NOT on
an error raised after the SMTP server already accepted the message. Retrying a post-acceptance
failure via the fallback identity would **double-deliver**. `send()` inspects the error (nodemailer
`err.responseCode` / code) and falls back only when the server never accepted.
- **Registry precedence is registration ORDER:** `register()` does `byChannel.set(ch, provider)`, so
the LAST registration for `EMAIL` wins. Bind sandbox first (all channels), then HTTP `EmailProvider`
if its URL is set, then `SmtpProvider` **last** if SMTP env is set → SMTP > HTTP > sandbox falls out.
## Task-by-task (TDD)
Each: failing test → red → implement → green → commit. Tests inject a stub transporter (no network).
**T1 — provider skeleton + config parse**
- `smtpIdentityFromEnv()` reads the env trio; returns null if incomplete.
- Test: full env → identity; missing pass → null; fallback trio → fallback identity.
**T2 — `send()` builds the correct envelope**
- Inject a recording stub transporter. Test: `from`/`to`/`subject`/`html`/`text` map from target+payload;
`inReplyTo` → header set when present; `messageId` generated and returned as `providerRef`; outcome `SENT`.
**T3 — failure handling + fallback**
- Stub throws on primary. Test: with a fallback identity → retries via fallback, `SENT` via fallback
transporter; without fallback → `FAILED` with `errorCode`, **never throws**.
**T4 — registry precedence**
- `capability.registry.spec` (or extend): with SMTP env set, `forChannel('EMAIL')` returns the SMTP
provider (not sandbox/HTTP); with only `IIOS_PROVIDER_URL_EMAIL` → HTTP; with neither → sandbox.
- The registry reads env in its constructor, so each case sets env, constructs a fresh
`CapabilityProviderRegistry`, asserts, then restores env (mirror the env save/restore other specs use).
**T5 — gate + manual real send**
- `vitest run` (all), `boundary`, `build` green.
- **Manual (ops):** point env at a real mailbox (or nodemailer **Ethereal** test SMTP for a no-mailbox
end-to-end), boot, `POST /v1/templates/send` the `welcome` seed to your own address, confirm receipt
and that From = `accounts@lynkeduppro.com`.
## Error handling
- Incomplete SMTP env → provider not bound → EMAIL falls back to sandbox (no accidental silent prod send).
- Transport failure → `FAILED` on the command (+ delivery attempt), never thrown.
- Fallback used → `providerRef` notes the fallback identity for audit.
## Risks / prerequisites
- 🔴 **Rotate `Qwerty@a2` BEFORE enabling.** This is the switch that turns queued sends into real
emails to real addresses — a forgeable IIOS token now reaches customer inboxes under your brand.
- **Workspace App Password, not the account password** (2FA accounts reject the raw password over SMTP).
- **Deliverability:** SPF + DKIM + DMARC on `lynkeduppro.com` or mail lands in spam. Ops task, before real customers.
- **Sending limits:** Google Workspace SMTP ≈ 2000/day. Fine — instant welcome/receipt is low volume; the
drip goes via Mailchimp, not SMTP.
- **Test safety:** never point CI/test env at a real mailbox; tests use an injected stub, the manual step
uses Ethereal or a throwaway inbox.
## Out of scope (separate plans)
Attachments over SMTP (extends `EmailPayload` + this provider); the inbox mirror / INTERNAL delivery
(no SMTP dependency).
+3 -1
View File
@@ -7,7 +7,9 @@
"build": "pnpm -r build", "build": "pnpm -r build",
"typecheck": "pnpm -r typecheck", "typecheck": "pnpm -r typecheck",
"test": "vitest run", "test": "vitest run",
"boundary": "node scripts/check-import-boundary.mjs" "boundary": "node scripts/check-import-boundary.mjs",
"release:dry": "pnpm -r build && pnpm -r publish --dry-run --no-git-checks",
"release": "pnpm -r build && pnpm -r publish --no-git-checks"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.0.1", "@types/node": "^26.0.1",
+7 -3
View File
@@ -1,15 +1,19 @@
{ {
"name": "@insignia/iios-adapter-sdk", "name": "@insignia/iios-adapter-sdk",
"version": "0.0.0", "version": "0.1.0",
"private": true,
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"files": ["dist"], "files": [
"dist"
],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@insignia/iios-contracts": "workspace:*" "@insignia/iios-contracts": "workspace:*"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
} }
} }
+13 -4
View File
@@ -1,13 +1,19 @@
{ {
"name": "@insignia/iios-ai-web", "name": "@insignia/iios-ai-web",
"version": "0.0.0", "version": "0.1.0",
"private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "exports": {
"files": ["dist"], ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0", "react": "^19.0.0",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"typescript": "^5.7.3" "typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
} }
} }
+13 -4
View File
@@ -1,13 +1,19 @@
{ {
"name": "@insignia/iios-community-web", "name": "@insignia/iios-community-web",
"version": "0.0.0", "version": "0.1.0",
"private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "exports": {
"files": ["dist"], ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0", "react": "^19.0.0",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"typescript": "^5.7.3" "typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
} }
} }
+7 -3
View File
@@ -1,12 +1,16 @@
{ {
"name": "@insignia/iios-contracts", "name": "@insignia/iios-contracts",
"version": "0.0.0", "version": "0.1.0",
"private": true,
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"files": ["dist"], "files": [
"dist"
],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
} }
} }
+1
View File
@@ -27,6 +27,7 @@ export interface IngestInteractionRequest {
bodyText?: string; bodyText?: string;
contentRef?: string; contentRef?: string;
mimeType?: string; mimeType?: string;
sizeBytes?: number;
}>; }>;
occurredAt: string; occurredAt: string;
providerEventId?: string; providerEventId?: string;
+13 -4
View File
@@ -1,13 +1,19 @@
{ {
"name": "@insignia/iios-inbox-web", "name": "@insignia/iios-inbox-web",
"version": "0.0.0", "version": "0.1.0",
"private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "exports": {
"files": ["dist"], ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0", "react": "^19.0.0",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"typescript": "^5.7.3" "typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
} }
} }
+13 -4
View File
@@ -1,13 +1,19 @@
{ {
"name": "@insignia/iios-kernel-client", "name": "@insignia/iios-kernel-client",
"version": "0.0.0", "version": "0.1.4",
"private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "exports": {
"files": ["dist"], ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
@@ -19,5 +25,8 @@
"devDependencies": { "devDependencies": {
"tsup": "^8.3.5", "tsup": "^8.3.5",
"typescript": "^5.7.3" "typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
} }
} }
@@ -8,14 +8,14 @@ export interface MessageSocketConfig {
} }
/** /**
* Framework-agnostic facade over the `/message` Socket.io namespace (ports the * Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck. * types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens
* On reconnect it re-opens the current thread so subscriptions resume with no * all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI
* lost messages (the docs' disconnect/reconnect requirement). * that watches multiple conversations keeps receiving live messages after a drop.
*/ */
export class MessageSocket { export class MessageSocket {
private readonly socket: SocketLike; private readonly socket: SocketLike;
private currentThreadId: string | null = null; private readonly joined = new Set<string>();
constructor(config: MessageSocketConfig, socket?: SocketLike) { constructor(config: MessageSocketConfig, socket?: SocketLike) {
this.socket = this.socket =
@@ -26,9 +26,9 @@ export class MessageSocket {
autoConnect: config.autoConnect ?? true, autoConnect: config.autoConnect ?? true,
}) as unknown as SocketLike); }) as unknown as SocketLike);
// Re-open the active thread after a reconnect. // Re-subscribe to every joined thread after a reconnect.
this.socket.on('connect', () => { this.socket.on('connect', () => {
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId }); for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
}); });
} }
@@ -40,27 +40,70 @@ export class MessageSocket {
this.socket.disconnect(); this.socket.disconnect();
} }
/** Run `handler` on every (re)connect, and immediately if already connected. */
onConnected(handler: () => void): () => void {
this.socket.on('connect', handler);
if (this.socket.connected) handler();
return () => this.socket.off('connect', handler);
}
/** Subscribe to a server event; returns an unsubscribe fn. */ /** Subscribe to a server event; returns an unsubscribe fn. */
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void { on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
const fn = handler as (...args: unknown[]) => void; const fn = handler as (...args: unknown[]) => void;
this.socket.on(event, fn); this.socket.on(event as string, fn);
return () => this.socket.off(event, fn); return () => this.socket.off(event as string, fn);
} }
async openThread(threadId?: string): Promise<OpenThreadResult> { async openThread(
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult; threadId?: string,
this.currentThreadId = result.threadId; opts?: { membership?: string; creatorRole?: string; subject?: string },
): Promise<OpenThreadResult> {
// Timeout (when the transport supports it) so a server error that never acks
// can't hang the caller forever.
const ack = this.socket.timeout ? this.socket.timeout(8000) : this.socket;
const result = (await ack.emitWithAck('open_thread', { threadId, ...opts })) as OpenThreadResult & { error?: string };
if (result?.error) throw new Error(result.error);
this.joined.add(result.threadId);
return result; return result;
} }
async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise<Message> { async sendMessage(
threadId: string,
content: string,
opts?: {
contentRef?: string;
parentInteractionId?: string;
mentions?: string[];
attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string };
},
): Promise<Message> {
return (await this.socket.emitWithAck('send_message', { return (await this.socket.emitWithAck('send_message', {
threadId, threadId,
content, content,
contentRef: opts?.contentRef, contentRef: opts?.attachment?.contentRef ?? opts?.contentRef,
mimeType: opts?.attachment?.mimeType,
sizeBytes: opts?.attachment?.sizeBytes,
checksumSha256: opts?.attachment?.checksumSha256,
parentInteractionId: opts?.parentInteractionId,
mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel
})) as Message; })) as Message;
} }
/** Pin a message in the thread (shared, generic annotation type "pin"). */
async pin(threadId: string, interactionId: string): Promise<void> {
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'pin', value: '' });
}
/** Save a message for myself (personal, generic annotation type "save"). */
async save(threadId: string, interactionId: string): Promise<void> {
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'save', value: '' });
}
/** Toggle an emoji reaction on a message (a generic annotation of type "reaction"). */
async react(threadId: string, interactionId: string, value: string): Promise<void> {
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'reaction', value });
}
async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> { async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean }; return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
} }
@@ -68,4 +111,9 @@ export class MessageSocket {
typing(threadId: string): void { typing(threadId: string): void {
this.socket.emit('typing', { threadId }); this.socket.emit('typing', { threadId });
} }
/** Tell the server which thread is in the foreground (or null when blurred) — drives presence. */
focus(threadId: string | null): void {
this.socket.emit('focus_thread', { threadId });
}
} }
+74 -5
View File
@@ -1,9 +1,16 @@
import type { IngestInteractionRequest } from '@insignia/iios-contracts'; import type { IngestInteractionRequest } from '@insignia/iios-contracts';
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types'; import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
export interface RestConfig { export interface RestConfig {
serviceUrl: string; serviceUrl: string;
token?: string; token?: string;
/**
* Extra headers for every request — e.g. `x-context-attestation` (the July 12 trust proof).
* Pass a FUNCTION to mint fresh headers per request: a context attestation carries a single-use
* nonce, so a static header would be replay-rejected on the 2nd call. The function is invoked
* once per request.
*/
headers?: Record<string, string> | (() => Record<string, string>);
} }
/** REST/polling client for kernel reads and the native-send fallback. */ /** REST/polling client for kernel reads and the native-send fallback. */
@@ -15,7 +22,8 @@ export class RestClient {
} }
private headers(extra: Record<string, string> = {}): Record<string, string> { private headers(extra: Record<string, string> = {}): Record<string, string> {
const h: Record<string, string> = { 'content-type': 'application/json', ...extra }; const custom = typeof this.config.headers === 'function' ? this.config.headers() : this.config.headers;
const h: Record<string, string> = { 'content-type': 'application/json', ...custom, ...extra };
if (this.config.token) h.authorization = `Bearer ${this.config.token}`; if (this.config.token) h.authorization = `Bearer ${this.config.token}`;
return h; return h;
} }
@@ -57,12 +65,73 @@ export class RestClient {
return (await r.json()) as InboxItem; return (await r.json()) as InboxItem;
} }
// ─── auth + threads (app surface) ─────────────────────────────
/** Dev IdP login (POST /v1/dev/login). A real IdP issues the same JWT — this is the swap point. */
async login(username: string, password: string): Promise<LoginResult> {
const r = await fetch(this.url('/v1/dev/login'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (r.status === 401) throw new Error('invalid username or password');
if (!r.ok) throw new Error(`login failed (${r.status}) — is the service running with IIOS_DEV_TOKENS=1?`);
return (await r.json()) as LoginResult;
}
/** Dev directory: known usernames. A real app validates against its user store / MDM. */
async listUsers(): Promise<string[]> {
const r = await fetch(this.url('/v1/dev/users'), { headers: this.headers() });
if (!r.ok) return [];
return ((await r.json()) as { users: string[] }).users;
}
/**
* Server-authoritative conversation list (works cross-device, shows unread + members).
* `filter.metadata` narrows to threads whose opaque attribute bag matches every key/value.
*/
async listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
const qs = filter?.metadata
? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
: '';
const r = await fetch(this.url(`/v1/threads${qs}`), { headers: this.headers() });
if (!r.ok) throw new Error(`listThreads ${r.status}`);
return (await r.json()) as ThreadSummary[];
}
/** Create a thread with generic app attributes (membership/creator role/subject + an opaque metadata bag). */
async createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }> {
return this.post<{ threadId: string }>('/v1/threads', opts);
}
/** My saved messages (personal bookmarks), newest first, with thread context. */
async listSaved(): Promise<SavedItem[]> {
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
if (!r.ok) throw new Error(`listSaved ${r.status}`);
return (await r.json()) as SavedItem[];
}
/** Governed add-participant. A policy 403 (e.g. DM cap) surfaces its reason. */
async addParticipant(threadId: string, userId: string): Promise<void> {
const r = await fetch(this.url(`/v1/threads/${threadId}/participants`), {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ userId }),
});
if (r.ok) return;
const body = (await r.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message ?? `could not add member (${r.status})`);
}
// ─── support ────────────────────────────────────────────────── // ─── support ──────────────────────────────────────────────────
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> { async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record<string, unknown> }): Promise<Ticket> {
return this.post<Ticket>('/v1/support/tickets', body); return this.post<Ticket>('/v1/support/tickets', body);
} }
async escalate(threadId: string, subject?: string): Promise<Ticket> { async escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
return this.post<Ticket>('/v1/support/escalate', { threadId, subject }); return this.post<Ticket>('/v1/support/escalate', { threadId, subject, metadata });
}
/** Manually assign a ticket to a specific user (generic assignment override). */
async assignTicket(id: string, userId: string): Promise<Ticket> {
return this.post<Ticket>(`/v1/support/tickets/${id}/assignee`, { userId });
} }
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> { async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() }); const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
+66
View File
@@ -1,10 +1,30 @@
/** A media/file part attached to a message (contentRef points at object storage). */
export interface Attachment {
contentRef: string;
mimeType: string;
sizeBytes: number;
kind: 'image' | 'video' | 'audio' | 'file';
}
/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */
export interface AnnotationGroup {
type: string;
value: string;
users: string[]; // usernames who applied it
}
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */ /** Wire shapes the kernel emits over socket / returns over REST (align with service). */
export interface Message { export interface Message {
id: string; id: string;
threadId: string; threadId: string;
senderActorId: string; senderActorId: string;
senderId: string; // sender's email/username — reliable "is this mine?" check
senderName: string;
content: string; content: string;
contentRef?: string; contentRef?: string;
attachment?: Attachment;
parentInteractionId?: string;
annotations?: AnnotationGroup[];
traceId?: string; traceId?: string;
createdAt: string; createdAt: string;
} }
@@ -26,10 +46,50 @@ export interface TypingEvent {
userId: string; userId: string;
} }
/** Broadcast when someone toggles an annotation — carries the refreshed user list. */
export interface AnnotationEvent {
threadId: string;
interactionId: string;
type: string;
value: string;
op: 'add' | 'remove';
users: string[];
userId: string;
}
export interface MessageEvents { export interface MessageEvents {
message: (m: Message) => void; message: (m: Message) => void;
receipt: (e: ReceiptEvent) => void; receipt: (e: ReceiptEvent) => void;
typing: (e: TypingEvent) => void; typing: (e: TypingEvent) => void;
annotation: (e: AnnotationEvent) => void;
}
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
export interface ThreadSummary {
threadId: string;
subject: string | null;
membership?: string; // 'dm' | 'group' (opaque app attribute)
/** The thread's opaque, app-supplied attribute bag — echoed verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
participants: string[]; // member usernames
participantCount: number;
unread: number;
muted?: boolean;
lastMessage?: string;
lastAt?: string;
}
/** A personally-saved message with its thread context (GET /v1/threads/my-annotations?type=save). */
export interface SavedItem {
message: Message;
threadId: string;
threadSubject: string | null;
}
/** Result of the dev IdP login (POST /v1/dev/login). A real IdP issues the same claims. */
export interface LoginResult {
token: string;
userId: string;
} }
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE'; export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
@@ -68,6 +128,8 @@ export interface Ticket {
assignedActorId?: string | null; assignedActorId?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** Opaque, app-supplied attribute bag on the ticket — echoed verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
threadLinks?: Array<{ threadId: string; relationKind: string }>; threadLinks?: Array<{ threadId: string; relationKind: string }>;
} }
@@ -188,4 +250,8 @@ export interface SocketLike {
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>; emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
connect(): unknown; connect(): unknown;
disconnect(): unknown; disconnect(): unknown;
/** True while the underlying transport is connected (socket.io exposes this). */
connected?: boolean;
/** Per-call ack timeout (socket.io). Optional so fakes can omit it. */
timeout?(ms: number): { emitWithAck(event: string, ...args: unknown[]): Promise<unknown> };
} }
+13 -4
View File
@@ -1,13 +1,19 @@
{ {
"name": "@insignia/iios-meeting-web", "name": "@insignia/iios-meeting-web",
"version": "0.0.0", "version": "0.1.0",
"private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "exports": {
"files": ["dist"], ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0", "react": "^19.0.0",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"typescript": "^5.7.3" "typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
} }
} }
+13 -4
View File
@@ -1,13 +1,19 @@
{ {
"name": "@insignia/iios-message-web", "name": "@insignia/iios-message-web",
"version": "0.0.0", "version": "0.1.0",
"private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "exports": {
"files": ["dist"], ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0", "react": "^19.0.0",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"typescript": "^5.7.3" "typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
} }
} }
+19
View File
@@ -49,3 +49,22 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
# ── Capability providers (governed egress targets) ─────────────────────────── # ── Capability providers (governed egress targets) ───────────────────────────
# Per-channel provider endpoint the CapabilityBroker calls, e.g.: # Per-channel provider endpoint the CapabilityBroker calls, e.g.:
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email # IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
# ── Context attestation (July 12 trust layer) ──
# Audience IIOS requires on attestations addressed to it.
IIOS_ATTESTATION_AUDIENCE=iios-core
# Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the
# appshell-crm client into the registry when IIOS_DEV_TOKENS=1).
# IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key
# When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403).
# Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless.
IIOS_REQUIRE_ATTESTATION=0
# ─── Object storage (media + email attachments) ───────────────────
# Unset → local disk (MEDIA_DIR). Set these → S3-compatible (AWS S3 / MinIO / R2 / Supabase).
# IIOS_S3_ENDPOINT=https://minio.your-server:9000 # omit for AWS S3
# IIOS_S3_BUCKET=iios-media
# IIOS_S3_ACCESS_KEY=...
# IIOS_S3_SECRET_KEY=...
# IIOS_S3_REGION=us-east-1 # any value for MinIO
# IIOS_S3_FORCE_PATH_STYLE=true # true for MinIO/self-hosted
+6 -4
View File
@@ -12,6 +12,7 @@
"prisma:studio": "prisma studio" "prisma:studio": "prisma studio"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1090.0",
"@insignia/iios-adapter-sdk": "workspace:*", "@insignia/iios-adapter-sdk": "workspace:*",
"@insignia/iios-contracts": "workspace:*", "@insignia/iios-contracts": "workspace:*",
"@nestjs/common": "^11.1.27", "@nestjs/common": "^11.1.27",
@@ -24,16 +25,16 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"handlebars": "^4.7.9",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0",
"nodemailer": "^9.0.3",
"prisma": "^6.2.1", "prisma": "^6.2.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"web-push": "^3.6.7", "web-push": "^3.6.7"
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/sdk-node": "^0.220.0",
"@opentelemetry/auto-instrumentations-node": "^0.78.0"
}, },
"devDependencies": { "devDependencies": {
"@insignia/iios-testkit": "workspace:*", "@insignia/iios-testkit": "workspace:*",
@@ -42,6 +43,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",
"@types/nodemailer": "^8.0.1",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"typescript": "^5.7.3" "typescript": "^5.7.3"
@@ -0,0 +1,24 @@
-- Trust plane (July 12): context attestation client registry + nonce replay ledger.
CREATE TABLE "IiosClientRegistry" (
"clientId" TEXT NOT NULL,
"clientType" TEXT NOT NULL,
"ownerService" TEXT,
"allowedAppIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"attestSecret" TEXT,
"jwksUri" TEXT,
"spiffeId" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IiosClientRegistry_pkey" PRIMARY KEY ("clientId")
);
CREATE TABLE "IiosAttestationNonce" (
"nonce" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosAttestationNonce_pkey" PRIMARY KEY ("nonce")
);
CREATE INDEX "IiosAttestationNonce_expiresAt_idx" ON "IiosAttestationNonce"("expiresAt");
@@ -0,0 +1,39 @@
-- Reusable message templates (email/SMS/in-app). DB is runtime truth; platform defaults seeded
-- from repo files on boot. Versions are immutable (a change = a new higher version).
CREATE TYPE "IiosTemplateChannel" AS ENUM ('EMAIL', 'SMS', 'INTERNAL');
CREATE TABLE "IiosMessageTemplate" (
"id" TEXT NOT NULL,
"scopeId" TEXT,
"key" TEXT NOT NULL,
"channel" "IiosTemplateChannel" NOT NULL,
"locale" TEXT NOT NULL DEFAULT 'en',
"version" INTEGER NOT NULL DEFAULT 1,
"subject" TEXT,
"bodyHtml" TEXT,
"bodyText" TEXT,
"variables" JSONB,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IiosMessageTemplate_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "IiosMessageTemplate"
ADD CONSTRAINT "IiosMessageTemplate_scopeId_fkey"
FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Uniqueness for SCOPED rows (scopeId NOT NULL).
CREATE UNIQUE INDEX "IiosMessageTemplate_scopeId_key_channel_locale_version_key"
ON "IiosMessageTemplate" ("scopeId", "key", "channel", "locale", "version");
-- Uniqueness for GLOBAL rows (scopeId IS NULL). Postgres treats NULL as distinct under a normal
-- UNIQUE, so the constraint above does NOT stop two platform defaults for the same key — this
-- partial index does. (Same footgun handled in the inbox idempotency migration.)
CREATE UNIQUE INDEX "IiosMessageTemplate_global_key"
ON "IiosMessageTemplate" ("key", "channel", "locale", "version")
WHERE "scopeId" IS NULL;
-- Resolution lookup: by key/channel/locale among active rows.
CREATE INDEX "IiosMessageTemplate_key_channel_locale_active_idx"
ON "IiosMessageTemplate" ("key", "channel", "locale", "active");
@@ -0,0 +1,9 @@
-- Template provenance on the outbound command: which template (key/version/locale) produced this
-- send, plus a hash of the rendered content. The rendered content itself already lives in `payload`;
-- these columns answer "which template version produced this send?" for replay/audit. All nullable —
-- non-templated sends leave them null.
ALTER TABLE "IiosOutboundCommand"
ADD COLUMN "templateKey" TEXT,
ADD COLUMN "templateVersion" INTEGER,
ADD COLUMN "templateLocale" TEXT,
ADD COLUMN "renderedHash" TEXT;
@@ -96,6 +96,12 @@ enum IiosInboxState {
STALE STALE
} }
enum IiosTemplateChannel {
EMAIL
SMS
INTERNAL
}
enum IiosTicketState { enum IiosTicketState {
NEW NEW
OPEN OPEN
@@ -310,6 +316,7 @@ model IiosScope {
tickets IiosTicket[] tickets IiosTicket[]
callbacks IiosCallbackRequest[] callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[] notificationSubscriptions IiosNotificationSubscription[]
messageTemplates IiosMessageTemplate[]
@@index([orgId, appId, tenantId]) @@index([orgId, appId, tenantId])
} }
@@ -677,6 +684,33 @@ model IiosInboxItem {
@@index([ownerActorId, state, priority]) @@index([ownerActorId, state, priority])
} }
/// A reusable message template (email/SMS/in-app). DB is runtime truth; platform defaults are
/// seeded from repo files on boot. Versions are immutable — a change writes a new (higher) version,
/// never edits in place, so a rendered send can always be traced to the exact source it used.
/// scopeId NULL = a platform default; set = a tenant scope's override of the same key. Resolution
/// prefers the scoped row, else the global default. (The global-uniqueness of NULL-scope rows is
/// enforced by a partial unique index added in the migration — Postgres treats NULL as distinct.)
model IiosMessageTemplate {
id String @id @default(cuid())
scopeId String?
scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade)
key String
channel IiosTemplateChannel
locale String @default("en")
version Int @default(1)
subject String?
bodyHtml String?
bodyText String?
/// Declared variable names — render throws if a declared var is missing (fail loud).
variables Json?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scopeId, key, channel, locale, version])
@@index([key, channel, locale, active])
}
/// Audit trail of inbox-item state transitions. /// Audit trail of inbox-item state transitions.
model IiosInboxItemStateHistory { model IiosInboxItemStateHistory {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -834,6 +868,11 @@ model IiosOutboundCommand {
idempotencyKey String @unique idempotencyKey String @unique
providerRef String? providerRef String?
consentReceiptRef String? // CMP consent receipt this send went out under (P9) consentReceiptRef String? // CMP consent receipt this send went out under (P9)
// Template provenance (which source produced this send) — null for non-templated sends.
templateKey String?
templateVersion Int?
templateLocale String?
renderedHash String? // sha256 of the rendered content, for replay/audit
createdAt DateTime @default(now()) createdAt DateTime @default(now())
attempts IiosDeliveryAttempt[] attempts IiosDeliveryAttempt[]
@@ -1321,3 +1360,31 @@ model IiosDlqItem {
@@unique([consumerName, sourceId]) @@unique([consumerName, sourceId])
@@index([scopeId, status]) @@index([scopeId, status])
} }
// ─── Trust plane (July 12) — context attestation ──────────────────
// Registered clients/BFFs/adapters allowed to call IIOS and attest context on
// behalf of an app. A valid actor token is NOT enough; the caller must present a
// context attestation signed by one of these registered clients.
model IiosClientRegistry {
clientId String @id
clientType String // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
ownerService String?
allowedAppIds String[] // which app_ids this client may attest for
attestSecret String? // dev: shared HS256 signing key (AppShell's key stand-in)
jwksUri String? // prod: verify the attestation signature via JWKS
spiffeId String? // prod: workload identity for mTLS proof
status String @default("ACTIVE") // ACTIVE | DISABLED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Single-use nonce ledger: once a context attestation's nonce is seen it can
// never be replayed. Reserve-if-absent (unique PK) makes the check atomic.
model IiosAttestationNonce {
nonce String @id
clientId String
expiresAt DateTime
firstSeenAt DateTime @default(now())
@@index([expiresAt])
}
@@ -34,8 +34,18 @@ export class OutboundService {
idempotencyKey?: string, idempotencyKey?: string,
scopeId?: string, scopeId?: string,
purpose?: string, purpose?: string,
/** Opaque template provenance recorded on the command. OutboundService neither renders nor
* resolves templates it only persists the four strings it is handed (guaranteed by the
* templated sender, never by caller convention). */
provenance?: { templateKey?: string | null; templateVersion?: number | null; templateLocale?: string | null; renderedHash?: string | null },
) { ) {
const key = idempotencyKey ?? randomUUID(); const key = idempotencyKey ?? randomUUID();
const prov = {
templateKey: provenance?.templateKey ?? null,
templateVersion: provenance?.templateVersion ?? null,
templateLocale: provenance?.templateLocale ?? null,
renderedHash: provenance?.renderedHash ?? null,
};
// The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried // The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried
// command returns the existing row instead of colliding on idempotencyKey @unique. // command returns the existing row instead of colliding on idempotencyKey @unique.
@@ -46,14 +56,14 @@ export class OutboundService {
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress). // Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) { if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
const cmd = await this.prisma.iiosOutboundCommand.create({ const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key }, data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key, ...prov },
}); });
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } }); await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
return cmd; return cmd;
} }
const cmd = await this.prisma.iiosOutboundCommand.create({ const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key }, data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key, ...prov },
}); });
let result; let result;
+6
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { AttestationModule } from './platform/attestation.module';
import { IdentityModule } from './identity/identity.module'; import { IdentityModule } from './identity/identity.module';
import { InteractionsModule } from './interactions/interactions.module'; import { InteractionsModule } from './interactions/interactions.module';
import { IdempotencyModule } from './idempotency/idempotency.module'; import { IdempotencyModule } from './idempotency/idempotency.module';
@@ -9,6 +10,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 { TemplateModule } from './templates/template.module';
import { MailModule } from './mail/mail.module';
import { MediaModule } from './media/media.module'; import { MediaModule } from './media/media.module';
import { NotificationModule } from './notifications/notification.module'; import { NotificationModule } from './notifications/notification.module';
import { SupportModule } from './support/support.module'; import { SupportModule } from './support/support.module';
@@ -27,6 +30,7 @@ import { DevController } from './dev/dev.controller';
imports: [ imports: [
PrismaModule, PrismaModule,
PlatformModule, PlatformModule,
AttestationModule,
IdentityModule, IdentityModule,
InteractionsModule, InteractionsModule,
IdempotencyModule, IdempotencyModule,
@@ -35,6 +39,8 @@ import { DevController } from './dev/dev.controller';
ThreadsModule, ThreadsModule,
MessageModule, MessageModule,
InboxModule, InboxModule,
TemplateModule,
MailModule,
MediaModule, MediaModule,
NotificationModule, NotificationModule,
SupportModule, SupportModule,
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MediaModule } from '../media/media.module';
import { CapabilityProviderRegistry } from './capability.registry'; import { CapabilityProviderRegistry } from './capability.registry';
import { CapabilityBroker } from './capability.broker'; import { CapabilityBroker } from './capability.broker';
@@ -8,6 +9,7 @@ import { CapabilityBroker } from './capability.broker';
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule. * provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
*/ */
@Module({ @Module({
imports: [MediaModule],
providers: [CapabilityProviderRegistry, CapabilityBroker], providers: [CapabilityProviderRegistry, CapabilityBroker],
exports: [CapabilityBroker, CapabilityProviderRegistry], exports: [CapabilityBroker, CapabilityProviderRegistry],
}) })
@@ -0,0 +1,34 @@
import { describe, it, expect, afterEach } from 'vitest';
import { CapabilityProviderRegistry } from './capability.registry';
// The registry reads env in its constructor, so each case sets env → constructs a fresh registry →
// asserts → restores env.
const SMTP_KEYS = ['IIOS_SMTP_HOST', 'IIOS_SMTP_USER', 'IIOS_SMTP_PASS', 'IIOS_PROVIDER_URL_EMAIL'];
const saved: Record<string, string | undefined> = {};
function set(env: Record<string, string | undefined>) {
for (const k of SMTP_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
for (const [k, v] of Object.entries(env)) if (v != null) process.env[k] = v;
}
afterEach(() => { for (const k of SMTP_KEYS) { if (saved[k] == null) delete process.env[k]; else process.env[k] = saved[k]; } });
describe('CapabilityProviderRegistry — EMAIL precedence (SMTP > HTTP > sandbox)', () => {
it('binds SMTP for EMAIL when the SMTP env trio is set', () => {
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' });
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
});
it('binds the HTTP EmailProvider when only IIOS_PROVIDER_URL_EMAIL is set', () => {
set({ IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('email-http');
});
it('SMTP wins over the HTTP relay when both are set', () => {
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p', IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
});
it('falls back to the sandbox when neither is configured', () => {
set({});
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('sandbox');
});
});
@@ -1,22 +1,27 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common';
import type { CapabilityProvider } from '@insignia/iios-contracts'; import type { CapabilityProvider } from '@insignia/iios-contracts';
import { SandboxProvider } from './sandbox.provider'; import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider'; import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider'; import { EmailProvider } from './email.provider';
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']; const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
/** /**
* Maps a channelType to the provider that executes egress for it. Sandbox by * Maps a channelType to the provider that executes egress for it. Sandbox by
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider * default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown * overrides the sandbox for that channel (the "flip the binding" swap). Unknown
* channels fail closed no silent egress path. * channels fail closed no silent egress path.
*
* Precedence is registration ORDER (register() does Map.set last wins). For EMAIL:
* sandbox HTTP EmailProvider (if URL set) SMTP (if SMTP env set), so SMTP > HTTP > sandbox.
*/ */
@Injectable() @Injectable()
export class CapabilityProviderRegistry { export class CapabilityProviderRegistry {
private readonly byChannel = new Map<string, CapabilityProvider>(); private readonly byChannel = new Map<string, CapabilityProvider>();
constructor() { constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) {
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch])); for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
for (const ch of DEFAULT_CHANNELS) { for (const ch of DEFAULT_CHANNELS) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`]; const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
@@ -24,6 +29,13 @@ export class CapabilityProviderRegistry {
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one. // EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url)); this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
} }
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments
// resolve through the media StoragePort when one is bound (else attachments FAIL closed).
const smtp = smtpIdentityFromEnv();
if (smtp) {
const resolver = this.storage ? storageResolver(this.storage) : undefined;
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
}
} }
register(provider: CapabilityProvider): void { register(provider: CapabilityProvider): void {
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, type MailTransport, type SmtpIdentity } from './smtp.provider';
import type { CapabilityRequest } from '@insignia/iios-contracts';
const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' };
const FB: SmtpIdentity = { ...ID, user: 'ceo@lynkeduppro.com', from: 'Justin <ceo@lynkeduppro.com>' };
const req = (payload: Record<string, unknown>): CapabilityRequest => ({
capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1',
});
/** A recording transport; optionally throws a given error on send. */
function stub(opts: { throwErr?: unknown; messageId?: string } = {}) {
const calls: Array<{ id: SmtpIdentity; mail: Parameters<MailTransport['sendMail']>[0] }> = [];
const make = (id: SmtpIdentity): MailTransport => ({
async sendMail(mail) {
calls.push({ id, mail });
if (opts.throwErr) throw opts.throwErr;
return { messageId: opts.messageId ?? '<generated@smtp.test>' };
},
});
return { make, calls };
}
describe('smtpIdentityFromEnv', () => {
const base = { IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' };
it('builds the identity from a complete env trio (defaults port 587, secure false)', () => {
const id = smtpIdentityFromEnv({ ...base } as NodeJS.ProcessEnv);
expect(id).toMatchObject({ host: 'smtp.test', port: 587, secure: false, user: 'accounts@x', from: 'accounts@x' });
});
it('returns null when the trio is incomplete', () => {
expect(smtpIdentityFromEnv({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'a@x' } as NodeJS.ProcessEnv)).toBeNull();
});
it('reads the fallback identity, reusing the primary host', () => {
const fb = smtpFallbackFromEnv({ ...base, IIOS_SMTP_FALLBACK_USER: 'ceo@x', IIOS_SMTP_FALLBACK_PASS: 'q', IIOS_SMTP_FALLBACK_FROM: 'CEO <ceo@x>' } as NodeJS.ProcessEnv);
expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO <ceo@x>' });
});
it('returns null fallback when not configured', () => {
expect(smtpFallbackFromEnv({ ...base } as NodeJS.ProcessEnv)).toBeNull();
});
});
describe('SmtpProvider.send — envelope', () => {
it('maps target + payload into the mail and returns the messageId as providerRef', async () => {
const t = stub({ messageId: '<abc@smtp.test>' });
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '<p>x</p>', text: 'x' }));
expect(res).toMatchObject({ outcome: 'SENT', providerRef: '<abc@smtp.test>' });
expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '<p>x</p>', text: 'x' });
});
it('sets In-Reply-To + References headers for a reply', async () => {
const t = stub();
await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 're', inReplyTo: '<parent@smtp.test>' }));
expect(t.calls[0].mail).toMatchObject({ inReplyTo: '<parent@smtp.test>', references: '<parent@smtp.test>' });
});
});
describe('SmtpProvider.send — failure + fallback', () => {
it('retries via the fallback identity on a pre-acceptance failure (SENT via fallback)', async () => {
// primary throws ECONNREFUSED (server never accepted) → fallback used.
const primaryThrows = { make: (id: SmtpIdentity): MailTransport => ({
async sendMail(mail) {
if (id.user === ID.user) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' });
return { messageId: '<viaFallback@smtp.test>' };
},
}) };
const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' }));
expect(res.outcome).toBe('SENT');
expect(res.providerRef).toBe('fallback:<viaFallback@smtp.test>');
});
it('does NOT retry a post-acceptance failure (avoids double delivery) → FAILED', async () => {
// responseCode present = the server already spoke; retrying could double-send.
const t = stub({ throwErr: Object.assign(new Error('rejected after data'), { responseCode: 550 }) });
const res = await new SmtpProvider(ID, FB, t.make).send(req({ subject: 'x' }));
expect(res.outcome).toBe('FAILED');
expect(t.calls).toHaveLength(1); // primary only — no fallback attempt
});
it('with no fallback, a failure is FAILED and never throws', async () => {
const t = stub({ throwErr: Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) });
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'x' }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('ETIMEDOUT');
});
});
describe('SmtpProvider.send — attachments', () => {
const resolver = (map: Record<string, { content: Buffer; contentType?: string; filename?: string }>) =>
async (ref: string) => map[ref] ?? null;
it('resolves attachment refs to bytes and attaches them', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } }))
.send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] }));
expect(res.outcome).toBe('SENT');
expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]);
});
it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({}))
.send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED');
expect(t.calls).toHaveLength(0); // nothing sent
});
it('FAILS when attachments are requested but no resolver is wired', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make) // no resolver
.send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] }));
expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' });
});
it('a plain send with no attachments is unaffected', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' }));
expect(res.outcome).toBe('SENT');
expect(t.calls[0].mail.attachments).toBeUndefined();
});
});
describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
it('maps storage bytes into an attachment; missing ref → null', async () => {
const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) };
const r = storageResolver(storage);
expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' });
expect(await r('nope')).toBeNull();
});
});
@@ -0,0 +1,185 @@
import { randomUUID } from 'node:crypto';
import { createTransport } from 'nodemailer';
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
/** One SMTP sending identity (a mailbox + how to reach its server). */
export interface SmtpIdentity {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
from: string;
}
export interface MailAttachment {
filename: string;
content: Buffer;
contentType?: string;
}
/** Fetch an attachment's bytes by its opaque contentRef (backed by the media StoragePort). */
export type AttachmentResolver = (contentRef: string) => Promise<{ filename?: string; content: Buffer; contentType?: string } | null>;
/** Build an AttachmentResolver over the media StoragePort (contentRef = the storage object key). */
export function storageResolver(storage: { get(key: string): Promise<{ data: Buffer; mime: string } | null> }): AttachmentResolver {
return async (contentRef) => {
const o = await storage.get(contentRef);
return o ? { content: o.data, contentType: o.mime } : null;
};
}
/** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */
export interface MailTransport {
sendMail(mail: {
from: string;
to: string;
subject?: string;
text?: string;
html?: string;
inReplyTo?: string;
references?: string;
attachments?: MailAttachment[];
}): Promise<{ messageId: string; accepted?: unknown[] }>;
}
const bool = (v: string | undefined): boolean => v === 'true' || v === '1';
/** Build the primary SMTP identity from env, or null if the required trio is incomplete. */
export function smtpIdentityFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
return identityFrom(env, '');
}
/** Build the optional fallback identity (accounts@ → ceo@), or null if not configured. */
export function smtpFallbackFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
const fb = identityFrom(env, 'FALLBACK_');
if (fb) return fb;
// Fallback may reuse the primary host/port and only override the mailbox identity.
const host = env.IIOS_SMTP_HOST;
const user = env.IIOS_SMTP_FALLBACK_USER;
const pass = env.IIOS_SMTP_FALLBACK_PASS;
if (!host || !user || !pass) return null;
return {
host,
port: Number(env.IIOS_SMTP_PORT ?? 587),
secure: bool(env.IIOS_SMTP_SECURE),
user,
pass,
from: env.IIOS_SMTP_FALLBACK_FROM ?? user,
};
}
function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | null {
const host = env[`IIOS_SMTP_${prefix}HOST`] ?? (prefix ? undefined : env.IIOS_SMTP_HOST);
const user = env[`IIOS_SMTP_${prefix}USER`];
const pass = env[`IIOS_SMTP_${prefix}PASS`];
if (!host || !user || !pass) return null;
return {
host,
port: Number(env[`IIOS_SMTP_${prefix}PORT`] ?? env.IIOS_SMTP_PORT ?? 587),
secure: bool(env[`IIOS_SMTP_${prefix}SECURE`] ?? env.IIOS_SMTP_SECURE),
user,
pass,
from: env[`IIOS_SMTP_${prefix}FROM`] ?? user,
};
}
interface EmailPayload {
subject?: string;
text?: string;
html?: string;
inReplyTo?: string;
/** Attachment REFS (not bytes) — resolved to bytes at send time via the injected resolver. */
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
}
/**
* SMTP egress provider (nodemailer). Bound for EMAIL when the SMTP env trio is set (else the sandbox
* stays). A transport failure surfaces as FAILED, never thrown. On a PRE-acceptance failure it retries
* once via the fallback identity (accounts@ ceo@); a post-acceptance failure is NOT retried, so a
* message the server already accepted can't be double-delivered.
*/
export class SmtpProvider implements CapabilityProvider {
readonly name = 'smtp';
readonly channelTypes = ['EMAIL'];
readonly capabilities = { canSend: true };
private readonly makeTransport: (id: SmtpIdentity) => MailTransport;
constructor(
private readonly primary: SmtpIdentity,
private readonly fallback?: SmtpIdentity,
makeTransport?: (id: SmtpIdentity) => MailTransport,
private readonly resolveAttachment?: AttachmentResolver,
) {
this.makeTransport = makeTransport ?? defaultTransport;
}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
const p = (req.payload ?? {}) as EmailPayload;
// Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing
// its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
let attachments: MailAttachment[] | undefined;
if (p.attachments && p.attachments.length > 0) {
if (!this.resolveAttachment) return this.failed('NO_ATTACHMENT_RESOLVER', started);
const out: MailAttachment[] = [];
for (const a of p.attachments) {
const r = await this.resolveAttachment(a.contentRef).catch(() => null);
if (!r) return this.failed('ATTACHMENT_UNRESOLVED', started);
out.push({ filename: a.filename ?? r.filename ?? 'attachment', content: r.content, ...(a.mimeType ?? r.contentType ? { contentType: a.mimeType ?? r.contentType } : {}) });
}
attachments = out;
}
const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> =>
this.makeTransport(id).sendMail({
from: id.from,
to: req.target,
subject: p.subject ?? '(no subject)',
text: p.text,
html: p.html,
...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}),
...(attachments ? { attachments } : {}),
});
try {
const info = await attempt(this.primary);
return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err) {
// Retry via the fallback identity ONLY if the primary never got the message accepted.
if (this.fallback && isPreAcceptanceFailure(err)) {
try {
const info = await attempt(this.fallback);
return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err2) {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started };
}
}
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started };
}
}
private failed(errorCode: string, started: number): ProviderResult {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode, latencyMs: Date.now() - started };
}
}
/** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */
function isPreAcceptanceFailure(err: unknown): boolean {
const e = err as { code?: string; responseCode?: number };
const preCodes = ['ECONNECTION', 'ETIMEDOUT', 'ECONNREFUSED', 'EDNS', 'EAUTH', 'ESOCKET', 'EENVELOPE'];
if (e.code && preCodes.includes(e.code)) return true;
// A responseCode present means the server spoke — treat 5xx after acceptance as terminal (no retry).
return e.responseCode == null && e.code == null;
}
function codeOf(err: unknown): string {
const e = err as { code?: string; message?: string };
return (e.code ?? e.message ?? 'SMTP_ERROR').slice(0, 60);
}
function defaultTransport(id: SmtpIdentity): MailTransport {
return createTransport({ host: id.host, port: id.port, secure: id.secure, auth: { user: id.user, pass: id.pass } }) as unknown as MailTransport;
}
@@ -8,6 +8,9 @@ export interface MessagePrincipal {
appId: string; appId: string;
tenantId?: string; tenantId?: string;
displayName?: string; displayName?: string;
/** The token's audience (`aud`). Used to enforce realtime delegation: a socket-scoped
* token (`iios-message`) must not be a full REST/actor token (`iios-core`), and vice-versa. */
audience?: string;
} }
/** /**
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsInt,
IsISO8601, IsISO8601,
IsObject, IsObject,
IsOptional, IsOptional,
@@ -40,6 +41,7 @@ class PartDto {
@IsOptional() @IsString() bodyText?: string; @IsOptional() @IsString() bodyText?: string;
@IsOptional() @IsString() contentRef?: string; @IsOptional() @IsString() contentRef?: string;
@IsOptional() @IsString() mimeType?: string; @IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
} }
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */ /** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
@@ -178,6 +178,7 @@ export class IngestService {
bodyText: p.bodyText, bodyText: p.bodyText,
contentRef: p.contentRef, contentRef: p.contentRef,
mimeType: p.mimeType, mimeType: p.mimeType,
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
})), })),
}); });
@@ -0,0 +1,57 @@
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
import { SessionVerifier } from '../platform/session.verifier';
import type { MessagePrincipal } from '../identity/actor.resolver';
import { MailService } from './mail.service';
import { MailInternalDto, MailSendDto } from './mail.dto';
import type { TemplateSource } from '../templates/template.model';
@Controller('v1/mail')
export class MailController {
constructor(
private readonly mail: MailService,
private readonly session: SessionVerifier,
) {}
/** App-to-app mail — renders a template and posts it into the recipient's in-app inbox (no SMTP). */
@Post('internal')
async internal(@Body() body: MailInternalDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
return this.mail.postInternal(principal, {
source: this.source(body),
recipientUserId: body.recipientUserId,
vars: body.vars ?? {},
...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey,
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
});
}
/** External email (SMTP) + an in-app mirror when a registered recipient is named. */
@Post('send')
async send(@Body() body: MailSendDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
return this.mail.sendExternalWithMirror(principal, {
source: this.source(body),
target: body.target,
vars: body.vars ?? {},
...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey,
...(body.purpose ? { purpose: body.purpose } : {}),
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}),
});
}
private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource {
if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both');
if (body.inline) return { inline: body.inline };
if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key };
throw new BadRequestException('one of "key" or "inline" is required');
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,41 @@
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { InlineTemplateDto } from '../templates/template.dto';
/** An email attachment reference — bytes live in the media store under `contentRef`. */
export class AttachmentDto {
@IsOptional() @IsString() filename?: string;
@IsString() @IsNotEmpty() contentRef!: string;
@IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
}
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
export class MailInternalDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsString() @IsNotEmpty() recipientUserId!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
/** In-app attachment refs — stored as message parts so the recipient's inbox can render them. */
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
}
/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
export class MailSendDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsString() @IsNotEmpty() target!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
@IsOptional() @IsString() purpose?: string;
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
/** The registered recipient to mirror to; omit for a pre-registration send (email only). */
@IsOptional() @IsString() mirrorToUserId?: string;
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TemplateModule } from '../templates/template.module';
import { InteractionsModule } from '../interactions/interactions.module';
import { MailService } from './mail.service';
import { MailController } from './mail.controller';
/**
* Mail orchestration: render a template (TemplateModule) deliver it either app-to-app (an EMAIL
* interaction via IngestService) or externally (TemplatedSender/SMTP) with an in-app mirror.
* SessionVerifier + ActorResolver are global.
*/
@Module({
imports: [TemplateModule, InteractionsModule],
controllers: [MailController],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
@@ -0,0 +1,121 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { IngestService } from '../interactions/ingest.service';
import { MessageService } from '../messaging/message.service';
import { OutboundService } from '../adapters/outbound.service';
import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { TemplateRepository } from '../templates/template.repository';
import { TemplateService } from '../templates/template.service';
import { TemplatedSender } from '../templates/templated-sender';
import { MailService, contentToParts } from './mail.service';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios_test?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const actors = new ActorResolver(asService);
function mail(): MailService {
const templates = new TemplateService(new TemplateRepository(asService));
const ingest = new IngestService(asService, makeFakePorts(), actors);
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const sender = new TemplatedSender(templates, outbound, makeFakePorts());
return new MailService(templates, ingest, sender, actors, asService);
}
const messages = () => new MessageService(asService, makeFakePorts(), actors);
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
const inline = { inline: { subject: 'Welcome Dana', html: '<p>Hi <b>Dana</b></p>', text: 'Hi Dana', variables: [] } };
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('contentToParts (pure)', () => {
it('produces HTML + TEXT parts and the subject', () => {
expect(contentToParts({ subject: 'S', html: '<p>h</p>', text: 't' })).toEqual({ subject: 'S', parts: [{ kind: 'HTML', bodyText: '<p>h</p>' }, { kind: 'TEXT', bodyText: 't' }] });
});
it('never yields zero parts (empty text fallback)', () => {
expect(contentToParts({ subject: 'S' }).parts).toEqual([{ kind: 'TEXT', bodyText: '' }]);
});
});
describe('MailService.postInternal', () => {
it('creates an EMAIL interaction and makes the thread visible to BOTH sender and recipient', async () => {
const res = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:1' });
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
expect(interaction.kind).toBe('EMAIL');
expect(interaction.parts.map((p) => p.kind).sort()).toEqual(['HTML', 'TEXT']);
const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: res.threadId } });
expect(thread.subject).toBe('Welcome Dana');
expect((thread.metadata as { source?: string } | null)?.source).toBe('crm-mail'); // lists separately from chat
// The load-bearing assertion: the RECIPIENT can see the thread in their inbox.
const bobThreads = await messages().listThreads(bob);
expect(bobThreads.map((t) => t.threadId)).toContain(res.threadId);
// ...and so can the sender.
const aliceThreads = await messages().listThreads(alice);
expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId);
});
it('stores an in-app attachment as a media part alongside the body', async () => {
const res = await mail().postInternal(alice, {
source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att',
attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }],
});
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
const file = interaction.parts.find((p) => p.contentRef);
expect(file).toBeDefined();
expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF
expect(file!.contentRef).toBe('scope/roof.png');
expect(file!.mimeType).toBe('image/png');
expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048);
});
it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => {
const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
expect(b.threadId).toBe(a.threadId);
expect(await prisma.iiosInteraction.count({ where: { threadId: a.threadId } })).toBe(1);
});
});
describe('MailService.sendExternalWithMirror', () => {
const ext = { source: inline, target: 'dana@acme.com', idempotencyKey: 'recv:1' } as const;
it('sends via the outbound pipeline AND mirrors into a registered recipient inbox', async () => {
const res = await mail().sendExternalWithMirror(alice, { ...ext, mirrorToUserId: 'bob' });
// outbound command exists (SENT via sandbox in tests)
const cmd = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id: res.commandId } });
expect(cmd.channelType).toBe('EMAIL');
expect(cmd.target).toBe('dana@acme.com');
// mirror interaction visible to the recipient
expect(res.mirror).toBeDefined();
const bobThreads = await messages().listThreads(bob);
expect(bobThreads.map((t) => t.threadId)).toContain(res.mirror!.threadId);
});
it('does NOT mirror when there is no registered recipient (pre-registration send)', async () => {
const res = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:2' }); // no mirrorToUserId
expect(res.mirror).toBeUndefined();
// an outbound command was created, but no mirror interaction
expect(await prisma.iiosOutboundCommand.count({ where: { id: res.commandId } })).toBe(1);
expect(await prisma.iiosInteraction.count()).toBe(0);
});
it('is idempotent — a replay yields one command and one mirror', async () => {
const a = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' });
const b = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' });
expect(b.commandId).toBe(a.commandId);
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'recv:dup' } })).toBe(1);
expect(await prisma.iiosInteraction.count({ where: { threadId: a.mirror!.threadId } })).toBe(1);
});
});
@@ -0,0 +1,133 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { IngestService } from '../interactions/ingest.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { TemplateService } from '../templates/template.service';
import { TemplatedSender } from '../templates/templated-sender';
import type { RenderedContent } from '../templates/template.renderer';
import type { TemplateSource } from '../templates/template.model';
export interface MailPart { kind: 'HTML' | 'TEXT'; bodyText: string }
/** Turn rendered content into ingest parts (HTML + TEXT) + the thread subject. Pure. */
export function contentToParts(content: RenderedContent): { subject?: string; parts: MailPart[] } {
const parts: MailPart[] = [];
if (content.html != null) parts.push({ kind: 'HTML', bodyText: content.html });
if (content.text != null) parts.push({ kind: 'TEXT', bodyText: content.text });
// A message must carry at least one part; fall back to an empty text part rather than fail ingest.
if (parts.length === 0) parts.push({ kind: 'TEXT', bodyText: '' });
return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
}
export interface MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number }
export interface PostInternalInput {
source: TemplateSource;
recipientUserId: string;
vars?: Record<string, unknown>;
locale?: string;
idempotencyKey: string;
/** In-app attachment refs — stored as FILE message parts alongside the body. */
attachments?: MailAttachment[];
}
export interface SendExternalInput {
source: TemplateSource;
target: string; // email address (external)
vars?: Record<string, unknown>;
locale?: string;
idempotencyKey: string;
purpose?: string;
/** Attachment refs (bytes resolved at send time). */
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
/** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */
mirrorToUserId?: string;
}
/**
* Mail orchestration: render a template, then deliver it.
* - INTERNAL (app-to-app): create an EMAIL interaction on a per-email thread no SMTP.
* - EXTERNAL: send via SMTP (TemplatedSender) AND mirror a copy into the recipient's in-app inbox,
* but only when they are a registered user (pre-registration sends have no inbox yet).
*
* ingest() writes the interaction + thread but adds no participants, and a thread is only visible to
* its participants so after each ingest we add BOTH the sender and the recipient as participants.
*/
@Injectable()
export class MailService {
constructor(
private readonly templates: TemplateService,
private readonly ingest: IngestService,
private readonly sender: TemplatedSender,
private readonly actors: ActorResolver,
private readonly prisma: PrismaService,
) {}
/** Marks a thread as CRM mail so it lists separately from chat (Messenger uses source=crm-messenger). */
static readonly SOURCE = 'crm-mail';
async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> {
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) });
return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL', input.attachments);
}
async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> {
const command = await this.sender.sendTemplated({
source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {},
...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}),
...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}),
});
// Mirror only for a registered recipient (timing rule: no inbox exists pre-registration).
if (!input.mirrorToUserId) return { commandId: command.id };
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) });
const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL', input.attachments);
return { commandId: command.id, mirror };
}
/** A stored media ref → a message part; kind follows the mime (image/video → MEDIA_REF, audio → VOICE_REF, else FILE_REF). */
private attachmentPart(a: MailAttachment): { kind: 'MEDIA_REF' | 'VOICE_REF' | 'FILE_REF'; bodyText?: string; contentRef: string; mimeType?: string; sizeBytes?: number } {
const mime = a.mimeType ?? '';
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
return { kind, ...(a.filename ? { bodyText: a.filename } : {}), contentRef: a.contentRef, ...(a.mimeType ? { mimeType: a.mimeType } : {}), ...(a.sizeBytes != null ? { sizeBytes: a.sizeBytes } : {}) };
}
/** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */
private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string, attachments?: MailAttachment[]): Promise<{ threadId: string; interactionId: string }> {
const { subject, parts } = contentToParts(content);
const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))];
const res = await this.ingest.ingest(
{
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) },
channel: { type: channelType, externalChannelId: channelType.toLowerCase() },
source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
kind: 'EMAIL',
thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
parts: allParts,
occurredAt: new Date().toISOString(),
providerEventId: key,
},
key,
);
// Make the thread visible to both the sender and the recipient (ingest adds no participants).
const scope = await this.actors.resolveScope(principal);
const senderActor = await this.actors.resolveActor(scope.id, principal);
const recipientActor = await this.actors.resolveActor(scope.id, {
userId: recipientUserId, appId: principal.appId, orgId: principal.orgId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}),
});
await this.actors.ensureParticipant(res.threadId, senderActor.id);
await this.actors.ensureParticipant(res.threadId, recipientActor.id);
// Tag the thread as CRM mail (thread metadata) so it lists separately from Messenger chat.
// ingest() puts req.metadata on the interaction, not the thread, so we set it here directly.
await this.prisma.iiosThread.update({
where: { id: res.threadId },
data: { metadata: { source: MailService.SOURCE } as Prisma.InputJsonValue },
});
return { threadId: res.threadId, interactionId: res.interactionId };
}
}
+4 -1
View File
@@ -1,4 +1,3 @@
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';
@@ -10,6 +9,10 @@ import { PolicyDeniedFilter } from './platform/policy-denied.filter';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { rawBody: true }); const app = await NestFactory.create(AppModule, { rawBody: true });
// Express 5 defaults to the 'simple' query parser, which ignores nested params like
// ?metadata[key]=value — so generic metadata filters would be silently dropped. Use the
// qs-based 'extended' parser so those parse into a nested object.
app.getHttpAdapter().getInstance().set('query parser', 'extended');
app.use(traceMiddleware); // request-scoped trace context + x-trace-id header (P9) app.use(traceMiddleware); // request-scoped trace context + x-trace-id header (P9)
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
@@ -5,6 +5,9 @@ import { SessionVerifier } from '../platform/session.verifier';
import { PresignDownloadDto, PresignUploadDto } from './media.dto'; import { PresignDownloadDto, PresignUploadDto } from './media.dto';
import type { MessagePrincipal } from '../identity/actor.resolver'; import type { MessagePrincipal } from '../identity/actor.resolver';
/** Types that can execute script if a browser renders them top-level — served as downloads only. */
const SCRIPTABLE_MIMES = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml']);
@Controller('v1/media') @Controller('v1/media')
export class MediaController { export class MediaController {
constructor( constructor(
@@ -37,6 +40,12 @@ export class MediaController {
async blob(@Param('token') token: string, @Res() res: Response) { async blob(@Param('token') token: string, @Res() res: Response) {
const { data, mime } = await this.media.get(token); const { data, mime } = await this.media.get(token);
res.setHeader('Content-Type', mime); res.setHeader('Content-Type', mime);
// Never let the browser MIME-sniff an upload into something executable.
res.setHeader('X-Content-Type-Options', 'nosniff');
// Script-capable types must not render inline from our origin (stored-XSS) — force a download.
// Images/video/audio/pdf stay inline so the app can preview them. Note <img>/<video> still embed
// fine even with attachment disposition; only top-level navigation to the blob is affected.
if (SCRIPTABLE_MIMES.has(mime)) res.setHeader('Content-Disposition', 'attachment');
res.setHeader('Cache-Control', 'private, max-age=3600'); res.setHeader('Cache-Control', 'private, max-age=3600');
res.send(data); res.send(data);
} }
@@ -1,15 +1,27 @@
import { Module } from '@nestjs/common'; import { Module, Logger } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module'; import { PlatformModule } from '../platform/platform.module';
import { IdentityModule } from '../identity/identity.module'; import { IdentityModule } from '../identity/identity.module';
import { MediaController } from './media.controller'; import { MediaController } from './media.controller';
import { MediaService } from './media.service'; import { MediaService } from './media.service';
import { LocalDiskStorage } from './local-disk.storage'; import { LocalDiskStorage } from './local-disk.storage';
import { STORAGE_PORT } from './storage.port'; import { S3Storage, s3ConfigFromEnv } from './s3.storage';
import { STORAGE_PORT, type StoragePort } from './storage.port';
/** S3/MinIO when its env is set (IIOS_S3_BUCKET + keys), else local disk. Env-driven swap. */
function makeStorage(): StoragePort {
const cfg = s3ConfigFromEnv();
if (cfg) {
new Logger('MediaStorage').log(`using S3 storage (bucket "${cfg.bucket}"${cfg.endpoint ? ` @ ${cfg.endpoint}` : ''})`);
return new S3Storage(cfg);
}
return new LocalDiskStorage();
}
@Module({ @Module({
imports: [PlatformModule, IdentityModule], imports: [PlatformModule, IdentityModule],
controllers: [MediaController], controllers: [MediaController],
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter. providers: [MediaService, { provide: STORAGE_PORT, useFactory: makeStorage }],
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }], // Exported so the capability layer can resolve email attachment bytes at send time.
exports: [STORAGE_PORT],
}) })
export class MediaModule {} export class MediaModule {}
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { S3Storage, s3ConfigFromEnv, type S3Config, type S3Like } from './s3.storage';
const CFG: S3Config = { endpoint: 'https://minio.test', region: 'us-east-1', bucket: 'iios', accessKeyId: 'k', secretAccessKey: 's', forcePathStyle: true };
/** A recording stub S3 client; GetObject returns whatever `store[key]` holds (or a NoSuchKey error). */
function stub(store: Record<string, { body: Buffer; mime: string }> = {}) {
const calls: unknown[] = [];
const client: S3Like = {
async send(command: unknown) {
calls.push(command);
if (command instanceof PutObjectCommand) { store[command.input.Key!] = { body: command.input.Body as Buffer, mime: command.input.ContentType ?? '' }; return {}; }
if (command instanceof DeleteObjectCommand) { delete store[command.input.Key!]; return {}; }
if (command instanceof GetObjectCommand) {
const hit = store[command.input.Key!];
if (!hit) throw Object.assign(new Error('missing'), { name: 'NoSuchKey' });
return { Body: { transformToByteArray: async () => new Uint8Array(hit.body) }, ContentType: hit.mime };
}
return {};
},
};
return { client, calls, store };
}
describe('s3ConfigFromEnv', () => {
it('builds config from env (path-style default true for MinIO)', () => {
const cfg = s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x', IIOS_S3_BUCKET: 'b', IIOS_S3_ACCESS_KEY: 'k', IIOS_S3_SECRET_KEY: 's' } as NodeJS.ProcessEnv);
expect(cfg).toMatchObject({ endpoint: 'https://minio.x', bucket: 'b', region: 'us-east-1', forcePathStyle: true });
});
it('returns null when bucket/keys are missing', () => {
expect(s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x' } as NodeJS.ProcessEnv)).toBeNull();
});
});
describe('S3Storage (StoragePort over S3/MinIO)', () => {
it('put stores the object and returns size + sha256', async () => {
const s = stub();
const store = new S3Storage(CFG, s.client);
const res = await store.put('scope/obj1', Buffer.from('hello'), 'text/plain');
expect(res.sizeBytes).toBe(5);
expect(res.checksumSha256).toMatch(/^[a-f0-9]{64}$/);
const put = s.calls[0] as PutObjectCommand;
expect(put.input).toMatchObject({ Bucket: 'iios', Key: 'scope/obj1', ContentType: 'text/plain' });
});
it('get round-trips the bytes + mime', async () => {
const s = stub();
const store = new S3Storage(CFG, s.client);
await store.put('scope/obj2', Buffer.from('PDFDATA'), 'application/pdf');
const got = await store.get('scope/obj2');
expect(got).toEqual({ data: Buffer.from('PDFDATA'), mime: 'application/pdf', sizeBytes: 7 });
});
it('get returns null for a missing key (NoSuchKey → null, not throw)', async () => {
const store = new S3Storage(CFG, stub().client);
expect(await store.get('nope')).toBeNull();
});
it('remove issues a DeleteObject', async () => {
const s = stub({ 'k': { body: Buffer.from('x'), mime: 't' } });
await new S3Storage(CFG, s.client).remove('k');
expect(s.calls.some((c) => c instanceof DeleteObjectCommand)).toBe(true);
expect(s.store['k']).toBeUndefined();
});
});
@@ -0,0 +1,87 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import type { StoragePort } from './storage.port';
export interface S3Config {
endpoint?: string; // MinIO/self-hosted URL; omit for AWS
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
forcePathStyle: boolean; // true for MinIO
}
/** The one method this adapter uses — lets tests inject a stub client (no live S3/MinIO). */
export interface S3Like {
send(command: unknown): Promise<unknown>;
}
/** Build an S3Config from env, or null if the required bits are missing (→ fall back to disk). */
export function s3ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): S3Config | null {
const bucket = env.IIOS_S3_BUCKET;
const accessKeyId = env.IIOS_S3_ACCESS_KEY;
const secretAccessKey = env.IIOS_S3_SECRET_KEY;
if (!bucket || !accessKeyId || !secretAccessKey) return null;
return {
...(env.IIOS_S3_ENDPOINT ? { endpoint: env.IIOS_S3_ENDPOINT } : {}),
region: env.IIOS_S3_REGION ?? 'us-east-1',
bucket,
accessKeyId,
secretAccessKey,
// MinIO/self-hosted needs path-style; default true unless explicitly disabled for AWS.
forcePathStyle: env.IIOS_S3_FORCE_PATH_STYLE !== 'false',
};
}
/**
* S3-compatible object storage (AWS S3, MinIO, R2, Supabase Storage). A drop-in for LocalDiskStorage:
* same StoragePort contract. sha256 is computed locally on put (S3 doesn't return it), matching the
* disk adapter. A missing object reads back as null (not an error).
*/
@Injectable()
export class S3Storage implements StoragePort {
private readonly client: S3Like;
private readonly bucket: string;
constructor(config: S3Config, client?: S3Like) {
this.bucket = config.bucket;
this.client = client ?? new S3Client({
region: config.region,
...(config.endpoint ? { endpoint: config.endpoint } : {}),
forcePathStyle: config.forcePathStyle,
credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey },
});
}
async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> {
const checksumSha256 = createHash('sha256').update(data).digest('hex');
await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, Body: data, ContentType: mime }));
return { sizeBytes: data.length, checksumSha256 };
}
async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> {
try {
const res = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }))) as {
Body?: { transformToByteArray(): Promise<Uint8Array> };
ContentType?: string;
};
if (!res.Body) return null;
const bytes = await res.Body.transformToByteArray();
const data = Buffer.from(bytes);
return { data, mime: res.ContentType ?? 'application/octet-stream', sizeBytes: data.length };
} catch (err) {
if (isNotFound(err)) return null;
throw err;
}
}
async remove(objectKey: string): Promise<void> {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: objectKey }));
}
}
function isNotFound(err: unknown): boolean {
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };
return e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404;
}
@@ -0,0 +1,93 @@
import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest';
import jwt from 'jsonwebtoken';
import type { Socket } from 'socket.io';
import { MessageGateway } from './message.gateway';
import type { MessageService, MessagePrincipal } from './message.service';
import { SessionVerifier } from '../platform/session.verifier';
import type { OutboxBus } from '../outbox/outbox.bus';
import type { PresenceService } from '../notifications/presence.service';
// Realtime delegation: the browser opens the socket with a short-lived token minted for the
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
// that audience — a full REST/actor token (iios-core) or an un-scoped token can't open the stream.
const APP = 'crm-web';
const SECRET = 'dev-crm-secret';
// ─── the real SessionVerifier must expose `aud` so the gateway can enforce it ───
describe('SessionVerifier — app-token audience surfacing', () => {
let v: SessionVerifier;
beforeAll(async () => {
delete process.env.AUTH_ISSUERS;
delete process.env.SUPABASE_URL;
process.env.APP_SECRETS = JSON.stringify({ [APP]: SECRET });
v = new SessionVerifier();
await v.onModuleInit();
});
afterAll(() => { delete process.env.APP_SECRETS; });
it('surfaces the aud claim of a realtime-scoped token', () => {
const tok = jwt.sign({ appId: APP, aud: 'iios-message' }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
expect(v.verify(tok).audience).toBe('iios-message');
});
it('leaves audience undefined for an un-scoped token (a REST/actor token)', () => {
const tok = jwt.sign({ appId: APP }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
expect(v.verify(tok).audience).toBeUndefined();
});
});
// ─── the gateway enforces the realtime audience on connect ───
function fakeSocket(): { sock: Socket; disconnected: () => boolean } {
let disconnected = false;
const sock = {
handshake: { auth: { token: 'tok' } },
disconnect: () => { disconnected = true; },
data: undefined as unknown,
} as unknown as Socket;
return { sock, disconnected: () => disconnected };
}
function gatewayReturning(principal: MessagePrincipal): MessageGateway {
const session = { verify: () => principal } as unknown as SessionVerifier;
return new MessageGateway(
undefined as unknown as MessageService,
session,
undefined as unknown as OutboxBus,
undefined as unknown as PresenceService,
);
}
const principal = (audience?: string): MessagePrincipal => ({ userId: 'u', appId: APP, orgId: 'org', audience });
describe('MessageGateway — realtime audience enforcement', () => {
afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; });
it('accepts any valid token when enforcement is OFF (env unset)', () => {
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal(undefined)).handleConnection(sock);
expect(disconnected()).toBe(false);
expect((sock.data as { principal: MessagePrincipal }).principal.userId).toBe('u');
});
it('accepts a token whose aud matches the required realtime audience', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal('iios-message')).handleConnection(sock);
expect(disconnected()).toBe(false);
});
it('rejects a full REST/actor token (aud=iios-core) on the socket', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal('iios-core')).handleConnection(sock);
expect(disconnected()).toBe(true);
});
it('rejects an un-scoped token (no aud) when enforcement is on', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal(undefined)).handleConnection(sock);
expect(disconnected()).toBe(true);
});
});
@@ -61,6 +61,14 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
try { try {
const token = String(client.handshake.auth?.token ?? ''); const token = String(client.handshake.auth?.token ?? '');
const principal = this.session.verify(token); const principal = this.session.verify(token);
// Realtime delegation (least privilege): when IIOS_REALTIME_AUDIENCE is set, the socket
// accepts ONLY a token minted for that audience (a short-lived `iios-message` delegate) —
// a full REST/actor token (`iios-core`) or an un-scoped token cannot open the stream. So a
// leaked socket token can't drive privileged REST, and vice-versa. Unset → no enforcement.
const required = process.env.IIOS_REALTIME_AUDIENCE?.trim();
if (required && principal.audience !== required) {
throw new Error(`realtime audience "${principal.audience ?? '(none)'}" != required "${required}"`);
}
client.data = { principal } satisfies SocketState; client.data = { principal } satisfies SocketState;
} catch (err) { } catch (err) {
this.logger.warn(`rejecting socket: ${(err as Error).message}`); this.logger.warn(`rejecting socket: ${(err as Error).message}`);
@@ -50,6 +50,8 @@ export interface ThreadSummary {
threadId: string; threadId: string;
subject: string | null; subject: string | null;
membership?: string; membership?: string;
/** The thread's opaque, app-supplied attribute bag — echoed back verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
participants: string[]; participants: string[];
participantCount: number; participantCount: number;
unread: number; unread: number;
@@ -85,19 +87,21 @@ export class MessageService {
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may * for a group. Opening an existing thread is a GOVERNED join: only an existing member may
* re-open it (policy `iios.thread.join`) new members enter via addParticipant. * re-open it (policy `iios.thread.join`) new members enter via addParticipant.
*/ */
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> { async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): 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 // `membership`/`creatorRole`/`subject`/`metadata` are generic, app-supplied thread attributes —
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app). // the kernel stores/echoes them as an opaque bag but never branches on their meaning (that lives
// in policy + the app). `membership` is folded into the same bag for back-compat.
const merged = { ...(opts?.metadata ?? {}), ...(opts?.membership ? { membership: opts.membership } : {}) };
const thread = await this.prisma.iiosThread.create({ const thread = await this.prisma.iiosThread.create({
data: { data: {
scopeId: scope.id, scopeId: scope.id,
createdByActorId: actor.id, createdByActorId: actor.id,
subject: opts?.subject?.trim() || undefined, subject: opts?.subject?.trim() || undefined,
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined, metadata: Object.keys(merged).length > 0 ? (merged as Prisma.InputJsonValue) : undefined,
}, },
}); });
await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER'); await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
@@ -155,6 +159,75 @@ export class MessageService {
return { threadId, participantCount: participantCount + 1 }; return { threadId, participantCount: participantCount + 1 };
} }
/**
* Governed thread rename (a generic subject update). Policy decides who may rename for a
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
* the subject; "group settings" meaning lives in the app + policy, not here.
*/
async renameThread(threadId: string, principal: MessagePrincipal, subject: string): Promise<{ threadId: string; subject: string }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, {
action: 'iios.thread.update',
threadId,
scopeId: thread.scopeId,
membership,
callerRole: callerP?.participantRole,
});
await this.prisma.iiosThread.update({ where: { id: threadId }, data: { subject } });
return { threadId, subject };
}
/**
* Governed participant removal. Policy decides who may remove for a membership thread the dev
* OPA requires the caller be a group ADMIN. Removing a non-participant is a no-op success.
*/
async removeParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string): Promise<{ threadId: string; participantCount: number }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, {
action: 'iios.thread.participant.remove',
threadId,
scopeId: thread.scopeId,
membership,
callerRole: callerP?.participantRole,
targetUserId,
});
const scope = await this.actors.resolveScope(principal);
const target = await this.actors.resolveActor(scope.id, {
userId: targetUserId, appId: principal.appId, orgId: principal.orgId, tenantId: principal.tenantId, displayName: targetUserId,
});
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: target.id } });
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
return { threadId, participantCount };
}
/** Members of a thread with their role — drives the group settings member list. Read is policy-scoped. */
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
const parts = await this.prisma.iiosThreadParticipant.findMany({
where: { threadId },
include: { actor: { include: { sourceHandle: true } } },
});
return parts.map((p) => ({
userId: p.actor?.sourceHandle?.externalId ?? '',
displayName: p.actor?.displayName ?? p.actor?.sourceHandle?.externalId ?? 'unknown',
role: p.participantRole ?? 'MEMBER',
}));
}
/** Toggle the caller's per-thread notification mute flag. */ /** Toggle the caller's per-thread notification mute flag. */
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> { async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
@@ -167,8 +240,12 @@ export class MessageService {
return { threadId, muted }; return { threadId, muted };
} }
/** Generic "my threads": every thread the caller participates in, with last message + unread. */ /**
async listThreads(principal: MessagePrincipal): Promise<ThreadSummary[]> { * Generic "my threads": every thread the caller participates in, with last message + unread.
* An optional `filter.metadata` narrows to threads whose opaque attribute bag matches ALL of the
* given key/values (an equality match on the JSON bag the kernel does not interpret the keys).
*/
async listThreads(principal: MessagePrincipal, filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
const scope = await this.actors.findScope(principal); const scope = await this.actors.findScope(principal);
if (!scope) return []; if (!scope) return [];
const actor = await this.actors.resolveActor(scope.id, principal); const actor = await this.actors.resolveActor(scope.id, principal);
@@ -177,7 +254,7 @@ export class MessageService {
if (threadIds.length === 0) return []; if (threadIds.length === 0) return [];
const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted])); const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted]));
const [threads, unreads, allParts] = await Promise.all([ const [allThreads, unreads, allParts] = await Promise.all([
this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }), this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }),
this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }), this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }),
this.prisma.iiosThreadParticipant.findMany({ this.prisma.iiosThreadParticipant.findMany({
@@ -185,6 +262,14 @@ export class MessageService {
include: { actor: { include: { sourceHandle: true } } }, include: { actor: { include: { sourceHandle: true } } },
}), }),
]); ]);
// Opaque equality filter on the metadata bag (every requested key must match).
const metaFilter = filter?.metadata;
const threads = metaFilter
? allThreads.filter((t) => {
const bag = (t.metadata as Record<string, unknown> | null) ?? {};
return Object.entries(metaFilter).every(([k, v]) => bag[k] === v);
})
: allThreads;
const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount])); const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
const membersBy = new Map<string, string[]>(); const membersBy = new Map<string, string[]>();
for (const p of allParts) { for (const p of allParts) {
@@ -192,27 +277,41 @@ export class MessageService {
membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]); membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]);
} }
const summaries = await Promise.all( // Latest message per thread in ONE query (Postgres DISTINCT ON) instead of one findFirst
threads.map(async (t) => { // per thread — a caller with many threads no longer fans out N parallel queries and
const last = await this.prisma.iiosInteraction.findFirst({ // exhausts the connection pool.
where: { threadId: t.id }, const lastRows =
orderBy: { occurredAt: 'desc' }, threads.length === 0
include: { parts: { where: { kind: 'TEXT' }, take: 1 } }, ? []
}); : await this.prisma.$queryRaw<Array<{ threadId: string; occurredAt: Date; lastMessage: string | null }>>(Prisma.sql`
SELECT DISTINCT ON (i."threadId")
i."threadId" AS "threadId",
i."occurredAt" AS "occurredAt",
(SELECT p."bodyText" FROM "IiosMessagePart" p
WHERE p."interactionId" = i.id AND p.kind::text = 'TEXT'
ORDER BY p."partIndex" ASC LIMIT 1) AS "lastMessage"
FROM "IiosInteraction" i
WHERE i."threadId" IN (${Prisma.join(threads.map((t) => t.id))})
ORDER BY i."threadId", i."occurredAt" DESC
`);
const lastBy = new Map(lastRows.map((r) => [r.threadId, r]));
const summaries = threads.map((t) => {
const last = lastBy.get(t.id);
const members = membersBy.get(t.id) ?? []; const members = membersBy.get(t.id) ?? [];
return { return {
threadId: t.id, threadId: t.id,
subject: t.subject, subject: t.subject,
membership: (t.metadata as { membership?: string } | null)?.membership, membership: (t.metadata as { membership?: string } | null)?.membership,
metadata: (t.metadata as Record<string, unknown> | null) ?? null,
participants: members, participants: members,
participantCount: members.length, participantCount: members.length,
unread: unreadBy.get(t.id) ?? 0, unread: unreadBy.get(t.id) ?? 0,
muted: mutedBy.get(t.id) ?? false, muted: mutedBy.get(t.id) ?? false,
lastMessage: last?.parts[0]?.bodyText ?? undefined, lastMessage: last?.lastMessage ?? undefined,
lastAt: last?.occurredAt, lastAt: last?.occurredAt,
}; };
}), });
);
return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0)); return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0));
} }
@@ -124,6 +124,28 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
}); });
it('group settings: admin renames + lists members + removes; a plain member cannot rename/remove', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
await s.addParticipant(threadId, alice, 'bob');
// admin renames
expect((await s.renameThread(threadId, alice, 'Design Team')).subject).toBe('Design Team');
// a plain member cannot rename
await expect(s.renameThread(threadId, bob, 'Hacked')).rejects.toBeInstanceOf(PolicyDeniedError);
// member list carries roles
const members = await s.listParticipants(threadId, alice);
expect(members.map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
expect(members.find((m) => m.userId === 'alice')?.role).toBe('ADMIN');
// a plain member cannot remove
await expect(s.removeParticipant(threadId, bob, 'alice')).rejects.toBeInstanceOf(PolicyDeniedError);
// admin removes bob
expect((await s.removeParticipant(threadId, alice, 'bob')).participantCount).toBe(1);
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
});
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => { it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
const s = gov(); const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
@@ -143,6 +165,23 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect((await s.listThreads(bob))[0]?.unread).toBe(1); expect((await s.listThreads(bob))[0]?.unread).toBe(1);
}); });
it('opaque metadata: stored on create, echoed in listThreads, and a metadata filter narrows the list', async () => {
const s = svc();
// Two threads with different opaque app attributes — the kernel never interprets these values.
await s.openThread(null, alice, { metadata: { source: 'crm-support', crmCustomerId: 'cust_1' } });
await s.openThread(null, alice, { metadata: { source: 'other' } });
const all = await s.listThreads(alice);
expect(all).toHaveLength(2);
const support = all.find((t) => (t.metadata as { source?: string } | null)?.source === 'crm-support');
expect(support?.metadata).toMatchObject({ source: 'crm-support', crmCustomerId: 'cust_1' });
// Equality filter on the opaque bag returns only the matching thread.
const filtered = await s.listThreads(alice, { metadata: { source: 'crm-support' } });
expect(filtered).toHaveLength(1);
expect(filtered[0]?.metadata).toMatchObject({ crmCustomerId: 'cust_1' });
});
it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => { it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => {
const s = gov(); const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'dm' }); const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
@@ -1,5 +1,4 @@
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';
@@ -10,13 +9,8 @@ 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 = otelTraceId || headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId(); const traceId = headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
res.setHeader('x-trace-id', traceId); res.setHeader('x-trace-id', traceId);
const startedAt = Date.now(); const startedAt = Date.now();
runWithTrace(traceId, () => { runWithTrace(traceId, () => {
@@ -1,50 +0,0 @@
/**
* OpenTelemetry bootstrap. MUST be imported before anything else in main.ts
* auto-instrumentation works by patching modules (http, express, pg, redis, )
* as they are require()d, so any module loaded before this runs is never traced.
*
* Env-driven on purpose: the OTel SDK reads OTEL_SERVICE_NAME,
* OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL itself, so the
* collector target is a deployment concern rather than a code change. When
* OTEL_EXPORTER_OTLP_ENDPOINT is unset the SDK never starts, so local dev and
* tests run with zero tracing overhead and no exporter errors.
*
* Traces land in Tempo and are viewable in Grafana. The existing P9 trace
* middleware adopts the active OTel trace id, so an `http.request` log line and
* its distributed trace share one id (Loki -> Tempo pivot).
*/
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (endpoint) {
const sdk = new NodeSDK({
instrumentations: [
getNodeAutoInstrumentations({
// Noisy and low value: every file read becomes a span.
'@opentelemetry/instrumentation-fs': { enabled: false },
// k8s probes hit /health constantly; tracing them would swamp Tempo
// and bury the real request traces.
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const url = req.url ?? '';
return ['/health', '/ready', '/healthz', '/readyz', '/metrics'].some((p) =>
url.startsWith(p),
);
},
},
}),
],
});
sdk.start();
const shutdown = (): void => {
// Flush buffered spans before exit, else the last requests before a
// rollout are lost.
void sdk.shutdown().finally(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { JwksClient } from 'jwks-rsa';
import { PrismaService } from '../prisma/prisma.service';
import type {
ClientRegistryEntry,
ClientRegistryPort,
NonceStorePort,
PublicKeyResolverPort,
} from './context-attestation';
/** Client registry backed by Postgres (IiosClientRegistry). */
@Injectable()
export class PrismaClientRegistry implements ClientRegistryPort {
constructor(private readonly prisma: PrismaService) {}
async find(clientId: string): Promise<ClientRegistryEntry | null> {
const r = await this.prisma.iiosClientRegistry.findUnique({ where: { clientId } });
if (!r) return null;
return {
clientId: r.clientId,
clientType: r.clientType,
allowedAppIds: r.allowedAppIds,
attestSecret: r.attestSecret ?? undefined,
jwksUri: r.jwksUri ?? undefined,
status: r.status === 'ACTIVE' ? 'ACTIVE' : 'DISABLED',
};
}
}
/**
* Nonce ledger backed by Postgres (IiosAttestationNonce). Reserve-if-absent is atomic via the
* unique primary key: a duplicate insert (P2002) means the nonce was already used replay.
*/
@Injectable()
export class PrismaNonceStore implements NonceStorePort {
constructor(private readonly prisma: PrismaService) {}
async reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean> {
try {
await this.prisma.iiosAttestationNonce.create({ data: input });
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') return false; // already seen
throw e;
}
}
}
/**
* Resolves a client's public signing key from its JWKS (prod ES256 path). Keeps ONE JwksClient
* per jwksUri the client caches keys and refetches on a cache miss (so key rotation is picked up
* without a restart). Returns null on any failure so the verifier fails closed with NO_KEY.
*/
@Injectable()
export class JwksPublicKeyResolver implements PublicKeyResolverPort {
private readonly clients = new Map<string, JwksClient>();
private clientFor(jwksUri: string): JwksClient {
let c = this.clients.get(jwksUri);
if (!c) {
c = new JwksClient({ jwksUri, cache: true, cacheMaxEntries: 8, cacheMaxAge: 10 * 60_000, rateLimit: true, jwksRequestsPerMinute: 12 });
this.clients.set(jwksUri, c);
}
return c;
}
async resolve(jwksUri: string, kid: string): Promise<string | null> {
try {
const key = await this.clientFor(jwksUri).getSigningKey(kid);
return key.getPublicKey(); // PEM (SPKI) — works for EC (ES256) and RSA keys
} catch {
return null; // unknown kid / unreachable JWKS / malformed key → fail closed
}
}
}
@@ -0,0 +1,45 @@
import { Global, Module, type OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ContextAttestationVerifier } from './context-attestation';
import { PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver } from './attestation-stores';
import { ContextAttestationGuard } from './context-attestation.guard';
/**
* Wires the context-attestation trust layer into DI and exposes the guard globally so any
* controller can enforce the three-proof gate. Also dev-seeds a registered client so locally
* minted attestations verify (prod registers clients out-of-band).
*/
@Global()
@Module({
providers: [
PrismaClientRegistry,
PrismaNonceStore,
JwksPublicKeyResolver,
{
provide: ContextAttestationVerifier,
useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore, keys: JwksPublicKeyResolver) =>
new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }, keys),
inject: [PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver],
},
ContextAttestationGuard,
],
exports: [ContextAttestationVerifier, ContextAttestationGuard],
})
export class AttestationModule implements OnModuleInit {
constructor(private readonly prisma: PrismaService) {}
/** Dev seed: register the CRM support client so an attestation signed with the shared dev
* secret verifies locally. Gated by IIOS_DEV_TOKENS + IIOS_ATTESTATION_DEV_SECRET; best-effort. */
async onModuleInit(): Promise<void> {
if (process.env.IIOS_DEV_TOKENS !== '1') return;
const secret = process.env.IIOS_ATTESTATION_DEV_SECRET;
if (!secret) return;
await this.prisma.iiosClientRegistry
.upsert({
where: { clientId: 'appshell-crm' },
create: { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' },
update: { clientType: 'APPSHELL', attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' },
})
.catch(() => undefined);
}
}
@@ -0,0 +1,96 @@
import { describe, it, expect, afterEach } from 'vitest';
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
import { ContextAttestationGuard } from './context-attestation.guard';
import {
ContextAttestationVerifier,
signDevAttestation,
type ClientRegistryPort,
type NonceStorePort,
} from './context-attestation';
import type { SessionVerifier } from './session.verifier';
const SECRET = 'appshell-dev-signing-key';
// SessionVerifier stand-in: the token always resolves to app "crm-web".
const fakeSession = {
verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }),
} as unknown as SessionVerifier;
const registryFor = (): ClientRegistryPort => ({
find: async (id) =>
id === 'appshell-crm'
? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' }
: null,
});
const freshNonces = (): NonceStorePort => {
const seen = new Set<string>();
return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) };
};
function makeGuard(): ContextAttestationGuard {
const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' });
return new ContextAttestationGuard(fakeSession, verifier);
}
// A guard whose actor token resolves to `tokenApp` — used to test attestation↔token app binding.
function makeGuardForApp(tokenApp: string): ContextAttestationGuard {
const session = {
verify: () => ({ userId: 'agent_1', appId: tokenApp, orgId: 'org', tenantId: 'tnt', displayName: 'A' }),
} as unknown as SessionVerifier;
const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' });
return new ContextAttestationGuard(session, verifier);
}
function ctxWith(headers: Record<string, string>): ExecutionContext {
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext;
}
function att(over: Record<string, unknown> = {}, secret = SECRET): string {
return signDevAttestation(
{ issuer: 'appshell.crm', audience: 'iios-core', clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over },
secret,
);
}
describe('ContextAttestationGuard (three-proof gate on the request path)', () => {
afterEach(() => { delete process.env.IIOS_REQUIRE_ATTESTATION; });
it('allows a request with no attestation when NOT required (dev / zero-trust)', async () => {
expect(await makeGuard().canActivate(ctxWith({}))).toBe(true);
});
it('rejects a request with no attestation when IIOS_REQUIRE_ATTESTATION=1', async () => {
process.env.IIOS_REQUIRE_ATTESTATION = '1';
await expect(makeGuard().canActivate(ctxWith({}))).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows a valid attestation bound to the token app', async () => {
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att() };
expect(await makeGuard().canActivate(ctxWith(headers))).toBe(true);
});
it('rejects a forged attestation (wrong signing key) — stolen-context defense', async () => {
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_forge' }, 'attacker-key') };
await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects a replayed attestation (same nonce twice)', async () => {
const guard = makeGuard();
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_replay' }) };
expect(await guard.canActivate(ctxWith(headers))).toBe(true);
await expect(guard.canActivate(ctxWith({ ...headers }))).rejects.toBeInstanceOf(ForbiddenException);
});
// ── doc rejection matrix: "valid token + wrong client/context → reject" ──
it('rejects a valid attestation whose app binding != the actor token app (stolen context reused by another app)', async () => {
// Attestation is validly signed for app crm-web, but the presented actor token is for a different app.
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_appmix' }) };
await expect(makeGuardForApp('other-app').canActivate(ctxWith(headers))).rejects.toThrow(/APP_MISMATCH/);
});
// ── doc rejection matrix: "valid token + wrong audience → reject before business logic" ──
it('rejects an attestation minted for another service (audience != iios-core) replayed at IIOS', async () => {
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_aud', audience: 'some-other-service' }) };
await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toThrow(/WRONG_AUDIENCE/);
});
});
@@ -0,0 +1,52 @@
import { type CanActivate, type ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { SessionVerifier } from './session.verifier';
import { ContextAttestationVerifier } from './context-attestation';
/**
* The July 12 three-proof gate on the request path. Proof #1 (a valid actor token) is done by
* the controllers; proof #2 (workload/mTLS) is the mesh's job in prod. This guard adds proof #3:
* a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.).
*
* Behaviour (safe rollout):
* attestation header present verify it; the attestation's app_id must match the actor
* token's app_id. Any failure 403.
* attestation header absent allowed ONLY when not required (dev / a zero-trust standalone
* caller that IIOS checks fully itself). With `IIOS_REQUIRE_ATTESTATION=1`, absence is a 403.
*
* The requirement is read per-request so it can be toggled without restarts and stays OFF by
* default existing callers that don't send an attestation keep working until the rollout flips.
*/
@Injectable()
export class ContextAttestationGuard implements CanActivate {
constructor(
private readonly session: SessionVerifier,
private readonly attest: ContextAttestationVerifier,
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const required = process.env.IIOS_REQUIRE_ATTESTATION === '1';
const req = ctx.switchToHttp().getRequest<Request>();
const raw = req.headers['x-context-attestation'];
const attestation = Array.isArray(raw) ? raw[0] : raw;
if (!attestation) {
if (required) throw new ForbiddenException('context attestation required');
return true; // dev / zero-trust: the actor token is still enforced downstream
}
// Bind the attestation to the token's app_id, so a stolen attestation for another app fails.
const auth = req.headers['authorization'] ?? '';
const token = String(auth).replace(/^Bearer\s+/i, '');
let appId: string;
try {
appId = this.session.verify(token).appId ?? '';
} catch {
throw new ForbiddenException('invalid actor token');
}
const result = await this.attest.verify(attestation, appId);
if (!result.ok) throw new ForbiddenException(`context attestation rejected: ${result.reason}`);
return true;
}
}
@@ -0,0 +1,193 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { generateKeyPairSync } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import {
ContextAttestationVerifier,
signDevAttestation,
signDevAttestationES256,
type ClientRegistryEntry,
type ClientRegistryPort,
type NonceStorePort,
type PublicKeyResolverPort,
} from './context-attestation';
import { PrismaNonceStore } from './attestation-stores';
import type { PrismaService } from '../prisma/prisma.service';
const APPSHELL_SECRET = 'appshell-dev-signing-key';
const AUD = 'iios-core';
const NOW = new Date('2026-07-13T12:00:00Z');
// ─── in-memory fakes (fast, deterministic — no DB needed) ─────────
function fakeRegistry(over: Partial<ClientRegistryEntry> = {}): ClientRegistryPort {
const entry: ClientRegistryEntry = {
clientId: 'appshell-crm',
clientType: 'SUPPORT_BFF',
allowedAppIds: ['crm-web'],
attestSecret: APPSHELL_SECRET,
status: 'ACTIVE',
...over,
};
return { find: async (id) => (id === entry.clientId ? entry : null) };
}
function fakeNonces(): NonceStorePort {
const seen = new Set<string>();
return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) };
}
const verifier = (reg: ClientRegistryPort = fakeRegistry(), nonces: NonceStorePort = fakeNonces()) =>
new ContextAttestationVerifier(reg, nonces, { audience: AUD });
/** A well-formed AppShell attestation, overridable per test. */
function attest(over: Record<string, unknown> = {}, secret = APPSHELL_SECRET, at = NOW): string {
return signDevAttestation(
{ issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: 'n_default', ...over },
secret,
at,
);
}
describe('ContextAttestationVerifier (July 12 trust proof)', () => {
it('accepts a valid attestation from a registered client for the matching app', async () => {
const res = await verifier().verify(attest({ nonce: 'n_ok' }), 'crm-web', NOW);
expect(res.ok).toBe(true);
if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm');
});
// ── the five rejection cases the CEO named ──
it('rejects an unknown / unregistered client (wrong client)', async () => {
const res = await verifier().verify(attest({ clientId: 'hacker-app', nonce: 'n1' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'UNKNOWN_CLIENT' });
});
it('rejects the wrong audience (token minted for another service)', async () => {
const res = await verifier().verify(attest({ audience: 'some-other-service', nonce: 'n2' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' });
});
it('rejects an expired attestation', async () => {
// signed 10 min ago with a 5 min TTL → already expired at NOW
const stale = attest({ nonce: 'n3', ttlSeconds: 300 }, APPSHELL_SECRET, new Date(NOW.getTime() - 600_000));
const res = await verifier().verify(stale, 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'EXPIRED' });
});
it('rejects a replayed nonce (same attestation used twice)', async () => {
const v = verifier();
const token = attest({ nonce: 'n_replay' });
const first = await v.verify(token, 'crm-web', NOW);
const second = await v.verify(token, 'crm-web', NOW);
expect(first.ok).toBe(true);
expect(second).toMatchObject({ ok: false, reason: 'REPLAY' });
});
it('rejects app mismatch: token app != attestation app', async () => {
const res = await verifier().verify(attest({ appId: 'crm-web', nonce: 'n4' }), 'some-other-app', NOW);
expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
});
// ── extra hardening ──
it('rejects a forged signature (signed with the wrong key)', async () => {
const res = await verifier().verify(attest({ nonce: 'n5' }, 'attacker-key'), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' });
});
it('rejects a disabled client', async () => {
const res = await verifier(fakeRegistry({ status: 'DISABLED' })).verify(attest({ nonce: 'n6' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'CLIENT_DISABLED' });
});
it('rejects an app the client is not allowed to attest for', async () => {
const reg = fakeRegistry({ allowedAppIds: ['other-app'] });
const res = await verifier(reg).verify(attest({ appId: 'crm-web', nonce: 'n7' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
});
});
// ─── prod asymmetric path: ES256 verified via the client's JWKS ───
describe('ContextAttestationVerifier — JWKS / ES256 (production signing path)', () => {
const JWKS = 'https://appshell.example/.well-known/jwks.json';
const KID = 'appshell-key-1';
// A real EC P-256 keypair (what AppShell would hold; only the public half is published as JWKS).
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
const pubPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
const { privateKey: otherPriv } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const otherPrivPem = otherPriv.export({ type: 'pkcs8', format: 'pem' }) as string;
// Registry entry that verifies via JWKS (no shared secret) + a resolver that maps KID → pubkey.
const jwksRegistry: ClientRegistryPort = {
find: async (id) =>
id === 'appshell-crm'
? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], jwksUri: JWKS, status: 'ACTIVE' }
: null,
};
const resolver: PublicKeyResolverPort = { resolve: async (_uri, kid) => (kid === KID ? pubPem : null) };
const v = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD }, resolver);
// No 4th arg → no key resolver wired at all (distinct from passing undefined, which hits the default).
const vNoResolver = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD });
function es256(over: Record<string, unknown> = {}, priv = privPem, kid = KID): string {
return signDevAttestationES256(
{ issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over },
priv,
kid,
NOW,
);
}
it('accepts an ES256 attestation whose signature verifies against the JWKS', async () => {
const res = await v().verify(es256({ nonce: 'es_ok' }), 'crm-web', NOW);
expect(res.ok).toBe(true);
if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm');
});
it('rejects an ES256 attestation signed with a DIFFERENT private key (forged)', async () => {
const res = await v().verify(es256({ nonce: 'es_forged' }, otherPrivPem), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' });
});
it('rejects when the JWT header carries an unknown kid (no matching JWKS key)', async () => {
const res = await v().verify(es256({ nonce: 'es_kid' }, privPem, 'rotated-away-kid'), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' });
});
it('rejects when a JWKS client is configured but no key resolver is wired', async () => {
const res = await vNoResolver().verify(es256({ nonce: 'es_nores' }), 'crm-web', NOW);
expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' });
});
it('still enforces audience / app / replay on the ES256 path', async () => {
const vv = v();
expect(await vv.verify(es256({ nonce: 'es_aud', audience: 'other' }), 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' });
expect(await vv.verify(es256({ nonce: 'es_app' }), 'some-other-app', NOW)).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
const tok = es256({ nonce: 'es_replay' });
expect((await vv.verify(tok, 'crm-web', NOW)).ok).toBe(true);
expect(await vv.verify(tok, 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'REPLAY' });
});
});
// ─── DB-level atomicity of the nonce ledger ───────────────────────
describe('PrismaNonceStore (atomic replay defense)', () => {
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const store = new PrismaNonceStore(prisma as unknown as PrismaService);
let dbUp = false;
beforeAll(async () => {
try { await prisma.$connect(); dbUp = true; } catch { dbUp = false; }
});
afterAll(async () => { if (dbUp) await prisma.$disconnect(); });
it('reserves a nonce once; a second reserve of the same nonce is rejected', async (ctx) => {
if (!dbUp) return ctx.skip(); // DB unreachable in this environment — the in-memory REPLAY test covers the logic
const nonce = `n_db_${NOW.getTime()}_${Math.floor(Math.random() * 1e9)}`;
const expiresAt = new Date(NOW.getTime() + 300_000);
const first = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt });
const second = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt });
expect(first).toBe(true);
expect(second).toBe(false);
await prisma.iiosAttestationNonce.delete({ where: { nonce } }).catch(() => undefined);
});
});
@@ -0,0 +1,190 @@
import jwt from 'jsonwebtoken';
/**
* The July 12 "third proof". A cryptographically valid actor token is NOT enough a hacked
* app can replay a stolen one. Before IIOS processes a privileged request it must also verify
* a CONTEXT ATTESTATION: a short-lived, single-use, signed statement from an authorized parent
* (AppShell for CRM, the adapter registry for channels, Calendar for calendar) that says
* "this request, this client, this app, this context is genuinely mine."
*
* This module is the verifier + its two ports (client registry, nonce ledger) + a dev signer.
* It is intentionally decoupled from the request path so it can be unit-tested in isolation and
* wired into the messaging guard as a separate, gated step.
*/
/** The subset of AppShell's context-attestation envelope that IIOS verifies. */
export interface ContextAttestation {
attestationId?: string;
issuer: string;
issuerType?: string; // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
audience: string; // must equal the configured IIOS audience
clientId: string; // must be a registered, ACTIVE client
appId: string; // must match the actor token's app_id AND be allowed for this client
scope?: { orgId?: string; appId?: string; tenantId?: string; buId?: string };
nonce: string; // single-use
iat?: number;
exp?: number; // short-lived
}
/** A registered caller allowed to attest context on behalf of one or more apps. */
export interface ClientRegistryEntry {
clientId: string;
clientType: string;
allowedAppIds: string[];
attestSecret?: string; // dev: shared HS256 signing key (AppShell's key stand-in)
jwksUri?: string; // prod: verify the attestation signature asymmetrically (ES256) via this JWKS
status: 'ACTIVE' | 'DISABLED';
}
export interface ClientRegistryPort {
find(clientId: string): Promise<ClientRegistryEntry | null>;
}
export interface NonceStorePort {
/** Atomically reserve a nonce. Returns true if fresh, false if it was already seen (replay). */
reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean>;
}
/**
* Resolves the PUBLIC key for a JWKS uri + key id, for the prod asymmetric (ES256) path.
* A port so the verifier stays network-free and unit-testable the real impl fetches +
* caches the client's JWKS; tests inject a fake that returns a known key.
*/
export interface PublicKeyResolverPort {
/** Return the signing key (PEM) for `kid` at `jwksUri`, or null if it can't be resolved. */
resolve(jwksUri: string, kid: string): Promise<string | null>;
}
export type AttestationReason =
| 'MALFORMED'
| 'UNKNOWN_CLIENT'
| 'CLIENT_DISABLED'
| 'NO_KEY'
| 'BAD_SIGNATURE'
| 'WRONG_AUDIENCE'
| 'APP_MISMATCH'
| 'EXPIRED'
| 'REPLAY';
export type AttestationResult =
| { ok: true; attestation: ContextAttestation }
| { ok: false; reason: AttestationReason; detail: string };
export interface AttestationConfig {
/** The audience IIOS requires on attestations addressed to it (e.g. 'iios-core'). */
audience: string;
}
const deny = (reason: AttestationReason, detail: string): AttestationResult => ({ ok: false, reason, detail });
export class ContextAttestationVerifier {
constructor(
private readonly registry: ClientRegistryPort,
private readonly nonces: NonceStorePort,
private readonly config: AttestationConfig,
/** Optional — required only for clients that verify via a JWKS (prod ES256). */
private readonly keys?: PublicKeyResolverPort,
) {}
/**
* Verify an attestation against an already-verified actor token. Fail-closed: any doubt deny.
* @param attestationJwt the signed attestation the parent issued
* @param tokenAppId the `app_id` from the verified actor token (Level-1 proof)
* @param now current time injected so tests are deterministic
*/
async verify(attestationJwt: string, tokenAppId: string, now: Date = new Date()): Promise<AttestationResult> {
// 1. Decode WITHOUT verifying, only to learn who claims to have signed it.
const claimed = jwt.decode(attestationJwt, { json: true }) as (ContextAttestation & jwt.JwtPayload) | null;
if (!claimed || typeof claimed !== 'object' || !claimed.clientId || !claimed.nonce) {
return deny('MALFORMED', 'attestation missing clientId/nonce');
}
// 2. The claimed client must be registered and ACTIVE.
const client = await this.registry.find(claimed.clientId);
if (!client) return deny('UNKNOWN_CLIENT', `client "${claimed.clientId}" is not registered`);
if (client.status !== 'ACTIVE') return deny('CLIENT_DISABLED', `client "${claimed.clientId}" is ${client.status}`);
// 3. Verify the signature with the client's key. A client verifies EITHER asymmetrically via
// its published JWKS (prod: AppShell signs ES256 with a private key) OR with a shared HS256
// secret (dev stand-in). ignoreExpiration so we can return a precise EXPIRED (step 6) rather
// than BAD_SIGNATURE.
const key = await this.resolveVerificationKey(attestationJwt, client);
if (!key.ok) return deny(key.reason, key.detail);
let att: ContextAttestation & jwt.JwtPayload;
try {
att = jwt.verify(attestationJwt, key.pem, { algorithms: [key.alg], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload;
} catch (e) {
return deny('BAD_SIGNATURE', (e as Error).message);
}
// 4. Audience must be IIOS — a token for another service can't be replayed at IIOS.
if (att.audience !== this.config.audience) return deny('WRONG_AUDIENCE', `audience "${att.audience}" != "${this.config.audience}"`);
// 5. app binding: the attestation's app must match the token's app AND be allowed for this client.
if (att.appId !== tokenAppId) return deny('APP_MISMATCH', `attestation app "${att.appId}" != token app "${tokenAppId}"`);
if (!client.allowedAppIds.includes(att.appId)) return deny('APP_MISMATCH', `client "${client.clientId}" not allowed for app "${att.appId}"`);
// 6. Freshness — attestations are short-lived.
const expMs = (att.exp ?? 0) * 1000;
if (!expMs || expMs <= now.getTime()) return deny('EXPIRED', 'attestation expired or missing exp');
// 7. Single-use — reserve the nonce. A replayed (stolen) attestation loses here. Fail-closed.
const fresh = await this.nonces.reserve({ nonce: att.nonce, clientId: client.clientId, expiresAt: new Date(expMs) });
if (!fresh) return deny('REPLAY', `nonce "${att.nonce}" already used`);
return { ok: true, attestation: att };
}
/**
* Pick the algorithm + verification key for a client. JWKS (ES256) wins when configured:
* read the `kid` from the JWT header and resolve the public key from the client's JWKS.
* Otherwise fall back to the shared HS256 dev secret. Either way returns a precise NO_KEY
* reason when no usable key can be obtained (fail-closed).
*/
private async resolveVerificationKey(
attestationJwt: string,
client: ClientRegistryEntry,
): Promise<{ ok: true; pem: string; alg: 'ES256' | 'HS256' } | { ok: false; reason: AttestationReason; detail: string }> {
if (client.jwksUri) {
if (!this.keys) return { ok: false, reason: 'NO_KEY', detail: `client "${client.clientId}" uses JWKS but no key resolver is wired` };
const decoded = jwt.decode(attestationJwt, { complete: true });
const kid = decoded && typeof decoded === 'object' ? (decoded.header?.kid as string | undefined) : undefined;
if (!kid) return { ok: false, reason: 'NO_KEY', detail: 'attestation header missing kid (required for JWKS)' };
const pem = await this.keys.resolve(client.jwksUri, kid).catch(() => null);
if (!pem) return { ok: false, reason: 'NO_KEY', detail: `no JWKS key for kid "${kid}"` };
return { ok: true, pem, alg: 'ES256' };
}
if (client.attestSecret) return { ok: true, pem: client.attestSecret, alg: 'HS256' };
return { ok: false, reason: 'NO_KEY', detail: `no verification key configured for "${client.clientId}"` };
}
}
/**
* DEV/TEST helper: mint a signed attestation the way AppShell would (HS256 stand-in for its
* signing key). Production AppShell signs asymmetrically and IIOS verifies via the client's JWKS.
*/
export function signDevAttestation(
claims: Omit<ContextAttestation, 'iat' | 'exp'> & { ttlSeconds?: number },
secret: string,
now: Date = new Date(),
): string {
const { ttlSeconds = 300, ...rest } = claims;
const iat = Math.floor(now.getTime() / 1000);
return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, secret, { algorithm: 'HS256' });
}
/**
* DEV/TEST helper: mint an attestation signed ASYMMETRICALLY (ES256), the way production AppShell
* does. `privateKeyPem` is a PKCS#8 EC private key; `kid` is stamped into the JWT header so the
* verifier can pick the matching public key from the client's JWKS.
*/
export function signDevAttestationES256(
claims: Omit<ContextAttestation, 'iat' | 'exp'> & { ttlSeconds?: number },
privateKeyPem: string,
kid: string,
now: Date = new Date(),
): string {
const { ttlSeconds = 300, ...rest } = claims;
const iat = Math.floor(now.getTime() / 1000);
return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, privateKeyPem, { algorithm: 'ES256', keyid: kid });
}
@@ -33,6 +33,35 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
expect(asAdmin.allow).toBe(true); expect(asAdmin.allow).toBe(true);
}); });
it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => {
const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' });
expect(asMember.allow).toBe(false);
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow
});
it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => {
const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' });
expect(asMember.allow).toBe(false);
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
});
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
expect((await up(mime)).allow).toBe(true);
}
const bad = await up('application/x-msdownload');
expect(bad.allow).toBe(false);
expect(bad.obligations[0]?.reason).toMatch(/not allowed/);
const big = await up('image/png', 30 * 1024 * 1024);
expect(big.allow).toBe(false);
expect(big.obligations[0]?.reason).toMatch(/too large/);
});
it('self-join is governed only on membership threads', async () => { it('self-join is governed only on membership threads', async () => {
// generic / support thread (no membership attr) → open join, unchanged // generic / support thread (no membership attr) → open join, unchanged
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true); expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
@@ -22,7 +22,7 @@ export interface OpaInput {
const MEDIA_MAX_BYTES = 25 * 1024 * 1024; const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
const MEDIA_ALLOWED = /^(image|video|audio)\//; const MEDIA_ALLOWED = /^(image|video|audio)\//;
const MEDIA_ALLOWED_DOCS = new Set([ const MEDIA_ALLOWED_DOCS = new Set([
'application/pdf', 'text/plain', 'application/zip', 'application/pdf', 'text/plain', 'text/markdown', 'text/x-markdown', 'text/html', 'text/csv', 'application/zip',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
@@ -42,6 +42,18 @@ export class DevOpaPort {
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members'); if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
return allow(); return allow();
} }
case 'iios.thread.participant.remove': {
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
// (no membership attr) threads allow removal by any member.
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
if (i.membership && i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can remove participants');
return allow();
}
case 'iios.thread.update': {
// Rename / settings change: for a group, only an ADMIN. Ungoverned threads allow it.
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can change group settings');
return allow();
}
case 'iios.thread.join': { case 'iios.thread.join': {
// Only threads that opted into a membership model (chat dm/group) are governed; // Only threads that opted into a membership model (chat dm/group) are governed;
// generic/support threads (no membership attr) keep open-join. For a governed // generic/support threads (no membership attr) keep open-join. For a governed
@@ -1,10 +1,19 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { PLATFORM_PORTS, LocalDevPorts } from './platform-ports'; import { PLATFORM_PORTS, LocalDevPorts } from './platform-ports';
import { RealtimeTokenRestGuard } from './realtime-token.guard';
/** Binds the platform ports (P1: the permissive in-service default). */ /**
* Binds the platform ports (P1: the permissive in-service default), and registers the
* realtime-delegation REST guard globally a socket-scoped token must not drive REST on ANY
* route, so it can't be per-controller (see realtime-token.guard).
*/
@Global() @Global()
@Module({ @Module({
providers: [{ provide: PLATFORM_PORTS, useClass: LocalDevPorts }], providers: [
{ provide: PLATFORM_PORTS, useClass: LocalDevPorts },
{ provide: APP_GUARD, useClass: RealtimeTokenRestGuard },
],
exports: [PLATFORM_PORTS], exports: [PLATFORM_PORTS],
}) })
export class PlatformModule {} export class PlatformModule {}
@@ -0,0 +1,68 @@
import { describe, it, expect, afterEach } from 'vitest';
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import { RealtimeTokenRestGuard } from './realtime-token.guard';
// Realtime delegation, REST half: a socket-scoped token (aud=iios-message) opens the /message
// socket and NOTHING else. The gateway enforces the mirror rule; this guard is the REST side.
const SECRET = 'dev-crm-secret';
const token = (payload: Record<string, unknown>) => jwt.sign(payload, SECRET, { algorithm: 'HS256', expiresIn: '5m' });
/** An HTTP ExecutionContext carrying the given authorization header. */
function httpCtx(authorization?: string): ExecutionContext {
return {
getType: () => 'http',
switchToHttp: () => ({ getRequest: () => ({ headers: authorization ? { authorization } : {} }) }),
} as unknown as ExecutionContext;
}
const wsCtx = () => ({ getType: () => 'ws' }) as unknown as ExecutionContext;
const guard = new RealtimeTokenRestGuard();
describe('RealtimeTokenRestGuard (socket tokens must not drive REST)', () => {
afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; });
it('rejects a socket-scoped token (aud=iios-message) on REST', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web', aud: 'iios-message' })}`);
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
it('allows a normal actor token (no aud)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web' })}`))).toBe(true);
});
it('allows a REST-audience token (aud=iios-core)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-core' })}`))).toBe(true);
});
it('rejects when the socket audience is one of several in an aud array', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: ['iios-core', 'iios-message'] })}`);
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
it('is a no-op when IIOS_REALTIME_AUDIENCE is unset (same switch as the socket half)', () => {
const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-message' })}`);
expect(guard.canActivate(ctx)).toBe(true);
});
it('passes through unauthenticated routes (health, metrics, HMAC adapter webhooks)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx())).toBe(true);
expect(guard.canActivate(httpCtx('Hmac abc123'))).toBe(true); // non-bearer scheme
});
it('never applies to the socket itself (ws context)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(wsCtx())).toBe(true);
});
it('passes a malformed bearer through (auth is the controller\'s job, not this filter\'s)', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
expect(guard.canActivate(httpCtx('Bearer not-a-jwt'))).toBe(true);
});
});
@@ -0,0 +1,43 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import type { Request } from 'express';
/**
* Realtime delegation the REST half.
*
* The browser's socket token is deliberately narrow: `aud = IIOS_REALTIME_AUDIENCE` (e.g.
* iios-message), minutes-long, and good for the /message socket ONLY. The gateway enforces the
* mirror of this rule (it accepts ONLY that audience). Without this guard the narrowing is
* one-directional: REST doesn't check the actor token's audience, so a leaked socket token would
* still drive privileged REST i.e. it would be a full actor token with extra steps.
*
* Applied GLOBALLY (APP_GUARD) on purpose: every REST route is a target, not just the ones behind
* ContextAttestationGuard (inbox, media, interactions, ai, would otherwise stay open).
*
* It is a decode-only REJECT filter, never an authenticator:
* - it does not verify signatures (each controller still calls SessionVerifier) cheap, and it
* can't be bypassed by stripping `aud`, because that invalidates the signature downstream;
* - no bearer token pass through (health, metrics, HMAC adapter webhooks are not its business);
* - unset IIOS_REALTIME_AUDIENCE no-op (same switch that turns on the socket half).
*/
@Injectable()
export class RealtimeTokenRestGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (context.getType() !== 'http') return true; // the socket enforces its own (mirror) rule
const socketAudience = process.env.IIOS_REALTIME_AUDIENCE?.trim();
if (!socketAudience) return true;
const auth = context.switchToHttp().getRequest<Request>().headers['authorization'];
const raw = Array.isArray(auth) ? auth[0] : auth;
if (typeof raw !== 'string' || !/^Bearer\s+/i.test(raw)) return true;
const decoded = jwt.decode(raw.replace(/^Bearer\s+/i, ''), { json: true });
const aud = decoded?.aud;
const isSocketToken = aud === socketAudience || (Array.isArray(aud) && aud.includes(socketAudience));
if (isSocketToken) {
throw new ForbiddenException(`socket-scoped token (aud="${socketAudience}") cannot be used for REST`);
}
return true;
}
}
@@ -135,6 +135,7 @@ export class SessionVerifier implements OnModuleInit {
orgId: entry.orgId, orgId: entry.orgId,
tenantId: undefined, tenantId: undefined,
displayName: meta.full_name ?? meta.name ?? email ?? userId, displayName: meta.full_name ?? meta.name ?? email ?? userId,
audience: typeof payload.aud === 'string' ? payload.aud : entry.audience,
}; };
} }
@@ -159,6 +160,7 @@ export class SessionVerifier implements OnModuleInit {
orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`, orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`,
tenantId: payload.tenantId ? String(payload.tenantId) : undefined, tenantId: payload.tenantId ? String(payload.tenantId) : undefined,
displayName: payload.name ? String(payload.name) : undefined, displayName: payload.name ? String(payload.name) : undefined,
audience: typeof payload.aud === 'string' ? payload.aud : undefined,
}; };
} }
@@ -117,3 +117,59 @@ describe('RetentionService (P9 — retention sweep)', () => {
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
}); });
}); });
// T8: an outbound email/SMS command holds PII (the recipient address, and the rendered body with
// their name). Once aged, redact those while KEEPING the template provenance for audit/replay.
describe('RetentionService — outbound command PII (T8)', () => {
const setOutboundSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) =>
prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'outbound_command', targetId } }, data });
async function command(target: string): Promise<{ id: string; scopeId: string }> {
const scope = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } });
const cmd = await prisma.iiosOutboundCommand.create({
data: {
channelType: 'EMAIL', target, scopeId: scope.id, status: 'SENT', idempotencyKey: `k-${randomUUID()}`,
payload: { subject: 'Hi Dana', html: '<p>secret body</p>' },
templateKey: 'welcome', templateVersion: 1, templateLocale: 'en', renderedHash: 'abc123',
},
});
return { id: cmd.id, scopeId: scope.id };
}
it('ensureOutboundSnapshots captures one snapshot per outbound command', async () => {
const { id } = await command('dana@acme.com');
const n = await svc().ensureOutboundSnapshots();
expect(n).toBe(1);
const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'outbound_command', targetId: id } } });
expect(snap.targetType).toBe('outbound_command');
expect(snap.dataClass).toBe('outbound');
});
it('redacts target + payload of an aged command but keeps template provenance', async () => {
const { id } = await command('dana@acme.com');
await svc().ensureOutboundSnapshots();
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
const res = await svc().applySweep();
expect(res.redacted).toBe(1);
const after = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } });
expect(after.target).toBe('[redacted]');
expect(after.payload).toEqual({ redacted: true });
// Provenance survives — you can still answer "which template version did we send?"
expect(after.templateKey).toBe('welcome');
expect(after.templateVersion).toBe(1);
expect(after.renderedHash).toBe('abc123');
expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted', resourceType: 'outbound_command' } })).toBe(1);
});
it('an active compliance hold blocks the command sweep', async () => {
const { id, scopeId } = await command('held@acme.com');
await svc().ensureOutboundSnapshots();
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'outbound_command', targetId: id, holdReason: 'legal' } });
const res = await svc().applySweep();
expect(res.skippedHeld).toBe(1);
expect((await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } })).target).toBe('held@acme.com');
});
});
@@ -72,6 +72,41 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
return created; return created;
} }
/**
* Capture a retention snapshot for any outbound command that lacks one. An email/SMS command
* holds PII (the recipient address, and the rendered body with their name), so it gets a single
* window and goes STRAIGHT to redact (archiveAfter == deleteAfter no archive phase). Unscoped
* system sends use an 'unscoped' partition tag so the global sweep still reaches them.
*/
async ensureOutboundSnapshots(scopeId?: string): Promise<number> {
const commands = await this.prisma.iiosOutboundCommand.findMany({
where: scopeId ? { scopeId } : {},
select: { id: true, scopeId: true, createdAt: true },
});
let created = 0;
for (const c of commands) {
const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({
where: { targetType_targetId: { targetType: 'outbound_command', targetId: c.id } },
});
if (exists) continue;
const deleteAt = new Date(c.createdAt.getTime() + this.windowDays('outbound', 'DELETE') * DAY_MS);
await this.prisma.iiosRetentionPolicySnapshot.create({
data: {
policyKey: 'outbound:pii:v1',
scopeSnapshotId: c.scopeId ?? 'unscoped',
targetType: 'outbound_command',
targetId: c.id,
dataClass: 'outbound',
archiveAfter: deleteAt,
deleteAfter: deleteAt,
sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1',
},
});
created++;
}
return created;
}
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */ /** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
async applySweep(scopeId?: string): Promise<SweepResult> { async applySweep(scopeId?: string): Promise<SweepResult> {
const now = new Date(); const now = new Date();
@@ -90,6 +125,14 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
} }
if (s.deleteAfter <= now) { if (s.deleteAfter <= now) {
if (s.targetType === 'outbound_command') {
// Redact the recipient address + rendered body; KEEP the template provenance columns.
await this.prisma.$transaction([
this.prisma.iiosOutboundCommand.update({ where: { id: s.targetId }, data: { target: '[redacted]', payload: { redacted: true } } }),
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
]);
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'outbound_command', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
} else {
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }), 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.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
@@ -97,6 +140,7 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }), this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
]); ]);
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId }); await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
}
result.redacted++; result.redacted++;
} else if (s.status === 'ACTIVE') { } else if (s.status === 'ACTIVE') {
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } }); await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
@@ -107,9 +151,10 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
return result; return result;
} }
/** Full sweep: capture missing snapshots, then act on due ones. */ /** Full sweep: capture missing snapshots (interactions + outbound commands), then act on due ones. */
async sweep(scopeId?: string): Promise<SweepResult> { async sweep(scopeId?: string): Promise<SweepResult> {
await this.ensureSnapshots(scopeId); await this.ensureSnapshots(scopeId);
await this.ensureOutboundSnapshots(scopeId);
return this.applySweep(scopeId); return this.applySweep(scopeId);
} }
@@ -1,10 +1,12 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { IiosTicketState } from '@prisma/client'; import { IiosTicketState } from '@prisma/client';
import { SupportService } from './support.service'; import { SupportService } from './support.service';
import { AssignmentService } from './assignment.service'; import { AssignmentService } from './assignment.service';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
import type { MessagePrincipal } from '../identity/actor.resolver'; import type { MessagePrincipal } from '../identity/actor.resolver';
import { import {
AssignTicketDto,
AvailabilityDto, AvailabilityDto,
CallbackDto, CallbackDto,
CreateQueueDto, CreateQueueDto,
@@ -14,6 +16,7 @@ import {
} from './support.dto'; } from './support.dto';
@Controller('v1/support') @Controller('v1/support')
@UseGuards(ContextAttestationGuard)
export class SupportController { export class SupportController {
constructor( constructor(
private readonly support: SupportService, private readonly support: SupportService,
@@ -32,7 +35,7 @@ export class SupportController {
@Post('escalate') @Post('escalate')
async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) { async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) {
return this.support.escalate(body.threadId, this.principal(auth), body.subject); return this.support.escalate(body.threadId, this.principal(auth), body.subject, body.metadata);
} }
@Get('tickets') @Get('tickets')
@@ -45,6 +48,12 @@ export class SupportController {
return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason); return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason);
} }
/** Manually assign a ticket to a specific actor (by userId) — generic assignment override. */
@Post('tickets/:id/assignee')
async assign(@Param('id') id: string, @Body() body: AssignTicketDto, @Headers('authorization') auth?: string) {
return this.support.assignTo(id, this.principal(auth), body.userId);
}
@Post('callbacks') @Post('callbacks')
async callback( async callback(
@Body() body: CallbackDto, @Body() body: CallbackDto,
@@ -1,4 +1,4 @@
import { IsIn, IsOptional, IsString } from 'class-validator'; import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const; const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const;
const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const; const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const;
@@ -7,11 +7,18 @@ export class CreateTicketDto {
@IsString() subject!: string; @IsString() subject!: string;
@IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number]; @IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number];
@IsOptional() @IsString() threadId?: string; @IsOptional() @IsString() threadId?: string;
/** Opaque, app-supplied attribute bag — stored on the ticket, never interpreted by the kernel. */
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
} }
export class EscalateDto { export class EscalateDto {
@IsString() threadId!: string; @IsString() threadId!: string;
@IsOptional() @IsString() subject?: string; @IsOptional() @IsString() subject?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class AssignTicketDto {
@IsString() userId!: string;
} }
export class PatchTicketDto { export class PatchTicketDto {
@@ -35,7 +35,7 @@ export class SupportService {
async createTicket( async createTicket(
principal: MessagePrincipal, principal: MessagePrincipal,
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string }, input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string; metadata?: Record<string, unknown> },
idempotencyKey?: string, idempotencyKey?: string,
) { ) {
await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal }); await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal });
@@ -63,6 +63,8 @@ export class SupportService {
subject: input.subject, subject: input.subject,
priority: input.priority ?? 'P3', priority: input.priority ?? 'P3',
traceId, traceId,
// Opaque, app-supplied attribute bag (e.g. a caller's external ref) — stored, never interpreted.
metadata: input.metadata ? (input.metadata as Prisma.InputJsonValue) : undefined,
}, },
}); });
await tx.iiosTicketStateHistory.create({ await tx.iiosTicketStateHistory.create({
@@ -99,11 +101,17 @@ export class SupportService {
return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body); return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body);
} }
/** Escalate a chat thread to support: create a ticket linked to that thread. */ /** Escalate a chat thread to support: create a ticket linked to that thread. `metadata` is opaque. */
async escalate(threadId: string, principal: MessagePrincipal, subject?: string) { async escalate(threadId: string, principal: MessagePrincipal, subject?: string, metadata?: Record<string, unknown>) {
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');
return this.createTicket(principal, { subject: subject ?? thread.subject ?? 'Support request', threadId }); // Inherit the thread's opaque bag so the ticket carries the same app context, unless overridden.
const inherited = { ...((thread.metadata as Record<string, unknown> | null) ?? {}), ...(metadata ?? {}) };
return this.createTicket(principal, {
subject: subject ?? thread.subject ?? 'Support request',
threadId,
metadata: Object.keys(inherited).length > 0 ? inherited : undefined,
});
} }
async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise<void> { async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise<void> {
@@ -120,6 +128,53 @@ export class SupportService {
.catch(() => undefined); .catch(() => undefined);
} }
/**
* Manually assign a ticket to a specific actor (by userId) a generic override of the
* event-driven auto-assignment. The target is resolved-or-created as an actor in the ticket's
* scope (so you can assign someone who hasn't logged in yet) and joined to the linked thread(s)
* so they can reply over the socket. Fail-closed via policy `iios.support.ticket.assign`.
*/
async assignTo(ticketId: string, principal: MessagePrincipal, targetUserId: string) {
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId }, include: { threadLinks: true } });
if (!ticket) throw new NotFoundException('ticket not found');
await decideOrThrow(this.ports, { action: 'iios.support.ticket.assign', ticketId, scopeId: ticket.scopeId, targetUserId });
const target = await this.actors.resolveActor(ticket.scopeId, {
userId: targetUserId,
appId: principal.appId,
orgId: principal.orgId,
tenantId: principal.tenantId,
displayName: targetUserId,
});
const toState: IiosTicketState = ticket.state === 'NEW' ? 'OPEN' : ticket.state;
const event: CloudEvent = {
specversion: '1.0',
id: `evt_assign_${ticketId}_${target.id}`,
type: IIOS_EVENTS.ticketStateChanged,
source: `iios/support/${ticket.scopeId}`,
subject: `ticket/${ticketId}`,
time: new Date().toISOString(),
datacontenttype: 'application/json',
insignia: { scopeSnapshotId: ticket.scopeId, correlationId: ticket.traceId ?? undefined, idempotencyKey: `assign:${ticketId}:${target.id}`, dataClass: 'internal' },
data: { ticketId, fromState: ticket.state, toState, assignedActorId: target.id },
};
await this.prisma.$transaction([
this.prisma.iiosTicket.update({ where: { id: ticketId }, data: { assignedActorId: target.id, state: toState } }),
this.prisma.iiosTicketStateHistory.create({ data: { ticketId, fromState: ticket.state, toState, actorId: target.id, reasonCode: 'assigned' } }),
this.prisma.iiosOutboxEvent.create({
data: {
aggregateType: 'ticket',
aggregateId: ticketId,
eventType: IIOS_EVENTS.ticketStateChanged,
cloudEvent: event as unknown as Prisma.InputJsonValue,
partitionKey: `${ticket.scopeId}:${ticketId}`,
},
}),
]);
for (const link of ticket.threadLinks) await this.actors.ensureParticipant(link.threadId, target.id);
return this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
}
async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) { async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) {
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } }); const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
if (!ticket) throw new NotFoundException('ticket not found'); if (!ticket) throw new NotFoundException('ticket not found');
@@ -57,6 +57,19 @@ describe('SupportService (P4)', () => {
expect(links[0]?.threadId).toBe(threadId); expect(links[0]?.threadId).toBe(threadId);
}); });
it('stores an opaque metadata bag on the ticket; assignTo assigns to a target actor and opens it', async () => {
const t = await support().createTicket(cust, { subject: 'help', metadata: { crmCustomerId: 'cust_1', source: 'crm-support' } });
expect(t.metadata).toMatchObject({ crmCustomerId: 'cust_1', source: 'crm-support' });
expect(t.state).toBe('NEW');
const assigned = await support().assignTo(t.id, cust, 'agent_1');
expect(assigned?.state).toBe('OPEN');
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'agent_1' } });
const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } });
expect(assigned?.assignedActorId).toBe(actor.id);
expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id, reasonCode: 'assigned' } })).toBe(1);
});
it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => { it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => {
const t = await support().createTicket(cust, { subject: 's' }); const t = await support().createTicket(cust, { subject: 's' });
await support().transition(t.id, cust, 'OPEN'); await support().transition(t.id, cust, 'OPEN');
@@ -0,0 +1,79 @@
import type { IiosTemplateChannel } from '@prisma/client';
/** A platform-default template shipped in the repo and seeded at boot (scopeId NULL). */
export interface TemplateSeed {
key: string;
channel: IiosTemplateChannel;
locale: string;
version: number;
subject?: string;
bodyHtml?: string;
bodyText?: string;
variables: string[];
}
// Placeholder copy — marketing (Nikita/Himanshu) owns the words, Gautam the HTML. These are
// functional defaults so the pipeline works end-to-end before final copy lands; bump `version`
// when the content changes (the old version stays for audit/replay).
const WELCOME_EMAIL: TemplateSeed = {
key: 'welcome',
channel: 'EMAIL',
locale: 'en',
version: 1,
subject: 'Welcome to the Founders Club, {{firstName}}',
bodyHtml:
'<p>Hi {{firstName}},</p>' +
'<p>Thanks for joining the Founders Club. Your account is ready to set up.</p>' +
'<p><a href="{{signupLink}}">Create your account</a></p>' +
'<p>We are launching soon — we will keep you posted.</p>',
bodyText:
'Hi {{firstName}},\n\nThanks for joining the Founders Club. Create your account: {{signupLink}}\n\nWe are launching soon.',
variables: ['firstName', 'signupLink'],
};
const PAYMENT_RECEIPT_EMAIL: TemplateSeed = {
key: 'payment.receipt',
channel: 'EMAIL',
locale: 'en',
version: 1,
subject: 'Thanks for your payment, {{firstName}}',
bodyHtml:
'<p>Hi {{firstName}},</p>' +
'<p>We have received your payment of <strong>{{amount}}</strong> for {{licenseCount}} license(s).</p>' +
'<p>A separate tax receipt from our payment processor will follow.</p>',
bodyText:
'Hi {{firstName}},\n\nWe have received your payment of {{amount}} for {{licenseCount}} license(s).\nA separate tax receipt will follow.',
variables: ['firstName', 'amount', 'licenseCount'],
};
const PAYMENT_RECEIPT_SMS: TemplateSeed = {
key: 'payment.receipt',
channel: 'SMS',
locale: 'en',
version: 1,
bodyText: 'Thanks {{firstName}}! We received your payment of {{amount}}. A receipt is on its way to your email.',
variables: ['firstName', 'amount'],
};
const ONBOARDING_REMINDER_EMAIL: TemplateSeed = {
key: 'onboarding.reminder',
channel: 'EMAIL',
locale: 'en',
version: 1,
subject: 'Looks like you have not finished setting up',
bodyHtml:
'<p>Hi {{firstName}},</p>' +
'<p>You paid but have not created your account yet. It only takes a minute.</p>' +
'<p><a href="{{signupLink}}">Finish creating your account</a></p>',
bodyText:
'Hi {{firstName}},\n\nYou paid but have not created your account yet. Finish here: {{signupLink}}',
variables: ['firstName', 'signupLink'],
};
export const TEMPLATE_SEEDS: TemplateSeed[] = [
WELCOME_EMAIL,
PAYMENT_RECEIPT_EMAIL,
PAYMENT_RECEIPT_SMS,
ONBOARDING_REMINDER_EMAIL,
];
@@ -0,0 +1,62 @@
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
import { IiosTemplateChannel } from '@prisma/client';
import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { TemplatedSender, type ExternalChannel } from './templated-sender';
import { TemplateService } from './template.service';
import { PreviewTemplateDto, SendTemplateDto } from './template.dto';
import type { TemplateSource } from './template.model';
@Controller('v1/templates')
export class TemplateController {
constructor(
private readonly sender: TemplatedSender,
private readonly templates: TemplateService,
private readonly session: SessionVerifier,
private readonly actors: ActorResolver,
) {}
/** Render a stored/inline template and queue it for external delivery (EMAIL/SMS). */
@Post('send')
async send(@Body() body: SendTemplateDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
const scope = await this.actors.resolveScope(principal);
return this.sender.sendTemplated({
source: this.source(body),
channel: body.channel as ExternalChannel,
target: body.target,
vars: body.vars ?? {},
scopeId: scope.id,
...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey,
...(body.purpose ? { purpose: body.purpose } : {}),
});
}
/** Render only — no send. For marketing/QA to eyeball copy before it goes out. */
@Post('preview')
async preview(@Body() body: PreviewTemplateDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
const scope = await this.actors.resolveScope(principal);
const { content } = await this.templates.render(this.source(body), body.vars ?? {}, {
channel: body.channel as IiosTemplateChannel,
...(body.locale ? { locale: body.locale } : {}),
scopeId: scope.id,
});
return content;
}
/** Map the DTO's key|inline into a TemplateSource (exactly one must be present). */
private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource {
if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both');
if (body.inline) return { inline: body.inline };
if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key };
throw new BadRequestException('one of "key" or "inline" is required');
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,38 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
const EXTERNAL_CHANNELS = ['EMAIL', 'SMS'] as const;
const ALL_CHANNELS = ['EMAIL', 'SMS', 'INTERNAL'] as const;
/** Inline template content — an ad-hoc source (e.g. finished HTML from marketing). */
export class InlineTemplateDto {
@IsOptional() @IsString() subject?: string;
@IsOptional() @IsString() html?: string;
@IsOptional() @IsString() text?: string;
@IsOptional() @IsString({ each: true }) variables?: string[];
}
/** Body of POST /v1/templates/send. Provide EITHER `key` (stored) OR `inline` (ad-hoc). */
export class SendTemplateDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsIn(EXTERNAL_CHANNELS) channel!: (typeof EXTERNAL_CHANNELS)[number];
@IsString() @IsNotEmpty() target!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
@IsOptional() @IsString() purpose?: string;
}
/** Body of POST /v1/templates/preview — render only, no send. INTERNAL allowed (preview only). */
export class PreviewTemplateDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsIn(ALL_CHANNELS) channel!: (typeof ALL_CHANNELS)[number];
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
}
@@ -0,0 +1,16 @@
import type { IiosTemplateChannel } from '@prisma/client';
/** What to render: a STORED template (by key, optionally pinned to a version) or INLINE content. */
export type TemplateSource =
| { key: string; version?: number }
| { inline: { subject?: string; html?: string; text?: string; variables?: string[] } };
export interface RenderOptions {
channel: IiosTemplateChannel;
locale?: string;
scopeId?: string;
}
export function isInlineSource(s: TemplateSource): s is { inline: { subject?: string; html?: string; text?: string; variables?: string[] } } {
return 'inline' in s;
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { AdaptersModule } from '../adapters/adapters.module';
import { TemplateRepository } from './template.repository';
import { TemplateService } from './template.service';
import { TemplatedSender } from './templated-sender';
import { TemplateSeeder } from './template.seeder';
import { TemplateController } from './template.controller';
/**
* Reusable message-template module: render a stored/inline template hand it to the existing
* outbound pipeline (via AdaptersModule's OutboundService). Content lives here; delivery stays in
* the outbound/capability layers. SessionVerifier, ActorResolver (IdentityModule) and PLATFORM_PORTS
* (PlatformModule) are global.
*/
@Module({
imports: [AdaptersModule],
controllers: [TemplateController],
providers: [TemplateRepository, TemplateService, TemplatedSender, TemplateSeeder],
exports: [TemplateService, TemplatedSender],
})
export class TemplateModule {}
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { renderTemplate, MissingTemplateVariableError } from './template.renderer';
describe('renderTemplate (pure)', () => {
it('substitutes variables into subject / html / text', () => {
const out = renderTemplate(
{ subject: 'Receipt for {{firstName}}', bodyHtml: '<p>Hi {{firstName}}</p>', bodyText: 'Hi {{firstName}}', variables: ['firstName'] },
{ firstName: 'Dana' },
);
expect(out.subject).toBe('Receipt for Dana');
expect(out.html).toBe('<p>Hi Dana</p>');
expect(out.text).toBe('Hi Dana');
});
// The load-bearing safety property: a customer-supplied name goes into an HTML email body.
it('escapes HTML in the html body, but leaves subject/text verbatim (XSS)', () => {
const out = renderTemplate(
{ subject: 'Hi {{firstName}}', bodyHtml: '<p>{{firstName}}</p>', bodyText: '{{firstName}}', variables: ['firstName'] },
{ firstName: '<script>alert(1)</script>' },
);
expect(out.html).toBe('<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
expect(out.html).not.toContain('<script>');
expect(out.text).toBe('<script>alert(1)</script>'); // plaintext is not HTML → not escaped
});
it('supports {{#if}} and {{#each}}', () => {
const out = renderTemplate(
{ bodyText: '{{#if companyName}}Company: {{companyName}}\n{{/if}}{{#each items}}- {{this}}\n{{/each}}', variables: [] },
{ companyName: 'Acme', items: ['a', 'b'] },
);
expect(out.text).toBe('Company: Acme\n- a\n- b\n');
});
it('throws when a declared variable is missing (fail loud)', () => {
expect(() => renderTemplate({ bodyText: 'Hi {{firstName}}', variables: ['firstName'] }, {})).toThrow(MissingTemplateVariableError);
});
it('renders only the parts that are present', () => {
const out = renderTemplate({ bodyText: 'code {{code}}', variables: ['code'] }, { code: '123' });
expect(out.text).toBe('code 123');
expect(out.subject).toBeUndefined();
expect(out.html).toBeUndefined();
});
});
@@ -0,0 +1,46 @@
import Handlebars from 'handlebars';
/** The raw, unrendered strings of a template (from the DB row or an inline source). */
export interface RawTemplate {
subject?: string | null;
bodyHtml?: string | null;
bodyText?: string | null;
/** Declared variable names. Every one MUST be supplied at render time (fail loud). */
variables?: string[] | null;
}
export interface RenderedContent {
subject?: string;
html?: string;
text?: string;
}
/** A declared variable was not supplied — we refuse to send a half-rendered "Hi ," message. */
export class MissingTemplateVariableError extends Error {
constructor(public readonly missing: string[]) {
super(`missing required template variable(s): ${missing.join(', ')}`);
this.name = 'MissingTemplateVariableError';
}
}
/**
* Pure render: interpolate `vars` into a template's strings. HTML output is auto-escaped (values
* like a customer name go into an email body XSS risk); subject and plaintext are NOT escaped.
* No I/O the caller supplies the raw template. Throws if any declared variable is absent.
*/
export function renderTemplate(tpl: RawTemplate, vars: Record<string, unknown>): RenderedContent {
const declared = tpl.variables ?? [];
const missing = declared.filter((name) => vars[name] === undefined || vars[name] === null);
if (missing.length > 0) throw new MissingTemplateVariableError(missing);
const out: RenderedContent = {};
if (tpl.subject != null) out.subject = compile(tpl.subject, false)(vars);
if (tpl.bodyHtml != null) out.html = compile(tpl.bodyHtml, true)(vars);
if (tpl.bodyText != null) out.text = compile(tpl.bodyText, false)(vars);
return out;
}
/** `escape=true` → HTML-escape interpolated values (for the html body); false → verbatim. */
function compile(src: string, escape: boolean): Handlebars.TemplateDelegate {
return Handlebars.compile(src, { noEscape: !escape, strict: false });
}
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { TemplateNotFoundError, TemplateRepository } from './template.repository';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const repo = new TemplateRepository(prisma as unknown as PrismaService);
async function scope(): Promise<string> {
const s = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } });
return s.id;
}
async function seed(data: Partial<Parameters<typeof prisma.iiosMessageTemplate.create>[0]['data']> & { key: string }) {
return prisma.iiosMessageTemplate.create({
data: { channel: 'EMAIL', locale: 'en', version: 1, bodyText: 't', ...data } as never,
});
}
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('TemplateRepository.resolve', () => {
it('returns the global default when no scope override exists', async () => {
await seed({ key: 'welcome', bodyText: 'global' });
const t = await repo.resolve('welcome', 'EMAIL', 'en', await scope());
expect(t.bodyText).toBe('global');
expect(t.scopeId).toBeNull();
});
it('prefers the scoped override over the global default', async () => {
const scopeId = await scope();
await seed({ key: 'welcome', bodyText: 'global' }); // scopeId NULL
await seed({ key: 'welcome', bodyText: 'scoped', scopeId }); // override
const t = await repo.resolve('welcome', 'EMAIL', 'en', scopeId);
expect(t.bodyText).toBe('scoped');
expect(t.scopeId).toBe(scopeId);
});
it('returns the highest active version', async () => {
await seed({ key: 'welcome', version: 1, bodyText: 'v1' });
await seed({ key: 'welcome', version: 3, bodyText: 'v3' });
await seed({ key: 'welcome', version: 2, bodyText: 'v2' });
const t = await repo.resolve('welcome', 'EMAIL', 'en');
expect(t.version).toBe(3);
});
it('ignores inactive versions (a retracted v3 falls back to v2)', async () => {
await seed({ key: 'welcome', version: 2, bodyText: 'v2' });
await seed({ key: 'welcome', version: 3, bodyText: 'v3', active: false });
const t = await repo.resolve('welcome', 'EMAIL', 'en');
expect(t.version).toBe(2);
});
it('throws TemplateNotFoundError for an unknown key', async () => {
await expect(repo.resolve('nope', 'EMAIL', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError);
});
it('does not cross channels (an EMAIL template is not an SMS template)', async () => {
await seed({ key: 'welcome', channel: 'EMAIL' });
await expect(repo.resolve('welcome', 'SMS', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError);
});
});
@@ -0,0 +1,49 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IiosMessageTemplate, IiosTemplateChannel } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export class TemplateNotFoundError extends NotFoundException {
constructor(key: string, channel: string, locale: string) {
super(`no active template for key="${key}" channel=${channel} locale=${locale}`);
this.name = 'TemplateNotFoundError';
}
}
@Injectable()
export class TemplateRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* Resolve the template to use: the highest active version for (key, channel, locale), preferring
* a row owned by `scopeId` (a tenant override) and falling back to the global default (scopeId
* NULL). Throws if neither exists.
*/
async resolve(
key: string,
channel: IiosTemplateChannel,
locale = 'en',
scopeId?: string,
version?: number,
): Promise<IiosMessageTemplate> {
// Scoped override first, then the global default — for both the pinned and highest-active paths.
if (scopeId) {
const scoped = await this.pick({ key, channel, locale, scopeId }, version);
if (scoped) return scoped;
}
const global = await this.pick({ key, channel, locale, scopeId: null }, version);
if (global) return global;
throw new TemplateNotFoundError(key, channel, locale);
}
/** A pinned `version` fetches that exact version (the caller was explicit); otherwise the highest
* active version a retracted (inactive) newest version falls back to the last good one. */
private pick(
where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null },
version?: number,
) {
return this.prisma.iiosMessageTemplate.findFirst({
where: version != null ? { ...where, version } : { ...where, active: true },
orderBy: { version: 'desc' },
});
}
}
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { TemplateSeeder } from './template.seeder';
import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const seeder = new TemplateSeeder(prisma as unknown as PrismaService);
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('TemplateSeeder', () => {
it('seeds the file defaults as global (scopeId NULL) templates', async () => {
const n = await seeder.seed(TEMPLATE_SEEDS);
expect(n).toBe(TEMPLATE_SEEDS.length);
const rows = await prisma.iiosMessageTemplate.findMany();
expect(rows).toHaveLength(TEMPLATE_SEEDS.length);
expect(rows.every((r) => r.scopeId === null)).toBe(true);
const welcome = rows.find((r) => r.key === 'welcome' && r.channel === 'EMAIL');
expect(welcome?.variables).toEqual(['firstName', 'signupLink']);
});
it('is idempotent — re-seeding inserts nothing and creates no duplicates', async () => {
await seeder.seed(TEMPLATE_SEEDS);
const second = await seeder.seed(TEMPLATE_SEEDS);
expect(second).toBe(0);
expect(await prisma.iiosMessageTemplate.count()).toBe(TEMPLATE_SEEDS.length);
});
it('a bumped version inserts a new row and leaves the old one', async () => {
await seeder.seed(TEMPLATE_SEEDS);
const bumped: TemplateSeed = { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'v2', bodyText: 'v2', variables: [] };
const n = await seeder.seed([bumped]);
expect(n).toBe(1);
const welcomes = await prisma.iiosMessageTemplate.findMany({ where: { key: 'welcome', channel: 'EMAIL' }, orderBy: { version: 'asc' } });
expect(welcomes.map((w) => w.version)).toEqual([1, 2]);
});
});
@@ -0,0 +1,49 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds';
/**
* Seeds platform-default templates (scopeId NULL) from the repo files on boot DB is runtime truth,
* files are the version-controlled source. Idempotent: a seed is inserted only if its exact
* (key, channel, locale, version) default is absent, so re-boots never duplicate and a bumped
* version adds a new row while leaving the old one for audit/replay.
*/
@Injectable()
export class TemplateSeeder implements OnModuleInit {
private readonly log = new Logger(TemplateSeeder.name);
constructor(private readonly prisma: PrismaService) {}
async onModuleInit(): Promise<void> {
const inserted = await this.seed(TEMPLATE_SEEDS);
if (inserted > 0) this.log.log(`seeded ${inserted} default template(s)`);
}
/** Returns how many rows were newly inserted. */
async seed(seeds: TemplateSeed[]): Promise<number> {
let inserted = 0;
for (const s of seeds) {
const exists = await this.prisma.iiosMessageTemplate.findFirst({
where: { scopeId: null, key: s.key, channel: s.channel, locale: s.locale, version: s.version },
select: { id: true },
});
if (exists) continue;
await this.prisma.iiosMessageTemplate.create({
data: {
scopeId: null,
key: s.key,
channel: s.channel,
locale: s.locale,
version: s.version,
subject: s.subject,
bodyHtml: s.bodyHtml,
bodyText: s.bodyText,
variables: s.variables as Prisma.InputJsonValue,
},
});
inserted++;
}
return inserted;
}
}
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { TemplateRepository } from './template.repository';
import { TemplateService } from './template.service';
import { TemplateNotFoundError } from './template.repository';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const svc = new TemplateService(new TemplateRepository(prisma as unknown as PrismaService));
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('TemplateService.render', () => {
it('resolves a stored template and renders it, with provenance', async () => {
await prisma.iiosMessageTemplate.create({
data: { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'Hi {{firstName}}', bodyHtml: '<p>{{firstName}}</p>', variables: ['firstName'] },
});
const { content, provenance } = await svc.render({ key: 'welcome' }, { firstName: 'Dana' }, { channel: 'EMAIL' });
expect(content.subject).toBe('Hi Dana');
expect(content.html).toBe('<p>Dana</p>');
expect(provenance).toMatchObject({ templateKey: 'welcome', templateVersion: 2, templateLocale: 'en' });
expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/);
});
it('renders an inline source without a DB row (key/version null)', async () => {
const { content, provenance } = await svc.render(
{ inline: { subject: 'Ad-hoc {{x}}', html: '<b>{{x}}</b>', variables: ['x'] } },
{ x: 'Y' },
{ channel: 'EMAIL' },
);
expect(content.subject).toBe('Ad-hoc Y');
expect(content.html).toBe('<b>Y</b>');
expect(provenance.templateKey).toBeNull();
expect(provenance.templateVersion).toBeNull();
expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/);
});
it('pins to an explicit version when asked', async () => {
await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 1, bodyText: 'v1' } });
await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 2, bodyText: 'v2' } });
const latest = await svc.render({ key: 'k' }, {}, { channel: 'EMAIL' });
const pinned = await svc.render({ key: 'k', version: 1 }, {}, { channel: 'EMAIL' });
expect(latest.content.text).toBe('v2');
expect(pinned.content.text).toBe('v1');
expect(pinned.provenance.templateVersion).toBe(1);
});
it('throws for an unknown stored key', async () => {
await expect(svc.render({ key: 'missing' }, {}, { channel: 'EMAIL' })).rejects.toBeInstanceOf(TemplateNotFoundError);
});
});
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { TemplateRepository } from './template.repository';
import { renderTemplate, type RawTemplate, type RenderedContent } from './template.renderer';
import { isInlineSource, type RenderOptions, type TemplateSource } from './template.model';
/** The provenance of a rendered send — carried onto the outbound command for replay/audit. */
export interface RenderProvenance {
templateKey: string | null; // null for inline sources
templateVersion: number | null;
templateLocale: string | null;
renderedHash: string; // sha256 of the rendered content
}
export interface RenderResult {
content: RenderedContent;
provenance: RenderProvenance;
}
@Injectable()
export class TemplateService {
constructor(private readonly repo: TemplateRepository) {}
/**
* Resolve (if a stored key) and render a template with `vars`, returning the content plus the
* provenance to record on the send. Inline sources skip the DB and carry null key/version the
* caller owns inline content, so no declared-variable contract is enforced for it.
*/
async render(source: TemplateSource, vars: Record<string, unknown>, opts: RenderOptions): Promise<RenderResult> {
const locale = opts.locale ?? 'en';
if (isInlineSource(source)) {
const raw: RawTemplate = {
subject: source.inline.subject,
bodyHtml: source.inline.html,
bodyText: source.inline.text,
variables: source.inline.variables ?? [],
};
const content = renderTemplate(raw, vars);
return { content, provenance: this.provenance(content, null, null, null) };
}
const tpl = await this.repo.resolve(source.key, opts.channel, locale, opts.scopeId, source.version);
const content = renderTemplate(
{ subject: tpl.subject, bodyHtml: tpl.bodyHtml, bodyText: tpl.bodyText, variables: (tpl.variables as string[] | null) ?? [] },
vars,
);
return { content, provenance: this.provenance(content, tpl.key, tpl.version, tpl.locale) };
}
private provenance(content: RenderedContent, key: string | null, version: number | null, locale: string | null): RenderProvenance {
const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex');
return { templateKey: key, templateVersion: version, templateLocale: locale, renderedHash: hash };
}
}
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { OutboundService } from '../adapters/outbound.service';
import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { TemplateRepository } from './template.repository';
import { TemplateService } from './template.service';
import { TemplatedSender } from './templated-sender';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
function sender(): TemplatedSender {
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const templates = new TemplateService(new TemplateRepository(asService));
return new TemplatedSender(templates, outbound, makeFakePorts());
}
async function seedReceipt() {
await prisma.iiosMessageTemplate.create({
data: { key: 'payment.receipt', channel: 'EMAIL', locale: 'en', version: 1, subject: 'Thanks {{firstName}}', bodyHtml: '<p>{{amount}}</p>', bodyText: '{{amount}}', variables: ['firstName', 'amount'] },
});
}
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('TemplatedSender.sendTemplated', () => {
it('renders a stored template, queues an outbound command, and stamps provenance', async () => {
await seedReceipt();
const cmd = await sender().sendTemplated({
source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com',
vars: { firstName: 'Dana', amount: '$2,000' }, idempotencyKey: 'receipt:cs_1',
});
expect(cmd.channelType).toBe('EMAIL');
expect(cmd.target).toBe('dana@acme.com');
expect((cmd.payload as { subject: string }).subject).toBe('Thanks Dana');
expect(cmd.templateKey).toBe('payment.receipt');
expect(cmd.templateVersion).toBe(1);
expect(cmd.templateLocale).toBe('en');
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
});
it('is idempotent per key: a replay yields ONE command', async () => {
await seedReceipt();
const s = sender();
const input = { source: { key: 'payment.receipt' }, channel: 'EMAIL' as const, target: 'dana@acme.com', vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'receipt:cs_dup' };
const a = await s.sendTemplated(input);
const b = await s.sendTemplated(input);
expect(b.id).toBe(a.id);
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'receipt:cs_dup' } })).toBe(1);
});
it('sends inline content with null template key but a hash', async () => {
const cmd = await sender().sendTemplated({
source: { inline: { subject: 'Hi {{n}}', html: '<b>{{n}}</b>', variables: ['n'] } },
channel: 'EMAIL', target: 'x@y.com', vars: { n: 'Z' }, idempotencyKey: 'inline:1',
});
expect((cmd.payload as { subject: string }).subject).toBe('Hi Z');
expect(cmd.templateKey).toBeNull();
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
});
it('carries attachment refs into the EMAIL command payload', async () => {
await seedReceipt();
const cmd = await sender().sendTemplated({
source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com',
vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'att:1',
attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }],
});
expect((cmd.payload as { attachments: unknown[] }).attachments).toEqual([{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }]);
});
it('shapes an SMS send as text only', async () => {
await prisma.iiosMessageTemplate.create({
data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] },
});
const cmd = await sender().sendTemplated({
source: { key: 'payment.receipt' }, channel: 'SMS', target: '+1415', vars: { amount: '$2,000' }, idempotencyKey: 'sms:1',
});
expect(cmd.channelType).toBe('SMS');
expect(cmd.payload).toEqual({ text: 'Paid $2,000' });
});
});
@@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
import { OutboundService } from '../adapters/outbound.service';
import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed';
import { TemplateService } from './template.service';
import type { RenderedContent } from './template.renderer';
import { isInlineSource, type TemplateSource } from './template.model';
/** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */
export type ExternalChannel = 'EMAIL' | 'SMS';
/** An email attachment REF (bytes resolved by the provider at send time). */
export interface AttachmentRef { filename?: string; contentRef: string; mimeType?: string }
export interface SendTemplatedInput {
source: TemplateSource;
channel: ExternalChannel;
target: string; // email address / phone number
vars: Record<string, unknown>;
scopeId?: string;
locale?: string;
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
purpose?: string;
attachments?: AttachmentRef[]; // EMAIL only
}
/**
* The single entry point for a templated external send: render OutboundService.send provenance
* stamped in one place (by construction, not caller convention). Rendering stays pure/testable;
* OutboundService stays content-agnostic (it only records the provenance it is handed).
*/
@Injectable()
export class TemplatedSender {
constructor(
private readonly templates: TemplateService,
private readonly outbound: OutboundService,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
) {}
async sendTemplated(input: SendTemplatedInput) {
// Inline HTML to a customer is more dangerous than a reviewed stored template — gate it apart,
// so policy can permit stored sends while restricting ad-hoc ones. Fail-closed.
const action = isInlineSource(input.source) ? 'iios.template.send.inline' : 'iios.template.send';
await decideOrThrow(this.ports, { action, scopeId: input.scopeId, channel: input.channel });
const { content, provenance } = await this.templates.render(input.source, input.vars, {
channel: input.channel,
locale: input.locale,
scopeId: input.scopeId,
});
return this.outbound.send(
input.channel,
input.target,
this.toPayload(input.channel, content, input.attachments),
input.idempotencyKey,
input.scopeId,
input.purpose,
provenance,
);
}
/** Shape rendered content into the channel's egress envelope (EmailProvider reads subject/text/html). */
private toPayload(channel: ExternalChannel, content: RenderedContent, attachments?: AttachmentRef[]): Record<string, unknown> {
if (channel === 'EMAIL') {
return { subject: content.subject, html: content.html, text: content.text, ...(attachments && attachments.length > 0 ? { attachments } : {}) };
}
return { text: content.text ?? content.subject ?? '' }; // SMS: text only
}
}
@@ -3,19 +3,24 @@ import {
BadRequestException, BadRequestException,
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Headers, Headers,
HttpCode, HttpCode,
Param, Param,
Patch,
Post, Post,
Query, Query,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ThreadsService } from './threads.service'; import { ThreadsService } from './threads.service';
import { MessageService } from '../messaging/message.service'; import { MessageService } from '../messaging/message.service';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
import { SendMessageDto } from './send-message.dto'; import { SendMessageDto } from './send-message.dto';
@Controller('v1/threads') @Controller('v1/threads')
@UseGuards(ContextAttestationGuard)
export class ThreadsController { export class ThreadsController {
constructor( constructor(
private readonly threads: ThreadsService, private readonly threads: ThreadsService,
@@ -23,10 +28,14 @@ export class ThreadsController {
private readonly session: SessionVerifier, private readonly session: SessionVerifier,
) {} ) {}
/** Generic "my threads" — every thread the caller participates in (last message + unread). */ /**
* Generic "my threads" every thread the caller participates in (last message + unread).
* `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key).
*/
@Get() @Get()
async listThreads(@Headers('authorization') auth?: string) { async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
return this.messages.listThreads(this.principal(auth)); const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
} }
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */ /** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
@@ -35,11 +44,11 @@ export class ThreadsController {
return this.messages.listMyAnnotated(this.principal(auth), type); return this.messages.listMyAnnotated(this.principal(auth), type);
} }
/** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */ /** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
@Post() @Post()
@HttpCode(201) @HttpCode(201)
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) { async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }, @Headers('authorization') auth?: string) {
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject }); return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata });
} }
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */ /** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
@@ -49,6 +58,27 @@ export class ThreadsController {
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role); return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
} }
/** Members of a thread with their role — drives the group settings member list. */
@Get(':id/participants')
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.listParticipants(id, this.principal(auth));
}
/** Governed removal: remove a user from a thread — policy enforces group-admin. */
@Delete(':id/participants/:userId')
@HttpCode(200)
async removeParticipant(@Param('id') id: string, @Param('userId') userId: string, @Headers('authorization') auth?: string) {
return this.messages.removeParticipant(id, this.principal(auth), userId);
}
/** Governed rename (group settings): change a thread subject — policy enforces group-admin. */
@Patch(':id')
@HttpCode(200)
async updateThread(@Param('id') id: string, @Body() body: { subject?: string }, @Headers('authorization') auth?: string) {
if (body?.subject == null) throw new BadRequestException('subject is required');
return this.messages.renameThread(id, this.principal(auth), body.subject);
}
@Get(':id/messages') @Get(':id/messages')
async listMessages(@Param('id') id: string) { async listMessages(@Param('id') id: string) {
return this.threads.getMessages(id); return this.threads.getMessages(id);
@@ -9,7 +9,7 @@ export interface ThreadMessage {
actorId: string | null; actorId: string | null;
kind: string; kind: string;
occurredAt: Date; occurredAt: Date;
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>; parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null; mimeType: string | null; sizeBytes: number | null }>;
} }
@Injectable() @Injectable()
@@ -39,7 +39,7 @@ export class ThreadsService {
actorId: i.actorId, actorId: i.actorId,
kind: i.kind, kind: i.kind,
occurredAt: i.occurredAt, occurredAt: i.occurredAt,
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef })), parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef, mimeType: p.mimeType, sizeBytes: p.sizeBytes != null ? Number(p.sizeBytes) : null })),
})), })),
}; };
} }

Some files were not shown because too many files have changed in this diff Show More