diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..ae82af3 --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -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 diff --git a/.claude/memory/feedback_generic_safety.md b/.claude/memory/feedback_generic_safety.md new file mode 100644 index 0000000..e0f0662 --- /dev/null +++ b/.claude/memory/feedback_generic_safety.md @@ -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]]. diff --git a/.claude/memory/feedback_workflow.md b/.claude/memory/feedback_workflow.md new file mode 100644 index 0000000..603c938 --- /dev/null +++ b/.claude/memory/feedback_workflow.md @@ -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. diff --git a/.claude/memory/project_iios_overview.md b/.claude/memory/project_iios_overview.md new file mode 100644 index 0000000..1ff1020 --- /dev/null +++ b/.claude/memory/project_iios_overview.md @@ -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]]. diff --git a/.claude/memory/project_recent_features.md b/.claude/memory/project_recent_features.md new file mode 100644 index 0000000..bb04db5 --- /dev/null +++ b/.claude/memory/project_recent_features.md @@ -0,0 +1,31 @@ +--- +name: project_recent_features +description: The "real-providers" era — Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications +metadata: + type: project +--- + +Beyond P0–P8 (kernel → messaging → inbox → support → adapters → routing → AI → +calendar/meetings), recent work (mostly driven by the chat app, the start of P9 "real +providers"): + +- **Real Supabase auth** — `SessionVerifier` verifies real OIDC tokens against issuer JWKS + (ES256, no secret), a **multi-issuer registry** routed by the `iss` claim → per-issuer `appId` + scope (`AUTH_ISSUERS` JSON, or `SUPABASE_URL` single-issuer shorthand). `userId = email`. + Legacy HS256 app-token path (`APP_SECRETS`) stays for dev/tests. +- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive (opaque + `annotationType`+`value`); socket `annotate` event → `annotation` broadcast; + `GET /v1/threads/my-annotations?type=save`. +- **@mentions → Inbox** — `send(... mentions[])` (opaque userId list) → `InboxProjector` fans out + a `MENTION` inbox item to mentioned participants; reading resolves it. +- **Media** — `StoragePort` (dev = local disk `MEDIA_DIR`; prod swap to S3/Supabase), presigned + upload/download (signed HS256 tokens, OPA-gated size/type, tenant-fenced), `attachment` on + `MessageDto`; parts use generic `MEDIA_REF/VOICE_REF/FILE_REF`. +- **Notifications** — presence-gated Web Push: `NotificationProjector` runs 3 gates + (policy=DM/mention/reply-to-you · presence=`focus_thread` signal · per-thread `muted`) → + swappable `NotificationPort` (Web Push/VAPID); dead sub (410) pruned. Presence is in-memory + (single-instance) → Redis for multi-replica. +- `senderId` (stable externalId) on `MessageDto` for reliable "is this mine?". + +~205 tests. Keep `docs/IIOS_API_AND_SDK_GUIDE.md` current when adding endpoints. +Related: [[reference_chat_web_consumer]], [[feedback_generic_safety]]. diff --git a/.claude/memory/reference_chat_web_consumer.md b/.claude/memory/reference_chat_web_consumer.md new file mode 100644 index 0000000..687c55b --- /dev/null +++ b/.claude/memory/reference_chat_web_consumer.md @@ -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]]. diff --git a/.claude/memory/reference_run_and_test.md b/.claude/memory/reference_run_and_test.md new file mode 100644 index 0000000..3285aee --- /dev/null +++ b/.claude/memory/reference_run_and_test.md @@ -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://.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`. diff --git a/.gitea/workflows/publish-sdks.yml b/.gitea/workflows/publish-sdks.yml new file mode 100644 index 0000000..8956bd4 --- /dev/null +++ b/.gitea/workflows/publish-sdks.yml @@ -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 }} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..83c8b52 --- /dev/null +++ b/.npmrc @@ -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} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b1f0e5b --- /dev/null +++ b/CLAUDE.md @@ -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://.supabase.co REDIS_URL=redis://localhost:6379 PORT=3200 \ + APP_SECRETS='{"portal-demo":"dev-secret"}' MEDIA_DIR=/tmp/iios-media \ + VAPID_PUBLIC_KEY=… VAPID_PRIVATE_KEY=… VAPID_SUBJECT=mailto:dev@insignia \ + node packages/iios-service/dist/main.js # → :3200 ; GET /health +``` + +- **`pnpm test`** — Vitest. Runs against an **isolated `iios_test` DB** (globalSetup creates + + migrates it; `DATABASE_URL` overridden). **It never wipes the dev `iios` DB.** ~205 tests. +- **TDD**: write the failing spec first (see `*.spec.ts` next to the code). DB specs use + `resetDb()` + real Postgres. +- **Flaky `outbox/replay.spec`**: it's clock/ordering-sensitive and pre-existing. If it fails + in a full run, `docker exec iios-db psql -U iios -d postgres -c "DROP DATABASE IF EXISTS iios_test WITH (FORCE)"` + then re-run — it's stale test-DB state, not a regression. +- **`pnpm boundary`** — import-boundary check (must stay OK). +- Smokes: `packages/iios-service/scripts/smoke-*.mjs` (run against a live service). + +## Conventions + +- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email + **`maaz@insigniaconsultancy.com`**, and co-author every commit with Claude. Branch off `main` + before committing if asked; otherwise the session has committed directly to `main`. +- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token + minting). Single most important prod flag. +- New kernel capability = a **generic primitive** only (see the #1 rule). Add domain meaning in + policy + app. +- Prisma: always `select` to avoid leaking `passwordHash`/PII; org/tenant scope every `where`. + +## What's built (state) + +P0–P8: kernel → messaging → inbox → support → adapters → routing → AI → calendar/meetings. +Recent (the "P9 real-providers" era, mostly driven by the chat app): +- **Real Supabase auth** — multi-issuer JWKS verification (`SessionVerifier`); first stubbed + port turned real. +- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive + (opaque type/value), `annotate` socket event, `GET /v1/threads/my-annotations`. +- **@mentions → Inbox** — `mentions[]` on send → `MENTION` inbox item (projector). +- **Media** — `StoragePort` (dev local disk → prod S3/Supabase), presigned upload/download, + attachment on `MessageDto`. +- **Notifications** — presence-gated Web Push: `NotificationProjector` (policy/presence/mute + gates) + swappable `NotificationPort`, `focus_thread` presence signal, per-thread mute. +- `senderId` (stable externalId) on messages for reliable "is this mine?". + +## Docs (as-built) + +- `docs/IIOS_API_AND_SDK_GUIDE.md` — the **as-built REST/socket/SDK reference** (endpoints, + shapes, env, vocab). Keep it current when adding endpoints. +- `docs/DEPLOYMENT.md` — deploy/topology/env/scaling. +- `docs/IIOS_OVERVIEW_FOR_CEO.md` — plain-language capability tour. diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..17ab5a1 --- /dev/null +++ b/PUBLISHING.md @@ -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= +``` +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= +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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b71255c..22b2a37 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -54,6 +54,10 @@ for the full, commented list. Highlights: app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests. - **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change. +- **Notifications (Web Push):** set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` + to enable push (unset → the engine no-ops). ⚠️ Presence (the "are you viewing this thread?" + gate) is **in-memory / single-instance** today — with N>1 replicas, back `PresenceService` + with Redis so the projector on one replica sees focus from another. - **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*` (unauthenticated token minting, webhook injection, chaos, retention sweep). This is the single most important prod-hardening flag. diff --git a/docs/IIOS_API_AND_SDK_GUIDE.md b/docs/IIOS_API_AND_SDK_GUIDE.md index 59a47ad..1691791 100644 --- a/docs/IIOS_API_AND_SDK_GUIDE.md +++ b/docs/IIOS_API_AND_SDK_GUIDE.md @@ -243,6 +243,54 @@ sequenceDiagram --- +### 5.11 Notifications (push, presence-gated) + +The engine reacts to `message.sent` and pushes to **absent** recipients through a swappable +**NotificationPort** (Web Push/VAPID now; email/FCM later). The DB holds only push +subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays `IiosInboxItem`. + +| Method | Path | Body | Returns | +|---|---|---|---| +| GET | `/v1/notifications/vapid-public-key` | — | `{key}` — the public VAPID key the client needs to subscribe | +| POST | `/v1/notifications/subscribe` | `{kind?, endpoint, keys:{p256dh, auth}, userAgent?}` | `{ok:true}` — store/refresh the caller's push subscription | +| DELETE | `/v1/notifications/subscribe` | `{endpoint}` | `{ok:true}` | +| POST | `/v1/threads/:id/mute` · `/unmute` | — | `{threadId, muted}` — per-thread notification mute for the caller | + +**Presence (socket):** the app emits `focus_thread` `{threadId | null}` on the `/message` +namespace whenever the foreground conversation changes (or the tab blurs). This is how the +engine knows you're *viewing* a thread — **room membership ≠ viewing**, because the sidebar +joins every thread room for live updates. `GET /v1/threads` returns each thread's `muted`. + +**The three gates (fail-closed, in the notification policy — not the kernel):** +1. **Policy** — DM always; a group message only if you were `@`-mentioned **or** it's a reply to you. +2. **Presence** — skip if you're currently focused on that thread (you saw it live). +3. **Mute** — skip if you muted the thread. +A dead subscription (Web Push `404/410`) is pruned. DM-vs-group is read from the opaque +`membership` thread attribute here in the *notification policy*; the kernel never branches on it. + +```mermaid +flowchart TB + SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"] + PROJ --> LOOP{{"for each recipient (never the sender)"}} + LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"} + G1 -->|no| X1["skip"] + G1 -->|yes| G2{"PRESENCE: focused on this thread?"} + G2 -->|yes| X2["skip — seen live"] + G2 -->|no| G3{"MUTE: thread muted?"} + G3 -->|yes| X3["skip"] + G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"] + PORT --> WP["Web Push (VAPID)"] + WP -->|sent| OUT["→ browser push service → service worker → OS notification"] + WP -->|"gone (404/410)"| PRUNE["prune dead subscription"] +``` + +**Client (SDK layer, `lib/notifications.ts` → belongs in `@insignia/iios-kernel-client`):** +`registerPush()` (permission → register service worker → `PushManager.subscribe` with the +VAPID key → POST the subscription), `muteThread(threadId, muted)`, and `MessageSocket.focus(threadId)`. +A service worker renders the OS notification on `push` and deep-links to the thread on click. + +--- + ## 6. SDK reference Two layers: a low-level `RestClient` (+ socket) and per-domain React hook packages. @@ -256,6 +304,7 @@ Methods (all return typed promises): - **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})` - **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates. - **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.* +- **Notifications:** `registerPush()` (service worker + `PushManager.subscribe` + POST subscription), `muteThread(threadId, muted)`, `MessageSocket.focus(threadId)` (presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering. - **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})` - **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)` - **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)` @@ -333,7 +382,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/`) | `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) | | `smoke-tenant.mjs` | cross-tenant 403 + list isolation | -Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check: `pnpm boundary`. +Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`. --- @@ -358,6 +407,7 @@ Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check | `MEDIA_DIR` | `/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 | diff --git a/docs/IIOS_OVERVIEW_FOR_CEO.md b/docs/IIOS_OVERVIEW_FOR_CEO.md index a114ac1..1fad5f2 100644 --- a/docs/IIOS_OVERVIEW_FOR_CEO.md +++ b/docs/IIOS_OVERVIEW_FOR_CEO.md @@ -159,9 +159,9 @@ Each product SDK is a handful of React hooks — a front-end dev wires the UI, t | Calendar/Zoom providers | Simulated sync | P9 | | Multi-tenant scale, retention, SLOs | Not yet | P9 | -**Proof it works:** 192 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture. +**Proof it works:** 205 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture. -**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, and media sharing** (images/video/audio/docs on a swappable storage port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off. +**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, media sharing** (images/video/audio/docs on a swappable storage port), and **presence-gated push notifications** (Web Push via a swappable notification port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off. --- @@ -177,4 +177,4 @@ Narrative arc for the CEO: **one engine → chat → inbox → support → chann --- -*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 53 Postgres tables across 19 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media). Boundary-enforced dependency law; 192 passing tests; 8+ end-to-end smoke scripts. First real-provider swap live: Supabase auth (JWKS-verified) + a media storage port.* +*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 54 Postgres tables across 20 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media → notifications). Boundary-enforced dependency law; 205 passing tests; 8+ end-to-end smoke scripts. First real-provider swaps live: Supabase auth (JWKS-verified), a media storage port, and Web Push notifications.* diff --git a/docs/email-attachments-plan.md b/docs/email-attachments-plan.md new file mode 100644 index 0000000..855d304 --- /dev/null +++ b/docs/email-attachments-plan.md @@ -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. diff --git a/docs/email-template-module-plan.md b/docs/email-template-module-plan.md new file mode 100644 index 0000000..ef5d87e --- /dev/null +++ b/docs/email-template-module-plan.md @@ -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 = ` 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, opts: { channel: IiosTemplateChannel; locale?: string; scopeId?: string }): Promise + +// templated-sender.ts — external egress +sendTemplated(input: { + source: TemplateSource; + channel: 'EMAIL' | 'SMS'; + target: string; // email address / phone + vars: Record; + scopeId?: string; + idempotencyKey: string; // REQUIRED — e.g. "receipt:" + purpose?: string; +}): Promise +``` + +**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 `' }, + ); + expect(out.html).toBe('

<script>alert(1)</script>

'); + expect(out.html).not.toContain(''); // plaintext is not HTML → not escaped + }); + + it('supports {{#if}} and {{#each}}', () => { + const out = renderTemplate( + { bodyText: '{{#if companyName}}Company: {{companyName}}\n{{/if}}{{#each items}}- {{this}}\n{{/each}}', variables: [] }, + { companyName: 'Acme', items: ['a', 'b'] }, + ); + expect(out.text).toBe('Company: Acme\n- a\n- b\n'); + }); + + it('throws when a declared variable is missing (fail loud)', () => { + expect(() => renderTemplate({ bodyText: 'Hi {{firstName}}', variables: ['firstName'] }, {})).toThrow(MissingTemplateVariableError); + }); + + it('renders only the parts that are present', () => { + const out = renderTemplate({ bodyText: 'code {{code}}', variables: ['code'] }, { code: '123' }); + expect(out.text).toBe('code 123'); + expect(out.subject).toBeUndefined(); + expect(out.html).toBeUndefined(); + }); +}); diff --git a/packages/iios-service/src/templates/template.renderer.ts b/packages/iios-service/src/templates/template.renderer.ts new file mode 100644 index 0000000..5668f59 --- /dev/null +++ b/packages/iios-service/src/templates/template.renderer.ts @@ -0,0 +1,46 @@ +import Handlebars from 'handlebars'; + +/** The raw, unrendered strings of a template (from the DB row or an inline source). */ +export interface RawTemplate { + subject?: string | null; + bodyHtml?: string | null; + bodyText?: string | null; + /** Declared variable names. Every one MUST be supplied at render time (fail loud). */ + variables?: string[] | null; +} + +export interface RenderedContent { + subject?: string; + html?: string; + text?: string; +} + +/** A declared variable was not supplied — we refuse to send a half-rendered "Hi ," message. */ +export class MissingTemplateVariableError extends Error { + constructor(public readonly missing: string[]) { + super(`missing required template variable(s): ${missing.join(', ')}`); + this.name = 'MissingTemplateVariableError'; + } +} + +/** + * Pure render: interpolate `vars` into a template's strings. HTML output is auto-escaped (values + * like a customer name go into an email body — XSS risk); subject and plaintext are NOT escaped. + * No I/O — the caller supplies the raw template. Throws if any declared variable is absent. + */ +export function renderTemplate(tpl: RawTemplate, vars: Record): RenderedContent { + const declared = tpl.variables ?? []; + const missing = declared.filter((name) => vars[name] === undefined || vars[name] === null); + if (missing.length > 0) throw new MissingTemplateVariableError(missing); + + const out: RenderedContent = {}; + if (tpl.subject != null) out.subject = compile(tpl.subject, false)(vars); + if (tpl.bodyHtml != null) out.html = compile(tpl.bodyHtml, true)(vars); + if (tpl.bodyText != null) out.text = compile(tpl.bodyText, false)(vars); + return out; +} + +/** `escape=true` → HTML-escape interpolated values (for the html body); false → verbatim. */ +function compile(src: string, escape: boolean): Handlebars.TemplateDelegate { + return Handlebars.compile(src, { noEscape: !escape, strict: false }); +} diff --git a/packages/iios-service/src/templates/template.repository.spec.ts b/packages/iios-service/src/templates/template.repository.spec.ts new file mode 100644 index 0000000..1e10040 --- /dev/null +++ b/packages/iios-service/src/templates/template.repository.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateNotFoundError, TemplateRepository } from './template.repository'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const repo = new TemplateRepository(prisma as unknown as PrismaService); + +async function scope(): Promise { + const s = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } }); + return s.id; +} +async function seed(data: Partial[0]['data']> & { key: string }) { + return prisma.iiosMessageTemplate.create({ + data: { channel: 'EMAIL', locale: 'en', version: 1, bodyText: 't', ...data } as never, + }); +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateRepository.resolve', () => { + it('returns the global default when no scope override exists', async () => { + await seed({ key: 'welcome', bodyText: 'global' }); + const t = await repo.resolve('welcome', 'EMAIL', 'en', await scope()); + expect(t.bodyText).toBe('global'); + expect(t.scopeId).toBeNull(); + }); + + it('prefers the scoped override over the global default', async () => { + const scopeId = await scope(); + await seed({ key: 'welcome', bodyText: 'global' }); // scopeId NULL + await seed({ key: 'welcome', bodyText: 'scoped', scopeId }); // override + const t = await repo.resolve('welcome', 'EMAIL', 'en', scopeId); + expect(t.bodyText).toBe('scoped'); + expect(t.scopeId).toBe(scopeId); + }); + + it('returns the highest active version', async () => { + await seed({ key: 'welcome', version: 1, bodyText: 'v1' }); + await seed({ key: 'welcome', version: 3, bodyText: 'v3' }); + await seed({ key: 'welcome', version: 2, bodyText: 'v2' }); + const t = await repo.resolve('welcome', 'EMAIL', 'en'); + expect(t.version).toBe(3); + }); + + it('ignores inactive versions (a retracted v3 falls back to v2)', async () => { + await seed({ key: 'welcome', version: 2, bodyText: 'v2' }); + await seed({ key: 'welcome', version: 3, bodyText: 'v3', active: false }); + const t = await repo.resolve('welcome', 'EMAIL', 'en'); + expect(t.version).toBe(2); + }); + + it('throws TemplateNotFoundError for an unknown key', async () => { + await expect(repo.resolve('nope', 'EMAIL', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError); + }); + + it('does not cross channels (an EMAIL template is not an SMS template)', async () => { + await seed({ key: 'welcome', channel: 'EMAIL' }); + await expect(repo.resolve('welcome', 'SMS', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError); + }); +}); diff --git a/packages/iios-service/src/templates/template.repository.ts b/packages/iios-service/src/templates/template.repository.ts new file mode 100644 index 0000000..248ad0a --- /dev/null +++ b/packages/iios-service/src/templates/template.repository.ts @@ -0,0 +1,49 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { IiosMessageTemplate, IiosTemplateChannel } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +export class TemplateNotFoundError extends NotFoundException { + constructor(key: string, channel: string, locale: string) { + super(`no active template for key="${key}" channel=${channel} locale=${locale}`); + this.name = 'TemplateNotFoundError'; + } +} + +@Injectable() +export class TemplateRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * Resolve the template to use: the highest active version for (key, channel, locale), preferring + * a row owned by `scopeId` (a tenant override) and falling back to the global default (scopeId + * NULL). Throws if neither exists. + */ + async resolve( + key: string, + channel: IiosTemplateChannel, + locale = 'en', + scopeId?: string, + version?: number, + ): Promise { + // Scoped override first, then the global default — for both the pinned and highest-active paths. + if (scopeId) { + const scoped = await this.pick({ key, channel, locale, scopeId }, version); + if (scoped) return scoped; + } + const global = await this.pick({ key, channel, locale, scopeId: null }, version); + if (global) return global; + throw new TemplateNotFoundError(key, channel, locale); + } + + /** A pinned `version` fetches that exact version (the caller was explicit); otherwise the highest + * active version — a retracted (inactive) newest version falls back to the last good one. */ + private pick( + where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null }, + version?: number, + ) { + return this.prisma.iiosMessageTemplate.findFirst({ + where: version != null ? { ...where, version } : { ...where, active: true }, + orderBy: { version: 'desc' }, + }); + } +} diff --git a/packages/iios-service/src/templates/template.seeder.spec.ts b/packages/iios-service/src/templates/template.seeder.spec.ts new file mode 100644 index 0000000..3459a5c --- /dev/null +++ b/packages/iios-service/src/templates/template.seeder.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateSeeder } from './template.seeder'; +import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const seeder = new TemplateSeeder(prisma as unknown as PrismaService); + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateSeeder', () => { + it('seeds the file defaults as global (scopeId NULL) templates', async () => { + const n = await seeder.seed(TEMPLATE_SEEDS); + expect(n).toBe(TEMPLATE_SEEDS.length); + const rows = await prisma.iiosMessageTemplate.findMany(); + expect(rows).toHaveLength(TEMPLATE_SEEDS.length); + expect(rows.every((r) => r.scopeId === null)).toBe(true); + const welcome = rows.find((r) => r.key === 'welcome' && r.channel === 'EMAIL'); + expect(welcome?.variables).toEqual(['firstName', 'signupLink']); + }); + + it('is idempotent — re-seeding inserts nothing and creates no duplicates', async () => { + await seeder.seed(TEMPLATE_SEEDS); + const second = await seeder.seed(TEMPLATE_SEEDS); + expect(second).toBe(0); + expect(await prisma.iiosMessageTemplate.count()).toBe(TEMPLATE_SEEDS.length); + }); + + it('a bumped version inserts a new row and leaves the old one', async () => { + await seeder.seed(TEMPLATE_SEEDS); + const bumped: TemplateSeed = { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'v2', bodyText: 'v2', variables: [] }; + const n = await seeder.seed([bumped]); + expect(n).toBe(1); + const welcomes = await prisma.iiosMessageTemplate.findMany({ where: { key: 'welcome', channel: 'EMAIL' }, orderBy: { version: 'asc' } }); + expect(welcomes.map((w) => w.version)).toEqual([1, 2]); + }); +}); diff --git a/packages/iios-service/src/templates/template.seeder.ts b/packages/iios-service/src/templates/template.seeder.ts new file mode 100644 index 0000000..8412edf --- /dev/null +++ b/packages/iios-service/src/templates/template.seeder.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds'; + +/** + * Seeds platform-default templates (scopeId NULL) from the repo files on boot — DB is runtime truth, + * files are the version-controlled source. Idempotent: a seed is inserted only if its exact + * (key, channel, locale, version) default is absent, so re-boots never duplicate and a bumped + * version adds a new row while leaving the old one for audit/replay. + */ +@Injectable() +export class TemplateSeeder implements OnModuleInit { + private readonly log = new Logger(TemplateSeeder.name); + + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit(): Promise { + const inserted = await this.seed(TEMPLATE_SEEDS); + if (inserted > 0) this.log.log(`seeded ${inserted} default template(s)`); + } + + /** Returns how many rows were newly inserted. */ + async seed(seeds: TemplateSeed[]): Promise { + let inserted = 0; + for (const s of seeds) { + const exists = await this.prisma.iiosMessageTemplate.findFirst({ + where: { scopeId: null, key: s.key, channel: s.channel, locale: s.locale, version: s.version }, + select: { id: true }, + }); + if (exists) continue; + await this.prisma.iiosMessageTemplate.create({ + data: { + scopeId: null, + key: s.key, + channel: s.channel, + locale: s.locale, + version: s.version, + subject: s.subject, + bodyHtml: s.bodyHtml, + bodyText: s.bodyText, + variables: s.variables as Prisma.InputJsonValue, + }, + }); + inserted++; + } + return inserted; + } +} diff --git a/packages/iios-service/src/templates/template.service.spec.ts b/packages/iios-service/src/templates/template.service.spec.ts new file mode 100644 index 0000000..e3b171d --- /dev/null +++ b/packages/iios-service/src/templates/template.service.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateRepository } from './template.repository'; +import { TemplateService } from './template.service'; +import { TemplateNotFoundError } from './template.repository'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const svc = new TemplateService(new TemplateRepository(prisma as unknown as PrismaService)); + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateService.render', () => { + it('resolves a stored template and renders it, with provenance', async () => { + await prisma.iiosMessageTemplate.create({ + data: { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'Hi {{firstName}}', bodyHtml: '

{{firstName}}

', variables: ['firstName'] }, + }); + const { content, provenance } = await svc.render({ key: 'welcome' }, { firstName: 'Dana' }, { channel: 'EMAIL' }); + expect(content.subject).toBe('Hi Dana'); + expect(content.html).toBe('

Dana

'); + expect(provenance).toMatchObject({ templateKey: 'welcome', templateVersion: 2, templateLocale: 'en' }); + expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('renders an inline source without a DB row (key/version null)', async () => { + const { content, provenance } = await svc.render( + { inline: { subject: 'Ad-hoc {{x}}', html: '{{x}}', variables: ['x'] } }, + { x: 'Y' }, + { channel: 'EMAIL' }, + ); + expect(content.subject).toBe('Ad-hoc Y'); + expect(content.html).toBe('Y'); + expect(provenance.templateKey).toBeNull(); + expect(provenance.templateVersion).toBeNull(); + expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('pins to an explicit version when asked', async () => { + await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 1, bodyText: 'v1' } }); + await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 2, bodyText: 'v2' } }); + const latest = await svc.render({ key: 'k' }, {}, { channel: 'EMAIL' }); + const pinned = await svc.render({ key: 'k', version: 1 }, {}, { channel: 'EMAIL' }); + expect(latest.content.text).toBe('v2'); + expect(pinned.content.text).toBe('v1'); + expect(pinned.provenance.templateVersion).toBe(1); + }); + + it('throws for an unknown stored key', async () => { + await expect(svc.render({ key: 'missing' }, {}, { channel: 'EMAIL' })).rejects.toBeInstanceOf(TemplateNotFoundError); + }); +}); diff --git a/packages/iios-service/src/templates/template.service.ts b/packages/iios-service/src/templates/template.service.ts new file mode 100644 index 0000000..4ed9bc5 --- /dev/null +++ b/packages/iios-service/src/templates/template.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { TemplateRepository } from './template.repository'; +import { renderTemplate, type RawTemplate, type RenderedContent } from './template.renderer'; +import { isInlineSource, type RenderOptions, type TemplateSource } from './template.model'; + +/** The provenance of a rendered send — carried onto the outbound command for replay/audit. */ +export interface RenderProvenance { + templateKey: string | null; // null for inline sources + templateVersion: number | null; + templateLocale: string | null; + renderedHash: string; // sha256 of the rendered content +} + +export interface RenderResult { + content: RenderedContent; + provenance: RenderProvenance; +} + +@Injectable() +export class TemplateService { + constructor(private readonly repo: TemplateRepository) {} + + /** + * Resolve (if a stored key) and render a template with `vars`, returning the content plus the + * provenance to record on the send. Inline sources skip the DB and carry null key/version — the + * caller owns inline content, so no declared-variable contract is enforced for it. + */ + async render(source: TemplateSource, vars: Record, opts: RenderOptions): Promise { + const locale = opts.locale ?? 'en'; + + if (isInlineSource(source)) { + const raw: RawTemplate = { + subject: source.inline.subject, + bodyHtml: source.inline.html, + bodyText: source.inline.text, + variables: source.inline.variables ?? [], + }; + const content = renderTemplate(raw, vars); + return { content, provenance: this.provenance(content, null, null, null) }; + } + + const tpl = await this.repo.resolve(source.key, opts.channel, locale, opts.scopeId, source.version); + const content = renderTemplate( + { subject: tpl.subject, bodyHtml: tpl.bodyHtml, bodyText: tpl.bodyText, variables: (tpl.variables as string[] | null) ?? [] }, + vars, + ); + return { content, provenance: this.provenance(content, tpl.key, tpl.version, tpl.locale) }; + } + + private provenance(content: RenderedContent, key: string | null, version: number | null, locale: string | null): RenderProvenance { + const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex'); + return { templateKey: key, templateVersion: version, templateLocale: locale, renderedHash: hash }; + } +} diff --git a/packages/iios-service/src/templates/templated-sender.spec.ts b/packages/iios-service/src/templates/templated-sender.spec.ts new file mode 100644 index 0000000..fc2d6b5 --- /dev/null +++ b/packages/iios-service/src/templates/templated-sender.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { resetDb } from '../test-utils/reset-db'; +import { OutboundService } from '../adapters/outbound.service'; +import { CapabilityBroker } from '../capability/capability.broker'; +import { CapabilityProviderRegistry } from '../capability/capability.registry'; +import { IdempotencyService } from '../idempotency/idempotency.service'; +import { TemplateRepository } from './template.repository'; +import { TemplateService } from './template.service'; +import { TemplatedSender } from './templated-sender'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const asService = prisma as unknown as PrismaService; + +function sender(): TemplatedSender { + const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)); + const templates = new TemplateService(new TemplateRepository(asService)); + return new TemplatedSender(templates, outbound, makeFakePorts()); +} + +async function seedReceipt() { + await prisma.iiosMessageTemplate.create({ + data: { key: 'payment.receipt', channel: 'EMAIL', locale: 'en', version: 1, subject: 'Thanks {{firstName}}', bodyHtml: '

{{amount}}

', bodyText: '{{amount}}', variables: ['firstName', 'amount'] }, + }); +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplatedSender.sendTemplated', () => { + it('renders a stored template, queues an outbound command, and stamps provenance', async () => { + await seedReceipt(); + const cmd = await sender().sendTemplated({ + source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com', + vars: { firstName: 'Dana', amount: '$2,000' }, idempotencyKey: 'receipt:cs_1', + }); + expect(cmd.channelType).toBe('EMAIL'); + expect(cmd.target).toBe('dana@acme.com'); + expect((cmd.payload as { subject: string }).subject).toBe('Thanks Dana'); + expect(cmd.templateKey).toBe('payment.receipt'); + expect(cmd.templateVersion).toBe(1); + expect(cmd.templateLocale).toBe('en'); + expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('is idempotent per key: a replay yields ONE command', async () => { + await seedReceipt(); + const s = sender(); + const input = { source: { key: 'payment.receipt' }, channel: 'EMAIL' as const, target: 'dana@acme.com', vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'receipt:cs_dup' }; + const a = await s.sendTemplated(input); + const b = await s.sendTemplated(input); + expect(b.id).toBe(a.id); + expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'receipt:cs_dup' } })).toBe(1); + }); + + it('sends inline content with null template key but a hash', async () => { + const cmd = await sender().sendTemplated({ + source: { inline: { subject: 'Hi {{n}}', html: '{{n}}', variables: ['n'] } }, + channel: 'EMAIL', target: 'x@y.com', vars: { n: 'Z' }, idempotencyKey: 'inline:1', + }); + expect((cmd.payload as { subject: string }).subject).toBe('Hi Z'); + expect(cmd.templateKey).toBeNull(); + expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('carries attachment refs into the EMAIL command payload', async () => { + await seedReceipt(); + const cmd = await sender().sendTemplated({ + source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com', + vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'att:1', + attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }], + }); + expect((cmd.payload as { attachments: unknown[] }).attachments).toEqual([{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }]); + }); + + it('shapes an SMS send as text only', async () => { + await prisma.iiosMessageTemplate.create({ + data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] }, + }); + const cmd = await sender().sendTemplated({ + source: { key: 'payment.receipt' }, channel: 'SMS', target: '+1415', vars: { amount: '$2,000' }, idempotencyKey: 'sms:1', + }); + expect(cmd.channelType).toBe('SMS'); + expect(cmd.payload).toEqual({ text: 'Paid $2,000' }); + }); +}); diff --git a/packages/iios-service/src/templates/templated-sender.ts b/packages/iios-service/src/templates/templated-sender.ts new file mode 100644 index 0000000..3d8acaf --- /dev/null +++ b/packages/iios-service/src/templates/templated-sender.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { IiosPlatformPorts } from '@insignia/iios-contracts'; +import { OutboundService } from '../adapters/outbound.service'; +import { PLATFORM_PORTS } from '../platform/platform-ports'; +import { decideOrThrow } from '../platform/fail-closed'; +import { TemplateService } from './template.service'; +import type { RenderedContent } from './template.renderer'; +import { isInlineSource, type TemplateSource } from './template.model'; + +/** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */ +export type ExternalChannel = 'EMAIL' | 'SMS'; + +/** An email attachment REF (bytes resolved by the provider at send time). */ +export interface AttachmentRef { filename?: string; contentRef: string; mimeType?: string } + +export interface SendTemplatedInput { + source: TemplateSource; + channel: ExternalChannel; + target: string; // email address / phone number + vars: Record; + scopeId?: string; + locale?: string; + idempotencyKey: string; // REQUIRED — e.g. "receipt:" + purpose?: string; + attachments?: AttachmentRef[]; // EMAIL only +} + +/** + * The single entry point for a templated external send: render → OutboundService.send → provenance + * stamped in one place (by construction, not caller convention). Rendering stays pure/testable; + * OutboundService stays content-agnostic (it only records the provenance it is handed). + */ +@Injectable() +export class TemplatedSender { + constructor( + private readonly templates: TemplateService, + private readonly outbound: OutboundService, + @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, + ) {} + + async sendTemplated(input: SendTemplatedInput) { + // Inline HTML to a customer is more dangerous than a reviewed stored template — gate it apart, + // so policy can permit stored sends while restricting ad-hoc ones. Fail-closed. + const action = isInlineSource(input.source) ? 'iios.template.send.inline' : 'iios.template.send'; + await decideOrThrow(this.ports, { action, scopeId: input.scopeId, channel: input.channel }); + + const { content, provenance } = await this.templates.render(input.source, input.vars, { + channel: input.channel, + locale: input.locale, + scopeId: input.scopeId, + }); + return this.outbound.send( + input.channel, + input.target, + this.toPayload(input.channel, content, input.attachments), + input.idempotencyKey, + input.scopeId, + input.purpose, + provenance, + ); + } + + /** Shape rendered content into the channel's egress envelope (EmailProvider reads subject/text/html). */ + private toPayload(channel: ExternalChannel, content: RenderedContent, attachments?: AttachmentRef[]): Record { + if (channel === 'EMAIL') { + return { subject: content.subject, html: content.html, text: content.text, ...(attachments && attachments.length > 0 ? { attachments } : {}) }; + } + return { text: content.text ?? content.subject ?? '' }; // SMS: text only + } +} diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 58fb66f..83d28f4 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -9,13 +9,16 @@ import { Param, Post, Query, + UseGuards, } from '@nestjs/common'; import { ThreadsService } from './threads.service'; import { MessageService } from '../messaging/message.service'; import { SessionVerifier } from '../platform/session.verifier'; +import { ContextAttestationGuard } from '../platform/context-attestation.guard'; import { SendMessageDto } from './send-message.dto'; @Controller('v1/threads') +@UseGuards(ContextAttestationGuard) export class ThreadsController { constructor( private readonly threads: ThreadsService, @@ -23,10 +26,14 @@ export class ThreadsController { private readonly session: SessionVerifier, ) {} - /** Generic "my threads" — every thread the caller participates in (last message + unread). */ + /** + * Generic "my threads" — every thread the caller participates in (last message + unread). + * `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key). + */ @Get() - async listThreads(@Headers('authorization') auth?: string) { - return this.messages.listThreads(this.principal(auth)); + async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record) { + const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined; + return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined); } /** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */ @@ -35,11 +42,11 @@ export class ThreadsController { return this.messages.listMyAnnotated(this.principal(auth), type); } - /** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */ + /** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */ @Post() @HttpCode(201) - async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) { - return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject }); + async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }, @Headers('authorization') auth?: string) { + return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata }); } /** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */ diff --git a/packages/iios-support-web/package.json b/packages/iios-support-web/package.json index d54cadd..1a70b0e 100644 --- a/packages/iios-support-web/package.json +++ b/packages/iios-support-web/package.json @@ -1,13 +1,19 @@ { "name": "@insignia/iios-support-web", - "version": "0.0.0", - "private": true, + "version": "0.1.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsup", "typecheck": "tsc --noEmit" @@ -24,5 +30,8 @@ "react": "^19.0.0", "tsup": "^8.3.5", "typescript": "^5.7.3" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b58d8bd..251f3b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: packages/iios-service: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1090.0 + version: 3.1090.0 '@insignia/iios-adapter-sdk': specifier: workspace:* version: link:../iios-adapter-sdk @@ -364,12 +367,21 @@ importers: dotenv: specifier: ^16.4.7 version: 16.6.1 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 ioredis: specifier: ^5.11.1 version: 5.11.1 jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + jwks-rsa: + specifier: ^4.1.0 + version: 4.1.0 + nodemailer: + specifier: ^9.0.3 + version: 9.0.3 prisma: specifier: ^6.2.1 version: 6.19.3(typescript@5.9.3) @@ -404,6 +416,9 @@ importers: '@types/node': specifier: ^26.0.1 version: 26.0.1 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/web-push': specifier: ^3.6.4 version: 3.6.4 @@ -475,6 +490,78 @@ packages: resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@aws-sdk/checksums@3.1000.18': + resolution: {integrity: sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1090.0': + resolution: {integrity: sha512-R6GX9cd1jljwzZ8xFmgAI/hHCuX1MobIKBdsymv7WL9SENvO9Vgz9KOR6avTnu0Ao+w1LmxnTe+jqmZXEn7Q/Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.975.3': + resolution: {integrity: sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.59': + resolution: {integrity: sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.61': + resolution: {integrity: sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.4': + resolution: {integrity: sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.66': + resolution: {integrity: sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.70': + resolution: {integrity: sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.59': + resolution: {integrity: sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.3': + resolution: {integrity: sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.65': + resolution: {integrity: sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.64': + resolution: {integrity: sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.33': + resolution: {integrity: sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.41': + resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1088.0': + resolution: {integrity: sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.36': + resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1438,6 +1525,30 @@ packages: cpu: [x64] os: [win32] + '@smithy/core@3.29.5': + resolution: {integrity: sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.10': + resolution: {integrity: sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.7': + resolution: {integrity: sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.7': + resolution: {integrity: sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.6': + resolution: {integrity: sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -1514,6 +1625,9 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1751,6 +1865,9 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -2221,6 +2338,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2297,6 +2419,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2343,6 +2468,10 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@4.1.0: + resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -2353,6 +2482,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2368,6 +2500,9 @@ packages: resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} engines: {node: '>=6.11.5'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -2406,6 +2541,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-memoizer@3.0.0: + resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -2518,6 +2656,10 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + notepack.io@3.0.1: resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} @@ -3033,6 +3175,11 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + uid2@1.0.0: resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} engines: {node: '>= 4.0.0'} @@ -3224,6 +3371,9 @@ packages: engines: {node: '>=8'} hasBin: true + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -3314,6 +3464,171 @@ snapshots: transitivePeerDependencies: - chokidar + '@aws-sdk/checksums@3.1000.18': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1090.0': + dependencies: + '@aws-sdk/checksums': 3.1000.18 + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-node': 3.972.70 + '@aws-sdk/middleware-sdk-s3': 3.972.64 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.975.3': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.36 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.5 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.61': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.4': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-login': 3.972.66 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.66': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.70': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-ini': 3.973.4 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.3': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/token-providers': 3.1088.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.65': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.64': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.33': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.41': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1088.0': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.36': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4057,6 +4372,39 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@smithy/core@3.29.5': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.10': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.6': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.8)': @@ -4160,6 +4508,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 26.0.1 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -4440,6 +4792,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -4994,6 +5348,15 @@ snapshots: graceful-fs@4.2.11: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5066,6 +5429,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -5113,6 +5478,17 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@4.1.0: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 6.2.3 + limiter: 1.1.5 + lru-cache: 11.5.1 + lru-memoizer: 3.0.0 + transitivePeerDependencies: + - supports-color + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -5122,6 +5498,8 @@ snapshots: lilconfig@3.1.3: {} + limiter@1.1.5: {} + lines-and-columns@1.2.4: {} load-esm@1.0.3: {} @@ -5130,6 +5508,8 @@ snapshots: loader-runner@4.3.2: {} + lodash.clonedeep@4.5.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -5159,6 +5539,11 @@ snapshots: dependencies: yallist: 3.1.1 + lru-memoizer@3.0.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 11.5.1 + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5251,6 +5636,8 @@ snapshots: node-releases@2.0.50: {} + nodemailer@9.0.3: {} + notepack.io@3.0.1: {} nypm@0.6.8: @@ -5780,6 +6167,9 @@ snapshots: ufo@1.6.4: {} + uglify-js@3.19.3: + optional: true + uid2@1.0.0: {} uid@2.0.2: @@ -5967,6 +6357,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0