68 Commits

Author SHA1 Message Date
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
maaz519 0a8544b6fc feat(iios): notification engine — presence-gated Web Push (engine phase)
Deploy iios-service / build-deploy (push) Failing after 4m37s
CI / build (push) Successful in 5m25s
Generic notification engine: reacts to message.sent and runs three gates before
delivering — policy (DM always; group only on @mention or reply-to-you), presence
(skip if the recipient is focused on that thread), mute (per-thread). Delivery via a
swappable NotificationPort (Web Push/VAPID adapter); a 'gone' (404/410) prunes the sub.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 06:27:42 +05:30
162 changed files with 8745 additions and 212 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`.
+16
View File
@@ -0,0 +1,16 @@
# Build context hygiene — keep the image small & builds reproducible.
**/node_modules
**/dist
**/.turbo
**/.next
**/coverage
.git
.gitignore
*.log
**/*.tsbuildinfo
.env
.env.*
!.env.example
/tmp
scratchpad
docs/openapi.yaml
+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 }}
+37 -2
View File
@@ -7,7 +7,13 @@ on:
jobs:
build:
runs-on: ubuntu-latest
# Host-mode runner (docker talks to the host daemon). We publish a pgvector
# Postgres to localhost:5434 and connect over localhost — the service-name
# networking of `services:` containers is not wired to jobs on this runner,
# so we start the DB explicitly (same proven pattern as the mdm CI).
runs-on: dind-builder
env:
DATABASE_URL: postgresql://iios:iios@localhost:5434/iios?schema=public
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -17,8 +23,37 @@ jobs:
with:
node-version: 22
cache: pnpm
# pgvector Postgres for the DB-backed *.spec.ts integration suites.
- name: Start pgvector Postgres
run: |
docker rm -f iios-ci-pg-${{ github.run_id }} >/dev/null 2>&1 || true
docker run -d --name iios-ci-pg-${{ github.run_id }} \
-e POSTGRES_USER=iios -e POSTGRES_PASSWORD=iios -e POSTGRES_DB=iios \
-p 5434:5432 pgvector/pgvector:pg16
for i in $(seq 1 30); do
docker exec iios-ci-pg-${{ github.run_id }} pg_isready -U iios >/dev/null 2>&1 && break
sleep 2
done
docker exec iios-ci-pg-${{ github.run_id }} pg_isready -U iios \
|| { echo "Postgres did not become ready"; exit 1; }
- run: pnpm install --frozen-lockfile
- run: pnpm boundary
- run: pnpm -r typecheck
# iios-service uses Prisma: generate the client so its generated types
# (schema enums, Prisma.InputJsonValue) are available for typecheck/build.
- run: pnpm --filter @insignia/iios-service prisma:generate
# Build before typecheck: the @insignia/* workspace packages publish their
# types via built dist/*.d.ts, so consumers (iios-kernel-client, etc.)
# can only resolve them once dist exists. Typechecking first fails with
# TS2307 "Cannot find module '@insignia/iios-contracts'".
- run: pnpm -r build
- run: pnpm -r typecheck
# Apply the schema to the CI Postgres before the DB-backed specs run
# (reset-db.ts TRUNCATEs existing tables; migrations must exist first).
- run: pnpm --filter @insignia/iios-service exec prisma migrate deploy
- run: pnpm test
- name: Stop Postgres
if: always()
run: docker rm -f iios-ci-pg-${{ github.run_id }} >/dev/null 2>&1 || true
+52
View File
@@ -0,0 +1,52 @@
# Build iios-service and deploy it via k8s-pods (ArgoCD). Runs on the dind-builder
# host-mode runner (mounts the host docker socket). On every push to main that
# touches the service, it builds+pushes the image (SHA-tagged) and bumps the tag in
# platform-engineering/k8s-pods -> ArgoCD rolls it.
name: Deploy iios-service
on:
push:
branches: [main]
paths:
- "packages/iios-service/**"
- "packages/iios-adapter-sdk/**"
- "packages/iios-contracts/**"
- "pnpm-lock.yaml"
- ".github/workflows/deploy-iios-service.yml"
jobs:
build-deploy:
runs-on: dind-builder
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lynkedup.cloud -u mcp-bot --password-stdin
- name: Ensure docker buildx (the Dockerfile uses BuildKit --mount)
run: |
if ! docker buildx version >/dev/null 2>&1; then
mkdir -p ~/.docker/cli-plugins
curl -sSL https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx
chmod +x ~/.docker/cli-plugins/docker-buildx
fi
docker buildx create --use --name iios-ci 2>/dev/null || docker buildx use iios-ci
- name: Build and push image
run: |
IMG=git.lynkedup.cloud/platform-engineering/iios-service
TAG=$(git rev-parse --short HEAD)
echo "TAG=$TAG" >> "$GITHUB_ENV"
docker buildx build --builder iios-ci --push \
-t "$IMG:$TAG" -f packages/iios-service/Dockerfile .
- name: Bump k8s-pods image tag (ArgoCD deploys)
run: |
git clone --depth 1 -b main \
"https://mcp-bot:${{ secrets.K8S_PODS_TOKEN }}@git.lynkedup.cloud/platform-engineering/k8s-pods.git" /tmp/kp
cd /tmp/kp
sed -i "s#image: git.lynkedup.cloud/platform-engineering/iios-service:.*#image: git.lynkedup.cloud/platform-engineering/iios-service:${TAG}#" services/iios/deployment.yaml
git config user.name "iios-ci"; git config user.email "ci@lynkedup.cloud"
if git diff --quiet; then echo "image tag unchanged"; exit 0; fi
git commit -am "ci(iios): deploy iios-service ${TAG}"
git push origin main
+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.
+13
View File
@@ -16,5 +16,18 @@ services:
timeout: 5s
retries: 10
# Realtime fan-out across replicas (socket.io Redis adapter). Point the service at it
# with REDIS_URL=redis://localhost:6379; without REDIS_URL the in-memory adapter is used.
redis:
image: redis:7-alpine
container_name: iios-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
volumes:
iios_postgres_data:
+121
View File
@@ -0,0 +1,121 @@
# Deploying IIOS
IIOS deploys as **one runtime service** (`@insignia/iios-service`) plus **one datastore**
(Postgres). Everything else in the repo is a **library** (SDKs) or a **dev demo**, not a
deployed service.
| Artifact | Type | How it ships |
|---|---|---|
| `@insignia/iios-service` | **Runtime service** | Docker image (this guide) |
| `iios-contracts`, `iios-adapter-sdk`, `iios-kernel-client`, `iios-testkit` | Library | published to npm; imported by host apps |
| `iios-message-web`, `iios-inbox-web`, `iios-support-web`, `iios-ai-web`, `iios-meeting-web`, `iios-community-web` | Web SDK (library) | published to npm; **bundled into host-app frontends** (optionally one CDN widget) |
| `apps/*` (message-demo, ai-studio, route-admin…) | Dev demo | not deployed |
The service is a **modular monolith**: a single NestJS process serves the HTTP API, the
WebSocket (socket.io) gateway, the outbox relay, and the projectors together.
## Build the image
Build context is the **monorepo root** (the image needs the workspace + lockfile):
```bash
docker build -f packages/iios-service/Dockerfile -t iios-service:latest .
```
The multi-stage build compiles the service + its workspace deps, generates the Prisma
client, and prunes to a self-contained prod bundle (`pnpm deploy --legacy`). The runtime
image runs as the non-root `node` user and starts via `docker-entrypoint.sh`, which runs
`prisma migrate deploy` then `node dist/main.js`.
## Run it
```bash
docker run --rm -p 3200:3200 \
-e DATABASE_URL='postgresql://USER:PASS@HOST:5432/iios?schema=public' \
-e APP_SECRETS='{"portal-demo":"<secret>"}' \
-e IIOS_DEV_TOKENS=0 \
iios-service:latest
```
Health check: `GET /health``200`. Metrics/ops: `GET /metrics` (relay lag, projection
cursors, retention snapshot counts).
## Configuration & secrets contract
Every knob is an environment variable — see [`packages/iios-service/.env.example`](../packages/iios-service/.env.example)
for the full, commented list. Highlights:
- **Secrets** (inject from a vault, never bake into the image): `DATABASE_URL`,
`APP_SECRETS` (per-app HS256 JWT keys), `ADAPTER_SECRETS` (webhook HMAC keys),
`MEDIA_SECRET` (signs presigned media upload/download URLs).
- **Auth (real IdP):** set `SUPABASE_URL` or `AUTH_ISSUERS` to trust real OIDC access
tokens, verified against the issuer's public **JWKS** (ES256) — **no secret to store**.
`AUTH_ISSUERS` is a JSON registry `[{url, appId, orgId?}]` routing many issuers → isolated
app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests.
- **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for
prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change.
- **Notifications (Web Push):** set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT`
to enable push (unset → the engine no-ops). ⚠️ Presence (the "are you viewing this thread?"
gate) is **in-memory / single-instance** today — with N>1 replicas, back `PresenceService`
with Redis so the projector on one replica sees focus from another.
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*`
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
single most important prod-hardening flag.
- **`IIOS_CELL_ID`** tags which physical cell this instance serves — the hook for splitting
noisy/regulated tenants into isolated cells later, with no code change.
- **Worker timers** (`IIOS_RELAY_INTERVAL_MS`, `IIOS_RETENTION_SWEEP_INTERVAL_MS`) — see
scaling notes below.
## Topology
```
host apps ──HTTPS/WSS──► [ LB / ingress ] ──► iios-service ×N ──► Postgres (pgvector)
(embed SDKs) (WS + sticky) (API+relay+ │
projectors) (+ Redis, + OPA/CMP/MDM
as external ports — later)
```
- **Postgres** — managed (RDS / Cloud SQL / Neon). One logical DB, tenant-isolated by scope.
- **Redis** — **not required for a single instance.** Set **`REDIS_URL`** when you run
**multiple replicas with live chat**: the built-in [socket.io Redis adapter](../packages/iios-service/src/realtime/redis-io.adapter.ts)
fans room emits across all instances, so a client on replica B sees messages emitted by
replica A. Without `REDIS_URL` the in-memory adapter is used (single instance). Verified by
`scripts/smoke-realtime-cluster.mjs` (two instances + one Redis; cross-instance delivery
fails without Redis, passes with it).
> Delivery is **at-least-once** across instances — with N>1 the inline send-emit and the
> relay's outbox-bridge emit can both fire for the same message, so clients should **dedupe
> by message `id`** (the payload always carries one).
- **Platform ports** (OPA policy, CMP consent, MDM, CRRE, SAS) — today in-process permissive
stubs (`LocalDevPorts`). For production, point these at real external services; the service
already calls them **fail-closed**. The **session** port already verifies real OIDC tokens
(Supabase/JWKS) when configured.
- **Media storage** (`StoragePort`) — dev = **local disk** (`MEDIA_DIR`). ⚠️ Local disk is
**single-instance and non-durable**; with N>1 replicas or for persistence, bind it to shared
**object storage** (S3/R2/Supabase Storage). Bytes always flow **client ↔ storage directly**
via presigned URLs — they never transit the service — so this scales independently.
## Scaling & release strategy
**Horizontal scaling is safe** because the event core was hardened for it:
- The outbox relay claims rows with `FOR UPDATE SKIP LOCKED` → each event is relayed by
exactly one replica; its co-located projectors process it once (idempotent `claim()` +
the projection-cursor + idempotency-command ledgers give exactly-once effects).
- The **DLQ + replay** path means a bad deploy loses nothing — fix and replay.
**Rolling / blue-green deploys:**
1. **Migrations:** run `prisma migrate deploy` as a **one-off pre-deploy job**, then set
`IIOS_SKIP_MIGRATE=1` on the replicas so N pods don't race the migration. (For a single
instance, the default boot-time migration is fine.)
2. Use **expand-contract (backward-compatible) schema changes** so old and new pods can run
against the same schema during the roll — this is the prerequisite for zero-downtime.
3. Roll replicas with a `/health` readiness gate; drain WebSocket connections on `SIGTERM`.
4. **WebSocket ingress** needs sticky sessions (and the Redis socket.io adapter once N>1).
## Not yet built (prod-readiness gaps)
- **CI/CD pipeline** to build/push this image and run migrations.
- **Real platform-port services** (OPA/CMP/MDM) — today permissive stubs.
- **Secrets manager** integration (currently plain env).
- A **schema-compatibility gate** in CI to enforce expand-contract migrations.
+128 -8
View File
@@ -58,6 +58,8 @@ Use it on every call: `-H "authorization: Bearer <token>"`.
- **Different `orgId` = different tenant.** Mint two tokens with `org_A` / `org_B` to test isolation.
- Token TTL: 2h.
**Real IdP tokens (production path).** Beyond the dev HS256 tokens, `SessionVerifier` also verifies **real OIDC access tokens** (ES256) against a trusted issuer's public **JWKS** — set `SUPABASE_URL` (single issuer) or `AUTH_ISSUERS` (a registry mapping many issuers → app scopes). The verifier routes a token by its `iss` claim, so two projects/IdPs map to two isolated `appId` scopes on one service, and app A's tokens can't reach app B. No shared secret needed. The dev token path stays for tests/local.
### Error responses (what QA will see)
| Status | When |
|---|---|
@@ -101,12 +103,24 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
### 5.3 Threads & Messaging (native chat)
| Method | Path | Body / Headers | Returns |
|---|---|---|---|
| GET | `/v1/threads` | — | `ThreadSummary[]` — your threads (members, unread, last message, `membership`) |
| POST | `/v1/threads` | `{membership?, creatorRole?, subject?}` | `{threadId, status, history}` (201) — `membership`/`creatorRole`/`subject` are **opaque app attributes** the kernel stores but never interprets |
| POST | `/v1/threads/:id/participants` | `{userId, role?}` | `{threadId, participantCount}` (201) — **governed** (DM cap / group-admin via OPA) |
| GET | `/v1/threads/:id/messages` | — | `{threadId, messages[]}` |
| POST | `/v1/threads/:id/messages` | `{content, contentRef?}` + `idempotency-key?` header | `Message` (201) |
| POST | `/v1/threads/:id/messages` | `{content, contentRef?, mimeType?, sizeBytes?, checksumSha256?, parentInteractionId?}` + `idempotency-key?` header | `Message` (201) |
| GET | `/v1/threads/my-annotations` | `?type=save` | `{message, threadId, threadSubject}[]` — messages you annotated (e.g. saved) |
**Realtime (Socket.IO, namespace `/message`):** connect, then emit client→server events:
- `open_thread` `{threadId?|otherUserId}`, `send_message` `{threadId, content, idempotencyKey?}`, `read` `{threadId}`, `delivered` `{threadId}`.
- Server emits to the thread room: `message` (a Message) and `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`.
A **`Message`** carries `{id, threadId, senderId, senderName, content, attachment?, parentInteractionId?, annotations[], traceId, createdAt}`. `senderId` is the sender's stable externalId (email/username) — the reliable "is this mine?" check. `attachment` = `{contentRef, mimeType, sizeBytes, kind}` (§5.10). `annotations` = `[{type, value, users[]}]` — the reaction/pin/save aggregate.
**Realtime (Socket.IO, namespace `/message`).** Token verified on connect (`auth.token`), then emit client→server:
- `open_thread` `{threadId?, membership?, creatorRole?, subject?}` — opens, or **creates** when no id; a **governed join** for membership threads. Returns `{threadId, status, history}` or `{error}` (acked, never a throw — clients don't hang).
- `send_message` `{threadId, content, contentRef?, mimeType?, sizeBytes?, parentInteractionId?, mentions?}``mentions` is an **opaque userId notify-list** (the app parses `@`; the kernel never does).
- `add_participant` `{threadId, userId, role?}` · `read` `{threadId, interactionId}` · `delivered` `{…}` · `typing` `{threadId}`.
- `annotate` `{threadId, interactionId, type, value}` — toggle a **generic annotation** (chat app uses `type: reaction|pin|save`; `value` = emoji, or empty).
Server → thread room: `message` (a Message), `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`, `typing` `{threadId, userId}`, `annotation` `{interactionId, type, value, op: add|remove, users[], userId}`.
**Governance & primitives (all fail-closed via OPA).** Membership (`iios.thread.participant.add`, `iios.thread.join`), annotating (`iios.interaction.annotate` — participant-only), and send all gate on policy. **Reactions/pins/saves are one generic primitive** — an *interaction annotation* the kernel stores + aggregates as opaque `(type, value)` but never interprets (the same primitive backs pins/saves/flags/tags). **Mentions → Inbox:** `mentions[]` flows into the message event; the inbox projector fans out a `MENTION` inbox item to each mentioned participant (never the sender); reading the thread resolves it.
### 5.4 Inbox (work queue)
| Method | Path | Query / Body | Returns |
@@ -114,6 +128,8 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
| GET | `/v1/inbox/items` | `?state=OPEN|SNOOZED|DONE|ARCHIVED|CANCELLED|STALE` | `InboxItem[]` |
| PATCH | `/v1/inbox/items/:id` | `{state, reason?}` | `InboxItem` |
Item **kinds** include `NEEDS_REPLY` (unreplied thread activity, one per owner+thread) and `MENTION` (someone @-mentioned you; `priority: HIGH`, one per source message). Reading the thread resolves both to `DONE`.
### 5.5 Support (tickets, escalation, callbacks, queues, agents)
| Method | Path | Body / Query | Returns |
|---|---|---|---|
@@ -179,6 +195,100 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
`ScheduleMeetingDto`: `{meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}`. `meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}`; consent `status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}`. **Transcript/summary is `BLOCKED` until every attendee has `GRANTED` consent.**
### 5.10 Media (attachments)
The DB stores a **reference**; the bytes live behind a **storage port** (dev = local disk `MEDIA_DIR`; prod = swap to S3/R2/Supabase). Bytes go **client ↔ storage directly** via short-lived signed URLs — they never pass through the kernel.
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
| POST | `/v1/media/presign-upload` | Bearer | `{mime, sizeBytes}` | `{objectKey, uploadUrl}`**OPA-gated** (size/type) |
| PUT | `/v1/media/upload/:token` | token in URL | raw bytes | `{objectKey, sizeBytes, checksumSha256}` |
| POST | `/v1/media/presign-download` | Bearer | `{contentRef, mime?}` | `{url}`**tenant-fenced**, signed 1h |
| GET | `/v1/media/blob/:token` | token in URL | — | streams the bytes with their `Content-Type` |
**Attaching to a message:** `send`/`send_message` accept `{contentRef, mimeType, sizeBytes, checksumSha256?}`. The stored `Message` then carries `attachment: { contentRef, mimeType, sizeBytes, kind }` where `kind ∈ {image, video, audio, file}` (a friendly view of the generic `MEDIA_REF`/`VOICE_REF`/`FILE_REF` part the kernel writes).
**Governance (fail-closed, built in):**
- **Upload policy** `iios.media.upload`**≤ 25 MB** and an allowlist (`image/*`, `video/*`, `audio/*`, `application/pdf`, common Office/text/zip). A violation → **403** *before* any bytes are sent.
- **Signed tokens** (HS256, `MEDIA_SECRET`) — upload URL lives **5 min**, download URL **1 h**; a tampered/expired token → 403.
- **Tenant fence** — object keys are prefixed with the caller's `scopeId`; a download for an object outside your scope → 403.
**The flow (with governance):**
```mermaid
sequenceDiagram
autonumber
participant C as Client (SDK uploadMedia)
participant API as IIOS Media API
participant OPA as OPA policy
participant ST as StoragePort (disk → S3)
participant K as Message kernel
C->>API: POST /v1/media/presign-upload {mime, sizeBytes}
API->>OPA: decide iios.media.upload (≤25MB? type allowed?)
alt denied
OPA-->>C: 403 (too large / type not allowed)
else allowed
API-->>C: { objectKey, uploadUrl } (signed, 5-min)
C->>ST: PUT bytes → uploadUrl (direct, not via kernel)
ST-->>C: { sizeBytes, checksumSha256 }
C->>K: send_message { contentRef, mime, size }
K-->>C: Message { attachment }
C->>API: POST /v1/media/presign-download { contentRef }
API->>API: tenant-fence (objectKey in my scope?)
API-->>C: { url } (signed, 1-hour)
C->>ST: GET url → bytes (stream, inline render / download)
end
```
---
### 5.11 Notifications (push, presence-gated)
The engine reacts to `message.sent` and pushes to **absent** recipients through a swappable
**NotificationPort** (Web Push/VAPID now; email/FCM later). The DB holds only push
subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays `IiosInboxItem`.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/v1/notifications/vapid-public-key` | — | `{key}` — the public VAPID key the client needs to subscribe |
| POST | `/v1/notifications/subscribe` | `{kind?, endpoint, keys:{p256dh, auth}, userAgent?}` | `{ok:true}` — store/refresh the caller's push subscription |
| DELETE | `/v1/notifications/subscribe` | `{endpoint}` | `{ok:true}` |
| POST | `/v1/threads/:id/mute` · `/unmute` | — | `{threadId, muted}` — per-thread notification mute for the caller |
**Presence (socket):** the app emits `focus_thread` `{threadId | null}` on the `/message`
namespace whenever the foreground conversation changes (or the tab blurs). This is how the
engine knows you're *viewing* a thread — **room membership ≠ viewing**, because the sidebar
joins every thread room for live updates. `GET /v1/threads` returns each thread's `muted`.
**The three gates (fail-closed, in the notification policy — not the kernel):**
1. **Policy** — DM always; a group message only if you were `@`-mentioned **or** it's a reply to you.
2. **Presence** — skip if you're currently focused on that thread (you saw it live).
3. **Mute** — skip if you muted the thread.
A dead subscription (Web Push `404/410`) is pruned. DM-vs-group is read from the opaque
`membership` thread attribute here in the *notification policy*; the kernel never branches on it.
```mermaid
flowchart TB
SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"]
PROJ --> LOOP{{"for each recipient (never the sender)"}}
LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"}
G1 -->|no| X1["skip"]
G1 -->|yes| G2{"PRESENCE: focused on this thread?"}
G2 -->|yes| X2["skip — seen live"]
G2 -->|no| G3{"MUTE: thread muted?"}
G3 -->|yes| X3["skip"]
G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"]
PORT --> WP["Web Push (VAPID)"]
WP -->|sent| OUT["→ browser push service → service worker → OS notification"]
WP -->|"gone (404/410)"| PRUNE["prune dead subscription"]
```
**Client (SDK layer, `lib/notifications.ts` → belongs in `@insignia/iios-kernel-client`):**
`registerPush()` (permission → register service worker → `PushManager.subscribe` with the
VAPID key → POST the subscription), `muteThread(threadId, muted)`, and `MessageSocket.focus(threadId)`.
A service worker renders the OS notification on `push` and deep-links to the thread on click.
---
## 6. SDK reference
@@ -191,7 +301,10 @@ import { RestClient } from '@insignia/iios-kernel-client';
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });
```
Methods (all return typed promises):
- **Messaging:** `getThreadMessages(threadId)`, `sendMessage(threadId, content, {contentRef?, idempotencyKey?})`
- **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})`
- **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates.
- **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.*
- **Notifications:** `registerPush()` (service worker + `PushManager.subscribe` + POST subscription), `muteThread(threadId, muted)`, `MessageSocket.focus(threadId)` (presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering.
- **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})`
- **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)`
@@ -269,7 +382,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/<name>`)
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation |
Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check: `pnpm boundary`.
Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`.
---
@@ -289,7 +402,12 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
|---|---|---|
| `PORT` | `3200` | HTTP port |
| `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres |
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app JWT secrets (`{appId: secret}`) |
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app HS256 JWT secrets (`{appId: secret}`) |
| `SUPABASE_URL` / `AUTH_ISSUERS` | — | trusted OIDC issuer(s) — verify real IdP tokens (ES256) via JWKS. `AUTH_ISSUERS` is a JSON array `[{url, appId, orgId?}]` mapping issuers → app scopes; `SUPABASE_URL` is the single-issuer shorthand |
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
| `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs |
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | — | Web Push (notifications). Unset → push disabled (engine no-ops). Generate once: `node -e "console.log(require('web-push').generateVAPIDKeys())"` |
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
@@ -305,7 +423,9 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
- **Interaction kind:** MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
- **Channel types:** WEBHOOK, EMAIL, WHATSAPP, PORTAL
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, **MENTION**, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
- **Message part kind:** TEXT, HTML, MARKDOWN, MEDIA_REF, FILE_REF, VOICE_REF, LOCATION, STRUCTURED_JSON · **Attachment kind (DTO):** image, video, audio, file
- **Interaction annotation (app-level, opaque to the kernel):** `type` = reaction | pin | save … ; `value` = emoji (reactions) or empty
- **Ticket state:** NEW, OPEN, PENDING, RESOLVED, CLOSED · **priority:** LOW, NORMAL, HIGH, URGENT
- **Route mode:** MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · **output format:** FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · **decision:** ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
- **AI job:** CLASSIFY, SUMMARIZE, EXTRACT · **artifact:** CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · **artifact status:** PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
+5 -3
View File
@@ -40,7 +40,7 @@ Today the external world (real WhatsApp, real AI models, real calendars, the rea
| **P8** | **Calendar & meetings** — schedule, attendee consent, transcript, summary, action items | `iios-meeting-web` | Support/scheduling; callback→meeting | meeting-studio (5178) |
| **P9** | *(next)* **Production hardening** — real providers, real policy/consent, scale, retention | — | Ops / platform | — |
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (46 tables across 9 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (53 tables across 19 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
---
@@ -159,7 +159,9 @@ Each product SDK is a handful of React hooks — a front-end dev wires the UI, t
| Calendar/Zoom providers | Simulated sync | P9 |
| Multi-tenant scale, retention, SLOs | Not yet | P9 |
**Proof it works:** 95 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
**Proof it works:** 205 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, media sharing** (images/video/audio/docs on a swappable storage port), and **presence-gated push notifications** (Web Push via a swappable notification port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off.
---
@@ -175,4 +177,4 @@ Narrative arc for the CEO: **one engine → chat → inbox → support → chann
---
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 46 Postgres tables across 9 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar). Boundary-enforced dependency law; 95 passing tests; 7 end-to-end smoke scripts.*
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 54 Postgres tables across 20 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media → notifications). Boundary-enforced dependency law; 205 passing tests; 8+ end-to-end smoke scripts. First real-provider swaps live: Supabase auth (JWKS-verified), a media storage port, and Web Push notifications.*
@@ -0,0 +1,107 @@
# ADR-0001: `support-service` → IIOS naming migration
- **Status:** Accepted
- **Date:** 2026-07-03
- **Deciders:** IIOS core
- **Context tags:** naming, packaging, migration, KG-16
## Context
IIOS (Insignia Interaction OS) is the greenfield realization of a vision first
prototyped in the standalone **`support-service`** app. That prototype already stated the
core idea — *"Everything is a message: a generic Message core (conversation / message /
channel / user) is the foundation; Support (ticket / agent / callback) is a thin
specialization on top"* — but it did so with **support-centric naming** and as a
single-purpose support desk.
IIOS generalizes that same idea to an **interaction OS** of much broader scope
(messaging, inbox, routing, AI enrichment, calendar, plus support as *one*
specialization among many). During P0P9 it landed a clean, generic vocabulary:
an `Iios`-prefixed persistence layer and `@insignia/iios-*` packages.
Two problems make an explicit decision necessary:
1. **Naming drift.** The prototype and IIOS use different words for the same concepts
(`Conversation` vs `IiosThread`, `Message` vs `IiosInteraction` + `IiosMessagePart`,
`User` vs `IiosSourceHandle` + `IiosActorRef`). Without a canonical mapping, contributors
coming from the prototype reintroduce support-centric names, and host apps don't know
which SDK is authoritative.
2. **KG-16 — "support wedge evidence overgeneralized."** The critics' analysis flags the
risk of treating the `support-service` architecture as proof of *all* IIOS capabilities.
Support is a **specialization**, not the core; the naming must make that structural
(support names are namespaced *under* the generic core, never the core itself).
An unresolved sub-question also blocks packaging: the workspace scope was historically
ambiguous between `@insignia/*` and `@lynkeduppro/*`.
## Decision
**1. Canonical vocabulary is interaction-centric and `Iios`-namespaced.**
The generic core is the noun set already shipped in `@insignia/iios-service`:
`IiosScope`, `IiosSourceHandle`/`IiosActorRef`, `IiosChannel`, `IiosThread`,
`IiosInteraction`/`IiosMessagePart`. **Support is a specialization layered on top** and is
always namespaced as such (`IiosSupport*`, `IiosTicket*`, `IiosCallbackRequest`) — never
promoted to a core concept. "Conversation" and support-centric "Message-as-ticket" framing
are retired.
**2. Package scope is `@insignia/iios-*`.** This resolves the `@insignia` vs `@lynkeduppro`
ambiguity in favour of `@insignia`. All IIOS packages already follow `@insignia/iios-<name>`;
that is now the standard.
**3. Migration is greenfield-and-deprecate (strangler), not rename-in-place.**
`support-service` is **frozen legacy** — we do not rename its models or ship a new version of
it. New work happens in IIOS; host apps cut over to the `@insignia/iios-*` SDKs feature-by-
feature. `support-service` is retired once every host app it serves has migrated.
### Canonical naming map (legacy → IIOS)
| `support-service` (legacy) | IIOS (canonical) | Note |
|---|---|---|
| `Channel` | `IiosChannel` | generic ingress/egress channel |
| `User` | `IiosSourceHandle` + `IiosActorRef` | identity split: external handle vs resolved actor |
| `Conversation` | `IiosThread` | "conversation" retired; thread is generic |
| `Message` | `IiosInteraction` + `IiosMessagePart` | envelope vs content parts |
| `Attachment` | `IiosMessagePart` (`kind=MEDIA`, `contentRef`) | attachments are just parts |
| `Team` / `TeamMember` | `IiosSupportQueue` / `IiosSupportTeamMember` | **support specialization**, namespaced |
| `ConversationAssignment` | support assignment (`IiosSupport*`) | specialization, not core |
| `Ticket` / `TicketConversation` | `IiosTicket` / `IiosTicketThreadLink` | ticket links to generic threads |
| `CallbackRequest` | `IiosCallbackRequest` | |
| `Meeting` | `IiosMeeting` (+ P8 calendar models) | promoted to first-class in IIOS |
| `Notification` | `notification_outbox` (planned) | not yet built in IIOS |
| `ProcessedCommand` | `IiosProcessedEvent` + `IiosIdempotencyCommand` | consumer ledger vs command ledger |
| `KnowledgeArticle` | (deferred — AI/RAG) | out of current scope |
### Rules going forward
- **Every persisted model is `Iios`-prefixed.** No un-prefixed domain models.
- **Support (and any future vertical) is namespaced under the generic core**, never the other
way round. If a name reads as "support-only," it must carry the `IiosSupport*`/`IiosTicket*`
prefix and depend on the core — the core never depends on it (enforced by the import-boundary
check).
- **New packages are `@insignia/iios-<name>`.**
- **Legacy references** in prose cite `support-service` explicitly as *legacy*, with a pointer
to this ADR.
## Consequences
**Positive**
- One authoritative vocabulary; contributors from the prototype have a lookup table.
- KG-16 is structurally addressed: support cannot masquerade as the core because its names are
subordinate and the boundary check enforces the dependency direction.
- Packaging is unblocked (`@insignia/iios-*` is canonical).
- No risky in-place rename of a running legacy service; cutover is incremental per host app.
**Negative / costs**
- Two systems coexist during the strangler window; the legacy `support-service` must be kept
running (but frozen) until host apps migrate.
- The legacy↔IIOS mapping must be consulted when porting features; this ADR is that map.
- `Notification` / `KnowledgeArticle` have no IIOS home yet — tracked as follow-ups, not blockers.
## Alternatives considered
- **Rename `support-service` in place** to the IIOS vocabulary. Rejected: high-risk migration
of a live service + DB for a system we intend to retire anyway; no benefit over greenfield.
- **Keep both vocabularies and bridge with adapters.** Rejected: perpetuates naming drift and
the KG-16 confusion indefinitely.
- **`@lynkeduppro/*` scope.** Rejected: all IIOS packages already ship under `@insignia`;
switching scopes now is churn with no upside. (A future rebrand can supersede this ADR.)
+19
View File
@@ -0,0 +1,19 @@
# Architecture Decision Records (ADRs)
This directory records the significant, hard-to-reverse decisions behind IIOS —
the *why*, not just the *what*. Code shows what we did; ADRs explain the choice so a
future contributor doesn't relitigate it or unknowingly violate it.
## Convention
- One file per decision: `NNNN-kebab-title.md` (zero-padded, monotonically increasing).
- Each ADR has: **Status**, **Context**, **Decision**, **Consequences**, and (where useful)
**Alternatives considered**.
- Status is one of `Proposed` · `Accepted` · `Superseded by ADR-XXXX` · `Deprecated`.
- ADRs are immutable once `Accepted`. To change a decision, write a new ADR that supersedes it.
## Index
| ADR | Title | Status |
|-----|-------|--------|
| [0001](0001-support-service-to-iios-naming-migration.md) | `support-service` → IIOS naming migration | Accepted |
+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",
"typecheck": "pnpm -r typecheck",
"test": "vitest run",
"boundary": "node scripts/check-import-boundary.mjs"
"boundary": "node scripts/check-import-boundary.mjs",
"release:dry": "pnpm -r build && pnpm -r publish --dry-run --no-git-checks",
"release": "pnpm -r build && pnpm -r publish --no-git-checks"
},
"devDependencies": {
"@types/node": "^26.0.1",
+7 -3
View File
@@ -1,15 +1,19 @@
{
"name": "@insignia/iios-adapter-sdk",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@insignia/iios-contracts": "workspace:*"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { EmailAdapter, emailInboundFixture, emailReplyFixture, signedFixture } from './index';
const ctx = { scope: { orgId: 'o', appId: 'a' } };
describe('EmailAdapter', () => {
const adapter = new EmailAdapter();
it('normalizes an inbound email into an ingest request', () => {
const req = adapter.normalize(emailInboundFixture, ctx);
expect(req.channel.type).toBe('EMAIL');
expect(req.source.handleKind).toBe('EXTERNAL_VISITOR');
expect(req.source.externalId).toBe(emailInboundFixture.from);
expect(req.parts[0]?.bodyText).toBe(emailInboundFixture.text);
expect(req.providerEventId).toBe('<m1@example.com>');
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>');
expect(req.thread?.subject).toBe('Need help with my order'); // Re:/Fwd: stripped
expect((req.metadata as { messageId: string }).messageId).toBe('<m1@example.com>');
});
it('threads a reply onto the SAME email thread as the original', () => {
const first = adapter.normalize(emailInboundFixture, ctx);
const reply = adapter.normalize(emailReplyFixture, ctx);
expect(reply.thread?.externalThreadId).toBe(first.thread?.externalThreadId); // email:<m1@example.com>
expect(reply.providerEventId).toBe('<m2@example.com>'); // but its own dedup id
});
it('falls back to the reply target when there is no references chain', () => {
const req = adapter.normalize({ ...emailReplyFixture, references: undefined }, ctx);
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>'); // uses inReplyTo
});
it('derives text from html when no plain text is present', () => {
const req = adapter.normalize({ messageId: '<h@x>', from: 'x@y', html: '<p>Hello <b>there</b></p>' }, ctx);
expect(req.parts[0]?.bodyText).toBe('Hello there');
});
it('verifies an HMAC-signed body and rejects a bad/absent signature', () => {
const secret = 'sekret';
const { body, headers } = signedFixture(emailInboundFixture, secret);
expect(adapter.verifySignature(body, headers, secret)).toBe(true);
expect(adapter.verifySignature(body, { 'x-iios-signature': 'sha256=bad' }, secret)).toBe(false);
expect(adapter.verifySignature(body, {}, secret)).toBe(false);
});
});
@@ -0,0 +1,71 @@
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
import type { AdapterCapability, ChannelAdapter, NormalizeContext, OutboundCommandInput, SendResult } from './types';
import { hmacVerify } from './hmac';
import { SandboxSink } from './sandbox';
/** Canonical inbound-email shape. Vendor payloads (SendGrid Inbound Parse, Mailgun
* Routes, Postmark) map into this — provider glue stays a thin transform. */
export interface EmailInboundPayload {
messageId: string; // RFC 5322 Message-ID → providerEventId (dedup on redelivery)
from: string;
fromName?: string;
to?: string[];
cc?: string[];
subject?: string;
text?: string;
html?: string;
inReplyTo?: string; // Message-ID this is a reply to
references?: string[]; // full reference chain, root-first (threading)
occurredAt?: string;
headers?: Record<string, string>;
}
const stripReply = (s?: string): string => (s ?? '').replace(/^(re|fwd|fw)\s*:\s*/i, '').trim();
const stripHtml = (h?: string): string => (h ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
/**
* Email channel adapter. Turns a provider's inbound email into a normalized
* interaction with email-native threading: the whole reply chain collapses to one
* IIOS thread (rooted at `references[0]`/`inReplyTo`), and `messageId` dedups
* redelivery. HMAC signature for now (per-vendor schemes are a follow-up).
*/
export class EmailAdapter implements ChannelAdapter {
readonly channelType = 'EMAIL';
readonly capability: AdapterCapability = {
canReceive: true,
canSend: true,
supportsThreads: true,
requiresFollowupFetch: false,
};
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean {
const sig = headers['x-iios-signature'] ?? headers['X-Iios-Signature'];
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
}
externalEventId(payload: unknown): string | undefined {
const id = (payload as EmailInboundPayload)?.messageId;
return typeof id === 'string' ? id : undefined;
}
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
const p = payload as EmailInboundPayload;
// A reply's thread root is the first Message-ID in its chain → same IIOS thread as the original.
const threadRoot = p.references?.[0] ?? p.inReplyTo ?? p.messageId;
return {
scope: ctx.scope,
channel: { type: 'EMAIL', externalChannelId: 'email' },
source: { handleKind: 'EXTERNAL_VISITOR', externalId: p.from, displayName: p.fromName ?? p.from },
kind: 'MESSAGE',
thread: { externalThreadId: `email:${threadRoot}`, subject: stripReply(p.subject) },
parts: [{ kind: 'TEXT', bodyText: p.text ?? stripHtml(p.html), mimeType: 'text/plain' }],
occurredAt: p.occurredAt ?? new Date().toISOString(),
providerEventId: p.messageId,
metadata: { to: p.to, cc: p.cc, messageId: p.messageId, inReplyTo: p.inReplyTo, references: p.references, html: p.html },
};
}
async send(cmd: OutboundCommandInput): Promise<SendResult> {
return SandboxSink.simulate(cmd);
}
}
+26 -1
View File
@@ -1,7 +1,8 @@
import { hmacSign } from './hmac';
import type { WebhookPayload } from './webhook-adapter';
import type { EmailInboundPayload } from './email-adapter';
/** Email-shaped provider payload (as the adapter's normalize input). */
/** Email-shaped WebhookPayload (legacy generic-webhook fixture). */
export const emailFixture: WebhookPayload = {
eventId: 'email-1',
from: 'someone@example.com',
@@ -11,6 +12,30 @@ export const emailFixture: WebhookPayload = {
text: 'Hello, I have a question.',
};
/** A first inbound email (starts a thread) for the EmailAdapter. */
export const emailInboundFixture: EmailInboundPayload = {
messageId: '<m1@example.com>',
from: 'buyer@example.com',
fromName: 'Buyer',
to: ['support@tower.example'],
subject: 'Need help with my order',
text: 'Hi, my order has not arrived yet.',
occurredAt: '2026-07-03T10:00:00.000Z',
};
/** A reply to `emailInboundFixture` — must land on the SAME email thread. */
export const emailReplyFixture: EmailInboundPayload = {
messageId: '<m2@example.com>',
from: 'buyer@example.com',
fromName: 'Buyer',
to: ['support@tower.example'],
subject: 'Re: Need help with my order',
text: 'Any update on this?',
inReplyTo: '<m1@example.com>',
references: ['<m1@example.com>'],
occurredAt: '2026-07-03T11:00:00.000Z',
};
/** WhatsApp-shaped provider payload. */
export const whatsappFixture: WebhookPayload = {
eventId: 'wa-1',
+2 -1
View File
@@ -2,4 +2,5 @@ export * from './types';
export { hmacSign, hmacVerify } from './hmac';
export { SandboxSink } from './sandbox';
export { WebhookAdapter, type WebhookPayload } from './webhook-adapter';
export { emailFixture, whatsappFixture, signedFixture } from './fixtures';
export { EmailAdapter, type EmailInboundPayload } from './email-adapter';
export { emailFixture, whatsappFixture, emailInboundFixture, emailReplyFixture, signedFixture } from './fixtures';
+2
View File
@@ -34,6 +34,8 @@ export interface ChannelAdapter {
channelType: string;
capability: AdapterCapability;
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean;
/** Provider-native id used to dedup replays/retries at the inbound edge (e.g. eventId, Message-ID). */
externalEventId?(payload: unknown): string | undefined;
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest;
fetch?(ref: string): Promise<unknown>;
send(cmd: OutboundCommandInput): Promise<SendResult>;
@@ -37,6 +37,11 @@ export class WebhookAdapter implements ChannelAdapter {
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
}
externalEventId(payload: unknown): string | undefined {
const id = (payload as WebhookPayload)?.eventId;
return typeof id === 'string' ? id : undefined;
}
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
const p = payload as WebhookPayload;
return {
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-ai-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-community-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+7 -3
View File
@@ -1,12 +1,16 @@
{
"name": "@insignia/iios-contracts",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+1
View File
@@ -27,6 +27,7 @@ export interface IngestInteractionRequest {
bodyText?: string;
contentRef?: string;
mimeType?: string;
sizeBytes?: number;
}>;
occurredAt: string;
providerEventId?: string;
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-inbox-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-kernel-client",
"version": "0.0.0",
"private": true,
"version": "0.1.4",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -19,5 +25,8 @@
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
@@ -8,14 +8,14 @@ export interface MessageSocketConfig {
}
/**
* Framework-agnostic facade over the `/message` Socket.io namespace (ports the
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck.
* On reconnect it re-opens the current thread so subscriptions resume with no
* lost messages (the docs' disconnect/reconnect requirement).
* Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io
* types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens
* all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI
* that watches multiple conversations keeps receiving live messages after a drop.
*/
export class MessageSocket {
private readonly socket: SocketLike;
private currentThreadId: string | null = null;
private readonly joined = new Set<string>();
constructor(config: MessageSocketConfig, socket?: SocketLike) {
this.socket =
@@ -26,9 +26,9 @@ export class MessageSocket {
autoConnect: config.autoConnect ?? true,
}) as unknown as SocketLike);
// Re-open the active thread after a reconnect.
// Re-subscribe to every joined thread after a reconnect.
this.socket.on('connect', () => {
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId });
for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
});
}
@@ -40,27 +40,70 @@ export class MessageSocket {
this.socket.disconnect();
}
/** Run `handler` on every (re)connect, and immediately if already connected. */
onConnected(handler: () => void): () => void {
this.socket.on('connect', handler);
if (this.socket.connected) handler();
return () => this.socket.off('connect', handler);
}
/** Subscribe to a server event; returns an unsubscribe fn. */
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
const fn = handler as (...args: unknown[]) => void;
this.socket.on(event, fn);
return () => this.socket.off(event, fn);
this.socket.on(event as string, fn);
return () => this.socket.off(event as string, fn);
}
async openThread(threadId?: string): Promise<OpenThreadResult> {
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult;
this.currentThreadId = result.threadId;
async openThread(
threadId?: string,
opts?: { membership?: string; creatorRole?: string; subject?: string },
): Promise<OpenThreadResult> {
// Timeout (when the transport supports it) so a server error that never acks
// can't hang the caller forever.
const ack = this.socket.timeout ? this.socket.timeout(8000) : this.socket;
const result = (await ack.emitWithAck('open_thread', { threadId, ...opts })) as OpenThreadResult & { error?: string };
if (result?.error) throw new Error(result.error);
this.joined.add(result.threadId);
return result;
}
async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise<Message> {
async sendMessage(
threadId: string,
content: string,
opts?: {
contentRef?: string;
parentInteractionId?: string;
mentions?: string[];
attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string };
},
): Promise<Message> {
return (await this.socket.emitWithAck('send_message', {
threadId,
content,
contentRef: opts?.contentRef,
contentRef: opts?.attachment?.contentRef ?? opts?.contentRef,
mimeType: opts?.attachment?.mimeType,
sizeBytes: opts?.attachment?.sizeBytes,
checksumSha256: opts?.attachment?.checksumSha256,
parentInteractionId: opts?.parentInteractionId,
mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel
})) as Message;
}
/** Pin a message in the thread (shared, generic annotation type "pin"). */
async pin(threadId: string, interactionId: string): Promise<void> {
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'pin', value: '' });
}
/** Save a message for myself (personal, generic annotation type "save"). */
async save(threadId: string, interactionId: string): Promise<void> {
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'save', value: '' });
}
/** Toggle an emoji reaction on a message (a generic annotation of type "reaction"). */
async react(threadId: string, interactionId: string, value: string): Promise<void> {
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'reaction', value });
}
async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
}
@@ -68,4 +111,9 @@ export class MessageSocket {
typing(threadId: string): void {
this.socket.emit('typing', { threadId });
}
/** Tell the server which thread is in the foreground (or null when blurred) — drives presence. */
focus(threadId: string | null): void {
this.socket.emit('focus_thread', { threadId });
}
}
+74 -5
View File
@@ -1,9 +1,16 @@
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types';
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
export interface RestConfig {
serviceUrl: string;
token?: string;
/**
* Extra headers for every request — e.g. `x-context-attestation` (the July 12 trust proof).
* Pass a FUNCTION to mint fresh headers per request: a context attestation carries a single-use
* nonce, so a static header would be replay-rejected on the 2nd call. The function is invoked
* once per request.
*/
headers?: Record<string, string> | (() => Record<string, string>);
}
/** REST/polling client for kernel reads and the native-send fallback. */
@@ -15,7 +22,8 @@ export class RestClient {
}
private headers(extra: Record<string, string> = {}): Record<string, string> {
const h: Record<string, string> = { 'content-type': 'application/json', ...extra };
const custom = typeof this.config.headers === 'function' ? this.config.headers() : this.config.headers;
const h: Record<string, string> = { 'content-type': 'application/json', ...custom, ...extra };
if (this.config.token) h.authorization = `Bearer ${this.config.token}`;
return h;
}
@@ -57,12 +65,73 @@ export class RestClient {
return (await r.json()) as InboxItem;
}
// ─── auth + threads (app surface) ─────────────────────────────
/** Dev IdP login (POST /v1/dev/login). A real IdP issues the same JWT — this is the swap point. */
async login(username: string, password: string): Promise<LoginResult> {
const r = await fetch(this.url('/v1/dev/login'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (r.status === 401) throw new Error('invalid username or password');
if (!r.ok) throw new Error(`login failed (${r.status}) — is the service running with IIOS_DEV_TOKENS=1?`);
return (await r.json()) as LoginResult;
}
/** Dev directory: known usernames. A real app validates against its user store / MDM. */
async listUsers(): Promise<string[]> {
const r = await fetch(this.url('/v1/dev/users'), { headers: this.headers() });
if (!r.ok) return [];
return ((await r.json()) as { users: string[] }).users;
}
/**
* Server-authoritative conversation list (works cross-device, shows unread + members).
* `filter.metadata` narrows to threads whose opaque attribute bag matches every key/value.
*/
async listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
const qs = filter?.metadata
? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
: '';
const r = await fetch(this.url(`/v1/threads${qs}`), { headers: this.headers() });
if (!r.ok) throw new Error(`listThreads ${r.status}`);
return (await r.json()) as ThreadSummary[];
}
/** Create a thread with generic app attributes (membership/creator role/subject + an opaque metadata bag). */
async createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }> {
return this.post<{ threadId: string }>('/v1/threads', opts);
}
/** My saved messages (personal bookmarks), newest first, with thread context. */
async listSaved(): Promise<SavedItem[]> {
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
if (!r.ok) throw new Error(`listSaved ${r.status}`);
return (await r.json()) as SavedItem[];
}
/** Governed add-participant. A policy 403 (e.g. DM cap) surfaces its reason. */
async addParticipant(threadId: string, userId: string): Promise<void> {
const r = await fetch(this.url(`/v1/threads/${threadId}/participants`), {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ userId }),
});
if (r.ok) return;
const body = (await r.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message ?? `could not add member (${r.status})`);
}
// ─── support ──────────────────────────────────────────────────
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> {
async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record<string, unknown> }): Promise<Ticket> {
return this.post<Ticket>('/v1/support/tickets', body);
}
async escalate(threadId: string, subject?: string): Promise<Ticket> {
return this.post<Ticket>('/v1/support/escalate', { threadId, subject });
async escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
return this.post<Ticket>('/v1/support/escalate', { threadId, subject, metadata });
}
/** Manually assign a ticket to a specific user (generic assignment override). */
async assignTicket(id: string, userId: string): Promise<Ticket> {
return this.post<Ticket>(`/v1/support/tickets/${id}/assignee`, { userId });
}
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
+66
View File
@@ -1,10 +1,30 @@
/** A media/file part attached to a message (contentRef points at object storage). */
export interface Attachment {
contentRef: string;
mimeType: string;
sizeBytes: number;
kind: 'image' | 'video' | 'audio' | 'file';
}
/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */
export interface AnnotationGroup {
type: string;
value: string;
users: string[]; // usernames who applied it
}
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */
export interface Message {
id: string;
threadId: string;
senderActorId: string;
senderId: string; // sender's email/username — reliable "is this mine?" check
senderName: string;
content: string;
contentRef?: string;
attachment?: Attachment;
parentInteractionId?: string;
annotations?: AnnotationGroup[];
traceId?: string;
createdAt: string;
}
@@ -26,10 +46,50 @@ export interface TypingEvent {
userId: string;
}
/** Broadcast when someone toggles an annotation — carries the refreshed user list. */
export interface AnnotationEvent {
threadId: string;
interactionId: string;
type: string;
value: string;
op: 'add' | 'remove';
users: string[];
userId: string;
}
export interface MessageEvents {
message: (m: Message) => void;
receipt: (e: ReceiptEvent) => void;
typing: (e: TypingEvent) => void;
annotation: (e: AnnotationEvent) => void;
}
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
export interface ThreadSummary {
threadId: string;
subject: string | null;
membership?: string; // 'dm' | 'group' (opaque app attribute)
/** The thread's opaque, app-supplied attribute bag — echoed verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
participants: string[]; // member usernames
participantCount: number;
unread: number;
muted?: boolean;
lastMessage?: string;
lastAt?: string;
}
/** A personally-saved message with its thread context (GET /v1/threads/my-annotations?type=save). */
export interface SavedItem {
message: Message;
threadId: string;
threadSubject: string | null;
}
/** Result of the dev IdP login (POST /v1/dev/login). A real IdP issues the same claims. */
export interface LoginResult {
token: string;
userId: string;
}
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
@@ -68,6 +128,8 @@ export interface Ticket {
assignedActorId?: string | null;
createdAt: string;
updatedAt: string;
/** Opaque, app-supplied attribute bag on the ticket — echoed verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
threadLinks?: Array<{ threadId: string; relationKind: string }>;
}
@@ -188,4 +250,8 @@ export interface SocketLike {
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
connect(): unknown;
disconnect(): unknown;
/** True while the underlying transport is connected (socket.io exposes this). */
connected?: boolean;
/** Per-call ack timeout (socket.io). Optional so fakes can omit it. */
timeout?(ms: number): { emitWithAck(event: string, ...args: unknown[]): Promise<unknown> };
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-meeting-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+13 -4
View File
@@ -1,13 +1,19 @@
{
"name": "@insignia/iios-message-web",
"version": "0.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
@@ -23,5 +29,8 @@
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+70
View File
@@ -0,0 +1,70 @@
# ─────────────────────────────────────────────────────────────────────────────
# iios-service configuration contract.
# Copy to `.env` for local dev; in prod, inject via your secrets manager / orchestrator.
# 🔒 = secret (never commit a real value). ⚠️ = must be set correctly for prod safety.
# ─────────────────────────────────────────────────────────────────────────────
# ── Core ─────────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://iios:iios@localhost:5434/iios?schema=public # 🔒 Postgres connection
PORT=3200
# NODE_ENV=production # set by the Docker image
# Realtime fan-out across replicas (socket.io Redis adapter). REQUIRED when running
# more than one instance with live chat; omit for a single instance (in-memory adapter).
# REDIS_URL=redis://localhost:6379 # 🔒
# ── Secrets ──────────────────────────────────────────────────────────────────
# JSON map of appId → HS256 signing secret used to verify session JWTs (SessionVerifier).
APP_SECRETS={"portal-demo":"dev-secret"} # 🔒
# JSON map of channelType → inbound webhook HMAC secret (adapter signature check).
ADAPTER_SECRETS={"WEBHOOK":"dev-adapter-secret"} # 🔒
# Default scope an unauthenticated adapter webhook ingests into.
ADAPTER_APP=portal-demo
ADAPTER_ORG=org_demo
# ── ⚠️ Production-safety flags ────────────────────────────────────────────────
# Enables /v1/dev/* (token mint, webhook inject, chaos, retention sweep). MUST be unset
# or 0 in production — leaving it on exposes unauthenticated token minting.
IIOS_DEV_TOKENS=0
# Skip `prisma migrate deploy` at boot (set to 1 when a separate migration job runs).
IIOS_SKIP_MIGRATE=0
# ── Tenant isolation ─────────────────────────────────────────────────────────
IIOS_CELL_ID=cell-default # physical cell this instance serves (blast-radius partition)
# ── Background workers ───────────────────────────────────────────────────────
IIOS_RELAY_INTERVAL_MS=500 # outbox relay tick; 0 disables the timer
IIOS_OUTBOX_MAX_ATTEMPTS=5 # relay dead-letters an event after N failed publishes
IIOS_RETENTION_SWEEP_INTERVAL_MS=0 # retention sweep tick; 0 disables (run via job instead)
IIOS_RETENTION_POLICY_VERSION=v1
IIOS_RETENTION_ARCHIVE_DAYS=90 # default archive window (per-class override: _INTERNAL/_RESTRICTED/_CONFIDENTIAL/_REGULATED)
IIOS_RETENTION_DELETE_DAYS=365 # default delete (redact) window; same per-class overrides
# ── Rate limits / quotas / budgets ───────────────────────────────────────────
IIOS_OUTBOUND_LIMIT=5 # per-(channel,target) sends per window
IIOS_OUTBOUND_WINDOW_MS=60000
IIOS_TENANT_OUTBOUND_LIMIT=10000 # per-tenant egress cap per window (noisy-neighbor guard)
IIOS_TENANT_OUTBOUND_WINDOW_MS=60000
IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
# ── Capability providers (governed egress targets) ───────────────────────────
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
# ── Context attestation (July 12 trust layer) ──
# Audience IIOS requires on attestations addressed to it.
IIOS_ATTESTATION_AUDIENCE=iios-core
# Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the
# appshell-crm client into the registry when IIOS_DEV_TOKENS=1).
# IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key
# When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403).
# Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless.
IIOS_REQUIRE_ATTESTATION=0
# ─── Object storage (media + email attachments) ───────────────────
# Unset → local disk (MEDIA_DIR). Set these → S3-compatible (AWS S3 / MinIO / R2 / Supabase).
# IIOS_S3_ENDPOINT=https://minio.your-server:9000 # omit for AWS S3
# IIOS_S3_BUCKET=iios-media
# IIOS_S3_ACCESS_KEY=...
# IIOS_S3_SECRET_KEY=...
# IIOS_S3_REGION=us-east-1 # any value for MinIO
# IIOS_S3_FORCE_PATH_STYLE=true # true for MinIO/self-hosted
+54
View File
@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1
# Production image for @insignia/iios-service (the single IIOS backend).
# Build context MUST be the monorepo ROOT (it needs the workspace + lockfile):
# docker build -f packages/iios-service/Dockerfile -t iios-service:latest .
#
# Multi-stage: (1) build the service + its workspace deps and prune to a
# self-contained prod bundle via `pnpm deploy`; (2) a slim runtime that only
# carries that bundle. Migrations run at container start (see docker-entrypoint.sh).
ARG NODE_VERSION=22-bookworm-slim
ARG PNPM_VERSION=10.6.2
# ── build ───────────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION} AS build
ARG PNPM_VERSION
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
# openssl + ca-certificates are required by Prisma's engine on debian-slim.
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /repo
COPY . .
# Full install (needs devDeps to compile), then build ONLY the service + its
# workspace dependencies, generate the Prisma client, and emit a pruned prod bundle.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile
# Generate the Prisma client BEFORE compiling — nest build (tsc) needs its types,
# otherwise Prisma results are `any` and noImplicitAny fails the build.
RUN pnpm --filter @insignia/iios-service exec prisma generate \
&& pnpm --filter "@insignia/iios-service..." build \
&& pnpm --filter @insignia/iios-service --prod --legacy deploy /app
# ── runtime ──────────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION} AS runtime
ENV NODE_ENV=production
# Prisma engine needs openssl at runtime too.
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app ./
# Regenerate the Prisma client against the final filesystem (deterministic).
RUN node_modules/.bin/prisma generate
# Run as the built-in non-root `node` user.
RUN chmod +x docker-entrypoint.sh && chown -R node:node /app
USER node
EXPOSE 3200
ENV PORT=3200
# migrate deploy → start; see docker-entrypoint.sh.
ENTRYPOINT ["./docker-entrypoint.sh"]
@@ -0,0 +1,19 @@
#!/bin/sh
# Container entrypoint for iios-service.
# 1) Apply pending DB migrations (idempotent; safe to run on every boot).
# 2) Exec the server as PID 1 so signals (SIGTERM) reach Node for graceful shutdown.
#
# NOTE: at higher replica counts, prefer running `prisma migrate deploy` ONCE as a
# separate pre-deploy job/init-container and set IIOS_SKIP_MIGRATE=1 here, so N
# replicas don't race the migration on rollout.
set -e
if [ "${IIOS_SKIP_MIGRATE:-0}" != "1" ]; then
echo "[entrypoint] applying migrations (prisma migrate deploy)…"
node_modules/.bin/prisma migrate deploy
else
echo "[entrypoint] IIOS_SKIP_MIGRATE=1 — skipping migrations"
fi
echo "[entrypoint] starting iios-service on :${PORT:-3200}"
exec node dist/main.js
+13 -4
View File
@@ -12,21 +12,29 @@
"prisma:studio": "prisma studio"
},
"dependencies": {
"@insignia/iios-contracts": "workspace:*",
"@aws-sdk/client-s3": "^3.1090.0",
"@insignia/iios-adapter-sdk": "workspace:*",
"@insignia/iios-contracts": "workspace:*",
"@nestjs/common": "^11.1.27",
"@nestjs/core": "^11.1.27",
"@nestjs/platform-express": "^11.1.27",
"@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/websockets": "^11.1.27",
"@prisma/client": "^6.2.1",
"socket.io": "^4.8.3",
"@socket.io/redis-adapter": "^8.3.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^16.4.7",
"handlebars": "^4.7.9",
"ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0",
"nodemailer": "^9.0.3",
"prisma": "^6.2.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
"rxjs": "^7.8.2",
"socket.io": "^4.8.3",
"web-push": "^3.6.7"
},
"devDependencies": {
"@insignia/iios-testkit": "workspace:*",
@@ -35,7 +43,8 @@
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.0.1",
"prisma": "^6.2.1",
"@types/nodemailer": "^8.0.1",
"@types/web-push": "^3.6.4",
"socket.io-client": "^4.8.3",
"typescript": "^5.7.3"
}
@@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "IiosInteraction" ADD COLUMN "dataClass" TEXT NOT NULL DEFAULT 'internal';
-- CreateTable
CREATE TABLE "IiosRetentionPolicySnapshot" (
"id" TEXT NOT NULL,
"policyKey" TEXT NOT NULL,
"scopeSnapshotId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"dataClass" TEXT NOT NULL,
"retainUntil" TIMESTAMP(3),
"archiveAfter" TIMESTAMP(3) NOT NULL,
"deleteAfter" TIMESTAMP(3) NOT NULL,
"sourceVersion" TEXT NOT NULL DEFAULT 'v1',
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosRetentionPolicySnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosRetentionPolicySnapshot_status_deleteAfter_idx" ON "IiosRetentionPolicySnapshot"("status", "deleteAfter");
-- CreateIndex
CREATE INDEX "IiosRetentionPolicySnapshot_scopeSnapshotId_status_idx" ON "IiosRetentionPolicySnapshot"("scopeSnapshotId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "IiosRetentionPolicySnapshot_targetType_targetId_key" ON "IiosRetentionPolicySnapshot"("targetType", "targetId");
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "IiosInteractionAnnotation" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"targetInteractionId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"annotationType" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosInteractionAnnotation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosInteractionAnnotation_targetInteractionId_idx" ON "IiosInteractionAnnotation"("targetInteractionId");
-- CreateIndex
CREATE UNIQUE INDEX "IiosInteractionAnnotation_scopeId_targetInteractionId_actor_key" ON "IiosInteractionAnnotation"("scopeId", "targetInteractionId", "actorId", "annotationType", "value");
-- AddForeignKey
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_targetInteractionId_fkey" FOREIGN KEY ("targetInteractionId") REFERENCES "IiosInteraction"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "IiosInboxItemKind" ADD VALUE 'MENTION';
@@ -0,0 +1,30 @@
-- AlterTable
ALTER TABLE "IiosThreadParticipant" ADD COLUMN "muted" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "IiosNotificationSubscription" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"kind" TEXT NOT NULL DEFAULT 'webpush',
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosNotificationSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "IiosNotificationSubscription_endpoint_key" ON "IiosNotificationSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "IiosNotificationSubscription_actorId_idx" ON "IiosNotificationSubscription"("actorId");
-- AddForeignKey
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -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;
+135
View File
@@ -79,6 +79,7 @@ enum IiosInboxItemKind {
NEEDS_REPLY
NEEDS_REVIEW
NEEDS_APPROVAL
MENTION
SUPPORT_UPDATE
MEETING_FOLLOWUP
DIGEST
@@ -95,6 +96,12 @@ enum IiosInboxState {
STALE
}
enum IiosTemplateChannel {
EMAIL
SMS
INTERNAL
}
enum IiosTicketState {
NEW
OPEN
@@ -303,10 +310,13 @@ model IiosScope {
channels IiosChannel[]
threads IiosThread[]
interactions IiosInteraction[]
annotations IiosInteractionAnnotation[]
inboxItems IiosInboxItem[]
supportQueues IiosSupportQueue[]
tickets IiosTicket[]
callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[]
messageTemplates IiosMessageTemplate[]
@@index([orgId, appId, tenantId])
}
@@ -348,6 +358,7 @@ model IiosActorRef {
threadsCreated IiosThread[] @relation("ThreadCreatedBy")
participations IiosThreadParticipant[]
interactions IiosInteraction[]
annotations IiosInteractionAnnotation[]
receipts IiosMessageReceipt[]
unreadCounters IiosUnreadCounter[]
inboxItemsOwned IiosInboxItem[]
@@ -355,6 +366,7 @@ model IiosActorRef {
ticketsRequested IiosTicket[] @relation("TicketRequester")
ticketsAssigned IiosTicket[] @relation("TicketAssignee")
callbacksRequested IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[]
}
/// A configured channel surface (PORTAL in P1).
@@ -409,12 +421,32 @@ model IiosThreadParticipant {
actorId String
actor IiosActorRef @relation(fields: [actorId], references: [id])
participantRole String @default("MEMBER")
muted Boolean @default(false)
joinedAt DateTime @default(now())
leftAt DateTime?
@@id([threadId, actorId])
}
/// A device/browser push subscription for an actor (Web Push endpoint + keys).
/// The notification engine delivers to these when the actor is absent.
model IiosNotificationSubscription {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
actorId String
actor IiosActorRef @relation(fields: [actorId], references: [id])
kind String @default("webpush")
endpoint String @unique
p256dh String
auth String
userAgent String?
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
@@index([actorId])
}
/// The semantic envelope. Idempotent per (scope, idempotencyKey).
model IiosInteraction {
id String @id @default(cuid())
@@ -435,6 +467,7 @@ model IiosInteraction {
occurredAt DateTime @default(now())
receivedAt DateTime @default(now())
status IiosDeliveryState @default(RECEIVED)
dataClass String @default("internal") // envelope data class; drives retention window (P9)
policyDecisionRef String?
consentReceiptRef String?
crreBundleRef String?
@@ -443,6 +476,7 @@ model IiosInteraction {
parts IiosMessagePart[]
receipts IiosMessageReceipt[]
annotations IiosInteractionAnnotation[]
inboxItems IiosInboxItem[]
ticketsCreatedFrom IiosTicket[]
@@ -450,6 +484,26 @@ model IiosInteraction {
@@index([threadId, occurredAt])
}
/// A generic annotation an actor attaches to an interaction. Both `annotationType`
/// and `value` are OPAQUE, app-supplied strings the kernel stores and aggregates
/// but never interprets — e.g. the chat app writes type "reaction" / value "👍".
/// The same primitive backs pins, saves, flags, tags later. No chat vocabulary here.
model IiosInteractionAnnotation {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
targetInteractionId String
target IiosInteraction @relation(fields: [targetInteractionId], references: [id], onDelete: Cascade)
actorId String
actor IiosActorRef @relation(fields: [actorId], references: [id])
annotationType String
value String
createdAt DateTime @default(now())
@@unique([scopeId, targetInteractionId, actorId, annotationType, value])
@@index([targetInteractionId])
}
model IiosMessagePart {
id String @id @default(cuid())
interactionId String
@@ -493,6 +547,27 @@ model IiosProcessedEvent {
@@id([consumerName, eventId])
}
/// Per-resource retention snapshot (P9). Absolute lifecycle timestamps frozen from the
/// resource's age + policy window; read by the retention sweep. delete = redact-in-place.
model IiosRetentionPolicySnapshot {
id String @id @default(cuid())
policyKey String
scopeSnapshotId String
targetType String // 'interaction'
targetId String
dataClass String
retainUntil DateTime?
archiveAfter DateTime
deleteAfter DateTime
sourceVersion String @default("v1")
status String @default("ACTIVE") // ACTIVE | ARCHIVED | REDACTED
createdAt DateTime @default(now())
@@unique([targetType, targetId])
@@index([status, deleteAfter])
@@index([scopeSnapshotId, status])
}
/// Legal/compliance hold (P9 DSR). An ACTIVE, unexpired hold over a target BLOCKS
/// erasure/retention deletion until an authorised release. Never auto-released.
model IiosComplianceHold {
@@ -609,6 +684,33 @@ model IiosInboxItem {
@@index([ownerActorId, state, priority])
}
/// A reusable message template (email/SMS/in-app). DB is runtime truth; platform defaults are
/// seeded from repo files on boot. Versions are immutable — a change writes a new (higher) version,
/// never edits in place, so a rendered send can always be traced to the exact source it used.
/// scopeId NULL = a platform default; set = a tenant scope's override of the same key. Resolution
/// prefers the scoped row, else the global default. (The global-uniqueness of NULL-scope rows is
/// enforced by a partial unique index added in the migration — Postgres treats NULL as distinct.)
model IiosMessageTemplate {
id String @id @default(cuid())
scopeId String?
scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade)
key String
channel IiosTemplateChannel
locale String @default("en")
version Int @default(1)
subject String?
bodyHtml String?
bodyText String?
/// Declared variable names — render throws if a declared var is missing (fail loud).
variables Json?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scopeId, key, channel, locale, version])
@@index([key, channel, locale, active])
}
/// Audit trail of inbox-item state transitions.
model IiosInboxItemStateHistory {
id String @id @default(cuid())
@@ -766,6 +868,11 @@ model IiosOutboundCommand {
idempotencyKey String @unique
providerRef String?
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())
attempts IiosDeliveryAttempt[]
@@ -1253,3 +1360,31 @@ model IiosDlqItem {
@@unique([consumerName, sourceId])
@@index([scopeId, status])
}
// ─── Trust plane (July 12) — context attestation ──────────────────
// Registered clients/BFFs/adapters allowed to call IIOS and attest context on
// behalf of an app. A valid actor token is NOT enough; the caller must present a
// context attestation signed by one of these registered clients.
model IiosClientRegistry {
clientId String @id
clientType String // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
ownerService String?
allowedAppIds String[] // which app_ids this client may attest for
attestSecret String? // dev: shared HS256 signing key (AppShell's key stand-in)
jwksUri String? // prod: verify the attestation signature via JWKS
spiffeId String? // prod: workload identity for mTLS proof
status String @default("ACTIVE") // ACTIVE | DISABLED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Single-use nonce ledger: once a context attestation's nonce is seen it can
// never be replayed. Reserve-if-absent (unique PK) makes the check atomic.
model IiosAttestationNonce {
nonce String @id
clientId String
expiresAt DateTime
firstSeenAt DateTime @default(now())
@@index([expiresAt])
}
@@ -0,0 +1,65 @@
// P9 slice-8 DSR smoke: right-to-erasure redacts a subject's PII in place (a ticket
// subject), an active compliance hold blocks erasure until released, erasure is
// idempotent, and holds are tenant-fenced (KG-03 / KG-02). Requires the service
// running with IIOS_DEV_TOKENS=1.
import 'dotenv/config';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const APP_ID = 'portal-demo';
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
async function devToken(userId, orgId) {
const r = await fetch(`${SERVICE}/v1/dev/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }),
});
if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`);
return (await r.json()).token;
}
function call(token, path, method = 'GET', body) {
return fetch(`${SERVICE}${path}`, {
method,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined,
});
}
async function json(token, path, method = 'GET', body) {
const r = await call(token, path, method, body);
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
return r.json();
}
const ticketSubject = async (token, id) => (await json(token, '/v1/support/tickets')).find((t) => t.id === id)?.subject;
const A = await devToken('dsr-a', 'org_dsr_A');
const B = await devToken('dsr-b', 'org_dsr_B');
const PII = 'my SSN is 123-45';
// A creates a ticket carrying PII.
const ticket = await json(A, '/v1/support/tickets', 'POST', { subject: PII });
assert((await ticketSubject(A, ticket.id)) === PII, 'ticket carries the subject PII');
// A places a compliance hold on their own subject.
const hold = await json(A, '/v1/dsr/holds', 'POST', { targetType: 'data_subject', targetId: 'self', reason: 'litigation' });
assert((await json(A, '/v1/dsr/holds')).some((h) => h.id === hold.id), 'compliance hold is listed');
// Erasure is blocked while the hold is active.
const blocked = await call(A, '/v1/dsr/erase', 'POST');
assert(blocked.status === 409, `erase blocked by the hold → 409 (got ${blocked.status})`);
assert((await ticketSubject(A, ticket.id)) === PII, 'PII still present while held');
// Release the hold → erasure now redacts the PII in place.
await json(A, `/v1/dsr/holds/${hold.id}/release`, 'POST');
const erased = await json(A, '/v1/dsr/erase', 'POST');
assert(erased.status === 'ERASED', `erase succeeds after release (status ${erased.status})`);
assert((await ticketSubject(A, ticket.id)) === '[redacted]', 'ticket subject is redacted (erased in place, not deleted)');
// Idempotent: a second erase is a no-op.
const again = await json(A, '/v1/dsr/erase', 'POST');
assert(again.status === 'ALREADY_ERASED', `re-erase is idempotent (status ${again.status})`);
// Tenant fence: tenant B never sees A's holds.
assert(!(await json(B, '/v1/dsr/holds')).some((h) => h.id === hold.id), "tenant B's hold list excludes A's hold");
console.log('\nP9 DSR smoke: PASS');
process.exit(0);
@@ -0,0 +1,88 @@
// Email adapter smoke: a signed inbound email → normalized interaction; a reply
// (In-Reply-To) lands on the SAME email thread; outbound EMAIL → SENT (sandbox).
// Requires the service running with IIOS_DEV_TOKENS=1 (relay timer on). No real network.
import 'dotenv/config';
import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const APP_ID = 'portal-demo';
const APP_SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
const EMAIL_SECRET = (() => {
try { return JSON.parse(process.env.ADAPTER_SECRETS ?? '{}').EMAIL ?? 'dev-adapter-secret'; } catch { return 'dev-adapter-secret'; }
})();
const prisma = new PrismaClient();
const token = jwt.sign({ sub: 'inspector', appId: APP_ID, orgId: `org_${APP_ID}` }, APP_SECRET, { algorithm: 'HS256', expiresIn: '1h' });
const sign = (body) => 'sha256=' + crypto.createHmac('sha256', EMAIL_SECRET).update(body).digest('hex');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
async function postEmail(payload) {
const body = JSON.stringify(payload);
return fetch(`${SERVICE}/v1/adapters/EMAIL/webhook`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-iios-signature': sign(body) },
body,
});
}
async function inboundList() {
const r = await fetch(`${SERVICE}/v1/adapters/inbound`, { headers: { authorization: `Bearer ${token}` } });
if (!r.ok) throw new Error(`GET /v1/adapters/inbound ${r.status}`);
return r.json();
}
async function waitNormalized(messageId) {
for (let i = 0; i < 40; i++) {
const e = (await inboundList()).find((x) => x.externalEventId === messageId);
if (e && e.status === 'NORMALIZED' && e.interactionId) return e.interactionId;
await sleep(300);
}
return null;
}
// Unique Message-IDs per run so re-runs don't dedup against earlier ones.
const run = randomUUID().slice(0, 8);
const mid1 = `<${run}-1@smoke>`;
const mid2 = `<${run}-2@smoke>`;
// 1) A first inbound email → normalized interaction.
const r1 = await postEmail({ messageId: mid1, from: 'buyer@smoke', fromName: 'Buyer', subject: 'Need help with my order', text: 'my order is late' });
assert(r1.status === 202, 'signed email accepted (202)');
const i1 = await waitNormalized(mid1);
assert(i1, 'first email normalized into an interaction');
// 2) A reply (In-Reply-To) → same email thread.
const r2 = await postEmail({ messageId: mid2, from: 'buyer@smoke', subject: 'Re: Need help with my order', text: 'any update?', inReplyTo: mid1, references: [mid1] });
assert(r2.status === 202, 'reply email accepted (202)');
const i2 = await waitNormalized(mid2);
assert(i2, 'reply email normalized into an interaction');
const [it1, it2] = await Promise.all([
prisma.iiosInteraction.findUniqueOrThrow({ where: { id: i1 }, include: { thread: true } }),
prisma.iiosInteraction.findUniqueOrThrow({ where: { id: i2 }, include: { thread: true } }),
]);
assert(it1.threadId === it2.threadId, 'reply joined the SAME email thread as the original');
assert(it1.thread?.externalThreadRef === `email:${mid1}`, `thread rooted at the original Message-ID (${it1.thread?.externalThreadRef})`);
// 3) Bad signature → rejected.
const bad = await fetch(`${SERVICE}/v1/adapters/EMAIL/webhook`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-iios-signature': 'sha256=bad' },
body: JSON.stringify({ messageId: `<${run}-bad@smoke>`, from: 'x', text: 'x' }),
});
assert(bad.status === 401, 'bad signature rejected (401)');
// 4) Outbound EMAIL → governed egress → SENT (sandbox).
const sendRes = await fetch(`${SERVICE}/v1/adapters/EMAIL/send`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ target: 'buyer@smoke', payload: { subject: 'Re: Need help', text: 'thanks, looking into it' } }),
});
const cmd = await sendRes.json();
assert(cmd.status === 'SENT', `outbound email SENT via broker (status ${cmd.status})`);
console.log('\nEmail adapter smoke: PASS');
await prisma.$disconnect();
process.exit(0);
@@ -0,0 +1,61 @@
// v1.1 smoke: dev IdP login, policy-enforced DM cap / group membership, listThreads,
// and threaded replies — all over REST. Requires the service with IIOS_DEV_TOKENS=1.
import 'dotenv/config';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
async function login(username, password) {
const r = await fetch(`${SERVICE}/v1/dev/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
return { status: r.status, body: r.ok ? await r.json() : null };
}
function call(token, path, method = 'GET', body) {
return fetch(`${SERVICE}${path}`, {
method,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined,
});
}
async function json(token, path, method = 'GET', body) {
const r = await call(token, path, method, body);
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
return r.json();
}
// 1) dev IdP: correct password issues a token; wrong password is rejected.
const ok = await login('alice', 'alice');
assert(ok.body?.token, 'dev IdP: alice logs in with password');
const bad = await login('alice', 'nope');
assert(bad.status === 401, 'dev IdP: wrong password → 401');
const alice = ok.body.token;
// 2) DM is capped at two (policy).
const dm = await json(alice, '/v1/threads', 'POST', { membership: 'dm' });
assert(dm.threadId, `alice created a DM (${dm.threadId})`);
const add2 = await json(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'bob' });
assert(add2.participantCount === 2, 'added bob → 2 participants');
const add3 = await call(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'carol' });
assert(add3.status === 403, `adding a 3rd to a DM → 403 policy denied (${add3.status})`);
// 3) group: creator (ADMIN) can add several.
const grp = await json(alice, '/v1/threads', 'POST', { membership: 'group', creatorRole: 'ADMIN' });
await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'bob' });
const g3 = await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'carol' });
assert(g3.participantCount === 3, 'group admin added bob + carol → 3 participants');
// 4) threaded reply round-trips.
const first = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'question?' });
const reply = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'answer', parentInteractionId: first.id });
assert(reply.parentInteractionId === first.id, 'a reply carries parentInteractionId');
// 5) listThreads shows the caller's threads with membership + last message.
const mine = await json(alice, '/v1/threads');
const g = mine.find((t) => t.threadId === grp.threadId);
assert(g && g.membership === 'group' && g.participantCount === 3, 'GET /v1/threads lists the group with membership + count');
assert(g.lastMessage === 'answer', 'thread summary carries the last message');
console.log('\nv1.1 membership + replies smoke: PASS');
process.exit(0);
@@ -0,0 +1,55 @@
// P9 scaling smoke: cross-instance realtime fan-out via the socket.io Redis adapter.
// Alice connects to instance A, Bob to instance B (two separate processes sharing one
// Redis + DB). Alice sends on A; Bob must receive it live on B — which only works if the
// Redis adapter fans room emits across replicas. Set A_URL / B_URL (default 3201/3202).
import 'dotenv/config';
import { io } from 'socket.io-client';
import jwt from 'jsonwebtoken';
const A_URL = process.env.A_URL ?? 'http://localhost:3201';
const B_URL = process.env.B_URL ?? 'http://localhost:3202';
const APP_ID = 'portal-demo';
const SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
const sign = (userId) =>
jwt.sign({ sub: userId, name: userId, appId: APP_ID, orgId: `org_${APP_ID}` }, SECRET, { algorithm: 'HS256', expiresIn: '1h' });
const connect = (url, userId) =>
io(`${url}/message`, { auth: { token: sign(userId) }, transports: ['websocket'], forceNew: true });
const once = (socket, event, ms = 8000) =>
new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(`timeout waiting for "${event}"`)), ms);
socket.once(event, (v) => { clearTimeout(t); res(v); });
});
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
const alice = connect(A_URL, 'alice-cluster');
const bob = connect(B_URL, 'bob-cluster');
try {
await Promise.all([once(alice, 'connect'), once(bob, 'connect')]);
assert(true, `Alice connected to A (${A_URL}), Bob to B (${B_URL})`);
// Alice opens a thread on instance A; membership is governed so Alice ADDS Bob, who
// then opens the SAME thread on instance B.
const opened = await alice.emitWithAck('open_thread', {});
const threadId = opened.threadId;
assert(!!threadId, `Alice opened thread ${threadId} on instance A`);
await alice.emitWithAck('add_participant', { threadId, userId: 'bob-cluster' });
await bob.emitWithAck('open_thread', { threadId });
assert(true, 'Bob joined the same thread on instance B');
// Alice sends on A → Bob must receive it on B (cross-instance fan-out via Redis).
const bobReceives = once(bob, 'message');
await alice.emitWithAck('send_message', { threadId, content: 'hello across instances' });
const received = await bobReceives;
assert(received.content === 'hello across instances', `Bob received "${received.content}" on B — emitted from A ✓`);
console.log('\nP9 cross-instance realtime smoke: PASS');
} catch (err) {
console.error('✗', err.message);
process.exit(1);
} finally {
alice.close();
bob.close();
}
process.exit(0);
@@ -44,10 +44,11 @@ const alice = connect('alice');
const bob = connect('bob');
try {
// Alice creates a thread; Bob joins it.
// Alice creates a thread; membership is governed, so Alice ADDS Bob (he can't self-join).
const opened = await alice.emitWithAck('open_thread', {});
const threadId = opened.threadId;
assert(!!threadId, `Alice created thread ${threadId}`);
await alice.emitWithAck('add_participant', { threadId, userId: 'bob' });
await bob.emitWithAck('open_thread', { threadId });
// Alice sends; Bob should receive it live.
@@ -0,0 +1,51 @@
// P9 slice-9 retention smoke: an aged interaction's snapshot passes its delete window
// and the sweep redacts the content in place (never a hard delete), visible as a
// REDACTED snapshot on /metrics. Requires the service running with IIOS_DEV_TOKENS=1
// and IIOS_RETENTION_DELETE_DAYS=0 IIOS_RETENTION_ARCHIVE_DAYS=0 (new content due at once;
// scheduled interval left OFF so only the explicit dev sweep runs).
import 'dotenv/config';
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
const APP_ID = 'portal-demo';
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function devToken(userId, orgId) {
const r = await fetch(`${SERVICE}/v1/dev/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }),
});
if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`);
return (await r.json()).token;
}
async function api(token, path, method = 'GET', body) {
const r = await fetch(`${SERVICE}${path}`, {
method,
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
return r.json();
}
const redactedCount = (m) => m.retention?.byStatus?.REDACTED ?? 0;
const token = await devToken('ret-a', 'org_ret_A');
const base = redactedCount(await api(token, '/metrics'));
console.log(`baseline REDACTED snapshots = ${base}`);
// Inject content and give the relay a moment to normalize it into an interaction.
await api(token, '/v1/dev/webhook/WEBHOOK', 'POST', { text: 'retention smoke content' });
await sleep(2000);
// Sweep: 0-day windows make every interaction's snapshot immediately due → redacted.
const swept = await api(token, '/v1/dev/retention/sweep', 'POST');
assert(swept.redacted >= 1, `sweep redacted the aged interaction(s) (redacted=${swept.redacted})`);
const after = await api(token, '/metrics');
assert(redactedCount(after) > base, `/metrics shows more REDACTED snapshots (${base}${redactedCount(after)})`);
assert((after.retention?.total ?? 0) >= 1, 'retention.total reflects captured snapshots');
console.log('\nP9 retention smoke: PASS');
process.exit(0);
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import type { ScopeVector } from '@insignia/iios-contracts';
import { WebhookAdapter, type ChannelAdapter } from '@insignia/iios-adapter-sdk';
import { WebhookAdapter, EmailAdapter, type ChannelAdapter } from '@insignia/iios-adapter-sdk';
export interface AdapterConfig {
secret: string;
@@ -8,10 +8,9 @@ export interface AdapterConfig {
}
/**
* Registers channel adapters + their per-channel config (secret + scope). In P5
* one reference WebhookAdapter backs several channelTypes (WEBHOOK/EMAIL/WHATSAPP)
* so the fixtures exercise the same anti-corruption pipeline. Secrets come from
* ADAPTER_SECRETS (JSON map); scope defaults to the demo scope.
* Registers channel adapters + their per-channel config (secret + scope). EMAIL uses
* the email-native EmailAdapter (RFC threading); the rest use the reference
* WebhookAdapter. Secrets come from ADAPTER_SECRETS (JSON map); scope defaults to demo.
*/
@Injectable()
export class AdapterRegistry {
@@ -29,8 +28,9 @@ export class AdapterRegistry {
appId: process.env.ADAPTER_APP ?? 'portal-demo',
};
for (const channelType of ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']) {
const adapter: ChannelAdapter = channelType === 'EMAIL' ? new EmailAdapter() : new WebhookAdapter(channelType);
this.adapters.set(channelType, {
adapter: new WebhookAdapter(channelType),
adapter,
config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope },
});
}
@@ -1,14 +1,16 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { ConflictException } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { DlqService } from '../outbox/dlq.service';
import { resetDb } from '../test-utils/reset-db';
import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { signedFixture, emailFixture } from '@insignia/iios-adapter-sdk';
import { signedFixture, emailFixture, emailInboundFixture, emailReplyFixture } from '@insignia/iios-adapter-sdk';
import { AdapterRegistry } from './adapter.registry';
import { InboundService } from './inbound.service';
import { OutboundService } from './outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { RawEventProjector } from './raw-event.projector';
@@ -79,9 +81,60 @@ describe('Adapter inbound (P5)', () => {
});
});
describe('Email adapter — inbound + threading', () => {
const receiveEmail = async (payload: object) => {
const { body, headers } = signedFixture(payload, SECRET);
return inbound().receive('EMAIL', body, headers);
};
const normalizeAll = async () => {
for (const ev of await rawReceivedEvents()) await projector().onRawReceived(ev);
};
it('a signed inbound email → a normalized interaction with the body on the email thread', async () => {
const ack = await receiveEmail(emailInboundFixture);
expect(ack.status).toBe('RECEIVED');
await normalizeAll();
const raw = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
expect(raw.status).toBe('NORMALIZED');
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: raw.interactionId! }, include: { parts: true, thread: true } });
expect(interaction.parts[0]?.bodyText).toBe(emailInboundFixture.text);
expect(interaction.thread?.externalThreadRef).toBe('email:<m1@example.com>');
});
it('a reply email (In-Reply-To) lands on the SAME email thread as the original', async () => {
await receiveEmail(emailInboundFixture);
await receiveEmail(emailReplyFixture);
await normalizeAll();
const interactions = await prisma.iiosInteraction.findMany({ orderBy: { occurredAt: 'asc' } });
expect(interactions).toHaveLength(2);
expect(interactions[0]!.threadId).toBe(interactions[1]!.threadId); // reply joined the thread
expect(await prisma.iiosThread.count()).toBe(1); // one email conversation
});
it('redelivered email (same Message-ID) → one raw event, one interaction (dedup)', async () => {
const svc = inbound();
const { body, headers } = signedFixture(emailInboundFixture, SECRET);
await svc.receive('EMAIL', body, headers);
const second = await svc.receive('EMAIL', body, headers);
expect(second.deduped).toBe(true);
await normalizeAll();
expect(await prisma.iiosInboundRawEvent.count()).toBe(1);
expect(await prisma.iiosInteraction.count()).toBe(1);
});
it('bad signature on EMAIL → REJECTED, nothing ingested (fail-closed)', async () => {
const { body } = signedFixture(emailInboundFixture, SECRET);
await expect(inbound().receive('EMAIL', body, { 'x-iios-signature': 'sha256=bad' })).rejects.toThrow();
expect(await prisma.iiosInboundRawEvent.count({ where: { status: 'REJECTED' } })).toBe(1);
expect(await prisma.iiosInteraction.count()).toBe(0);
});
});
describe('Adapter outbound (P5)', () => {
const outbound = (limit?: number): OutboundService => {
const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
if (limit) s.limit = limit;
return s;
};
@@ -112,7 +165,7 @@ describe('Adapter outbound (P5)', () => {
it('broker obligation DENY_OUTBOUND → command BLOCKED, no send (P9)', async () => {
const ports = makeFakePorts();
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'recipient opted out' }] });
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'blk-1');
expect(cmd.status).toBe('BLOCKED');
expect(cmd.providerRef).toBe('blocked');
@@ -121,7 +174,7 @@ describe('Adapter outbound (P5)', () => {
it('CMP allow → command records the consent receipt (P9 slice 2)', async () => {
const ports = makeFakePorts();
ports.cmp.setStatus('ALLOW');
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support');
expect(cmd.status).toBe('SENT');
expect(cmd.consentReceiptRef).toBe('fake-receipt');
@@ -130,7 +183,7 @@ describe('Adapter outbound (P5)', () => {
it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => {
const ports = makeFakePorts();
ports.cmp.denyPurpose('marketing');
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing');
expect(cmd.status).toBe('BLOCKED');
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
@@ -146,4 +199,18 @@ describe('Adapter outbound (P5)', () => {
expect(a2.status).toBe('RATE_LIMITED');
expect(b1.status).toBe('SENT');
});
it('idempotency ledger: same key + a different target → conflict, no second command (P9 slice 10)', async () => {
const svc = outbound();
const first = await svc.send('WEBHOOK', 'user@a', { text: 'hi' }, 'dup-key');
expect(first.status).toBe('SENT');
await expect(svc.send('WEBHOOK', 'user@b', { text: 'hi' }, 'dup-key')).rejects.toBeInstanceOf(ConflictException);
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'dup-key' } })).toBe(1);
});
it('opt-in: a keyless send creates a command but writes no idempotency-ledger row (P9 slice 10)', async () => {
const cmd = await outbound().send('WEBHOOK', 'user@x', { text: 'hi' });
expect(cmd.status).toBe('SENT');
expect(await prisma.iiosIdempotencyCommand.count()).toBe(0);
});
});
@@ -28,7 +28,8 @@ export class InboundService {
const { adapter, config } = reg;
const payload = this.safeParse(rawBody);
const externalEventId = typeof payload?.eventId === 'string' ? payload.eventId : undefined;
// The adapter extracts its provider-native id (eventId, Message-ID, …) for replay dedup.
const externalEventId = adapter.externalEventId?.(payload) ?? (typeof payload?.eventId === 'string' ? payload.eventId : undefined);
// Fail-closed: a bad/absent signature is recorded and rejected — never ingested.
if (!adapter.verifySignature(rawBody, headers, config.secret)) {
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CapabilityBroker } from '../capability/capability.broker';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { recordAudit } from '../observability/audit';
/**
@@ -23,55 +24,77 @@ export class OutboundService {
constructor(
private readonly prisma: PrismaService,
private readonly broker: CapabilityBroker,
private readonly idempotency: IdempotencyService,
) {}
async send(
channelType: string,
target: string,
payload: Record<string, unknown>,
idempotencyKey: string = randomUUID(),
idempotencyKey?: string,
scopeId?: string,
purpose?: string,
/** Opaque template provenance recorded on the command. OutboundService neither renders nor
* resolves templates it only persists the four strings it is handed (guaranteed by the
* templated sender, never by caller convention). */
provenance?: { templateKey?: string | null; templateVersion?: number | null; templateLocale?: string | null; renderedHash?: string | null },
) {
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } });
if (existing) return existing;
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
// command returns the existing row instead of colliding on idempotencyKey @unique.
const body = async () => {
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey: key } });
if (existing) return existing;
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key, ...prov },
});
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
return cmd;
}
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey },
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key, ...prov },
});
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
return cmd;
}
const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey },
});
let result;
try {
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey: key, scopeId, purpose });
} catch (err) {
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
await this.prisma.$transaction([
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }),
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }),
]);
throw err;
}
let result;
try {
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId, purpose });
} catch (err) {
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
await this.prisma.$transaction([
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }),
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }),
const [updated] = await this.prisma.$transaction([
this.prisma.iiosOutboundCommand.update({
where: { id: cmd.id },
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
}),
this.prisma.iiosDeliveryAttempt.create({
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
}),
]);
throw err;
}
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
return updated;
};
const [updated] = await this.prisma.$transaction([
this.prisma.iiosOutboundCommand.update({
where: { id: cmd.id },
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
}),
this.prisma.iiosDeliveryAttempt.create({
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
}),
]);
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
return updated;
// Opt-in idempotency: an explicit key routes through the ledger (request-hash conflict → 409,
// response replay); a keyless send just runs with a fresh uuid (old behavior).
if (!idempotencyKey) return body();
return this.idempotency.run({ scopeId, commandName: 'channel.send', key: idempotencyKey, request: { channelType, target, payload, purpose } }, body);
}
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
+12
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { PlatformModule } from './platform/platform.module';
import { AttestationModule } from './platform/attestation.module';
import { IdentityModule } from './identity/identity.module';
import { InteractionsModule } from './interactions/interactions.module';
import { IdempotencyModule } from './idempotency/idempotency.module';
@@ -9,6 +10,10 @@ import { OutboxModule } from './outbox/outbox.module';
import { ThreadsModule } from './threads/threads.module';
import { MessageModule } from './messaging/message.module';
import { InboxModule } from './inbox/inbox.module';
import { TemplateModule } from './templates/template.module';
import { MailModule } from './mail/mail.module';
import { MediaModule } from './media/media.module';
import { NotificationModule } from './notifications/notification.module';
import { SupportModule } from './support/support.module';
import { AdaptersModule } from './adapters/adapters.module';
import { RoutingModule } from './routing/routing.module';
@@ -16,6 +21,7 @@ import { AiModule } from './ai/ai.module';
import { CalendarModule } from './calendar/calendar.module';
import { CapabilityModule } from './capability/capability.module';
import { DsrModule } from './dsr/dsr.module';
import { RetentionModule } from './retention/retention.module';
import { HealthController } from './health.controller';
import { MetricsController } from './observability/metrics.controller';
import { DevController } from './dev/dev.controller';
@@ -24,6 +30,7 @@ import { DevController } from './dev/dev.controller';
imports: [
PrismaModule,
PlatformModule,
AttestationModule,
IdentityModule,
InteractionsModule,
IdempotencyModule,
@@ -32,6 +39,10 @@ import { DevController } from './dev/dev.controller';
ThreadsModule,
MessageModule,
InboxModule,
TemplateModule,
MailModule,
MediaModule,
NotificationModule,
SupportModule,
AdaptersModule,
RoutingModule,
@@ -39,6 +50,7 @@ import { DevController } from './dev/dev.controller';
CalendarModule,
CapabilityModule,
DsrModule,
RetentionModule,
],
controllers: [HealthController, MetricsController, DevController],
})
@@ -7,6 +7,7 @@ import { ActorResolver } from '../identity/actor.resolver';
import { AiJobService } from '../ai/ai.service';
import { AiBudgetGuard } from '../ai/ai-budget.guard';
import { OutboundService } from '../adapters/outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { CalendarService } from './calendar.service';
@@ -22,7 +23,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
const START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())));
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)));
async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) {
const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees });
@@ -11,6 +11,7 @@ import { ActorResolver } from '../identity/actor.resolver';
import { AiJobService } from '../ai/ai.service';
import { AiBudgetGuard } from '../ai/ai-budget.guard';
import { OutboundService } from '../adapters/outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { CalendarService } from './calendar.service';
@@ -27,7 +28,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
const START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())));
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)));
async function makeInteraction(text: string): Promise<string> {
const m = new MessageService(asService, makeFakePorts(), actors);
@@ -9,6 +9,7 @@ import { ActorResolver } from '../identity/actor.resolver';
import { AiJobService } from '../ai/ai.service';
import { AiBudgetGuard } from '../ai/ai-budget.guard';
import { OutboundService } from '../adapters/outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { CalendarService } from './calendar.service';
@@ -24,7 +25,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
const START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), outbound());
async function makeInteraction(text: string): Promise<string> {
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MediaModule } from '../media/media.module';
import { CapabilityProviderRegistry } from './capability.registry';
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.
*/
@Module({
imports: [MediaModule],
providers: [CapabilityProviderRegistry, CapabilityBroker],
exports: [CapabilityBroker, CapabilityProviderRegistry],
})
@@ -0,0 +1,34 @@
import { describe, it, expect, afterEach } from 'vitest';
import { CapabilityProviderRegistry } from './capability.registry';
// The registry reads env in its constructor, so each case sets env → constructs a fresh registry →
// asserts → restores env.
const SMTP_KEYS = ['IIOS_SMTP_HOST', 'IIOS_SMTP_USER', 'IIOS_SMTP_PASS', 'IIOS_PROVIDER_URL_EMAIL'];
const saved: Record<string, string | undefined> = {};
function set(env: Record<string, string | undefined>) {
for (const k of SMTP_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
for (const [k, v] of Object.entries(env)) if (v != null) process.env[k] = v;
}
afterEach(() => { for (const k of SMTP_KEYS) { if (saved[k] == null) delete process.env[k]; else process.env[k] = saved[k]; } });
describe('CapabilityProviderRegistry — EMAIL precedence (SMTP > HTTP > sandbox)', () => {
it('binds SMTP for EMAIL when the SMTP env trio is set', () => {
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' });
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
});
it('binds the HTTP EmailProvider when only IIOS_PROVIDER_URL_EMAIL is set', () => {
set({ IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('email-http');
});
it('SMTP wins over the HTTP relay when both are set', () => {
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p', IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
});
it('falls back to the sandbox when neither is configured', () => {
set({});
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('sandbox');
});
});
@@ -1,25 +1,40 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common';
import type { CapabilityProvider } from '@insignia/iios-contracts';
import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider';
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
/**
* Maps a channelType to the provider that executes egress for it. Sandbox by
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
* 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()
export class CapabilityProviderRegistry {
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) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
if (url) this.register(new HttpProvider(ch, url));
if (!url) continue;
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
}
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments
// resolve through the media StoragePort when one is bound (else attachments FAIL closed).
const smtp = smtpIdentityFromEnv();
if (smtp) {
const resolver = this.storage ? storageResolver(this.storage) : undefined;
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
}
}
@@ -0,0 +1,40 @@
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
interface EmailPayload {
subject?: string;
text?: string;
html?: string;
inReplyTo?: string;
}
/**
* Email egress provider: shapes the outbound command into an email envelope
* ({to, subject, text, html, inReplyTo}) and POSTs it to a configured send endpoint
* (a vendor mail API / SMTP relay behind a URL). Activated only when
* IIOS_PROVIDER_URL_EMAIL is set; a transport failure is surfaced as FAILED, never thrown.
*/
export class EmailProvider implements CapabilityProvider {
readonly name = 'email-http';
readonly channelTypes = ['EMAIL'];
readonly capabilities = { canSend: true };
constructor(private readonly url: string) {}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
const p = (req.payload ?? {}) as EmailPayload;
const email = { to: req.target, subject: p.subject ?? '(no subject)', text: p.text, html: p.html, inReplyTo: p.inReplyTo };
try {
const res = await fetch(this.url, {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': req.idempotencyKey },
body: JSON.stringify(email),
});
const latencyMs = Date.now() - started;
if (!res.ok) return { providerRef: `email-${res.status}`, outcome: 'FAILED', errorCode: `HTTP_${res.status}`, latencyMs };
return { providerRef: `email-${req.idempotencyKey}`, outcome: 'SENT', latencyMs };
} catch (err) {
return { providerRef: 'email-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
}
}
}
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, type MailTransport, type SmtpIdentity } from './smtp.provider';
import type { CapabilityRequest } from '@insignia/iios-contracts';
const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' };
const FB: SmtpIdentity = { ...ID, user: 'ceo@lynkeduppro.com', from: 'Justin <ceo@lynkeduppro.com>' };
const req = (payload: Record<string, unknown>): CapabilityRequest => ({
capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1',
});
/** A recording transport; optionally throws a given error on send. */
function stub(opts: { throwErr?: unknown; messageId?: string } = {}) {
const calls: Array<{ id: SmtpIdentity; mail: Parameters<MailTransport['sendMail']>[0] }> = [];
const make = (id: SmtpIdentity): MailTransport => ({
async sendMail(mail) {
calls.push({ id, mail });
if (opts.throwErr) throw opts.throwErr;
return { messageId: opts.messageId ?? '<generated@smtp.test>' };
},
});
return { make, calls };
}
describe('smtpIdentityFromEnv', () => {
const base = { IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' };
it('builds the identity from a complete env trio (defaults port 587, secure false)', () => {
const id = smtpIdentityFromEnv({ ...base } as NodeJS.ProcessEnv);
expect(id).toMatchObject({ host: 'smtp.test', port: 587, secure: false, user: 'accounts@x', from: 'accounts@x' });
});
it('returns null when the trio is incomplete', () => {
expect(smtpIdentityFromEnv({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'a@x' } as NodeJS.ProcessEnv)).toBeNull();
});
it('reads the fallback identity, reusing the primary host', () => {
const fb = smtpFallbackFromEnv({ ...base, IIOS_SMTP_FALLBACK_USER: 'ceo@x', IIOS_SMTP_FALLBACK_PASS: 'q', IIOS_SMTP_FALLBACK_FROM: 'CEO <ceo@x>' } as NodeJS.ProcessEnv);
expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO <ceo@x>' });
});
it('returns null fallback when not configured', () => {
expect(smtpFallbackFromEnv({ ...base } as NodeJS.ProcessEnv)).toBeNull();
});
});
describe('SmtpProvider.send — envelope', () => {
it('maps target + payload into the mail and returns the messageId as providerRef', async () => {
const t = stub({ messageId: '<abc@smtp.test>' });
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '<p>x</p>', text: 'x' }));
expect(res).toMatchObject({ outcome: 'SENT', providerRef: '<abc@smtp.test>' });
expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '<p>x</p>', text: 'x' });
});
it('sets In-Reply-To + References headers for a reply', async () => {
const t = stub();
await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 're', inReplyTo: '<parent@smtp.test>' }));
expect(t.calls[0].mail).toMatchObject({ inReplyTo: '<parent@smtp.test>', references: '<parent@smtp.test>' });
});
});
describe('SmtpProvider.send — failure + fallback', () => {
it('retries via the fallback identity on a pre-acceptance failure (SENT via fallback)', async () => {
// primary throws ECONNREFUSED (server never accepted) → fallback used.
const primaryThrows = { make: (id: SmtpIdentity): MailTransport => ({
async sendMail(mail) {
if (id.user === ID.user) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' });
return { messageId: '<viaFallback@smtp.test>' };
},
}) };
const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' }));
expect(res.outcome).toBe('SENT');
expect(res.providerRef).toBe('fallback:<viaFallback@smtp.test>');
});
it('does NOT retry a post-acceptance failure (avoids double delivery) → FAILED', async () => {
// responseCode present = the server already spoke; retrying could double-send.
const t = stub({ throwErr: Object.assign(new Error('rejected after data'), { responseCode: 550 }) });
const res = await new SmtpProvider(ID, FB, t.make).send(req({ subject: 'x' }));
expect(res.outcome).toBe('FAILED');
expect(t.calls).toHaveLength(1); // primary only — no fallback attempt
});
it('with no fallback, a failure is FAILED and never throws', async () => {
const t = stub({ throwErr: Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) });
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'x' }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('ETIMEDOUT');
});
});
describe('SmtpProvider.send — attachments', () => {
const resolver = (map: Record<string, { content: Buffer; contentType?: string; filename?: string }>) =>
async (ref: string) => map[ref] ?? null;
it('resolves attachment refs to bytes and attaches them', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } }))
.send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] }));
expect(res.outcome).toBe('SENT');
expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]);
});
it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({}))
.send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED');
expect(t.calls).toHaveLength(0); // nothing sent
});
it('FAILS when attachments are requested but no resolver is wired', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make) // no resolver
.send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] }));
expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' });
});
it('a plain send with no attachments is unaffected', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' }));
expect(res.outcome).toBe('SENT');
expect(t.calls[0].mail.attachments).toBeUndefined();
});
});
describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
it('maps storage bytes into an attachment; missing ref → null', async () => {
const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) };
const r = storageResolver(storage);
expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' });
expect(await r('nope')).toBeNull();
});
});
@@ -0,0 +1,185 @@
import { randomUUID } from 'node:crypto';
import { createTransport } from 'nodemailer';
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
/** One SMTP sending identity (a mailbox + how to reach its server). */
export interface SmtpIdentity {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
from: string;
}
export interface MailAttachment {
filename: string;
content: Buffer;
contentType?: string;
}
/** Fetch an attachment's bytes by its opaque contentRef (backed by the media StoragePort). */
export type AttachmentResolver = (contentRef: string) => Promise<{ filename?: string; content: Buffer; contentType?: string } | null>;
/** Build an AttachmentResolver over the media StoragePort (contentRef = the storage object key). */
export function storageResolver(storage: { get(key: string): Promise<{ data: Buffer; mime: string } | null> }): AttachmentResolver {
return async (contentRef) => {
const o = await storage.get(contentRef);
return o ? { content: o.data, contentType: o.mime } : null;
};
}
/** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */
export interface MailTransport {
sendMail(mail: {
from: string;
to: string;
subject?: string;
text?: string;
html?: string;
inReplyTo?: string;
references?: string;
attachments?: MailAttachment[];
}): Promise<{ messageId: string; accepted?: unknown[] }>;
}
const bool = (v: string | undefined): boolean => v === 'true' || v === '1';
/** Build the primary SMTP identity from env, or null if the required trio is incomplete. */
export function smtpIdentityFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
return identityFrom(env, '');
}
/** Build the optional fallback identity (accounts@ → ceo@), or null if not configured. */
export function smtpFallbackFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
const fb = identityFrom(env, 'FALLBACK_');
if (fb) return fb;
// Fallback may reuse the primary host/port and only override the mailbox identity.
const host = env.IIOS_SMTP_HOST;
const user = env.IIOS_SMTP_FALLBACK_USER;
const pass = env.IIOS_SMTP_FALLBACK_PASS;
if (!host || !user || !pass) return null;
return {
host,
port: Number(env.IIOS_SMTP_PORT ?? 587),
secure: bool(env.IIOS_SMTP_SECURE),
user,
pass,
from: env.IIOS_SMTP_FALLBACK_FROM ?? user,
};
}
function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | null {
const host = env[`IIOS_SMTP_${prefix}HOST`] ?? (prefix ? undefined : env.IIOS_SMTP_HOST);
const user = env[`IIOS_SMTP_${prefix}USER`];
const pass = env[`IIOS_SMTP_${prefix}PASS`];
if (!host || !user || !pass) return null;
return {
host,
port: Number(env[`IIOS_SMTP_${prefix}PORT`] ?? env.IIOS_SMTP_PORT ?? 587),
secure: bool(env[`IIOS_SMTP_${prefix}SECURE`] ?? env.IIOS_SMTP_SECURE),
user,
pass,
from: env[`IIOS_SMTP_${prefix}FROM`] ?? user,
};
}
interface EmailPayload {
subject?: string;
text?: string;
html?: string;
inReplyTo?: string;
/** Attachment REFS (not bytes) — resolved to bytes at send time via the injected resolver. */
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
}
/**
* SMTP egress provider (nodemailer). Bound for EMAIL when the SMTP env trio is set (else the sandbox
* stays). A transport failure surfaces as FAILED, never thrown. On a PRE-acceptance failure it retries
* once via the fallback identity (accounts@ ceo@); a post-acceptance failure is NOT retried, so a
* message the server already accepted can't be double-delivered.
*/
export class SmtpProvider implements CapabilityProvider {
readonly name = 'smtp';
readonly channelTypes = ['EMAIL'];
readonly capabilities = { canSend: true };
private readonly makeTransport: (id: SmtpIdentity) => MailTransport;
constructor(
private readonly primary: SmtpIdentity,
private readonly fallback?: SmtpIdentity,
makeTransport?: (id: SmtpIdentity) => MailTransport,
private readonly resolveAttachment?: AttachmentResolver,
) {
this.makeTransport = makeTransport ?? defaultTransport;
}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
const p = (req.payload ?? {}) as EmailPayload;
// Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing
// its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
let attachments: MailAttachment[] | undefined;
if (p.attachments && p.attachments.length > 0) {
if (!this.resolveAttachment) return this.failed('NO_ATTACHMENT_RESOLVER', started);
const out: MailAttachment[] = [];
for (const a of p.attachments) {
const r = await this.resolveAttachment(a.contentRef).catch(() => null);
if (!r) return this.failed('ATTACHMENT_UNRESOLVED', started);
out.push({ filename: a.filename ?? r.filename ?? 'attachment', content: r.content, ...(a.mimeType ?? r.contentType ? { contentType: a.mimeType ?? r.contentType } : {}) });
}
attachments = out;
}
const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> =>
this.makeTransport(id).sendMail({
from: id.from,
to: req.target,
subject: p.subject ?? '(no subject)',
text: p.text,
html: p.html,
...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}),
...(attachments ? { attachments } : {}),
});
try {
const info = await attempt(this.primary);
return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err) {
// Retry via the fallback identity ONLY if the primary never got the message accepted.
if (this.fallback && isPreAcceptanceFailure(err)) {
try {
const info = await attempt(this.fallback);
return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err2) {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started };
}
}
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started };
}
}
private failed(errorCode: string, started: number): ProviderResult {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode, latencyMs: Date.now() - started };
}
}
/** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */
function isPreAcceptanceFailure(err: unknown): boolean {
const e = err as { code?: string; responseCode?: number };
const preCodes = ['ECONNECTION', 'ETIMEDOUT', 'ECONNREFUSED', 'EDNS', 'EAUTH', 'ESOCKET', 'EENVELOPE'];
if (e.code && preCodes.includes(e.code)) return true;
// A responseCode present means the server spoke — treat 5xx after acceptance as terminal (no retry).
return e.responseCode == null && e.code == null;
}
function codeOf(err: unknown): string {
const e = err as { code?: string; message?: string };
return (e.code ?? e.message ?? 'SMTP_ERROR').slice(0, 60);
}
function defaultTransport(id: SmtpIdentity): MailTransport {
return createTransport({ host: id.host, port: id.port, secure: id.secure, auth: { user: id.user, pass: id.pass } }) as unknown as MailTransport;
}
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException, Body, Controller, ForbiddenException, Headers, Param, Post } from '@nestjs/common';
import { BadRequestException, Body, Controller, ForbiddenException, Get, Headers, Param, Post, UnauthorizedException } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import { IIOS_EVENTS } from '@insignia/iios-contracts';
import { hmacSign } from '@insignia/iios-adapter-sdk';
@@ -8,6 +8,7 @@ import { AdapterRegistry } from '../adapters/adapter.registry';
import { PrismaService } from '../prisma/prisma.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { SessionVerifier } from '../platform/session.verifier';
import { RetentionService } from '../retention/retention.service';
/**
* Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production.
@@ -20,25 +21,64 @@ export class DevController {
private readonly prisma: PrismaService,
private readonly actors: ActorResolver,
private readonly session: SessionVerifier,
private readonly retention: RetentionService,
) {}
@Post('token')
token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } {
this.assertDev();
return { token: this.signToken(body.appId, body.userId, body.name, body.orgId) };
}
/**
* Dev IdP: a credentialed login that mints the SAME JWT claims a real IdP / Session
* Broker would issue (sub/name/appId/orgId). Credentials come from DEV_USERS (JSON
* `{username: password}`; defaults to a few demo users). SessionVerifier is the stable
* verify seam swapping in a real IdP means issuing these same claims, nothing else.
*/
@Post('login')
login(@Body() body: { username: string; password: string; appId?: string }): { token: string; userId: string } {
this.assertDev();
const appId = body.appId ?? 'portal-demo';
let users: Record<string, string>;
try {
users = JSON.parse(process.env.DEV_USERS ?? '{"alice":"alice","bob":"bob","carol":"carol"}');
} catch {
users = {};
}
const expected = users[body.username];
if (expected === undefined || expected !== body.password) throw new UnauthorizedException('invalid username or password');
return { token: this.signToken(appId, body.username, body.username), userId: body.username };
}
/** Dev directory: the known dev usernames (from DEV_USERS). A real app validates
* add-by-username against its user store / the MDM port; this stands in for it. */
@Get('users')
users(): { users: string[] } {
this.assertDev();
let map: Record<string, string>;
try {
map = JSON.parse(process.env.DEV_USERS ?? '{"alice":"alice","bob":"bob","carol":"carol"}');
} catch {
map = {};
}
return { users: Object.keys(map) };
}
private signToken(appId: string, userId: string, name?: string, orgId?: string): string {
let secrets: Record<string, string>;
try {
secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
} catch {
secrets = {};
}
const secret = secrets[body.appId];
if (!secret) throw new BadRequestException(`unknown app: ${body.appId}`);
const token = jwt.sign(
{ sub: body.userId, name: body.name ?? body.userId, appId: body.appId, orgId: body.orgId ?? `org_${body.appId}` },
const secret = secrets[appId];
if (!secret) throw new BadRequestException(`unknown app: ${appId}`);
return jwt.sign(
{ sub: userId, name: name ?? userId, appId, orgId: orgId ?? `org_${appId}` },
secret,
{ algorithm: 'HS256', expiresIn: '2h' },
);
return { token };
}
/** Server signs a sample provider payload and feeds it to the inbound pipeline. */
@@ -96,6 +136,13 @@ export class DevController {
return { eventId: id, scopeId: scope.id };
}
/** Run the retention sweep on demand (drives the retention smoke). */
@Post('retention/sweep')
async retentionSweep() {
this.assertDev();
return this.retention.sweep();
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
@@ -8,6 +8,9 @@ export interface MessagePrincipal {
appId: string;
tenantId?: string;
displayName?: string;
/** The token's audience (`aud`). Used to enforce realtime delegation: a socket-scoped
* token (`iios-message`) must not be a full REST/actor token (`iios-core`), and vice-versa. */
audience?: string;
}
/**
@@ -68,10 +71,10 @@ export class ActorResolver {
);
}
async ensureParticipant(threadId: string, actorId: string): Promise<void> {
async ensureParticipant(threadId: string, actorId: string, participantRole = 'MEMBER'): Promise<void> {
await this.prisma.iiosThreadParticipant.upsert({
where: { threadId_actorId: { threadId, actorId } },
create: { threadId, actorId },
create: { threadId, actorId, participantRole },
update: {},
});
}
@@ -10,6 +10,7 @@ interface MessageSentData {
interactionId: string;
threadId: string;
senderActorId: string;
mentions?: string[]; // opaque app notify-list (userIds); the kernel never parses "@"
}
interface MessageReadData {
threadId: string;
@@ -57,6 +58,60 @@ export class InboxProjector implements OnModuleInit {
if (p.actorId === data.senderActorId) continue;
await this.upsertNeedsReply(thread.scopeId, p.actorId, data.threadId, data.interactionId, traceId, thread.subject);
}
// Targeted mention notifications from the app-supplied opaque notify-list.
const mentions = data.mentions ?? [];
if (mentions.length > 0) {
const src = await this.prisma.iiosInteraction.findUnique({
where: { id: data.interactionId },
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
});
const senderName = src?.actor?.sourceHandle?.externalId ?? 'Someone';
const text = src?.parts[0]?.bodyText ?? undefined;
for (const userId of mentions) {
await this.createMention(thread.scopeId, userId, data.threadId, data.interactionId, data.senderActorId, senderName, text, traceId);
}
}
}
/** One MENTION item per (owner, source message) for a mentioned participant (never the sender). */
private async createMention(
scopeId: string,
userId: string,
threadId: string,
sourceInteractionId: string,
senderActorId: string,
senderName: string,
summary: string | undefined,
traceId: string | undefined,
): Promise<void> {
const handle = await this.prisma.iiosSourceHandle.findUnique({
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: userId } },
});
if (!handle) return;
const actor = await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } });
if (!actor || actor.id === senderActorId) return; // resolvable, and never notify the sender
const isMember = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } });
if (!isMember) return; // only thread participants get mention items
const existing = await this.prisma.iiosInboxItem.findFirst({ where: { ownerActorId: actor.id, sourceInteractionId, kind: 'MENTION' } });
if (existing) return; // idempotent per source message
const item = await this.prisma.iiosInboxItem.create({
data: {
scopeId,
ownerActorId: actor.id,
kind: 'MENTION',
title: `${senderName} mentioned you`,
summary,
priority: 'HIGH',
threadId,
sourceInteractionId,
traceId,
},
});
await this.prisma.iiosInboxItemStateHistory.create({
data: { inboxItemId: item.id, toState: 'OPEN', reasonCode: 'mentioned' },
});
}
async onMessageRead(event: CloudEvent): Promise<void> {
@@ -67,21 +122,24 @@ export class InboxProjector implements OnModuleInit {
private async applyMessageRead(event: CloudEvent): Promise<void> {
const data = event.data as MessageReadData;
const item = await this.prisma.iiosInboxItem.findFirst({
// Reading the thread clears the reader's activity AND mention items for it.
const items = await this.prisma.iiosInboxItem.findMany({
where: {
threadId: data.threadId,
ownerActorId: data.actorId,
kind: 'NEEDS_REPLY',
kind: { in: ['NEEDS_REPLY', 'MENTION'] },
state: { in: ['OPEN', 'SNOOZED'] },
},
});
if (!item) return;
await this.prisma.$transaction([
this.prisma.iiosInboxItem.update({ where: { id: item.id }, data: { state: 'DONE' } }),
this.prisma.iiosInboxItemStateHistory.create({
data: { inboxItemId: item.id, fromState: item.state, toState: 'DONE', reasonCode: 'message_read' },
}),
]);
if (items.length === 0) return;
await this.prisma.$transaction(
items.flatMap((item) => [
this.prisma.iiosInboxItem.update({ where: { id: item.id }, data: { state: 'DONE' } }),
this.prisma.iiosInboxItemStateHistory.create({
data: { inboxItemId: item.id, fromState: item.state, toState: 'DONE', reasonCode: 'message_read' },
}),
]),
);
}
private async upsertNeedsReply(
@@ -88,6 +88,38 @@ describe('InboxProjector (P3 work surface)', () => {
expect(await itemsFor('bob')).toHaveLength(1);
});
it('message.sent with mentions → a MENTION item for the mentioned participant only', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
await m.addParticipant(threadId, alice, 'carol');
await m.send(threadId, alice, { content: 'hey @bob look' }, 'k1', undefined, undefined, ['bob']);
await projector().onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
const bobMentions = (await itemsFor('bob')).filter((i) => i.kind === 'MENTION');
expect(bobMentions).toHaveLength(1);
expect(bobMentions[0]?.state).toBe('OPEN');
expect(bobMentions[0]?.sourceInteractionId).toBeTruthy();
expect((await itemsFor('carol')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // not mentioned
expect((await itemsFor('alice')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // never the sender
});
it('reading the thread resolves the readers MENTION item to DONE', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await m.addParticipant(threadId, alice, 'bob');
const sent = await m.send(threadId, alice, { content: '@bob ping' }, 'k1', undefined, undefined, ['bob']);
const proj = projector();
await proj.onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('OPEN');
await m.markRead(threadId, bob, sent.id);
await proj.onMessageRead((await eventsOf(IIOS_EVENTS.messageRead))[0]!);
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('DONE');
});
it('message.read → the recipient item goes DONE', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice);
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsInt,
IsISO8601,
IsObject,
IsOptional,
@@ -40,6 +41,7 @@ class PartDto {
@IsOptional() @IsString() bodyText?: string;
@IsOptional() @IsString() contentRef?: string;
@IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
}
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
@@ -178,6 +178,7 @@ export class IngestService {
bodyText: p.bodyText,
contentRef: p.contentRef,
mimeType: p.mimeType,
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
})),
});
+2 -1
View File
@@ -12,6 +12,7 @@ import { AiJobService } from './ai/ai.service';
import { AiBudgetGuard } from './ai/ai-budget.guard';
import { CalendarService } from './calendar/calendar.service';
import { OutboundService } from './adapters/outbound.service';
import { IdempotencyService } from './idempotency/idempotency.service';
import { CapabilityBroker } from './capability/capability.broker';
import { CapabilityProviderRegistry } from './capability/capability.registry';
import type { PrismaService } from './prisma/prisma.service';
@@ -27,7 +28,7 @@ const B: MessagePrincipal = { userId: 'b1', orgId: 'org_B', appId: 'portal-demo'
const START = '2026-07-02T10:00:00.000Z';
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), outbound());
@@ -0,0 +1,57 @@
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
import { SessionVerifier } from '../platform/session.verifier';
import type { MessagePrincipal } from '../identity/actor.resolver';
import { MailService } from './mail.service';
import { MailInternalDto, MailSendDto } from './mail.dto';
import type { TemplateSource } from '../templates/template.model';
@Controller('v1/mail')
export class MailController {
constructor(
private readonly mail: MailService,
private readonly session: SessionVerifier,
) {}
/** App-to-app mail — renders a template and posts it into the recipient's in-app inbox (no SMTP). */
@Post('internal')
async internal(@Body() body: MailInternalDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
return this.mail.postInternal(principal, {
source: this.source(body),
recipientUserId: body.recipientUserId,
vars: body.vars ?? {},
...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey,
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
});
}
/** External email (SMTP) + an in-app mirror when a registered recipient is named. */
@Post('send')
async send(@Body() body: MailSendDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
return this.mail.sendExternalWithMirror(principal, {
source: this.source(body),
target: body.target,
vars: body.vars ?? {},
...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey,
...(body.purpose ? { purpose: body.purpose } : {}),
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}),
});
}
private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource {
if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both');
if (body.inline) return { inline: body.inline };
if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key };
throw new BadRequestException('one of "key" or "inline" is required');
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,41 @@
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { InlineTemplateDto } from '../templates/template.dto';
/** An email attachment reference — bytes live in the media store under `contentRef`. */
export class AttachmentDto {
@IsOptional() @IsString() filename?: string;
@IsString() @IsNotEmpty() contentRef!: string;
@IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
}
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
export class MailInternalDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsString() @IsNotEmpty() recipientUserId!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
/** In-app attachment refs — stored as message parts so the recipient's inbox can render them. */
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
}
/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
export class MailSendDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsString() @IsNotEmpty() target!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
@IsOptional() @IsString() purpose?: string;
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
/** The registered recipient to mirror to; omit for a pre-registration send (email only). */
@IsOptional() @IsString() mirrorToUserId?: string;
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TemplateModule } from '../templates/template.module';
import { InteractionsModule } from '../interactions/interactions.module';
import { MailService } from './mail.service';
import { MailController } from './mail.controller';
/**
* Mail orchestration: render a template (TemplateModule) deliver it either app-to-app (an EMAIL
* interaction via IngestService) or externally (TemplatedSender/SMTP) with an in-app mirror.
* SessionVerifier + ActorResolver are global.
*/
@Module({
imports: [TemplateModule, InteractionsModule],
controllers: [MailController],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
@@ -0,0 +1,121 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { IngestService } from '../interactions/ingest.service';
import { MessageService } from '../messaging/message.service';
import { OutboundService } from '../adapters/outbound.service';
import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { TemplateRepository } from '../templates/template.repository';
import { TemplateService } from '../templates/template.service';
import { TemplatedSender } from '../templates/templated-sender';
import { MailService, contentToParts } from './mail.service';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios_test?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const actors = new ActorResolver(asService);
function mail(): MailService {
const templates = new TemplateService(new TemplateRepository(asService));
const ingest = new IngestService(asService, makeFakePorts(), actors);
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const sender = new TemplatedSender(templates, outbound, makeFakePorts());
return new MailService(templates, ingest, sender, actors, asService);
}
const messages = () => new MessageService(asService, makeFakePorts(), actors);
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
const inline = { inline: { subject: 'Welcome Dana', html: '<p>Hi <b>Dana</b></p>', text: 'Hi Dana', variables: [] } };
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('contentToParts (pure)', () => {
it('produces HTML + TEXT parts and the subject', () => {
expect(contentToParts({ subject: 'S', html: '<p>h</p>', text: 't' })).toEqual({ subject: 'S', parts: [{ kind: 'HTML', bodyText: '<p>h</p>' }, { kind: 'TEXT', bodyText: 't' }] });
});
it('never yields zero parts (empty text fallback)', () => {
expect(contentToParts({ subject: 'S' }).parts).toEqual([{ kind: 'TEXT', bodyText: '' }]);
});
});
describe('MailService.postInternal', () => {
it('creates an EMAIL interaction and makes the thread visible to BOTH sender and recipient', async () => {
const res = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:1' });
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
expect(interaction.kind).toBe('EMAIL');
expect(interaction.parts.map((p) => p.kind).sort()).toEqual(['HTML', 'TEXT']);
const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: res.threadId } });
expect(thread.subject).toBe('Welcome Dana');
expect((thread.metadata as { source?: string } | null)?.source).toBe('crm-mail'); // lists separately from chat
// The load-bearing assertion: the RECIPIENT can see the thread in their inbox.
const bobThreads = await messages().listThreads(bob);
expect(bobThreads.map((t) => t.threadId)).toContain(res.threadId);
// ...and so can the sender.
const aliceThreads = await messages().listThreads(alice);
expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId);
});
it('stores an in-app attachment as a media part alongside the body', async () => {
const res = await mail().postInternal(alice, {
source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att',
attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }],
});
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
const file = interaction.parts.find((p) => p.contentRef);
expect(file).toBeDefined();
expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF
expect(file!.contentRef).toBe('scope/roof.png');
expect(file!.mimeType).toBe('image/png');
expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048);
});
it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => {
const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
expect(b.threadId).toBe(a.threadId);
expect(await prisma.iiosInteraction.count({ where: { threadId: a.threadId } })).toBe(1);
});
});
describe('MailService.sendExternalWithMirror', () => {
const ext = { source: inline, target: 'dana@acme.com', idempotencyKey: 'recv:1' } as const;
it('sends via the outbound pipeline AND mirrors into a registered recipient inbox', async () => {
const res = await mail().sendExternalWithMirror(alice, { ...ext, mirrorToUserId: 'bob' });
// outbound command exists (SENT via sandbox in tests)
const cmd = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id: res.commandId } });
expect(cmd.channelType).toBe('EMAIL');
expect(cmd.target).toBe('dana@acme.com');
// mirror interaction visible to the recipient
expect(res.mirror).toBeDefined();
const bobThreads = await messages().listThreads(bob);
expect(bobThreads.map((t) => t.threadId)).toContain(res.mirror!.threadId);
});
it('does NOT mirror when there is no registered recipient (pre-registration send)', async () => {
const res = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:2' }); // no mirrorToUserId
expect(res.mirror).toBeUndefined();
// an outbound command was created, but no mirror interaction
expect(await prisma.iiosOutboundCommand.count({ where: { id: res.commandId } })).toBe(1);
expect(await prisma.iiosInteraction.count()).toBe(0);
});
it('is idempotent — a replay yields one command and one mirror', async () => {
const a = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' });
const b = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' });
expect(b.commandId).toBe(a.commandId);
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'recv:dup' } })).toBe(1);
expect(await prisma.iiosInteraction.count({ where: { threadId: a.mirror!.threadId } })).toBe(1);
});
});
@@ -0,0 +1,133 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { IngestService } from '../interactions/ingest.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { TemplateService } from '../templates/template.service';
import { TemplatedSender } from '../templates/templated-sender';
import type { RenderedContent } from '../templates/template.renderer';
import type { TemplateSource } from '../templates/template.model';
export interface MailPart { kind: 'HTML' | 'TEXT'; bodyText: string }
/** Turn rendered content into ingest parts (HTML + TEXT) + the thread subject. Pure. */
export function contentToParts(content: RenderedContent): { subject?: string; parts: MailPart[] } {
const parts: MailPart[] = [];
if (content.html != null) parts.push({ kind: 'HTML', bodyText: content.html });
if (content.text != null) parts.push({ kind: 'TEXT', bodyText: content.text });
// A message must carry at least one part; fall back to an empty text part rather than fail ingest.
if (parts.length === 0) parts.push({ kind: 'TEXT', bodyText: '' });
return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
}
export interface MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number }
export interface PostInternalInput {
source: TemplateSource;
recipientUserId: string;
vars?: Record<string, unknown>;
locale?: string;
idempotencyKey: string;
/** In-app attachment refs — stored as FILE message parts alongside the body. */
attachments?: MailAttachment[];
}
export interface SendExternalInput {
source: TemplateSource;
target: string; // email address (external)
vars?: Record<string, unknown>;
locale?: string;
idempotencyKey: string;
purpose?: string;
/** Attachment refs (bytes resolved at send time). */
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
/** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */
mirrorToUserId?: string;
}
/**
* Mail orchestration: render a template, then deliver it.
* - INTERNAL (app-to-app): create an EMAIL interaction on a per-email thread no SMTP.
* - EXTERNAL: send via SMTP (TemplatedSender) AND mirror a copy into the recipient's in-app inbox,
* but only when they are a registered user (pre-registration sends have no inbox yet).
*
* ingest() writes the interaction + thread but adds no participants, and a thread is only visible to
* its participants so after each ingest we add BOTH the sender and the recipient as participants.
*/
@Injectable()
export class MailService {
constructor(
private readonly templates: TemplateService,
private readonly ingest: IngestService,
private readonly sender: TemplatedSender,
private readonly actors: ActorResolver,
private readonly prisma: PrismaService,
) {}
/** Marks a thread as CRM mail so it lists separately from chat (Messenger uses source=crm-messenger). */
static readonly SOURCE = 'crm-mail';
async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> {
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) });
return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL', input.attachments);
}
async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> {
const command = await this.sender.sendTemplated({
source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {},
...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}),
...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}),
});
// Mirror only for a registered recipient (timing rule: no inbox exists pre-registration).
if (!input.mirrorToUserId) return { commandId: command.id };
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) });
const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL', input.attachments);
return { commandId: command.id, mirror };
}
/** A stored media ref → a message part; kind follows the mime (image/video → MEDIA_REF, audio → VOICE_REF, else FILE_REF). */
private attachmentPart(a: MailAttachment): { kind: 'MEDIA_REF' | 'VOICE_REF' | 'FILE_REF'; bodyText?: string; contentRef: string; mimeType?: string; sizeBytes?: number } {
const mime = a.mimeType ?? '';
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
return { kind, ...(a.filename ? { bodyText: a.filename } : {}), contentRef: a.contentRef, ...(a.mimeType ? { mimeType: a.mimeType } : {}), ...(a.sizeBytes != null ? { sizeBytes: a.sizeBytes } : {}) };
}
/** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */
private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string, attachments?: MailAttachment[]): Promise<{ threadId: string; interactionId: string }> {
const { subject, parts } = contentToParts(content);
const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))];
const res = await this.ingest.ingest(
{
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) },
channel: { type: channelType, externalChannelId: channelType.toLowerCase() },
source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
kind: 'EMAIL',
thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
parts: allParts,
occurredAt: new Date().toISOString(),
providerEventId: key,
},
key,
);
// Make the thread visible to both the sender and the recipient (ingest adds no participants).
const scope = await this.actors.resolveScope(principal);
const senderActor = await this.actors.resolveActor(scope.id, principal);
const recipientActor = await this.actors.resolveActor(scope.id, {
userId: recipientUserId, appId: principal.appId, orgId: principal.orgId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}),
});
await this.actors.ensureParticipant(res.threadId, senderActor.id);
await this.actors.ensureParticipant(res.threadId, recipientActor.id);
// Tag the thread as CRM mail (thread metadata) so it lists separately from Messenger chat.
// ingest() puts req.metadata on the interaction, not the thread, so we set it here directly.
await this.prisma.iiosThread.update({
where: { id: res.threadId },
data: { metadata: { source: MailService.SOURCE } as Prisma.InputJsonValue },
});
return { threadId: res.threadId, interactionId: res.interactionId };
}
}
+16
View File
@@ -4,14 +4,30 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { traceMiddleware } from './observability/trace.middleware';
import { RedisIoAdapter } from './realtime/redis-io.adapter';
import { PolicyDeniedFilter } from './platform/policy-denied.filter';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { rawBody: true });
// Express 5 defaults to the 'simple' query parser, which ignores nested params like
// ?metadata[key]=value — so generic metadata filters would be silently dropped. Use the
// qs-based 'extended' parser so those parse into a nested object.
app.getHttpAdapter().getInstance().set('query parser', 'extended');
app.use(traceMiddleware); // request-scoped trace context + x-trace-id header (P9)
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
app.useGlobalFilters(new PolicyDeniedFilter()); // fail-closed policy denials → 403
app.enableCors({ origin: true, credentials: true });
// Multi-replica realtime: fan socket.io room emits across instances via Redis.
// Opt-in — without REDIS_URL the default in-memory adapter is used (single instance).
if (process.env.REDIS_URL) {
const redisAdapter = new RedisIoAdapter(app, process.env.REDIS_URL);
await redisAdapter.connect();
app.useWebSocketAdapter(redisAdapter);
}
const port = Number(process.env.PORT ?? 3200);
await app.listen(port);
// eslint-disable-next-line no-console
@@ -0,0 +1,49 @@
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { Injectable } from '@nestjs/common';
import type { StoragePort } from './storage.port';
/**
* Dev storage: bytes on local disk under MEDIA_DIR, with a sidecar `.meta.json`
* holding the mime/size/checksum. Object keys may contain a scope subdirectory.
* A drop-in for an S3/Supabase adapter in prod.
*/
@Injectable()
export class LocalDiskStorage implements StoragePort {
private readonly root = process.env.MEDIA_DIR?.trim() || path.join(os.tmpdir(), 'iios-media');
private full(objectKey: string): string {
// Prevent path traversal: keep everything under root.
const p = path.normalize(path.join(this.root, objectKey));
if (!p.startsWith(path.normalize(this.root))) throw new Error('invalid object key');
return p;
}
async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> {
const file = this.full(objectKey);
await fs.mkdir(path.dirname(file), { recursive: true });
const checksumSha256 = createHash('sha256').update(data).digest('hex');
await fs.writeFile(file, data);
await fs.writeFile(`${file}.meta.json`, JSON.stringify({ mime, sizeBytes: data.length, checksumSha256 }));
return { sizeBytes: data.length, checksumSha256 };
}
async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> {
const file = this.full(objectKey);
try {
const data = await fs.readFile(file);
const meta = JSON.parse(await fs.readFile(`${file}.meta.json`, 'utf8')) as { mime: string };
return { data, mime: meta.mime || 'application/octet-stream', sizeBytes: data.length };
} catch {
return null;
}
}
async remove(objectKey: string): Promise<void> {
const file = this.full(objectKey);
await fs.rm(file, { force: true });
await fs.rm(`${file}.meta.json`, { force: true });
}
}
@@ -0,0 +1,70 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Put, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { MediaService } from './media.service';
import { SessionVerifier } from '../platform/session.verifier';
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
import type { MessagePrincipal } from '../identity/actor.resolver';
/** Types that can execute script if a browser renders them top-level — served as downloads only. */
const SCRIPTABLE_MIMES = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml']);
@Controller('v1/media')
export class MediaController {
constructor(
private readonly media: MediaService,
private readonly session: SessionVerifier,
) {}
/** Authorize an upload → short-lived signed PUT url + object key. */
@Post('presign-upload')
async presignUpload(@Body() body: PresignUploadDto, @Headers('authorization') auth?: string) {
return this.media.presignUpload(this.principal(auth), { mime: body.mime, sizeBytes: body.sizeBytes });
}
/** Authorize a download → short-lived signed GET url. */
@Post('presign-download')
async presignDownload(@Body() body: PresignDownloadDto, @Headers('authorization') auth?: string) {
return this.media.presignDownload(this.principal(auth), body.contentRef, body.mime);
}
/** The presigned PUT target — token IS the auth. Reads the raw binary body stream. */
@Put('upload/:token')
async upload(@Param('token') token: string, @Req() req: Request) {
const data = await readBody(req);
if (data.length === 0) throw new BadRequestException('empty upload body');
return this.media.put(token, data);
}
/** The presigned GET target — token IS the auth. Streams the bytes with their mime. */
@Get('blob/:token')
async blob(@Param('token') token: string, @Res() res: Response) {
const { data, mime } = await this.media.get(token);
res.setHeader('Content-Type', mime);
// Never let the browser MIME-sniff an upload into something executable.
res.setHeader('X-Content-Type-Options', 'nosniff');
// Script-capable types must not render inline from our origin (stored-XSS) — force a download.
// Images/video/audio/pdf stay inline so the app can preview them. Note <img>/<video> still embed
// fine even with attachment disposition; only top-level navigation to the blob is affected.
if (SCRIPTABLE_MIMES.has(mime)) res.setHeader('Content-Disposition', 'attachment');
res.setHeader('Cache-Control', 'private, max-age=3600');
res.send(data);
}
private principal(auth?: string): MessagePrincipal {
const token = (auth ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
/** Collect a request body stream into a Buffer (binary media upload; no body parser touches it). */
function readBody(req: Request): Promise<Buffer> {
const raw = (req as Request & { rawBody?: Buffer }).rawBody;
if (raw && raw.length) return Promise.resolve(raw);
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
@@ -0,0 +1,11 @@
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PresignUploadDto {
@IsString() mime!: string;
@IsInt() @Min(1) @Max(26 * 1024 * 1024) sizeBytes!: number;
}
export class PresignDownloadDto {
@IsString() contentRef!: string;
@IsOptional() @IsString() mime?: string;
}
@@ -0,0 +1,27 @@
import { Module, Logger } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { IdentityModule } from '../identity/identity.module';
import { MediaController } from './media.controller';
import { MediaService } from './media.service';
import { LocalDiskStorage } from './local-disk.storage';
import { S3Storage, s3ConfigFromEnv } from './s3.storage';
import { STORAGE_PORT, type StoragePort } from './storage.port';
/** S3/MinIO when its env is set (IIOS_S3_BUCKET + keys), else local disk. Env-driven swap. */
function makeStorage(): StoragePort {
const cfg = s3ConfigFromEnv();
if (cfg) {
new Logger('MediaStorage').log(`using S3 storage (bucket "${cfg.bucket}"${cfg.endpoint ? ` @ ${cfg.endpoint}` : ''})`);
return new S3Storage(cfg);
}
return new LocalDiskStorage();
}
@Module({
imports: [PlatformModule, IdentityModule],
controllers: [MediaController],
providers: [MediaService, { provide: STORAGE_PORT, useFactory: makeStorage }],
// Exported so the capability layer can resolve email attachment bytes at send time.
exports: [STORAGE_PORT],
})
export class MediaModule {}
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { DevOpaPort } from '../platform/dev-opa.port';
import { LocalDiskStorage } from './local-disk.storage';
import { MediaService } from './media.service';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService));
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
// point storage at an isolated temp dir for the test run
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
function tokenFrom(url: string): string {
return url.split('/').pop()!;
}
describe('MediaService (presigned local storage)', () => {
it('presign → PUT → presign-download → GET round-trips the bytes with mime + checksum', async () => {
const s = svc();
const bytes = Buffer.from('hello-image-bytes');
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
expect(objectKey).toContain('/'); // scopeId/uuid
const put = await s.put(tokenFrom(uploadUrl), bytes);
expect(put.sizeBytes).toBe(bytes.length);
expect(put.checksumSha256).toHaveLength(64);
const { url } = await s.presignDownload(alice, objectKey, 'image/png');
const got = await s.get(tokenFrom(url));
expect(got.data.equals(bytes)).toBe(true);
expect(got.mime).toBe('image/png');
});
it('rejects an over-the-cap upload (OPA policy)', async () => {
await expect(svc().presignUpload(alice, { mime: 'image/png', sizeBytes: 26 * 1024 * 1024 })).rejects.toBeInstanceOf(PolicyDeniedError);
});
it('rejects a disallowed file type (OPA policy)', async () => {
await expect(svc().presignUpload(alice, { mime: 'application/x-msdownload', sizeBytes: 1000 })).rejects.toBeInstanceOf(PolicyDeniedError);
});
it('a PUT larger than the presigned size is refused', async () => {
const s = svc();
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
await expect(s.put(tokenFrom(uploadUrl), Buffer.from('way too many bytes'))).rejects.toThrow();
});
it('rejects a tampered / non-media token', async () => {
await expect(svc().get('not-a-real-token')).rejects.toThrow();
// an upload token cannot be used to download
const s = svc();
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
await expect(s.get(tokenFrom(uploadUrl))).rejects.toThrow();
});
it('tenant-fences downloads: an object outside my scope is forbidden', async () => {
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
});
});
@@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { STORAGE_PORT, type StoragePort } from './storage.port';
interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number }
interface DownloadToken { op: 'get'; objectKey: string; mime: string }
/**
* Presigned media. IIOS never trusts the client with storage it authorizes an
* upload (OPA: size/type/scope), then hands back a short-lived signed URL that
* points at its OWN storage endpoints. The bytes live behind the StoragePort; the
* kernel only ever stores the object key (contentRef) on a MessagePart.
*/
@Injectable()
export class MediaService {
private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret';
private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, '');
constructor(
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
private readonly actors: ActorResolver,
) {}
/** Authorize an upload and return a short-lived signed PUT url + the object key. */
async presignUpload(
principal: MessagePrincipal,
input: { mime: string; sizeBytes: number },
): Promise<{ objectKey: string; uploadUrl: string }> {
const scope = await this.actors.resolveScope(principal);
await decideOrThrow(this.ports, { action: 'iios.media.upload', scopeId: scope.id, mime: input.mime, sizeBytes: input.sizeBytes });
const objectKey = `${scope.id}/${randomUUID()}`;
const token = jwt.sign({ op: 'put', objectKey, mime: input.mime, maxBytes: input.sizeBytes } satisfies UploadToken, this.secret, { expiresIn: '5m' });
return { objectKey, uploadUrl: `${this.publicUrl}/v1/media/upload/${token}` };
}
/** Store bytes for a valid, unexpired upload token (enforcing the declared size cap). */
async put(token: string, data: Buffer): Promise<{ objectKey: string; sizeBytes: number; checksumSha256: string }> {
const t = this.verify<UploadToken>(token, 'put');
if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size');
const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime);
return { objectKey: t.objectKey, sizeBytes, checksumSha256 };
}
/** Authorize a download and return a short-lived signed GET url (tenant-fenced). */
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
const scope = await this.actors.resolveScope(principal);
if (!contentRef.startsWith(`${scope.id}/`)) throw new ForbiddenException('object not in your scope');
const token = jwt.sign({ op: 'get', objectKey: contentRef, mime } satisfies DownloadToken, this.secret, { expiresIn: '1h' });
return { url: `${this.publicUrl}/v1/media/blob/${token}` };
}
/** Fetch bytes for a valid, unexpired download token. */
async get(token: string): Promise<{ data: Buffer; mime: string; sizeBytes: number }> {
const t = this.verify<DownloadToken>(token, 'get');
const obj = await this.storage.get(t.objectKey);
if (!obj) throw new BadRequestException('object not found');
return obj;
}
private verify<T extends { op: string }>(token: string, op: T['op']): T {
let payload: T;
try {
payload = jwt.verify(token, this.secret) as T;
} catch {
throw new ForbiddenException('invalid or expired media token');
}
if (payload.op !== op) throw new ForbiddenException('wrong media token');
return payload;
}
}
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { S3Storage, s3ConfigFromEnv, type S3Config, type S3Like } from './s3.storage';
const CFG: S3Config = { endpoint: 'https://minio.test', region: 'us-east-1', bucket: 'iios', accessKeyId: 'k', secretAccessKey: 's', forcePathStyle: true };
/** A recording stub S3 client; GetObject returns whatever `store[key]` holds (or a NoSuchKey error). */
function stub(store: Record<string, { body: Buffer; mime: string }> = {}) {
const calls: unknown[] = [];
const client: S3Like = {
async send(command: unknown) {
calls.push(command);
if (command instanceof PutObjectCommand) { store[command.input.Key!] = { body: command.input.Body as Buffer, mime: command.input.ContentType ?? '' }; return {}; }
if (command instanceof DeleteObjectCommand) { delete store[command.input.Key!]; return {}; }
if (command instanceof GetObjectCommand) {
const hit = store[command.input.Key!];
if (!hit) throw Object.assign(new Error('missing'), { name: 'NoSuchKey' });
return { Body: { transformToByteArray: async () => new Uint8Array(hit.body) }, ContentType: hit.mime };
}
return {};
},
};
return { client, calls, store };
}
describe('s3ConfigFromEnv', () => {
it('builds config from env (path-style default true for MinIO)', () => {
const cfg = s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x', IIOS_S3_BUCKET: 'b', IIOS_S3_ACCESS_KEY: 'k', IIOS_S3_SECRET_KEY: 's' } as NodeJS.ProcessEnv);
expect(cfg).toMatchObject({ endpoint: 'https://minio.x', bucket: 'b', region: 'us-east-1', forcePathStyle: true });
});
it('returns null when bucket/keys are missing', () => {
expect(s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x' } as NodeJS.ProcessEnv)).toBeNull();
});
});
describe('S3Storage (StoragePort over S3/MinIO)', () => {
it('put stores the object and returns size + sha256', async () => {
const s = stub();
const store = new S3Storage(CFG, s.client);
const res = await store.put('scope/obj1', Buffer.from('hello'), 'text/plain');
expect(res.sizeBytes).toBe(5);
expect(res.checksumSha256).toMatch(/^[a-f0-9]{64}$/);
const put = s.calls[0] as PutObjectCommand;
expect(put.input).toMatchObject({ Bucket: 'iios', Key: 'scope/obj1', ContentType: 'text/plain' });
});
it('get round-trips the bytes + mime', async () => {
const s = stub();
const store = new S3Storage(CFG, s.client);
await store.put('scope/obj2', Buffer.from('PDFDATA'), 'application/pdf');
const got = await store.get('scope/obj2');
expect(got).toEqual({ data: Buffer.from('PDFDATA'), mime: 'application/pdf', sizeBytes: 7 });
});
it('get returns null for a missing key (NoSuchKey → null, not throw)', async () => {
const store = new S3Storage(CFG, stub().client);
expect(await store.get('nope')).toBeNull();
});
it('remove issues a DeleteObject', async () => {
const s = stub({ 'k': { body: Buffer.from('x'), mime: 't' } });
await new S3Storage(CFG, s.client).remove('k');
expect(s.calls.some((c) => c instanceof DeleteObjectCommand)).toBe(true);
expect(s.store['k']).toBeUndefined();
});
});
@@ -0,0 +1,87 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import type { StoragePort } from './storage.port';
export interface S3Config {
endpoint?: string; // MinIO/self-hosted URL; omit for AWS
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
forcePathStyle: boolean; // true for MinIO
}
/** The one method this adapter uses — lets tests inject a stub client (no live S3/MinIO). */
export interface S3Like {
send(command: unknown): Promise<unknown>;
}
/** Build an S3Config from env, or null if the required bits are missing (→ fall back to disk). */
export function s3ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): S3Config | null {
const bucket = env.IIOS_S3_BUCKET;
const accessKeyId = env.IIOS_S3_ACCESS_KEY;
const secretAccessKey = env.IIOS_S3_SECRET_KEY;
if (!bucket || !accessKeyId || !secretAccessKey) return null;
return {
...(env.IIOS_S3_ENDPOINT ? { endpoint: env.IIOS_S3_ENDPOINT } : {}),
region: env.IIOS_S3_REGION ?? 'us-east-1',
bucket,
accessKeyId,
secretAccessKey,
// MinIO/self-hosted needs path-style; default true unless explicitly disabled for AWS.
forcePathStyle: env.IIOS_S3_FORCE_PATH_STYLE !== 'false',
};
}
/**
* S3-compatible object storage (AWS S3, MinIO, R2, Supabase Storage). A drop-in for LocalDiskStorage:
* same StoragePort contract. sha256 is computed locally on put (S3 doesn't return it), matching the
* disk adapter. A missing object reads back as null (not an error).
*/
@Injectable()
export class S3Storage implements StoragePort {
private readonly client: S3Like;
private readonly bucket: string;
constructor(config: S3Config, client?: S3Like) {
this.bucket = config.bucket;
this.client = client ?? new S3Client({
region: config.region,
...(config.endpoint ? { endpoint: config.endpoint } : {}),
forcePathStyle: config.forcePathStyle,
credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey },
});
}
async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> {
const checksumSha256 = createHash('sha256').update(data).digest('hex');
await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, Body: data, ContentType: mime }));
return { sizeBytes: data.length, checksumSha256 };
}
async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> {
try {
const res = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }))) as {
Body?: { transformToByteArray(): Promise<Uint8Array> };
ContentType?: string;
};
if (!res.Body) return null;
const bytes = await res.Body.transformToByteArray();
const data = Buffer.from(bytes);
return { data, mime: res.ContentType ?? 'application/octet-stream', sizeBytes: data.length };
} catch (err) {
if (isNotFound(err)) return null;
throw err;
}
}
async remove(objectKey: string): Promise<void> {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: objectKey }));
}
}
function isNotFound(err: unknown): boolean {
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };
return e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404;
}
@@ -0,0 +1,12 @@
/**
* Byte storage seam. The kernel stores a `contentRef` (an opaque object key) on a
* MessagePart; the actual bytes live behind this port. Dev binds LocalDiskStorage;
* prod swaps to S3/R2/Supabase Storage with zero changes to the media service or app.
*/
export interface StoragePort {
put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }>;
get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null>;
remove(objectKey: string): Promise<void>;
}
export const STORAGE_PORT = Symbol('STORAGE_PORT');
@@ -0,0 +1,93 @@
import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest';
import jwt from 'jsonwebtoken';
import type { Socket } from 'socket.io';
import { MessageGateway } from './message.gateway';
import type { MessageService, MessagePrincipal } from './message.service';
import { SessionVerifier } from '../platform/session.verifier';
import type { OutboxBus } from '../outbox/outbox.bus';
import type { PresenceService } from '../notifications/presence.service';
// Realtime delegation: the browser opens the socket with a short-lived token minted for the
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
// that audience — a full REST/actor token (iios-core) or an un-scoped token can't open the stream.
const APP = 'crm-web';
const SECRET = 'dev-crm-secret';
// ─── the real SessionVerifier must expose `aud` so the gateway can enforce it ───
describe('SessionVerifier — app-token audience surfacing', () => {
let v: SessionVerifier;
beforeAll(async () => {
delete process.env.AUTH_ISSUERS;
delete process.env.SUPABASE_URL;
process.env.APP_SECRETS = JSON.stringify({ [APP]: SECRET });
v = new SessionVerifier();
await v.onModuleInit();
});
afterAll(() => { delete process.env.APP_SECRETS; });
it('surfaces the aud claim of a realtime-scoped token', () => {
const tok = jwt.sign({ appId: APP, aud: 'iios-message' }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
expect(v.verify(tok).audience).toBe('iios-message');
});
it('leaves audience undefined for an un-scoped token (a REST/actor token)', () => {
const tok = jwt.sign({ appId: APP }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
expect(v.verify(tok).audience).toBeUndefined();
});
});
// ─── the gateway enforces the realtime audience on connect ───
function fakeSocket(): { sock: Socket; disconnected: () => boolean } {
let disconnected = false;
const sock = {
handshake: { auth: { token: 'tok' } },
disconnect: () => { disconnected = true; },
data: undefined as unknown,
} as unknown as Socket;
return { sock, disconnected: () => disconnected };
}
function gatewayReturning(principal: MessagePrincipal): MessageGateway {
const session = { verify: () => principal } as unknown as SessionVerifier;
return new MessageGateway(
undefined as unknown as MessageService,
session,
undefined as unknown as OutboxBus,
undefined as unknown as PresenceService,
);
}
const principal = (audience?: string): MessagePrincipal => ({ userId: 'u', appId: APP, orgId: 'org', audience });
describe('MessageGateway — realtime audience enforcement', () => {
afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; });
it('accepts any valid token when enforcement is OFF (env unset)', () => {
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal(undefined)).handleConnection(sock);
expect(disconnected()).toBe(false);
expect((sock.data as { principal: MessagePrincipal }).principal.userId).toBe('u');
});
it('accepts a token whose aud matches the required realtime audience', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal('iios-message')).handleConnection(sock);
expect(disconnected()).toBe(false);
});
it('rejects a full REST/actor token (aud=iios-core) on the socket', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal('iios-core')).handleConnection(sock);
expect(disconnected()).toBe(true);
});
it('rejects an un-scoped token (no aud) when enforcement is on', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal(undefined)).handleConnection(sock);
expect(disconnected()).toBe(true);
});
});
@@ -4,6 +4,7 @@ import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
@@ -15,6 +16,7 @@ import { logJson } from '../observability/logger';
import { MessageService, type MessagePrincipal } from './message.service';
import { SessionVerifier } from '../platform/session.verifier';
import { OutboxBus } from '../outbox/outbox.bus';
import { PresenceService } from '../notifications/presence.service';
interface SocketState {
principal: MessagePrincipal;
@@ -27,7 +29,7 @@ interface SocketState {
* propagates messages persisted elsewhere (REST/ingest) to connected clients.
*/
@WebSocketGateway({ namespace: '/message', cors: { origin: true } })
export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(MessageGateway.name);
private readonly emittedLocally = new Set<string>();
@@ -37,6 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
private readonly messages: MessageService,
private readonly session: SessionVerifier,
private readonly bus: OutboxBus,
private readonly presence: PresenceService,
) {}
afterInit(): void {
@@ -58,6 +61,14 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
try {
const token = String(client.handshake.auth?.token ?? '');
const principal = this.session.verify(token);
// Realtime delegation (least privilege): when IIOS_REALTIME_AUDIENCE is set, the socket
// accepts ONLY a token minted for that audience (a short-lived `iios-message` delegate) —
// a full REST/actor token (`iios-core`) or an un-scoped token cannot open the stream. So a
// leaked socket token can't drive privileged REST, and vice-versa. Unset → no enforcement.
const required = process.env.IIOS_REALTIME_AUDIENCE?.trim();
if (required && principal.audience !== required) {
throw new Error(`realtime audience "${principal.audience ?? '(none)'}" != required "${required}"`);
}
client.data = { principal } satisfies SocketState;
} catch (err) {
this.logger.warn(`rejecting socket: ${(err as Error).message}`);
@@ -65,31 +76,82 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
}
}
handleDisconnect(client: Socket): void {
this.presence.clearSocket(client.id);
}
/** The app reports which thread is in the foreground (or null when blurred) — presence. */
@SubscribeMessage('focus_thread')
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
const state = client.data as SocketState | undefined;
if (!state?.principal) return;
this.presence.setFocus(client.id, state.principal.userId, body?.threadId ?? null);
}
@SubscribeMessage('open_thread')
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string }) {
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string; subject?: string }) {
const { principal } = client.data as SocketState;
const result = await this.messages.openThread(body?.threadId ?? null, principal);
await client.join(result.threadId);
return result;
try {
const result = await this.messages.openThread(body?.threadId ?? null, principal, {
membership: body?.membership,
creatorRole: body?.creatorRole,
subject: body?.subject,
});
await client.join(result.threadId);
return result;
} catch (err) {
// Fail to an ACK'd error instead of throwing (which never acks → client hangs on "loading").
return { error: (err as Error).message ?? 'could not open the conversation' };
}
}
@SubscribeMessage('add_participant')
async addParticipant(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; userId: string; role?: string }) {
const { principal } = client.data as SocketState;
return this.messages.addParticipant(body.threadId, principal, body.userId, body.role);
}
@SubscribeMessage('send_message')
async sendMessage(
@ConnectedSocket() client: Socket,
@MessageBody() body: { threadId: string; content: string; contentRef?: string },
@MessageBody()
body: { threadId: string; content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string; parentInteractionId?: string; mentions?: string[] },
) {
const { principal } = client.data as SocketState;
const msg = await this.messages.send(
body.threadId,
principal,
{ content: body.content, contentRef: body.contentRef },
{ content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
randomUUID(),
undefined,
body.parentInteractionId,
body.mentions,
);
this.emittedLocally.add(msg.id);
this.server.to(body.threadId).emit('message', msg);
return msg;
}
@SubscribeMessage('annotate')
async annotate(
@ConnectedSocket() client: Socket,
@MessageBody() body: { threadId: string; interactionId: string; type: string; value: string },
) {
const { principal } = client.data as SocketState;
const r = await this.messages.toggleAnnotation(body.interactionId, principal, body.type, body.value);
// Broadcast the refreshed user list for this (interaction,type,value) so every client re-renders.
this.server.to(r.threadId).emit('annotation', {
threadId: r.threadId,
interactionId: r.interactionId,
type: r.type,
value: r.value,
op: r.op,
users: r.users,
userId: principal.userId,
});
return r;
}
@SubscribeMessage('read')
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
const { principal } = client.data as SocketState;

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