Compare commits
33 Commits
611ca74cea
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eb385707fa | |||
| 0bdae453fc | |||
| 0a8544b6fc | |||
| f2ef8922ce | |||
| 48c2e6589c | |||
| ba2d5f4193 | |||
| e49a71feaf | |||
| ac7f790303 | |||
| 24a87f6fb6 | |||
| 1256664361 | |||
| 0ecc8a4ada | |||
| e956ad3cb9 | |||
| 8c814d9b86 | |||
| f3c4ba72b5 | |||
| c4206f9809 | |||
| 1af10d0f4a | |||
| 31f7682a46 | |||
| ba745bb71a | |||
| 2056391f9d | |||
| 23f5159521 | |||
| 3abad4970f | |||
| d5e0dacae3 | |||
| 43e9928ce3 | |||
| f0ea1ca553 | |||
| f0afca89e3 | |||
| ae50f56404 | |||
| 427396653f | |||
| a520b26398 | |||
| 5bd1f646ca | |||
| a933e2e3af | |||
| cdba0f0179 | |||
| 035315ef73 | |||
| a5cd8f0ec4 |
@@ -0,0 +1,16 @@
|
|||||||
|
# Build context hygiene — keep the image small & builds reproducible.
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.turbo
|
||||||
|
**/.next
|
||||||
|
**/coverage
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
/tmp
|
||||||
|
scratchpad
|
||||||
|
docs/openapi.yaml
|
||||||
@@ -7,7 +7,13 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
# Host-mode runner (docker talks to the host daemon). We publish a pgvector
|
||||||
|
# Postgres to localhost:5434 and connect over localhost — the service-name
|
||||||
|
# networking of `services:` containers is not wired to jobs on this runner,
|
||||||
|
# so we start the DB explicitly (same proven pattern as the mdm CI).
|
||||||
|
runs-on: dind-builder
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://iios:iios@localhost:5434/iios?schema=public
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
@@ -17,8 +23,37 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
|
# pgvector Postgres for the DB-backed *.spec.ts integration suites.
|
||||||
|
- name: Start pgvector Postgres
|
||||||
|
run: |
|
||||||
|
docker rm -f iios-ci-pg-${{ github.run_id }} >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name iios-ci-pg-${{ github.run_id }} \
|
||||||
|
-e POSTGRES_USER=iios -e POSTGRES_PASSWORD=iios -e POSTGRES_DB=iios \
|
||||||
|
-p 5434:5432 pgvector/pgvector:pg16
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker exec iios-ci-pg-${{ github.run_id }} pg_isready -U iios >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
docker exec iios-ci-pg-${{ github.run_id }} pg_isready -U iios \
|
||||||
|
|| { echo "Postgres did not become ready"; exit 1; }
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm boundary
|
- run: pnpm boundary
|
||||||
- run: pnpm -r typecheck
|
# iios-service uses Prisma: generate the client so its generated types
|
||||||
|
# (schema enums, Prisma.InputJsonValue) are available for typecheck/build.
|
||||||
|
- run: pnpm --filter @insignia/iios-service prisma:generate
|
||||||
|
# Build before typecheck: the @insignia/* workspace packages publish their
|
||||||
|
# types via built dist/*.d.ts, so consumers (iios-kernel-client, etc.)
|
||||||
|
# can only resolve them once dist exists. Typechecking first fails with
|
||||||
|
# TS2307 "Cannot find module '@insignia/iios-contracts'".
|
||||||
- run: pnpm -r build
|
- run: pnpm -r build
|
||||||
|
- run: pnpm -r typecheck
|
||||||
|
# Apply the schema to the CI Postgres before the DB-backed specs run
|
||||||
|
# (reset-db.ts TRUNCATEs existing tables; migrations must exist first).
|
||||||
|
- run: pnpm --filter @insignia/iios-service exec prisma migrate deploy
|
||||||
- run: pnpm test
|
- run: pnpm test
|
||||||
|
|
||||||
|
- name: Stop Postgres
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f iios-ci-pg-${{ github.run_id }} >/dev/null 2>&1 || true
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Build iios-service and deploy it via k8s-pods (ArgoCD). Runs on the dind-builder
|
||||||
|
# host-mode runner (mounts the host docker socket). On every push to main that
|
||||||
|
# touches the service, it builds+pushes the image (SHA-tagged) and bumps the tag in
|
||||||
|
# platform-engineering/k8s-pods -> ArgoCD rolls it.
|
||||||
|
name: Deploy iios-service
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "packages/iios-service/**"
|
||||||
|
- "packages/iios-adapter-sdk/**"
|
||||||
|
- "packages/iios-contracts/**"
|
||||||
|
- "pnpm-lock.yaml"
|
||||||
|
- ".github/workflows/deploy-iios-service.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-deploy:
|
||||||
|
runs-on: dind-builder
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Gitea container registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lynkedup.cloud -u mcp-bot --password-stdin
|
||||||
|
|
||||||
|
- name: Ensure docker buildx (the Dockerfile uses BuildKit --mount)
|
||||||
|
run: |
|
||||||
|
if ! docker buildx version >/dev/null 2>&1; then
|
||||||
|
mkdir -p ~/.docker/cli-plugins
|
||||||
|
curl -sSL https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx
|
||||||
|
chmod +x ~/.docker/cli-plugins/docker-buildx
|
||||||
|
fi
|
||||||
|
docker buildx create --use --name iios-ci 2>/dev/null || docker buildx use iios-ci
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
run: |
|
||||||
|
IMG=git.lynkedup.cloud/platform-engineering/iios-service
|
||||||
|
TAG=$(git rev-parse --short HEAD)
|
||||||
|
echo "TAG=$TAG" >> "$GITHUB_ENV"
|
||||||
|
docker buildx build --builder iios-ci --push \
|
||||||
|
-t "$IMG:$TAG" -f packages/iios-service/Dockerfile .
|
||||||
|
|
||||||
|
- name: Bump k8s-pods image tag (ArgoCD deploys)
|
||||||
|
run: |
|
||||||
|
rm -rf /tmp/kp # dind-builder is a persistent host: clear any stale checkout from a prior run
|
||||||
|
git clone --depth 1 -b main \
|
||||||
|
"https://mcp-bot:${{ secrets.K8S_PODS_TOKEN }}@git.lynkedup.cloud/platform-engineering/k8s-pods.git" /tmp/kp
|
||||||
|
cd /tmp/kp
|
||||||
|
sed -i "s#image: git.lynkedup.cloud/platform-engineering/iios-service:.*#image: git.lynkedup.cloud/platform-engineering/iios-service:${TAG}#" services/iios/deployment.yaml
|
||||||
|
git config user.name "iios-ci"; git config user.email "ci@lynkedup.cloud"
|
||||||
|
if git diff --quiet; then echo "image tag unchanged"; exit 0; fi
|
||||||
|
git commit -am "ci(iios): deploy iios-service ${TAG}"
|
||||||
|
git push origin main
|
||||||
@@ -16,5 +16,18 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
# Realtime fan-out across replicas (socket.io Redis adapter). Point the service at it
|
||||||
|
# with REDIS_URL=redis://localhost:6379; without REDIS_URL the in-memory adapter is used.
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: iios-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
iios_postgres_data:
|
iios_postgres_data:
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Deploying IIOS
|
||||||
|
|
||||||
|
IIOS deploys as **one runtime service** (`@insignia/iios-service`) plus **one datastore**
|
||||||
|
(Postgres). Everything else in the repo is a **library** (SDKs) or a **dev demo**, not a
|
||||||
|
deployed service.
|
||||||
|
|
||||||
|
| Artifact | Type | How it ships |
|
||||||
|
|---|---|---|
|
||||||
|
| `@insignia/iios-service` | **Runtime service** | Docker image (this guide) |
|
||||||
|
| `iios-contracts`, `iios-adapter-sdk`, `iios-kernel-client`, `iios-testkit` | Library | published to npm; imported by host apps |
|
||||||
|
| `iios-message-web`, `iios-inbox-web`, `iios-support-web`, `iios-ai-web`, `iios-meeting-web`, `iios-community-web` | Web SDK (library) | published to npm; **bundled into host-app frontends** (optionally one CDN widget) |
|
||||||
|
| `apps/*` (message-demo, ai-studio, route-admin…) | Dev demo | not deployed |
|
||||||
|
|
||||||
|
The service is a **modular monolith**: a single NestJS process serves the HTTP API, the
|
||||||
|
WebSocket (socket.io) gateway, the outbox relay, and the projectors together.
|
||||||
|
|
||||||
|
## Build the image
|
||||||
|
|
||||||
|
Build context is the **monorepo root** (the image needs the workspace + lockfile):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f packages/iios-service/Dockerfile -t iios-service:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
The multi-stage build compiles the service + its workspace deps, generates the Prisma
|
||||||
|
client, and prunes to a self-contained prod bundle (`pnpm deploy --legacy`). The runtime
|
||||||
|
image runs as the non-root `node` user and starts via `docker-entrypoint.sh`, which runs
|
||||||
|
`prisma migrate deploy` then `node dist/main.js`.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 3200:3200 \
|
||||||
|
-e DATABASE_URL='postgresql://USER:PASS@HOST:5432/iios?schema=public' \
|
||||||
|
-e APP_SECRETS='{"portal-demo":"<secret>"}' \
|
||||||
|
-e IIOS_DEV_TOKENS=0 \
|
||||||
|
iios-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Health check: `GET /health` → `200`. Metrics/ops: `GET /metrics` (relay lag, projection
|
||||||
|
cursors, retention snapshot counts).
|
||||||
|
|
||||||
|
## Configuration & secrets contract
|
||||||
|
|
||||||
|
Every knob is an environment variable — see [`packages/iios-service/.env.example`](../packages/iios-service/.env.example)
|
||||||
|
for the full, commented list. Highlights:
|
||||||
|
|
||||||
|
- **Secrets** (inject from a vault, never bake into the image): `DATABASE_URL`,
|
||||||
|
`APP_SECRETS` (per-app HS256 JWT keys), `ADAPTER_SECRETS` (webhook HMAC keys),
|
||||||
|
`MEDIA_SECRET` (signs presigned media upload/download URLs).
|
||||||
|
- **Auth (real IdP):** set `SUPABASE_URL` or `AUTH_ISSUERS` to trust real OIDC access
|
||||||
|
tokens, verified against the issuer's public **JWKS** (ES256) — **no secret to store**.
|
||||||
|
`AUTH_ISSUERS` is a JSON registry `[{url, appId, orgId?}]` routing many issuers → isolated
|
||||||
|
app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests.
|
||||||
|
- **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for
|
||||||
|
prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change.
|
||||||
|
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*`
|
||||||
|
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
|
||||||
|
single most important prod-hardening flag.
|
||||||
|
- **`IIOS_CELL_ID`** tags which physical cell this instance serves — the hook for splitting
|
||||||
|
noisy/regulated tenants into isolated cells later, with no code change.
|
||||||
|
- **Worker timers** (`IIOS_RELAY_INTERVAL_MS`, `IIOS_RETENTION_SWEEP_INTERVAL_MS`) — see
|
||||||
|
scaling notes below.
|
||||||
|
|
||||||
|
## Topology
|
||||||
|
|
||||||
|
```
|
||||||
|
host apps ──HTTPS/WSS──► [ LB / ingress ] ──► iios-service ×N ──► Postgres (pgvector)
|
||||||
|
(embed SDKs) (WS + sticky) (API+relay+ │
|
||||||
|
projectors) (+ Redis, + OPA/CMP/MDM
|
||||||
|
as external ports — later)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Postgres** — managed (RDS / Cloud SQL / Neon). One logical DB, tenant-isolated by scope.
|
||||||
|
- **Redis** — **not required for a single instance.** Set **`REDIS_URL`** when you run
|
||||||
|
**multiple replicas with live chat**: the built-in [socket.io Redis adapter](../packages/iios-service/src/realtime/redis-io.adapter.ts)
|
||||||
|
fans room emits across all instances, so a client on replica B sees messages emitted by
|
||||||
|
replica A. Without `REDIS_URL` the in-memory adapter is used (single instance). Verified by
|
||||||
|
`scripts/smoke-realtime-cluster.mjs` (two instances + one Redis; cross-instance delivery
|
||||||
|
fails without Redis, passes with it).
|
||||||
|
> Delivery is **at-least-once** across instances — with N>1 the inline send-emit and the
|
||||||
|
> relay's outbox-bridge emit can both fire for the same message, so clients should **dedupe
|
||||||
|
> by message `id`** (the payload always carries one).
|
||||||
|
- **Platform ports** (OPA policy, CMP consent, MDM, CRRE, SAS) — today in-process permissive
|
||||||
|
stubs (`LocalDevPorts`). For production, point these at real external services; the service
|
||||||
|
already calls them **fail-closed**. The **session** port already verifies real OIDC tokens
|
||||||
|
(Supabase/JWKS) when configured.
|
||||||
|
- **Media storage** (`StoragePort`) — dev = **local disk** (`MEDIA_DIR`). ⚠️ Local disk is
|
||||||
|
**single-instance and non-durable**; with N>1 replicas or for persistence, bind it to shared
|
||||||
|
**object storage** (S3/R2/Supabase Storage). Bytes always flow **client ↔ storage directly**
|
||||||
|
via presigned URLs — they never transit the service — so this scales independently.
|
||||||
|
|
||||||
|
## Scaling & release strategy
|
||||||
|
|
||||||
|
**Horizontal scaling is safe** because the event core was hardened for it:
|
||||||
|
|
||||||
|
- The outbox relay claims rows with `FOR UPDATE SKIP LOCKED` → each event is relayed by
|
||||||
|
exactly one replica; its co-located projectors process it once (idempotent `claim()` +
|
||||||
|
the projection-cursor + idempotency-command ledgers give exactly-once effects).
|
||||||
|
- The **DLQ + replay** path means a bad deploy loses nothing — fix and replay.
|
||||||
|
|
||||||
|
**Rolling / blue-green deploys:**
|
||||||
|
|
||||||
|
1. **Migrations:** run `prisma migrate deploy` as a **one-off pre-deploy job**, then set
|
||||||
|
`IIOS_SKIP_MIGRATE=1` on the replicas so N pods don't race the migration. (For a single
|
||||||
|
instance, the default boot-time migration is fine.)
|
||||||
|
2. Use **expand-contract (backward-compatible) schema changes** so old and new pods can run
|
||||||
|
against the same schema during the roll — this is the prerequisite for zero-downtime.
|
||||||
|
3. Roll replicas with a `/health` readiness gate; drain WebSocket connections on `SIGTERM`.
|
||||||
|
4. **WebSocket ingress** needs sticky sessions (and the Redis socket.io adapter once N>1).
|
||||||
|
|
||||||
|
## Not yet built (prod-readiness gaps)
|
||||||
|
|
||||||
|
- **CI/CD pipeline** to build/push this image and run migrations.
|
||||||
|
- **Real platform-port services** (OPA/CMP/MDM) — today permissive stubs.
|
||||||
|
- **Secrets manager** integration (currently plain env).
|
||||||
|
- A **schema-compatibility gate** in CI to enforce expand-contract migrations.
|
||||||
@@ -58,6 +58,8 @@ Use it on every call: `-H "authorization: Bearer <token>"`.
|
|||||||
- **Different `orgId` = different tenant.** Mint two tokens with `org_A` / `org_B` to test isolation.
|
- **Different `orgId` = different tenant.** Mint two tokens with `org_A` / `org_B` to test isolation.
|
||||||
- Token TTL: 2h.
|
- Token TTL: 2h.
|
||||||
|
|
||||||
|
**Real IdP tokens (production path).** Beyond the dev HS256 tokens, `SessionVerifier` also verifies **real OIDC access tokens** (ES256) against a trusted issuer's public **JWKS** — set `SUPABASE_URL` (single issuer) or `AUTH_ISSUERS` (a registry mapping many issuers → app scopes). The verifier routes a token by its `iss` claim, so two projects/IdPs map to two isolated `appId` scopes on one service, and app A's tokens can't reach app B. No shared secret needed. The dev token path stays for tests/local.
|
||||||
|
|
||||||
### Error responses (what QA will see)
|
### Error responses (what QA will see)
|
||||||
| Status | When |
|
| Status | When |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -101,12 +103,24 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
|
|||||||
### 5.3 Threads & Messaging (native chat)
|
### 5.3 Threads & Messaging (native chat)
|
||||||
| Method | Path | Body / Headers | Returns |
|
| Method | Path | Body / Headers | Returns |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| GET | `/v1/threads` | — | `ThreadSummary[]` — your threads (members, unread, last message, `membership`) |
|
||||||
|
| POST | `/v1/threads` | `{membership?, creatorRole?, subject?}` | `{threadId, status, history}` (201) — `membership`/`creatorRole`/`subject` are **opaque app attributes** the kernel stores but never interprets |
|
||||||
|
| POST | `/v1/threads/:id/participants` | `{userId, role?}` | `{threadId, participantCount}` (201) — **governed** (DM cap / group-admin via OPA) |
|
||||||
| GET | `/v1/threads/:id/messages` | — | `{threadId, messages[]}` |
|
| GET | `/v1/threads/:id/messages` | — | `{threadId, messages[]}` |
|
||||||
| POST | `/v1/threads/:id/messages` | `{content, contentRef?}` + `idempotency-key?` header | `Message` (201) |
|
| POST | `/v1/threads/:id/messages` | `{content, contentRef?, mimeType?, sizeBytes?, checksumSha256?, parentInteractionId?}` + `idempotency-key?` header | `Message` (201) |
|
||||||
|
| GET | `/v1/threads/my-annotations` | `?type=save` | `{message, threadId, threadSubject}[]` — messages you annotated (e.g. saved) |
|
||||||
|
|
||||||
**Realtime (Socket.IO, namespace `/message`):** connect, then emit client→server events:
|
A **`Message`** carries `{id, threadId, senderId, senderName, content, attachment?, parentInteractionId?, annotations[], traceId, createdAt}`. `senderId` is the sender's stable externalId (email/username) — the reliable "is this mine?" check. `attachment` = `{contentRef, mimeType, sizeBytes, kind}` (§5.10). `annotations` = `[{type, value, users[]}]` — the reaction/pin/save aggregate.
|
||||||
- `open_thread` `{threadId?|otherUserId}`, `send_message` `{threadId, content, idempotencyKey?}`, `read` `{threadId}`, `delivered` `{threadId}`.
|
|
||||||
- Server emits to the thread room: `message` (a Message) and `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`.
|
**Realtime (Socket.IO, namespace `/message`).** Token verified on connect (`auth.token`), then emit client→server:
|
||||||
|
- `open_thread` `{threadId?, membership?, creatorRole?, subject?}` — opens, or **creates** when no id; a **governed join** for membership threads. Returns `{threadId, status, history}` or `{error}` (acked, never a throw — clients don't hang).
|
||||||
|
- `send_message` `{threadId, content, contentRef?, mimeType?, sizeBytes?, parentInteractionId?, mentions?}` — `mentions` is an **opaque userId notify-list** (the app parses `@`; the kernel never does).
|
||||||
|
- `add_participant` `{threadId, userId, role?}` · `read` `{threadId, interactionId}` · `delivered` `{…}` · `typing` `{threadId}`.
|
||||||
|
- `annotate` `{threadId, interactionId, type, value}` — toggle a **generic annotation** (chat app uses `type: reaction|pin|save`; `value` = emoji, or empty).
|
||||||
|
|
||||||
|
Server → thread room: `message` (a Message), `receipt` `{interactionId, actorId, kind: READ|DELIVERED}`, `typing` `{threadId, userId}`, `annotation` `{interactionId, type, value, op: add|remove, users[], userId}`.
|
||||||
|
|
||||||
|
**Governance & primitives (all fail-closed via OPA).** Membership (`iios.thread.participant.add`, `iios.thread.join`), annotating (`iios.interaction.annotate` — participant-only), and send all gate on policy. **Reactions/pins/saves are one generic primitive** — an *interaction annotation* the kernel stores + aggregates as opaque `(type, value)` but never interprets (the same primitive backs pins/saves/flags/tags). **Mentions → Inbox:** `mentions[]` flows into the message event; the inbox projector fans out a `MENTION` inbox item to each mentioned participant (never the sender); reading the thread resolves it.
|
||||||
|
|
||||||
### 5.4 Inbox (work queue)
|
### 5.4 Inbox (work queue)
|
||||||
| Method | Path | Query / Body | Returns |
|
| Method | Path | Query / Body | Returns |
|
||||||
@@ -114,6 +128,8 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
|
|||||||
| GET | `/v1/inbox/items` | `?state=OPEN|SNOOZED|DONE|ARCHIVED|CANCELLED|STALE` | `InboxItem[]` |
|
| GET | `/v1/inbox/items` | `?state=OPEN|SNOOZED|DONE|ARCHIVED|CANCELLED|STALE` | `InboxItem[]` |
|
||||||
| PATCH | `/v1/inbox/items/:id` | `{state, reason?}` | `InboxItem` |
|
| PATCH | `/v1/inbox/items/:id` | `{state, reason?}` | `InboxItem` |
|
||||||
|
|
||||||
|
Item **kinds** include `NEEDS_REPLY` (unreplied thread activity, one per owner+thread) and `MENTION` (someone @-mentioned you; `priority: HIGH`, one per source message). Reading the thread resolves both to `DONE`.
|
||||||
|
|
||||||
### 5.5 Support (tickets, escalation, callbacks, queues, agents)
|
### 5.5 Support (tickets, escalation, callbacks, queues, agents)
|
||||||
| Method | Path | Body / Query | Returns |
|
| Method | Path | Body / Query | Returns |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -179,6 +195,52 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token
|
|||||||
|
|
||||||
`ScheduleMeetingDto`: `{meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}`. `meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}`; consent `status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}`. **Transcript/summary is `BLOCKED` until every attendee has `GRANTED` consent.**
|
`ScheduleMeetingDto`: `{meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}`. `meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}`; consent `status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}`. **Transcript/summary is `BLOCKED` until every attendee has `GRANTED` consent.**
|
||||||
|
|
||||||
|
### 5.10 Media (attachments)
|
||||||
|
|
||||||
|
The DB stores a **reference**; the bytes live behind a **storage port** (dev = local disk `MEDIA_DIR`; prod = swap to S3/R2/Supabase). Bytes go **client ↔ storage directly** via short-lived signed URLs — they never pass through the kernel.
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body | Returns |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| POST | `/v1/media/presign-upload` | Bearer | `{mime, sizeBytes}` | `{objectKey, uploadUrl}` — **OPA-gated** (size/type) |
|
||||||
|
| PUT | `/v1/media/upload/:token` | token in URL | raw bytes | `{objectKey, sizeBytes, checksumSha256}` |
|
||||||
|
| POST | `/v1/media/presign-download` | Bearer | `{contentRef, mime?}` | `{url}` — **tenant-fenced**, signed 1h |
|
||||||
|
| GET | `/v1/media/blob/:token` | token in URL | — | streams the bytes with their `Content-Type` |
|
||||||
|
|
||||||
|
**Attaching to a message:** `send`/`send_message` accept `{contentRef, mimeType, sizeBytes, checksumSha256?}`. The stored `Message` then carries `attachment: { contentRef, mimeType, sizeBytes, kind }` where `kind ∈ {image, video, audio, file}` (a friendly view of the generic `MEDIA_REF`/`VOICE_REF`/`FILE_REF` part the kernel writes).
|
||||||
|
|
||||||
|
**Governance (fail-closed, built in):**
|
||||||
|
- **Upload policy** `iios.media.upload` — **≤ 25 MB** and an allowlist (`image/*`, `video/*`, `audio/*`, `application/pdf`, common Office/text/zip). A violation → **403** *before* any bytes are sent.
|
||||||
|
- **Signed tokens** (HS256, `MEDIA_SECRET`) — upload URL lives **5 min**, download URL **1 h**; a tampered/expired token → 403.
|
||||||
|
- **Tenant fence** — object keys are prefixed with the caller's `scopeId`; a download for an object outside your scope → 403.
|
||||||
|
|
||||||
|
**The flow (with governance):**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant C as Client (SDK uploadMedia)
|
||||||
|
participant API as IIOS Media API
|
||||||
|
participant OPA as OPA policy
|
||||||
|
participant ST as StoragePort (disk → S3)
|
||||||
|
participant K as Message kernel
|
||||||
|
|
||||||
|
C->>API: POST /v1/media/presign-upload {mime, sizeBytes}
|
||||||
|
API->>OPA: decide iios.media.upload (≤25MB? type allowed?)
|
||||||
|
alt denied
|
||||||
|
OPA-->>C: 403 (too large / type not allowed)
|
||||||
|
else allowed
|
||||||
|
API-->>C: { objectKey, uploadUrl } (signed, 5-min)
|
||||||
|
C->>ST: PUT bytes → uploadUrl (direct, not via kernel)
|
||||||
|
ST-->>C: { sizeBytes, checksumSha256 }
|
||||||
|
C->>K: send_message { contentRef, mime, size }
|
||||||
|
K-->>C: Message { attachment }
|
||||||
|
C->>API: POST /v1/media/presign-download { contentRef }
|
||||||
|
API->>API: tenant-fence (objectKey in my scope?)
|
||||||
|
API-->>C: { url } (signed, 1-hour)
|
||||||
|
C->>ST: GET url → bytes (stream, inline render / download)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. SDK reference
|
## 6. SDK reference
|
||||||
@@ -191,7 +253,9 @@ import { RestClient } from '@insignia/iios-kernel-client';
|
|||||||
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });
|
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });
|
||||||
```
|
```
|
||||||
Methods (all return typed promises):
|
Methods (all return typed promises):
|
||||||
- **Messaging:** `getThreadMessages(threadId)`, `sendMessage(threadId, content, {contentRef?, idempotencyKey?})`
|
- **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})`
|
||||||
|
- **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates.
|
||||||
|
- **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.*
|
||||||
- **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})`
|
- **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})`
|
||||||
- **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)`
|
- **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)`
|
||||||
- **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)`
|
- **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)`
|
||||||
@@ -269,7 +333,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/<name>`)
|
|||||||
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
|
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
|
||||||
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation |
|
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation |
|
||||||
|
|
||||||
Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check: `pnpm boundary`.
|
Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check: `pnpm boundary`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -289,7 +353,11 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `3200` | HTTP port |
|
| `PORT` | `3200` | HTTP port |
|
||||||
| `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres |
|
| `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres |
|
||||||
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app JWT secrets (`{appId: secret}`) |
|
| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app HS256 JWT secrets (`{appId: secret}`) |
|
||||||
|
| `SUPABASE_URL` / `AUTH_ISSUERS` | — | trusted OIDC issuer(s) — verify real IdP tokens (ES256) via JWKS. `AUTH_ISSUERS` is a JSON array `[{url, appId, orgId?}]` mapping issuers → app scopes; `SUPABASE_URL` is the single-issuer shorthand |
|
||||||
|
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
|
||||||
|
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
|
||||||
|
| `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs |
|
||||||
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
|
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
|
||||||
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
|
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
|
||||||
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
|
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
|
||||||
@@ -305,7 +373,9 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check
|
|||||||
|
|
||||||
- **Interaction kind:** MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
|
- **Interaction kind:** MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
|
||||||
- **Channel types:** WEBHOOK, EMAIL, WHATSAPP, PORTAL
|
- **Channel types:** WEBHOOK, EMAIL, WHATSAPP, PORTAL
|
||||||
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
|
- **Inbox state:** OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · **Inbox kind:** NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, **MENTION**, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
|
||||||
|
- **Message part kind:** TEXT, HTML, MARKDOWN, MEDIA_REF, FILE_REF, VOICE_REF, LOCATION, STRUCTURED_JSON · **Attachment kind (DTO):** image, video, audio, file
|
||||||
|
- **Interaction annotation (app-level, opaque to the kernel):** `type` = reaction | pin | save … ; `value` = emoji (reactions) or empty
|
||||||
- **Ticket state:** NEW, OPEN, PENDING, RESOLVED, CLOSED · **priority:** LOW, NORMAL, HIGH, URGENT
|
- **Ticket state:** NEW, OPEN, PENDING, RESOLVED, CLOSED · **priority:** LOW, NORMAL, HIGH, URGENT
|
||||||
- **Route mode:** MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · **output format:** FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · **decision:** ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
|
- **Route mode:** MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · **output format:** FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · **decision:** ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
|
||||||
- **AI job:** CLASSIFY, SUMMARIZE, EXTRACT · **artifact:** CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · **artifact status:** PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
|
- **AI job:** CLASSIFY, SUMMARIZE, EXTRACT · **artifact:** CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · **artifact status:** PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Today the external world (real WhatsApp, real AI models, real calendars, the rea
|
|||||||
| **P8** | **Calendar & meetings** — schedule, attendee consent, transcript, summary, action items | `iios-meeting-web` | Support/scheduling; callback→meeting | meeting-studio (5178) |
|
| **P8** | **Calendar & meetings** — schedule, attendee consent, transcript, summary, action items | `iios-meeting-web` | Support/scheduling; callback→meeting | meeting-studio (5178) |
|
||||||
| **P9** | *(next)* **Production hardening** — real providers, real policy/consent, scale, retention | — | Ops / platform | — |
|
| **P9** | *(next)* **Production hardening** — real providers, real policy/consent, scale, retention | — | Ops / platform | — |
|
||||||
|
|
||||||
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (46 tables across 9 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
|
All of it runs on **one NestJS service** (`iios-service`) with **one Postgres database** (53 tables across 19 migrations), fronted by **one low-level client** (`iios-kernel-client`) that every React SDK is built on.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -159,7 +159,9 @@ Each product SDK is a handful of React hooks — a front-end dev wires the UI, t
|
|||||||
| Calendar/Zoom providers | Simulated sync | P9 |
|
| Calendar/Zoom providers | Simulated sync | P9 |
|
||||||
| Multi-tenant scale, retention, SLOs | Not yet | P9 |
|
| Multi-tenant scale, retention, SLOs | Not yet | P9 |
|
||||||
|
|
||||||
**Proof it works:** 95 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
|
**Proof it works:** 192 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
|
||||||
|
|
||||||
|
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, and media sharing** (images/video/audio/docs on a swappable storage port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,4 +177,4 @@ Narrative arc for the CEO: **one engine → chat → inbox → support → chann
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 46 Postgres tables across 9 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar). Boundary-enforced dependency law; 95 passing tests; 7 end-to-end smoke scripts.*
|
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 53 Postgres tables across 19 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media). Boundary-enforced dependency law; 192 passing tests; 8+ end-to-end smoke scripts. First real-provider swap live: Supabase auth (JWKS-verified) + a media storage port.*
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# ADR-0001: `support-service` → IIOS naming migration
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-03
|
||||||
|
- **Deciders:** IIOS core
|
||||||
|
- **Context tags:** naming, packaging, migration, KG-16
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
IIOS (Insignia Interaction OS) is the greenfield realization of a vision first
|
||||||
|
prototyped in the standalone **`support-service`** app. That prototype already stated the
|
||||||
|
core idea — *"Everything is a message: a generic Message core (conversation / message /
|
||||||
|
channel / user) is the foundation; Support (ticket / agent / callback) is a thin
|
||||||
|
specialization on top"* — but it did so with **support-centric naming** and as a
|
||||||
|
single-purpose support desk.
|
||||||
|
|
||||||
|
IIOS generalizes that same idea to an **interaction OS** of much broader scope
|
||||||
|
(messaging, inbox, routing, AI enrichment, calendar, plus support as *one*
|
||||||
|
specialization among many). During P0–P9 it landed a clean, generic vocabulary:
|
||||||
|
an `Iios`-prefixed persistence layer and `@insignia/iios-*` packages.
|
||||||
|
|
||||||
|
Two problems make an explicit decision necessary:
|
||||||
|
|
||||||
|
1. **Naming drift.** The prototype and IIOS use different words for the same concepts
|
||||||
|
(`Conversation` vs `IiosThread`, `Message` vs `IiosInteraction` + `IiosMessagePart`,
|
||||||
|
`User` vs `IiosSourceHandle` + `IiosActorRef`). Without a canonical mapping, contributors
|
||||||
|
coming from the prototype reintroduce support-centric names, and host apps don't know
|
||||||
|
which SDK is authoritative.
|
||||||
|
2. **KG-16 — "support wedge evidence overgeneralized."** The critics' analysis flags the
|
||||||
|
risk of treating the `support-service` architecture as proof of *all* IIOS capabilities.
|
||||||
|
Support is a **specialization**, not the core; the naming must make that structural
|
||||||
|
(support names are namespaced *under* the generic core, never the core itself).
|
||||||
|
|
||||||
|
An unresolved sub-question also blocks packaging: the workspace scope was historically
|
||||||
|
ambiguous between `@insignia/*` and `@lynkeduppro/*`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**1. Canonical vocabulary is interaction-centric and `Iios`-namespaced.**
|
||||||
|
The generic core is the noun set already shipped in `@insignia/iios-service`:
|
||||||
|
`IiosScope`, `IiosSourceHandle`/`IiosActorRef`, `IiosChannel`, `IiosThread`,
|
||||||
|
`IiosInteraction`/`IiosMessagePart`. **Support is a specialization layered on top** and is
|
||||||
|
always namespaced as such (`IiosSupport*`, `IiosTicket*`, `IiosCallbackRequest`) — never
|
||||||
|
promoted to a core concept. "Conversation" and support-centric "Message-as-ticket" framing
|
||||||
|
are retired.
|
||||||
|
|
||||||
|
**2. Package scope is `@insignia/iios-*`.** This resolves the `@insignia` vs `@lynkeduppro`
|
||||||
|
ambiguity in favour of `@insignia`. All IIOS packages already follow `@insignia/iios-<name>`;
|
||||||
|
that is now the standard.
|
||||||
|
|
||||||
|
**3. Migration is greenfield-and-deprecate (strangler), not rename-in-place.**
|
||||||
|
`support-service` is **frozen legacy** — we do not rename its models or ship a new version of
|
||||||
|
it. New work happens in IIOS; host apps cut over to the `@insignia/iios-*` SDKs feature-by-
|
||||||
|
feature. `support-service` is retired once every host app it serves has migrated.
|
||||||
|
|
||||||
|
### Canonical naming map (legacy → IIOS)
|
||||||
|
|
||||||
|
| `support-service` (legacy) | IIOS (canonical) | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `Channel` | `IiosChannel` | generic ingress/egress channel |
|
||||||
|
| `User` | `IiosSourceHandle` + `IiosActorRef` | identity split: external handle vs resolved actor |
|
||||||
|
| `Conversation` | `IiosThread` | "conversation" retired; thread is generic |
|
||||||
|
| `Message` | `IiosInteraction` + `IiosMessagePart` | envelope vs content parts |
|
||||||
|
| `Attachment` | `IiosMessagePart` (`kind=MEDIA`, `contentRef`) | attachments are just parts |
|
||||||
|
| `Team` / `TeamMember` | `IiosSupportQueue` / `IiosSupportTeamMember` | **support specialization**, namespaced |
|
||||||
|
| `ConversationAssignment` | support assignment (`IiosSupport*`) | specialization, not core |
|
||||||
|
| `Ticket` / `TicketConversation` | `IiosTicket` / `IiosTicketThreadLink` | ticket links to generic threads |
|
||||||
|
| `CallbackRequest` | `IiosCallbackRequest` | |
|
||||||
|
| `Meeting` | `IiosMeeting` (+ P8 calendar models) | promoted to first-class in IIOS |
|
||||||
|
| `Notification` | `notification_outbox` (planned) | not yet built in IIOS |
|
||||||
|
| `ProcessedCommand` | `IiosProcessedEvent` + `IiosIdempotencyCommand` | consumer ledger vs command ledger |
|
||||||
|
| `KnowledgeArticle` | (deferred — AI/RAG) | out of current scope |
|
||||||
|
|
||||||
|
### Rules going forward
|
||||||
|
|
||||||
|
- **Every persisted model is `Iios`-prefixed.** No un-prefixed domain models.
|
||||||
|
- **Support (and any future vertical) is namespaced under the generic core**, never the other
|
||||||
|
way round. If a name reads as "support-only," it must carry the `IiosSupport*`/`IiosTicket*`
|
||||||
|
prefix and depend on the core — the core never depends on it (enforced by the import-boundary
|
||||||
|
check).
|
||||||
|
- **New packages are `@insignia/iios-<name>`.**
|
||||||
|
- **Legacy references** in prose cite `support-service` explicitly as *legacy*, with a pointer
|
||||||
|
to this ADR.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
- One authoritative vocabulary; contributors from the prototype have a lookup table.
|
||||||
|
- KG-16 is structurally addressed: support cannot masquerade as the core because its names are
|
||||||
|
subordinate and the boundary check enforces the dependency direction.
|
||||||
|
- Packaging is unblocked (`@insignia/iios-*` is canonical).
|
||||||
|
- No risky in-place rename of a running legacy service; cutover is incremental per host app.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
- Two systems coexist during the strangler window; the legacy `support-service` must be kept
|
||||||
|
running (but frozen) until host apps migrate.
|
||||||
|
- The legacy↔IIOS mapping must be consulted when porting features; this ADR is that map.
|
||||||
|
- `Notification` / `KnowledgeArticle` have no IIOS home yet — tracked as follow-ups, not blockers.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Rename `support-service` in place** to the IIOS vocabulary. Rejected: high-risk migration
|
||||||
|
of a live service + DB for a system we intend to retire anyway; no benefit over greenfield.
|
||||||
|
- **Keep both vocabularies and bridge with adapters.** Rejected: perpetuates naming drift and
|
||||||
|
the KG-16 confusion indefinitely.
|
||||||
|
- **`@lynkeduppro/*` scope.** Rejected: all IIOS packages already ship under `@insignia`;
|
||||||
|
switching scopes now is churn with no upside. (A future rebrand can supersede this ADR.)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Architecture Decision Records (ADRs)
|
||||||
|
|
||||||
|
This directory records the significant, hard-to-reverse decisions behind IIOS —
|
||||||
|
the *why*, not just the *what*. Code shows what we did; ADRs explain the choice so a
|
||||||
|
future contributor doesn't relitigate it or unknowingly violate it.
|
||||||
|
|
||||||
|
## Convention
|
||||||
|
|
||||||
|
- One file per decision: `NNNN-kebab-title.md` (zero-padded, monotonically increasing).
|
||||||
|
- Each ADR has: **Status**, **Context**, **Decision**, **Consequences**, and (where useful)
|
||||||
|
**Alternatives considered**.
|
||||||
|
- Status is one of `Proposed` · `Accepted` · `Superseded by ADR-XXXX` · `Deprecated`.
|
||||||
|
- ADRs are immutable once `Accepted`. To change a decision, write a new ADR that supersedes it.
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
| ADR | Title | Status |
|
||||||
|
|-----|-------|--------|
|
||||||
|
| [0001](0001-support-service-to-iios-naming-migration.md) | `support-service` → IIOS naming migration | Accepted |
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { EmailAdapter, emailInboundFixture, emailReplyFixture, signedFixture } from './index';
|
||||||
|
|
||||||
|
const ctx = { scope: { orgId: 'o', appId: 'a' } };
|
||||||
|
|
||||||
|
describe('EmailAdapter', () => {
|
||||||
|
const adapter = new EmailAdapter();
|
||||||
|
|
||||||
|
it('normalizes an inbound email into an ingest request', () => {
|
||||||
|
const req = adapter.normalize(emailInboundFixture, ctx);
|
||||||
|
expect(req.channel.type).toBe('EMAIL');
|
||||||
|
expect(req.source.handleKind).toBe('EXTERNAL_VISITOR');
|
||||||
|
expect(req.source.externalId).toBe(emailInboundFixture.from);
|
||||||
|
expect(req.parts[0]?.bodyText).toBe(emailInboundFixture.text);
|
||||||
|
expect(req.providerEventId).toBe('<m1@example.com>');
|
||||||
|
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>');
|
||||||
|
expect(req.thread?.subject).toBe('Need help with my order'); // Re:/Fwd: stripped
|
||||||
|
expect((req.metadata as { messageId: string }).messageId).toBe('<m1@example.com>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads a reply onto the SAME email thread as the original', () => {
|
||||||
|
const first = adapter.normalize(emailInboundFixture, ctx);
|
||||||
|
const reply = adapter.normalize(emailReplyFixture, ctx);
|
||||||
|
expect(reply.thread?.externalThreadId).toBe(first.thread?.externalThreadId); // email:<m1@example.com>
|
||||||
|
expect(reply.providerEventId).toBe('<m2@example.com>'); // but its own dedup id
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the reply target when there is no references chain', () => {
|
||||||
|
const req = adapter.normalize({ ...emailReplyFixture, references: undefined }, ctx);
|
||||||
|
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>'); // uses inReplyTo
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives text from html when no plain text is present', () => {
|
||||||
|
const req = adapter.normalize({ messageId: '<h@x>', from: 'x@y', html: '<p>Hello <b>there</b></p>' }, ctx);
|
||||||
|
expect(req.parts[0]?.bodyText).toBe('Hello there');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies an HMAC-signed body and rejects a bad/absent signature', () => {
|
||||||
|
const secret = 'sekret';
|
||||||
|
const { body, headers } = signedFixture(emailInboundFixture, secret);
|
||||||
|
expect(adapter.verifySignature(body, headers, secret)).toBe(true);
|
||||||
|
expect(adapter.verifySignature(body, { 'x-iios-signature': 'sha256=bad' }, secret)).toBe(false);
|
||||||
|
expect(adapter.verifySignature(body, {}, secret)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||||
|
import type { AdapterCapability, ChannelAdapter, NormalizeContext, OutboundCommandInput, SendResult } from './types';
|
||||||
|
import { hmacVerify } from './hmac';
|
||||||
|
import { SandboxSink } from './sandbox';
|
||||||
|
|
||||||
|
/** Canonical inbound-email shape. Vendor payloads (SendGrid Inbound Parse, Mailgun
|
||||||
|
* Routes, Postmark) map into this — provider glue stays a thin transform. */
|
||||||
|
export interface EmailInboundPayload {
|
||||||
|
messageId: string; // RFC 5322 Message-ID → providerEventId (dedup on redelivery)
|
||||||
|
from: string;
|
||||||
|
fromName?: string;
|
||||||
|
to?: string[];
|
||||||
|
cc?: string[];
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
inReplyTo?: string; // Message-ID this is a reply to
|
||||||
|
references?: string[]; // full reference chain, root-first (threading)
|
||||||
|
occurredAt?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripReply = (s?: string): string => (s ?? '').replace(/^(re|fwd|fw)\s*:\s*/i, '').trim();
|
||||||
|
const stripHtml = (h?: string): string => (h ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email channel adapter. Turns a provider's inbound email into a normalized
|
||||||
|
* interaction with email-native threading: the whole reply chain collapses to one
|
||||||
|
* IIOS thread (rooted at `references[0]`/`inReplyTo`), and `messageId` dedups
|
||||||
|
* redelivery. HMAC signature for now (per-vendor schemes are a follow-up).
|
||||||
|
*/
|
||||||
|
export class EmailAdapter implements ChannelAdapter {
|
||||||
|
readonly channelType = 'EMAIL';
|
||||||
|
readonly capability: AdapterCapability = {
|
||||||
|
canReceive: true,
|
||||||
|
canSend: true,
|
||||||
|
supportsThreads: true,
|
||||||
|
requiresFollowupFetch: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean {
|
||||||
|
const sig = headers['x-iios-signature'] ?? headers['X-Iios-Signature'];
|
||||||
|
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
externalEventId(payload: unknown): string | undefined {
|
||||||
|
const id = (payload as EmailInboundPayload)?.messageId;
|
||||||
|
return typeof id === 'string' ? id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
|
||||||
|
const p = payload as EmailInboundPayload;
|
||||||
|
// A reply's thread root is the first Message-ID in its chain → same IIOS thread as the original.
|
||||||
|
const threadRoot = p.references?.[0] ?? p.inReplyTo ?? p.messageId;
|
||||||
|
return {
|
||||||
|
scope: ctx.scope,
|
||||||
|
channel: { type: 'EMAIL', externalChannelId: 'email' },
|
||||||
|
source: { handleKind: 'EXTERNAL_VISITOR', externalId: p.from, displayName: p.fromName ?? p.from },
|
||||||
|
kind: 'MESSAGE',
|
||||||
|
thread: { externalThreadId: `email:${threadRoot}`, subject: stripReply(p.subject) },
|
||||||
|
parts: [{ kind: 'TEXT', bodyText: p.text ?? stripHtml(p.html), mimeType: 'text/plain' }],
|
||||||
|
occurredAt: p.occurredAt ?? new Date().toISOString(),
|
||||||
|
providerEventId: p.messageId,
|
||||||
|
metadata: { to: p.to, cc: p.cc, messageId: p.messageId, inReplyTo: p.inReplyTo, references: p.references, html: p.html },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(cmd: OutboundCommandInput): Promise<SendResult> {
|
||||||
|
return SandboxSink.simulate(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { hmacSign } from './hmac';
|
import { hmacSign } from './hmac';
|
||||||
import type { WebhookPayload } from './webhook-adapter';
|
import type { WebhookPayload } from './webhook-adapter';
|
||||||
|
import type { EmailInboundPayload } from './email-adapter';
|
||||||
|
|
||||||
/** Email-shaped provider payload (as the adapter's normalize input). */
|
/** Email-shaped WebhookPayload (legacy generic-webhook fixture). */
|
||||||
export const emailFixture: WebhookPayload = {
|
export const emailFixture: WebhookPayload = {
|
||||||
eventId: 'email-1',
|
eventId: 'email-1',
|
||||||
from: 'someone@example.com',
|
from: 'someone@example.com',
|
||||||
@@ -11,6 +12,30 @@ export const emailFixture: WebhookPayload = {
|
|||||||
text: 'Hello, I have a question.',
|
text: 'Hello, I have a question.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A first inbound email (starts a thread) for the EmailAdapter. */
|
||||||
|
export const emailInboundFixture: EmailInboundPayload = {
|
||||||
|
messageId: '<m1@example.com>',
|
||||||
|
from: 'buyer@example.com',
|
||||||
|
fromName: 'Buyer',
|
||||||
|
to: ['support@tower.example'],
|
||||||
|
subject: 'Need help with my order',
|
||||||
|
text: 'Hi, my order has not arrived yet.',
|
||||||
|
occurredAt: '2026-07-03T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A reply to `emailInboundFixture` — must land on the SAME email thread. */
|
||||||
|
export const emailReplyFixture: EmailInboundPayload = {
|
||||||
|
messageId: '<m2@example.com>',
|
||||||
|
from: 'buyer@example.com',
|
||||||
|
fromName: 'Buyer',
|
||||||
|
to: ['support@tower.example'],
|
||||||
|
subject: 'Re: Need help with my order',
|
||||||
|
text: 'Any update on this?',
|
||||||
|
inReplyTo: '<m1@example.com>',
|
||||||
|
references: ['<m1@example.com>'],
|
||||||
|
occurredAt: '2026-07-03T11:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
/** WhatsApp-shaped provider payload. */
|
/** WhatsApp-shaped provider payload. */
|
||||||
export const whatsappFixture: WebhookPayload = {
|
export const whatsappFixture: WebhookPayload = {
|
||||||
eventId: 'wa-1',
|
eventId: 'wa-1',
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from './types';
|
|||||||
export { hmacSign, hmacVerify } from './hmac';
|
export { hmacSign, hmacVerify } from './hmac';
|
||||||
export { SandboxSink } from './sandbox';
|
export { SandboxSink } from './sandbox';
|
||||||
export { WebhookAdapter, type WebhookPayload } from './webhook-adapter';
|
export { WebhookAdapter, type WebhookPayload } from './webhook-adapter';
|
||||||
export { emailFixture, whatsappFixture, signedFixture } from './fixtures';
|
export { EmailAdapter, type EmailInboundPayload } from './email-adapter';
|
||||||
|
export { emailFixture, whatsappFixture, emailInboundFixture, emailReplyFixture, signedFixture } from './fixtures';
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface ChannelAdapter {
|
|||||||
channelType: string;
|
channelType: string;
|
||||||
capability: AdapterCapability;
|
capability: AdapterCapability;
|
||||||
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean;
|
verifySignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean;
|
||||||
|
/** Provider-native id used to dedup replays/retries at the inbound edge (e.g. eventId, Message-ID). */
|
||||||
|
externalEventId?(payload: unknown): string | undefined;
|
||||||
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest;
|
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest;
|
||||||
fetch?(ref: string): Promise<unknown>;
|
fetch?(ref: string): Promise<unknown>;
|
||||||
send(cmd: OutboundCommandInput): Promise<SendResult>;
|
send(cmd: OutboundCommandInput): Promise<SendResult>;
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export class WebhookAdapter implements ChannelAdapter {
|
|||||||
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
|
return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
externalEventId(payload: unknown): string | undefined {
|
||||||
|
const id = (payload as WebhookPayload)?.eventId;
|
||||||
|
return typeof id === 'string' ? id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
|
normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest {
|
||||||
const p = payload as WebhookPayload;
|
const p = payload as WebhookPayload;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# iios-service configuration contract.
|
||||||
|
# Copy to `.env` for local dev; in prod, inject via your secrets manager / orchestrator.
|
||||||
|
# 🔒 = secret (never commit a real value). ⚠️ = must be set correctly for prod safety.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Core ─────────────────────────────────────────────────────────────────────
|
||||||
|
DATABASE_URL=postgresql://iios:iios@localhost:5434/iios?schema=public # 🔒 Postgres connection
|
||||||
|
PORT=3200
|
||||||
|
# NODE_ENV=production # set by the Docker image
|
||||||
|
# Realtime fan-out across replicas (socket.io Redis adapter). REQUIRED when running
|
||||||
|
# more than one instance with live chat; omit for a single instance (in-memory adapter).
|
||||||
|
# REDIS_URL=redis://localhost:6379 # 🔒
|
||||||
|
|
||||||
|
# ── Secrets ──────────────────────────────────────────────────────────────────
|
||||||
|
# JSON map of appId → HS256 signing secret used to verify session JWTs (SessionVerifier).
|
||||||
|
APP_SECRETS={"portal-demo":"dev-secret"} # 🔒
|
||||||
|
# JSON map of channelType → inbound webhook HMAC secret (adapter signature check).
|
||||||
|
ADAPTER_SECRETS={"WEBHOOK":"dev-adapter-secret"} # 🔒
|
||||||
|
# Default scope an unauthenticated adapter webhook ingests into.
|
||||||
|
ADAPTER_APP=portal-demo
|
||||||
|
ADAPTER_ORG=org_demo
|
||||||
|
|
||||||
|
# ── ⚠️ Production-safety flags ────────────────────────────────────────────────
|
||||||
|
# Enables /v1/dev/* (token mint, webhook inject, chaos, retention sweep). MUST be unset
|
||||||
|
# or 0 in production — leaving it on exposes unauthenticated token minting.
|
||||||
|
IIOS_DEV_TOKENS=0
|
||||||
|
# Skip `prisma migrate deploy` at boot (set to 1 when a separate migration job runs).
|
||||||
|
IIOS_SKIP_MIGRATE=0
|
||||||
|
|
||||||
|
# ── Tenant isolation ─────────────────────────────────────────────────────────
|
||||||
|
IIOS_CELL_ID=cell-default # physical cell this instance serves (blast-radius partition)
|
||||||
|
|
||||||
|
# ── Background workers ───────────────────────────────────────────────────────
|
||||||
|
IIOS_RELAY_INTERVAL_MS=500 # outbox relay tick; 0 disables the timer
|
||||||
|
IIOS_OUTBOX_MAX_ATTEMPTS=5 # relay dead-letters an event after N failed publishes
|
||||||
|
IIOS_RETENTION_SWEEP_INTERVAL_MS=0 # retention sweep tick; 0 disables (run via job instead)
|
||||||
|
IIOS_RETENTION_POLICY_VERSION=v1
|
||||||
|
IIOS_RETENTION_ARCHIVE_DAYS=90 # default archive window (per-class override: _INTERNAL/_RESTRICTED/_CONFIDENTIAL/_REGULATED)
|
||||||
|
IIOS_RETENTION_DELETE_DAYS=365 # default delete (redact) window; same per-class overrides
|
||||||
|
|
||||||
|
# ── Rate limits / quotas / budgets ───────────────────────────────────────────
|
||||||
|
IIOS_OUTBOUND_LIMIT=5 # per-(channel,target) sends per window
|
||||||
|
IIOS_OUTBOUND_WINDOW_MS=60000
|
||||||
|
IIOS_TENANT_OUTBOUND_LIMIT=10000 # per-tenant egress cap per window (noisy-neighbor guard)
|
||||||
|
IIOS_TENANT_OUTBOUND_WINDOW_MS=60000
|
||||||
|
IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
|
||||||
|
|
||||||
|
# ── Capability providers (governed egress targets) ───────────────────────────
|
||||||
|
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
|
||||||
|
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Production image for @insignia/iios-service (the single IIOS backend).
|
||||||
|
# Build context MUST be the monorepo ROOT (it needs the workspace + lockfile):
|
||||||
|
# docker build -f packages/iios-service/Dockerfile -t iios-service:latest .
|
||||||
|
#
|
||||||
|
# Multi-stage: (1) build the service + its workspace deps and prune to a
|
||||||
|
# self-contained prod bundle via `pnpm deploy`; (2) a slim runtime that only
|
||||||
|
# carries that bundle. Migrations run at container start (see docker-entrypoint.sh).
|
||||||
|
|
||||||
|
ARG NODE_VERSION=22-bookworm-slim
|
||||||
|
ARG PNPM_VERSION=10.6.2
|
||||||
|
|
||||||
|
# ── build ───────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:${NODE_VERSION} AS build
|
||||||
|
ARG PNPM_VERSION
|
||||||
|
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
|
||||||
|
# openssl + ca-certificates are required by Prisma's engine on debian-slim.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||||
|
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Full install (needs devDeps to compile), then build ONLY the service + its
|
||||||
|
# workspace dependencies, generate the Prisma client, and emit a pruned prod bundle.
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
# Generate the Prisma client BEFORE compiling — nest build (tsc) needs its types,
|
||||||
|
# otherwise Prisma results are `any` and noImplicitAny fails the build.
|
||||||
|
RUN pnpm --filter @insignia/iios-service exec prisma generate \
|
||||||
|
&& pnpm --filter "@insignia/iios-service..." build \
|
||||||
|
&& pnpm --filter @insignia/iios-service --prod --legacy deploy /app
|
||||||
|
|
||||||
|
# ── runtime ──────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:${NODE_VERSION} AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Prisma engine needs openssl at runtime too.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app ./
|
||||||
|
# Regenerate the Prisma client against the final filesystem (deterministic).
|
||||||
|
RUN node_modules/.bin/prisma generate
|
||||||
|
|
||||||
|
# Run as the built-in non-root `node` user.
|
||||||
|
RUN chmod +x docker-entrypoint.sh && chown -R node:node /app
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3200
|
||||||
|
ENV PORT=3200
|
||||||
|
# migrate deploy → start; see docker-entrypoint.sh.
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Container entrypoint for iios-service.
|
||||||
|
# 1) Apply pending DB migrations (idempotent; safe to run on every boot).
|
||||||
|
# 2) Exec the server as PID 1 so signals (SIGTERM) reach Node for graceful shutdown.
|
||||||
|
#
|
||||||
|
# NOTE: at higher replica counts, prefer running `prisma migrate deploy` ONCE as a
|
||||||
|
# separate pre-deploy job/init-container and set IIOS_SKIP_MIGRATE=1 here, so N
|
||||||
|
# replicas don't race the migration on rollout.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${IIOS_SKIP_MIGRATE:-0}" != "1" ]; then
|
||||||
|
echo "[entrypoint] applying migrations (prisma migrate deploy)…"
|
||||||
|
node_modules/.bin/prisma migrate deploy
|
||||||
|
else
|
||||||
|
echo "[entrypoint] IIOS_SKIP_MIGRATE=1 — skipping migrations"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] starting iios-service on :${PORT:-3200}"
|
||||||
|
exec node dist/main.js
|
||||||
@@ -12,21 +12,28 @@
|
|||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@insignia/iios-contracts": "workspace:*",
|
|
||||||
"@insignia/iios-adapter-sdk": "workspace:*",
|
"@insignia/iios-adapter-sdk": "workspace:*",
|
||||||
|
"@insignia/iios-contracts": "workspace:*",
|
||||||
"@nestjs/common": "^11.1.27",
|
"@nestjs/common": "^11.1.27",
|
||||||
"@nestjs/core": "^11.1.27",
|
"@nestjs/core": "^11.1.27",
|
||||||
"@nestjs/platform-express": "^11.1.27",
|
"@nestjs/platform-express": "^11.1.27",
|
||||||
"@nestjs/platform-socket.io": "^11.1.27",
|
"@nestjs/platform-socket.io": "^11.1.27",
|
||||||
"@nestjs/websockets": "^11.1.27",
|
"@nestjs/websockets": "^11.1.27",
|
||||||
"@prisma/client": "^6.2.1",
|
"@prisma/client": "^6.2.1",
|
||||||
"socket.io": "^4.8.3",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"ioredis": "^5.11.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"prisma": "^6.2.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
|
"@opentelemetry/api": "^1.9.1",
|
||||||
|
"@opentelemetry/sdk-node": "^0.220.0",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.78.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@insignia/iios-testkit": "workspace:*",
|
"@insignia/iios-testkit": "workspace:*",
|
||||||
@@ -35,7 +42,7 @@
|
|||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
"prisma": "^6.2.1",
|
"@types/web-push": "^3.6.4",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "IiosInteraction" ADD COLUMN "dataClass" TEXT NOT NULL DEFAULT 'internal';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IiosRetentionPolicySnapshot" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"policyKey" TEXT NOT NULL,
|
||||||
|
"scopeSnapshotId" TEXT NOT NULL,
|
||||||
|
"targetType" TEXT NOT NULL,
|
||||||
|
"targetId" TEXT NOT NULL,
|
||||||
|
"dataClass" TEXT NOT NULL,
|
||||||
|
"retainUntil" TIMESTAMP(3),
|
||||||
|
"archiveAfter" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deleteAfter" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sourceVersion" TEXT NOT NULL DEFAULT 'v1',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "IiosRetentionPolicySnapshot_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosRetentionPolicySnapshot_status_deleteAfter_idx" ON "IiosRetentionPolicySnapshot"("status", "deleteAfter");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosRetentionPolicySnapshot_scopeSnapshotId_status_idx" ON "IiosRetentionPolicySnapshot"("scopeSnapshotId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IiosRetentionPolicySnapshot_targetType_targetId_key" ON "IiosRetentionPolicySnapshot"("targetType", "targetId");
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IiosInteractionAnnotation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"scopeId" TEXT NOT NULL,
|
||||||
|
"targetInteractionId" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT NOT NULL,
|
||||||
|
"annotationType" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "IiosInteractionAnnotation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosInteractionAnnotation_targetInteractionId_idx" ON "IiosInteractionAnnotation"("targetInteractionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IiosInteractionAnnotation_scopeId_targetInteractionId_actor_key" ON "IiosInteractionAnnotation"("scopeId", "targetInteractionId", "actorId", "annotationType", "value");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_targetInteractionId_fkey" FOREIGN KEY ("targetInteractionId") REFERENCES "IiosInteraction"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "IiosInboxItemKind" ADD VALUE 'MENTION';
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "IiosThreadParticipant" ADD COLUMN "muted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IiosNotificationSubscription" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"scopeId" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL DEFAULT 'webpush',
|
||||||
|
"endpoint" TEXT NOT NULL,
|
||||||
|
"p256dh" TEXT NOT NULL,
|
||||||
|
"auth" TEXT NOT NULL,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "IiosNotificationSubscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IiosNotificationSubscription_endpoint_key" ON "IiosNotificationSubscription"("endpoint");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosNotificationSubscription_actorId_idx" ON "IiosNotificationSubscription"("actorId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosNotificationSubscription" ADD CONSTRAINT "IiosNotificationSubscription_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -79,6 +79,7 @@ enum IiosInboxItemKind {
|
|||||||
NEEDS_REPLY
|
NEEDS_REPLY
|
||||||
NEEDS_REVIEW
|
NEEDS_REVIEW
|
||||||
NEEDS_APPROVAL
|
NEEDS_APPROVAL
|
||||||
|
MENTION
|
||||||
SUPPORT_UPDATE
|
SUPPORT_UPDATE
|
||||||
MEETING_FOLLOWUP
|
MEETING_FOLLOWUP
|
||||||
DIGEST
|
DIGEST
|
||||||
@@ -303,10 +304,12 @@ model IiosScope {
|
|||||||
channels IiosChannel[]
|
channels IiosChannel[]
|
||||||
threads IiosThread[]
|
threads IiosThread[]
|
||||||
interactions IiosInteraction[]
|
interactions IiosInteraction[]
|
||||||
|
annotations IiosInteractionAnnotation[]
|
||||||
inboxItems IiosInboxItem[]
|
inboxItems IiosInboxItem[]
|
||||||
supportQueues IiosSupportQueue[]
|
supportQueues IiosSupportQueue[]
|
||||||
tickets IiosTicket[]
|
tickets IiosTicket[]
|
||||||
callbacks IiosCallbackRequest[]
|
callbacks IiosCallbackRequest[]
|
||||||
|
notificationSubscriptions IiosNotificationSubscription[]
|
||||||
|
|
||||||
@@index([orgId, appId, tenantId])
|
@@index([orgId, appId, tenantId])
|
||||||
}
|
}
|
||||||
@@ -348,6 +351,7 @@ model IiosActorRef {
|
|||||||
threadsCreated IiosThread[] @relation("ThreadCreatedBy")
|
threadsCreated IiosThread[] @relation("ThreadCreatedBy")
|
||||||
participations IiosThreadParticipant[]
|
participations IiosThreadParticipant[]
|
||||||
interactions IiosInteraction[]
|
interactions IiosInteraction[]
|
||||||
|
annotations IiosInteractionAnnotation[]
|
||||||
receipts IiosMessageReceipt[]
|
receipts IiosMessageReceipt[]
|
||||||
unreadCounters IiosUnreadCounter[]
|
unreadCounters IiosUnreadCounter[]
|
||||||
inboxItemsOwned IiosInboxItem[]
|
inboxItemsOwned IiosInboxItem[]
|
||||||
@@ -355,6 +359,7 @@ model IiosActorRef {
|
|||||||
ticketsRequested IiosTicket[] @relation("TicketRequester")
|
ticketsRequested IiosTicket[] @relation("TicketRequester")
|
||||||
ticketsAssigned IiosTicket[] @relation("TicketAssignee")
|
ticketsAssigned IiosTicket[] @relation("TicketAssignee")
|
||||||
callbacksRequested IiosCallbackRequest[]
|
callbacksRequested IiosCallbackRequest[]
|
||||||
|
notificationSubscriptions IiosNotificationSubscription[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A configured channel surface (PORTAL in P1).
|
/// A configured channel surface (PORTAL in P1).
|
||||||
@@ -409,12 +414,32 @@ model IiosThreadParticipant {
|
|||||||
actorId String
|
actorId String
|
||||||
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
||||||
participantRole String @default("MEMBER")
|
participantRole String @default("MEMBER")
|
||||||
|
muted Boolean @default(false)
|
||||||
joinedAt DateTime @default(now())
|
joinedAt DateTime @default(now())
|
||||||
leftAt DateTime?
|
leftAt DateTime?
|
||||||
|
|
||||||
@@id([threadId, actorId])
|
@@id([threadId, actorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A device/browser push subscription for an actor (Web Push endpoint + keys).
|
||||||
|
/// The notification engine delivers to these when the actor is absent.
|
||||||
|
model IiosNotificationSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scopeId String
|
||||||
|
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||||
|
actorId String
|
||||||
|
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
||||||
|
kind String @default("webpush")
|
||||||
|
endpoint String @unique
|
||||||
|
p256dh String
|
||||||
|
auth String
|
||||||
|
userAgent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
lastSeenAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([actorId])
|
||||||
|
}
|
||||||
|
|
||||||
/// The semantic envelope. Idempotent per (scope, idempotencyKey).
|
/// The semantic envelope. Idempotent per (scope, idempotencyKey).
|
||||||
model IiosInteraction {
|
model IiosInteraction {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -435,6 +460,7 @@ model IiosInteraction {
|
|||||||
occurredAt DateTime @default(now())
|
occurredAt DateTime @default(now())
|
||||||
receivedAt DateTime @default(now())
|
receivedAt DateTime @default(now())
|
||||||
status IiosDeliveryState @default(RECEIVED)
|
status IiosDeliveryState @default(RECEIVED)
|
||||||
|
dataClass String @default("internal") // envelope data class; drives retention window (P9)
|
||||||
policyDecisionRef String?
|
policyDecisionRef String?
|
||||||
consentReceiptRef String?
|
consentReceiptRef String?
|
||||||
crreBundleRef String?
|
crreBundleRef String?
|
||||||
@@ -443,6 +469,7 @@ model IiosInteraction {
|
|||||||
|
|
||||||
parts IiosMessagePart[]
|
parts IiosMessagePart[]
|
||||||
receipts IiosMessageReceipt[]
|
receipts IiosMessageReceipt[]
|
||||||
|
annotations IiosInteractionAnnotation[]
|
||||||
inboxItems IiosInboxItem[]
|
inboxItems IiosInboxItem[]
|
||||||
ticketsCreatedFrom IiosTicket[]
|
ticketsCreatedFrom IiosTicket[]
|
||||||
|
|
||||||
@@ -450,6 +477,26 @@ model IiosInteraction {
|
|||||||
@@index([threadId, occurredAt])
|
@@index([threadId, occurredAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A generic annotation an actor attaches to an interaction. Both `annotationType`
|
||||||
|
/// and `value` are OPAQUE, app-supplied strings the kernel stores and aggregates
|
||||||
|
/// but never interprets — e.g. the chat app writes type "reaction" / value "👍".
|
||||||
|
/// The same primitive backs pins, saves, flags, tags later. No chat vocabulary here.
|
||||||
|
model IiosInteractionAnnotation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scopeId String
|
||||||
|
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||||
|
targetInteractionId String
|
||||||
|
target IiosInteraction @relation(fields: [targetInteractionId], references: [id], onDelete: Cascade)
|
||||||
|
actorId String
|
||||||
|
actor IiosActorRef @relation(fields: [actorId], references: [id])
|
||||||
|
annotationType String
|
||||||
|
value String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([scopeId, targetInteractionId, actorId, annotationType, value])
|
||||||
|
@@index([targetInteractionId])
|
||||||
|
}
|
||||||
|
|
||||||
model IiosMessagePart {
|
model IiosMessagePart {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
interactionId String
|
interactionId String
|
||||||
@@ -493,6 +540,27 @@ model IiosProcessedEvent {
|
|||||||
@@id([consumerName, eventId])
|
@@id([consumerName, eventId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-resource retention snapshot (P9). Absolute lifecycle timestamps frozen from the
|
||||||
|
/// resource's age + policy window; read by the retention sweep. delete = redact-in-place.
|
||||||
|
model IiosRetentionPolicySnapshot {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
policyKey String
|
||||||
|
scopeSnapshotId String
|
||||||
|
targetType String // 'interaction'
|
||||||
|
targetId String
|
||||||
|
dataClass String
|
||||||
|
retainUntil DateTime?
|
||||||
|
archiveAfter DateTime
|
||||||
|
deleteAfter DateTime
|
||||||
|
sourceVersion String @default("v1")
|
||||||
|
status String @default("ACTIVE") // ACTIVE | ARCHIVED | REDACTED
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([targetType, targetId])
|
||||||
|
@@index([status, deleteAfter])
|
||||||
|
@@index([scopeSnapshotId, status])
|
||||||
|
}
|
||||||
|
|
||||||
/// Legal/compliance hold (P9 DSR). An ACTIVE, unexpired hold over a target BLOCKS
|
/// Legal/compliance hold (P9 DSR). An ACTIVE, unexpired hold over a target BLOCKS
|
||||||
/// erasure/retention deletion until an authorised release. Never auto-released.
|
/// erasure/retention deletion until an authorised release. Never auto-released.
|
||||||
model IiosComplianceHold {
|
model IiosComplianceHold {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// P9 slice-8 DSR smoke: right-to-erasure redacts a subject's PII in place (a ticket
|
||||||
|
// subject), an active compliance hold blocks erasure until released, erasure is
|
||||||
|
// idempotent, and holds are tenant-fenced (KG-03 / KG-02). Requires the service
|
||||||
|
// running with IIOS_DEV_TOKENS=1.
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
|
||||||
|
async function devToken(userId, orgId) {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/dev/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`);
|
||||||
|
return (await r.json()).token;
|
||||||
|
}
|
||||||
|
function call(token, path, method = 'GET', body) {
|
||||||
|
return fetch(`${SERVICE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function json(token, path, method = 'GET', body) {
|
||||||
|
const r = await call(token, path, method, body);
|
||||||
|
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
const ticketSubject = async (token, id) => (await json(token, '/v1/support/tickets')).find((t) => t.id === id)?.subject;
|
||||||
|
|
||||||
|
const A = await devToken('dsr-a', 'org_dsr_A');
|
||||||
|
const B = await devToken('dsr-b', 'org_dsr_B');
|
||||||
|
const PII = 'my SSN is 123-45';
|
||||||
|
|
||||||
|
// A creates a ticket carrying PII.
|
||||||
|
const ticket = await json(A, '/v1/support/tickets', 'POST', { subject: PII });
|
||||||
|
assert((await ticketSubject(A, ticket.id)) === PII, 'ticket carries the subject PII');
|
||||||
|
|
||||||
|
// A places a compliance hold on their own subject.
|
||||||
|
const hold = await json(A, '/v1/dsr/holds', 'POST', { targetType: 'data_subject', targetId: 'self', reason: 'litigation' });
|
||||||
|
assert((await json(A, '/v1/dsr/holds')).some((h) => h.id === hold.id), 'compliance hold is listed');
|
||||||
|
|
||||||
|
// Erasure is blocked while the hold is active.
|
||||||
|
const blocked = await call(A, '/v1/dsr/erase', 'POST');
|
||||||
|
assert(blocked.status === 409, `erase blocked by the hold → 409 (got ${blocked.status})`);
|
||||||
|
assert((await ticketSubject(A, ticket.id)) === PII, 'PII still present while held');
|
||||||
|
|
||||||
|
// Release the hold → erasure now redacts the PII in place.
|
||||||
|
await json(A, `/v1/dsr/holds/${hold.id}/release`, 'POST');
|
||||||
|
const erased = await json(A, '/v1/dsr/erase', 'POST');
|
||||||
|
assert(erased.status === 'ERASED', `erase succeeds after release (status ${erased.status})`);
|
||||||
|
assert((await ticketSubject(A, ticket.id)) === '[redacted]', 'ticket subject is redacted (erased in place, not deleted)');
|
||||||
|
|
||||||
|
// Idempotent: a second erase is a no-op.
|
||||||
|
const again = await json(A, '/v1/dsr/erase', 'POST');
|
||||||
|
assert(again.status === 'ALREADY_ERASED', `re-erase is idempotent (status ${again.status})`);
|
||||||
|
|
||||||
|
// Tenant fence: tenant B never sees A's holds.
|
||||||
|
assert(!(await json(B, '/v1/dsr/holds')).some((h) => h.id === hold.id), "tenant B's hold list excludes A's hold");
|
||||||
|
|
||||||
|
console.log('\nP9 DSR smoke: PASS');
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Email adapter smoke: a signed inbound email → normalized interaction; a reply
|
||||||
|
// (In-Reply-To) lands on the SAME email thread; outbound EMAIL → SENT (sandbox).
|
||||||
|
// Requires the service running with IIOS_DEV_TOKENS=1 (relay timer on). No real network.
|
||||||
|
import 'dotenv/config';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const APP_SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
|
||||||
|
const EMAIL_SECRET = (() => {
|
||||||
|
try { return JSON.parse(process.env.ADAPTER_SECRETS ?? '{}').EMAIL ?? 'dev-adapter-secret'; } catch { return 'dev-adapter-secret'; }
|
||||||
|
})();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const token = jwt.sign({ sub: 'inspector', appId: APP_ID, orgId: `org_${APP_ID}` }, APP_SECRET, { algorithm: 'HS256', expiresIn: '1h' });
|
||||||
|
const sign = (body) => 'sha256=' + crypto.createHmac('sha256', EMAIL_SECRET).update(body).digest('hex');
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
|
||||||
|
async function postEmail(payload) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
return fetch(`${SERVICE}/v1/adapters/EMAIL/webhook`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-iios-signature': sign(body) },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function inboundList() {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/adapters/inbound`, { headers: { authorization: `Bearer ${token}` } });
|
||||||
|
if (!r.ok) throw new Error(`GET /v1/adapters/inbound ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function waitNormalized(messageId) {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const e = (await inboundList()).find((x) => x.externalEventId === messageId);
|
||||||
|
if (e && e.status === 'NORMALIZED' && e.interactionId) return e.interactionId;
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique Message-IDs per run so re-runs don't dedup against earlier ones.
|
||||||
|
const run = randomUUID().slice(0, 8);
|
||||||
|
const mid1 = `<${run}-1@smoke>`;
|
||||||
|
const mid2 = `<${run}-2@smoke>`;
|
||||||
|
|
||||||
|
// 1) A first inbound email → normalized interaction.
|
||||||
|
const r1 = await postEmail({ messageId: mid1, from: 'buyer@smoke', fromName: 'Buyer', subject: 'Need help with my order', text: 'my order is late' });
|
||||||
|
assert(r1.status === 202, 'signed email accepted (202)');
|
||||||
|
const i1 = await waitNormalized(mid1);
|
||||||
|
assert(i1, 'first email normalized into an interaction');
|
||||||
|
|
||||||
|
// 2) A reply (In-Reply-To) → same email thread.
|
||||||
|
const r2 = await postEmail({ messageId: mid2, from: 'buyer@smoke', subject: 'Re: Need help with my order', text: 'any update?', inReplyTo: mid1, references: [mid1] });
|
||||||
|
assert(r2.status === 202, 'reply email accepted (202)');
|
||||||
|
const i2 = await waitNormalized(mid2);
|
||||||
|
assert(i2, 'reply email normalized into an interaction');
|
||||||
|
|
||||||
|
const [it1, it2] = await Promise.all([
|
||||||
|
prisma.iiosInteraction.findUniqueOrThrow({ where: { id: i1 }, include: { thread: true } }),
|
||||||
|
prisma.iiosInteraction.findUniqueOrThrow({ where: { id: i2 }, include: { thread: true } }),
|
||||||
|
]);
|
||||||
|
assert(it1.threadId === it2.threadId, 'reply joined the SAME email thread as the original');
|
||||||
|
assert(it1.thread?.externalThreadRef === `email:${mid1}`, `thread rooted at the original Message-ID (${it1.thread?.externalThreadRef})`);
|
||||||
|
|
||||||
|
// 3) Bad signature → rejected.
|
||||||
|
const bad = await fetch(`${SERVICE}/v1/adapters/EMAIL/webhook`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-iios-signature': 'sha256=bad' },
|
||||||
|
body: JSON.stringify({ messageId: `<${run}-bad@smoke>`, from: 'x', text: 'x' }),
|
||||||
|
});
|
||||||
|
assert(bad.status === 401, 'bad signature rejected (401)');
|
||||||
|
|
||||||
|
// 4) Outbound EMAIL → governed egress → SENT (sandbox).
|
||||||
|
const sendRes = await fetch(`${SERVICE}/v1/adapters/EMAIL/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ target: 'buyer@smoke', payload: { subject: 'Re: Need help', text: 'thanks, looking into it' } }),
|
||||||
|
});
|
||||||
|
const cmd = await sendRes.json();
|
||||||
|
assert(cmd.status === 'SENT', `outbound email SENT via broker (status ${cmd.status})`);
|
||||||
|
|
||||||
|
console.log('\nEmail adapter smoke: PASS');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// v1.1 smoke: dev IdP login, policy-enforced DM cap / group membership, listThreads,
|
||||||
|
// and threaded replies — all over REST. Requires the service with IIOS_DEV_TOKENS=1.
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/dev/login`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return { status: r.status, body: r.ok ? await r.json() : null };
|
||||||
|
}
|
||||||
|
function call(token, path, method = 'GET', body) {
|
||||||
|
return fetch(`${SERVICE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function json(token, path, method = 'GET', body) {
|
||||||
|
const r = await call(token, path, method, body);
|
||||||
|
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) dev IdP: correct password issues a token; wrong password is rejected.
|
||||||
|
const ok = await login('alice', 'alice');
|
||||||
|
assert(ok.body?.token, 'dev IdP: alice logs in with password');
|
||||||
|
const bad = await login('alice', 'nope');
|
||||||
|
assert(bad.status === 401, 'dev IdP: wrong password → 401');
|
||||||
|
const alice = ok.body.token;
|
||||||
|
|
||||||
|
// 2) DM is capped at two (policy).
|
||||||
|
const dm = await json(alice, '/v1/threads', 'POST', { membership: 'dm' });
|
||||||
|
assert(dm.threadId, `alice created a DM (${dm.threadId})`);
|
||||||
|
const add2 = await json(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'bob' });
|
||||||
|
assert(add2.participantCount === 2, 'added bob → 2 participants');
|
||||||
|
const add3 = await call(alice, `/v1/threads/${dm.threadId}/participants`, 'POST', { userId: 'carol' });
|
||||||
|
assert(add3.status === 403, `adding a 3rd to a DM → 403 policy denied (${add3.status})`);
|
||||||
|
|
||||||
|
// 3) group: creator (ADMIN) can add several.
|
||||||
|
const grp = await json(alice, '/v1/threads', 'POST', { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'bob' });
|
||||||
|
const g3 = await json(alice, `/v1/threads/${grp.threadId}/participants`, 'POST', { userId: 'carol' });
|
||||||
|
assert(g3.participantCount === 3, 'group admin added bob + carol → 3 participants');
|
||||||
|
|
||||||
|
// 4) threaded reply round-trips.
|
||||||
|
const first = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'question?' });
|
||||||
|
const reply = await json(alice, `/v1/threads/${grp.threadId}/messages`, 'POST', { content: 'answer', parentInteractionId: first.id });
|
||||||
|
assert(reply.parentInteractionId === first.id, 'a reply carries parentInteractionId');
|
||||||
|
|
||||||
|
// 5) listThreads shows the caller's threads with membership + last message.
|
||||||
|
const mine = await json(alice, '/v1/threads');
|
||||||
|
const g = mine.find((t) => t.threadId === grp.threadId);
|
||||||
|
assert(g && g.membership === 'group' && g.participantCount === 3, 'GET /v1/threads lists the group with membership + count');
|
||||||
|
assert(g.lastMessage === 'answer', 'thread summary carries the last message');
|
||||||
|
|
||||||
|
console.log('\nv1.1 membership + replies smoke: PASS');
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// P9 scaling smoke: cross-instance realtime fan-out via the socket.io Redis adapter.
|
||||||
|
// Alice connects to instance A, Bob to instance B (two separate processes sharing one
|
||||||
|
// Redis + DB). Alice sends on A; Bob must receive it live on B — which only works if the
|
||||||
|
// Redis adapter fans room emits across replicas. Set A_URL / B_URL (default 3201/3202).
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const A_URL = process.env.A_URL ?? 'http://localhost:3201';
|
||||||
|
const B_URL = process.env.B_URL ?? 'http://localhost:3202';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID];
|
||||||
|
|
||||||
|
const sign = (userId) =>
|
||||||
|
jwt.sign({ sub: userId, name: userId, appId: APP_ID, orgId: `org_${APP_ID}` }, SECRET, { algorithm: 'HS256', expiresIn: '1h' });
|
||||||
|
const connect = (url, userId) =>
|
||||||
|
io(`${url}/message`, { auth: { token: sign(userId) }, transports: ['websocket'], forceNew: true });
|
||||||
|
const once = (socket, event, ms = 8000) =>
|
||||||
|
new Promise((res, rej) => {
|
||||||
|
const t = setTimeout(() => rej(new Error(`timeout waiting for "${event}"`)), ms);
|
||||||
|
socket.once(event, (v) => { clearTimeout(t); res(v); });
|
||||||
|
});
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
|
||||||
|
const alice = connect(A_URL, 'alice-cluster');
|
||||||
|
const bob = connect(B_URL, 'bob-cluster');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([once(alice, 'connect'), once(bob, 'connect')]);
|
||||||
|
assert(true, `Alice connected to A (${A_URL}), Bob to B (${B_URL})`);
|
||||||
|
|
||||||
|
// Alice opens a thread on instance A; membership is governed so Alice ADDS Bob, who
|
||||||
|
// then opens the SAME thread on instance B.
|
||||||
|
const opened = await alice.emitWithAck('open_thread', {});
|
||||||
|
const threadId = opened.threadId;
|
||||||
|
assert(!!threadId, `Alice opened thread ${threadId} on instance A`);
|
||||||
|
await alice.emitWithAck('add_participant', { threadId, userId: 'bob-cluster' });
|
||||||
|
await bob.emitWithAck('open_thread', { threadId });
|
||||||
|
assert(true, 'Bob joined the same thread on instance B');
|
||||||
|
|
||||||
|
// Alice sends on A → Bob must receive it on B (cross-instance fan-out via Redis).
|
||||||
|
const bobReceives = once(bob, 'message');
|
||||||
|
await alice.emitWithAck('send_message', { threadId, content: 'hello across instances' });
|
||||||
|
const received = await bobReceives;
|
||||||
|
assert(received.content === 'hello across instances', `Bob received "${received.content}" on B — emitted from A ✓`);
|
||||||
|
|
||||||
|
console.log('\nP9 cross-instance realtime smoke: PASS');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('✗', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
alice.close();
|
||||||
|
bob.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
@@ -44,10 +44,11 @@ const alice = connect('alice');
|
|||||||
const bob = connect('bob');
|
const bob = connect('bob');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Alice creates a thread; Bob joins it.
|
// Alice creates a thread; membership is governed, so Alice ADDS Bob (he can't self-join).
|
||||||
const opened = await alice.emitWithAck('open_thread', {});
|
const opened = await alice.emitWithAck('open_thread', {});
|
||||||
const threadId = opened.threadId;
|
const threadId = opened.threadId;
|
||||||
assert(!!threadId, `Alice created thread ${threadId}`);
|
assert(!!threadId, `Alice created thread ${threadId}`);
|
||||||
|
await alice.emitWithAck('add_participant', { threadId, userId: 'bob' });
|
||||||
await bob.emitWithAck('open_thread', { threadId });
|
await bob.emitWithAck('open_thread', { threadId });
|
||||||
|
|
||||||
// Alice sends; Bob should receive it live.
|
// Alice sends; Bob should receive it live.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// P9 slice-9 retention smoke: an aged interaction's snapshot passes its delete window
|
||||||
|
// and the sweep redacts the content in place (never a hard delete), visible as a
|
||||||
|
// REDACTED snapshot on /metrics. Requires the service running with IIOS_DEV_TOKENS=1
|
||||||
|
// and IIOS_RETENTION_DELETE_DAYS=0 IIOS_RETENTION_ARCHIVE_DAYS=0 (new content due at once;
|
||||||
|
// scheduled interval left OFF so only the explicit dev sweep runs).
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200';
|
||||||
|
const APP_ID = 'portal-demo';
|
||||||
|
const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); };
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function devToken(userId, orgId) {
|
||||||
|
const r = await fetch(`${SERVICE}/v1/dev/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ appId: APP_ID, userId, name: userId, orgId }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`devToken ${r.status} (service with IIOS_DEV_TOKENS=1?)`);
|
||||||
|
return (await r.json()).token;
|
||||||
|
}
|
||||||
|
async function api(token, path, method = 'GET', body) {
|
||||||
|
const r = await fetch(`${SERVICE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
const redactedCount = (m) => m.retention?.byStatus?.REDACTED ?? 0;
|
||||||
|
|
||||||
|
const token = await devToken('ret-a', 'org_ret_A');
|
||||||
|
|
||||||
|
const base = redactedCount(await api(token, '/metrics'));
|
||||||
|
console.log(`baseline REDACTED snapshots = ${base}`);
|
||||||
|
|
||||||
|
// Inject content and give the relay a moment to normalize it into an interaction.
|
||||||
|
await api(token, '/v1/dev/webhook/WEBHOOK', 'POST', { text: 'retention smoke content' });
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
// Sweep: 0-day windows make every interaction's snapshot immediately due → redacted.
|
||||||
|
const swept = await api(token, '/v1/dev/retention/sweep', 'POST');
|
||||||
|
assert(swept.redacted >= 1, `sweep redacted the aged interaction(s) (redacted=${swept.redacted})`);
|
||||||
|
|
||||||
|
const after = await api(token, '/metrics');
|
||||||
|
assert(redactedCount(after) > base, `/metrics shows more REDACTED snapshots (${base} → ${redactedCount(after)})`);
|
||||||
|
assert((after.retention?.total ?? 0) >= 1, 'retention.total reflects captured snapshots');
|
||||||
|
|
||||||
|
console.log('\nP9 retention smoke: PASS');
|
||||||
|
process.exit(0);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { ScopeVector } from '@insignia/iios-contracts';
|
import type { ScopeVector } from '@insignia/iios-contracts';
|
||||||
import { WebhookAdapter, type ChannelAdapter } from '@insignia/iios-adapter-sdk';
|
import { WebhookAdapter, EmailAdapter, type ChannelAdapter } from '@insignia/iios-adapter-sdk';
|
||||||
|
|
||||||
export interface AdapterConfig {
|
export interface AdapterConfig {
|
||||||
secret: string;
|
secret: string;
|
||||||
@@ -8,10 +8,9 @@ export interface AdapterConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers channel adapters + their per-channel config (secret + scope). In P5
|
* Registers channel adapters + their per-channel config (secret + scope). EMAIL uses
|
||||||
* one reference WebhookAdapter backs several channelTypes (WEBHOOK/EMAIL/WHATSAPP)
|
* the email-native EmailAdapter (RFC threading); the rest use the reference
|
||||||
* so the fixtures exercise the same anti-corruption pipeline. Secrets come from
|
* WebhookAdapter. Secrets come from ADAPTER_SECRETS (JSON map); scope defaults to demo.
|
||||||
* ADAPTER_SECRETS (JSON map); scope defaults to the demo scope.
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdapterRegistry {
|
export class AdapterRegistry {
|
||||||
@@ -29,8 +28,9 @@ export class AdapterRegistry {
|
|||||||
appId: process.env.ADAPTER_APP ?? 'portal-demo',
|
appId: process.env.ADAPTER_APP ?? 'portal-demo',
|
||||||
};
|
};
|
||||||
for (const channelType of ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']) {
|
for (const channelType of ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL']) {
|
||||||
|
const adapter: ChannelAdapter = channelType === 'EMAIL' ? new EmailAdapter() : new WebhookAdapter(channelType);
|
||||||
this.adapters.set(channelType, {
|
this.adapters.set(channelType, {
|
||||||
adapter: new WebhookAdapter(channelType),
|
adapter,
|
||||||
config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope },
|
config: { secret: secrets[channelType] ?? 'dev-adapter-secret', scope },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { DlqService } from '../outbox/dlq.service';
|
import { DlqService } from '../outbox/dlq.service';
|
||||||
import { resetDb } from '../test-utils/reset-db';
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
||||||
import { makeFakePorts } from '@insignia/iios-testkit';
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
|
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
|
||||||
import { signedFixture, emailFixture } from '@insignia/iios-adapter-sdk';
|
import { signedFixture, emailFixture, emailInboundFixture, emailReplyFixture } from '@insignia/iios-adapter-sdk';
|
||||||
import { AdapterRegistry } from './adapter.registry';
|
import { AdapterRegistry } from './adapter.registry';
|
||||||
import { InboundService } from './inbound.service';
|
import { InboundService } from './inbound.service';
|
||||||
import { OutboundService } from './outbound.service';
|
import { OutboundService } from './outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { RawEventProjector } from './raw-event.projector';
|
import { RawEventProjector } from './raw-event.projector';
|
||||||
@@ -79,9 +81,60 @@ describe('Adapter inbound (P5)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Email adapter — inbound + threading', () => {
|
||||||
|
const receiveEmail = async (payload: object) => {
|
||||||
|
const { body, headers } = signedFixture(payload, SECRET);
|
||||||
|
return inbound().receive('EMAIL', body, headers);
|
||||||
|
};
|
||||||
|
const normalizeAll = async () => {
|
||||||
|
for (const ev of await rawReceivedEvents()) await projector().onRawReceived(ev);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('a signed inbound email → a normalized interaction with the body on the email thread', async () => {
|
||||||
|
const ack = await receiveEmail(emailInboundFixture);
|
||||||
|
expect(ack.status).toBe('RECEIVED');
|
||||||
|
await normalizeAll();
|
||||||
|
|
||||||
|
const raw = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
|
||||||
|
expect(raw.status).toBe('NORMALIZED');
|
||||||
|
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: raw.interactionId! }, include: { parts: true, thread: true } });
|
||||||
|
expect(interaction.parts[0]?.bodyText).toBe(emailInboundFixture.text);
|
||||||
|
expect(interaction.thread?.externalThreadRef).toBe('email:<m1@example.com>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reply email (In-Reply-To) lands on the SAME email thread as the original', async () => {
|
||||||
|
await receiveEmail(emailInboundFixture);
|
||||||
|
await receiveEmail(emailReplyFixture);
|
||||||
|
await normalizeAll();
|
||||||
|
|
||||||
|
const interactions = await prisma.iiosInteraction.findMany({ orderBy: { occurredAt: 'asc' } });
|
||||||
|
expect(interactions).toHaveLength(2);
|
||||||
|
expect(interactions[0]!.threadId).toBe(interactions[1]!.threadId); // reply joined the thread
|
||||||
|
expect(await prisma.iiosThread.count()).toBe(1); // one email conversation
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redelivered email (same Message-ID) → one raw event, one interaction (dedup)', async () => {
|
||||||
|
const svc = inbound();
|
||||||
|
const { body, headers } = signedFixture(emailInboundFixture, SECRET);
|
||||||
|
await svc.receive('EMAIL', body, headers);
|
||||||
|
const second = await svc.receive('EMAIL', body, headers);
|
||||||
|
expect(second.deduped).toBe(true);
|
||||||
|
await normalizeAll();
|
||||||
|
expect(await prisma.iiosInboundRawEvent.count()).toBe(1);
|
||||||
|
expect(await prisma.iiosInteraction.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bad signature on EMAIL → REJECTED, nothing ingested (fail-closed)', async () => {
|
||||||
|
const { body } = signedFixture(emailInboundFixture, SECRET);
|
||||||
|
await expect(inbound().receive('EMAIL', body, { 'x-iios-signature': 'sha256=bad' })).rejects.toThrow();
|
||||||
|
expect(await prisma.iiosInboundRawEvent.count({ where: { status: 'REJECTED' } })).toBe(1);
|
||||||
|
expect(await prisma.iiosInteraction.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Adapter outbound (P5)', () => {
|
describe('Adapter outbound (P5)', () => {
|
||||||
const outbound = (limit?: number): OutboundService => {
|
const outbound = (limit?: number): OutboundService => {
|
||||||
const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
|
const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
if (limit) s.limit = limit;
|
if (limit) s.limit = limit;
|
||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
@@ -112,7 +165,7 @@ describe('Adapter outbound (P5)', () => {
|
|||||||
it('broker obligation DENY_OUTBOUND → command BLOCKED, no send (P9)', async () => {
|
it('broker obligation DENY_OUTBOUND → command BLOCKED, no send (P9)', async () => {
|
||||||
const ports = makeFakePorts();
|
const ports = makeFakePorts();
|
||||||
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'recipient opted out' }] });
|
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'recipient opted out' }] });
|
||||||
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
|
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'blk-1');
|
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'blk-1');
|
||||||
expect(cmd.status).toBe('BLOCKED');
|
expect(cmd.status).toBe('BLOCKED');
|
||||||
expect(cmd.providerRef).toBe('blocked');
|
expect(cmd.providerRef).toBe('blocked');
|
||||||
@@ -121,7 +174,7 @@ describe('Adapter outbound (P5)', () => {
|
|||||||
it('CMP allow → command records the consent receipt (P9 slice 2)', async () => {
|
it('CMP allow → command records the consent receipt (P9 slice 2)', async () => {
|
||||||
const ports = makeFakePorts();
|
const ports = makeFakePorts();
|
||||||
ports.cmp.setStatus('ALLOW');
|
ports.cmp.setStatus('ALLOW');
|
||||||
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
|
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support');
|
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support');
|
||||||
expect(cmd.status).toBe('SENT');
|
expect(cmd.status).toBe('SENT');
|
||||||
expect(cmd.consentReceiptRef).toBe('fake-receipt');
|
expect(cmd.consentReceiptRef).toBe('fake-receipt');
|
||||||
@@ -130,7 +183,7 @@ describe('Adapter outbound (P5)', () => {
|
|||||||
it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => {
|
it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => {
|
||||||
const ports = makeFakePorts();
|
const ports = makeFakePorts();
|
||||||
ports.cmp.denyPurpose('marketing');
|
ports.cmp.denyPurpose('marketing');
|
||||||
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
|
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing');
|
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing');
|
||||||
expect(cmd.status).toBe('BLOCKED');
|
expect(cmd.status).toBe('BLOCKED');
|
||||||
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
|
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
|
||||||
@@ -146,4 +199,18 @@ describe('Adapter outbound (P5)', () => {
|
|||||||
expect(a2.status).toBe('RATE_LIMITED');
|
expect(a2.status).toBe('RATE_LIMITED');
|
||||||
expect(b1.status).toBe('SENT');
|
expect(b1.status).toBe('SENT');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('idempotency ledger: same key + a different target → conflict, no second command (P9 slice 10)', async () => {
|
||||||
|
const svc = outbound();
|
||||||
|
const first = await svc.send('WEBHOOK', 'user@a', { text: 'hi' }, 'dup-key');
|
||||||
|
expect(first.status).toBe('SENT');
|
||||||
|
await expect(svc.send('WEBHOOK', 'user@b', { text: 'hi' }, 'dup-key')).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'dup-key' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opt-in: a keyless send creates a command but writes no idempotency-ledger row (P9 slice 10)', async () => {
|
||||||
|
const cmd = await outbound().send('WEBHOOK', 'user@x', { text: 'hi' });
|
||||||
|
expect(cmd.status).toBe('SENT');
|
||||||
|
expect(await prisma.iiosIdempotencyCommand.count()).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export class InboundService {
|
|||||||
const { adapter, config } = reg;
|
const { adapter, config } = reg;
|
||||||
|
|
||||||
const payload = this.safeParse(rawBody);
|
const payload = this.safeParse(rawBody);
|
||||||
const externalEventId = typeof payload?.eventId === 'string' ? payload.eventId : undefined;
|
// The adapter extracts its provider-native id (eventId, Message-ID, …) for replay dedup.
|
||||||
|
const externalEventId = adapter.externalEventId?.(payload) ?? (typeof payload?.eventId === 'string' ? payload.eventId : undefined);
|
||||||
|
|
||||||
// Fail-closed: a bad/absent signature is recorded and rejected — never ingested.
|
// Fail-closed: a bad/absent signature is recorded and rejected — never ingested.
|
||||||
if (!adapter.verifySignature(rawBody, headers, config.secret)) {
|
if (!adapter.verifySignature(rawBody, headers, config.secret)) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { recordAudit } from '../observability/audit';
|
import { recordAudit } from '../observability/audit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,55 +24,67 @@ export class OutboundService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly broker: CapabilityBroker,
|
private readonly broker: CapabilityBroker,
|
||||||
|
private readonly idempotency: IdempotencyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async send(
|
async send(
|
||||||
channelType: string,
|
channelType: string,
|
||||||
target: string,
|
target: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
idempotencyKey: string = randomUUID(),
|
idempotencyKey?: string,
|
||||||
scopeId?: string,
|
scopeId?: string,
|
||||||
purpose?: string,
|
purpose?: string,
|
||||||
) {
|
) {
|
||||||
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } });
|
const key = idempotencyKey ?? randomUUID();
|
||||||
if (existing) return existing;
|
|
||||||
|
// The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried
|
||||||
|
// command returns the existing row instead of colliding on idempotencyKey @unique.
|
||||||
|
const body = async () => {
|
||||||
|
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey: key } });
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
||||||
|
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||||
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
|
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key },
|
||||||
|
});
|
||||||
|
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
|
||||||
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey },
|
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key },
|
||||||
});
|
});
|
||||||
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
let result;
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey },
|
try {
|
||||||
});
|
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey: key, scopeId, purpose });
|
||||||
|
} catch (err) {
|
||||||
|
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }),
|
||||||
|
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }),
|
||||||
|
]);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
let result;
|
const [updated] = await this.prisma.$transaction([
|
||||||
try {
|
this.prisma.iiosOutboundCommand.update({
|
||||||
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId, purpose });
|
where: { id: cmd.id },
|
||||||
} catch (err) {
|
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
|
||||||
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
|
}),
|
||||||
await this.prisma.$transaction([
|
this.prisma.iiosDeliveryAttempt.create({
|
||||||
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: 'FAILED' } }),
|
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
|
||||||
this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'FAILED', errorCode: (err as Error).name } }),
|
}),
|
||||||
]);
|
]);
|
||||||
throw err;
|
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
|
||||||
}
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
const [updated] = await this.prisma.$transaction([
|
// Opt-in idempotency: an explicit key routes through the ledger (request-hash conflict → 409,
|
||||||
this.prisma.iiosOutboundCommand.update({
|
// response replay); a keyless send just runs with a fresh uuid (old behavior).
|
||||||
where: { id: cmd.id },
|
if (!idempotencyKey) return body();
|
||||||
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
|
return this.idempotency.run({ scopeId, commandName: 'channel.send', key: idempotencyKey, request: { channelType, target, payload, purpose } }, body);
|
||||||
}),
|
|
||||||
this.prisma.iiosDeliveryAttempt.create({
|
|
||||||
data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
|
|
||||||
return updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
|
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { OutboxModule } from './outbox/outbox.module';
|
|||||||
import { ThreadsModule } from './threads/threads.module';
|
import { ThreadsModule } from './threads/threads.module';
|
||||||
import { MessageModule } from './messaging/message.module';
|
import { MessageModule } from './messaging/message.module';
|
||||||
import { InboxModule } from './inbox/inbox.module';
|
import { InboxModule } from './inbox/inbox.module';
|
||||||
|
import { MediaModule } from './media/media.module';
|
||||||
|
import { NotificationModule } from './notifications/notification.module';
|
||||||
import { SupportModule } from './support/support.module';
|
import { SupportModule } from './support/support.module';
|
||||||
import { AdaptersModule } from './adapters/adapters.module';
|
import { AdaptersModule } from './adapters/adapters.module';
|
||||||
import { RoutingModule } from './routing/routing.module';
|
import { RoutingModule } from './routing/routing.module';
|
||||||
@@ -16,6 +18,7 @@ import { AiModule } from './ai/ai.module';
|
|||||||
import { CalendarModule } from './calendar/calendar.module';
|
import { CalendarModule } from './calendar/calendar.module';
|
||||||
import { CapabilityModule } from './capability/capability.module';
|
import { CapabilityModule } from './capability/capability.module';
|
||||||
import { DsrModule } from './dsr/dsr.module';
|
import { DsrModule } from './dsr/dsr.module';
|
||||||
|
import { RetentionModule } from './retention/retention.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { MetricsController } from './observability/metrics.controller';
|
import { MetricsController } from './observability/metrics.controller';
|
||||||
import { DevController } from './dev/dev.controller';
|
import { DevController } from './dev/dev.controller';
|
||||||
@@ -32,6 +35,8 @@ import { DevController } from './dev/dev.controller';
|
|||||||
ThreadsModule,
|
ThreadsModule,
|
||||||
MessageModule,
|
MessageModule,
|
||||||
InboxModule,
|
InboxModule,
|
||||||
|
MediaModule,
|
||||||
|
NotificationModule,
|
||||||
SupportModule,
|
SupportModule,
|
||||||
AdaptersModule,
|
AdaptersModule,
|
||||||
RoutingModule,
|
RoutingModule,
|
||||||
@@ -39,6 +44,7 @@ import { DevController } from './dev/dev.controller';
|
|||||||
CalendarModule,
|
CalendarModule,
|
||||||
CapabilityModule,
|
CapabilityModule,
|
||||||
DsrModule,
|
DsrModule,
|
||||||
|
RetentionModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController, MetricsController, DevController],
|
controllers: [HealthController, MetricsController, DevController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ActorResolver } from '../identity/actor.resolver';
|
|||||||
import { AiJobService } from '../ai/ai.service';
|
import { AiJobService } from '../ai/ai.service';
|
||||||
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
||||||
import { OutboundService } from '../adapters/outbound.service';
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { CalendarService } from './calendar.service';
|
import { CalendarService } from './calendar.service';
|
||||||
@@ -22,7 +23,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
|
|||||||
const START = '2026-07-02T10:00:00.000Z';
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())));
|
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)));
|
||||||
|
|
||||||
async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) {
|
async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) {
|
||||||
const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees });
|
const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ActorResolver } from '../identity/actor.resolver';
|
|||||||
import { AiJobService } from '../ai/ai.service';
|
import { AiJobService } from '../ai/ai.service';
|
||||||
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
||||||
import { OutboundService } from '../adapters/outbound.service';
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { CalendarService } from './calendar.service';
|
import { CalendarService } from './calendar.service';
|
||||||
@@ -27,7 +28,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
|
|||||||
const START = '2026-07-02T10:00:00.000Z';
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())));
|
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)));
|
||||||
|
|
||||||
async function makeInteraction(text: string): Promise<string> {
|
async function makeInteraction(text: string): Promise<string> {
|
||||||
const m = new MessageService(asService, makeFakePorts(), actors);
|
const m = new MessageService(asService, makeFakePorts(), actors);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ActorResolver } from '../identity/actor.resolver';
|
|||||||
import { AiJobService } from '../ai/ai.service';
|
import { AiJobService } from '../ai/ai.service';
|
||||||
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
||||||
import { OutboundService } from '../adapters/outbound.service';
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { CalendarService } from './calendar.service';
|
import { CalendarService } from './calendar.service';
|
||||||
@@ -24,7 +25,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
|
|||||||
const START = '2026-07-02T10:00:00.000Z';
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
|
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), outbound());
|
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai(), outbound());
|
||||||
|
|
||||||
async function makeInteraction(text: string): Promise<string> {
|
async function makeInteraction(text: string): Promise<string> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import type { CapabilityProvider } from '@insignia/iios-contracts';
|
import type { CapabilityProvider } from '@insignia/iios-contracts';
|
||||||
import { SandboxProvider } from './sandbox.provider';
|
import { SandboxProvider } from './sandbox.provider';
|
||||||
import { HttpProvider } from './http.provider';
|
import { HttpProvider } from './http.provider';
|
||||||
|
import { EmailProvider } from './email.provider';
|
||||||
|
|
||||||
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
|
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
|
||||||
|
|
||||||
@@ -19,7 +20,9 @@ export class CapabilityProviderRegistry {
|
|||||||
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
|
||||||
for (const ch of DEFAULT_CHANNELS) {
|
for (const ch of DEFAULT_CHANNELS) {
|
||||||
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
||||||
if (url) this.register(new HttpProvider(ch, url));
|
if (!url) continue;
|
||||||
|
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
|
||||||
|
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
|
||||||
|
|
||||||
|
interface EmailPayload {
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
inReplyTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email egress provider: shapes the outbound command into an email envelope
|
||||||
|
* ({to, subject, text, html, inReplyTo}) and POSTs it to a configured send endpoint
|
||||||
|
* (a vendor mail API / SMTP relay behind a URL). Activated only when
|
||||||
|
* IIOS_PROVIDER_URL_EMAIL is set; a transport failure is surfaced as FAILED, never thrown.
|
||||||
|
*/
|
||||||
|
export class EmailProvider implements CapabilityProvider {
|
||||||
|
readonly name = 'email-http';
|
||||||
|
readonly channelTypes = ['EMAIL'];
|
||||||
|
readonly capabilities = { canSend: true };
|
||||||
|
|
||||||
|
constructor(private readonly url: string) {}
|
||||||
|
|
||||||
|
async send(req: CapabilityRequest): Promise<ProviderResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const p = (req.payload ?? {}) as EmailPayload;
|
||||||
|
const email = { to: req.target, subject: p.subject ?? '(no subject)', text: p.text, html: p.html, inReplyTo: p.inReplyTo };
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'idempotency-key': req.idempotencyKey },
|
||||||
|
body: JSON.stringify(email),
|
||||||
|
});
|
||||||
|
const latencyMs = Date.now() - started;
|
||||||
|
if (!res.ok) return { providerRef: `email-${res.status}`, outcome: 'FAILED', errorCode: `HTTP_${res.status}`, latencyMs };
|
||||||
|
return { providerRef: `email-${req.idempotencyKey}`, outcome: 'SENT', latencyMs };
|
||||||
|
} catch (err) {
|
||||||
|
return { providerRef: 'email-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { BadRequestException, Body, Controller, ForbiddenException, Headers, Param, Post } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, ForbiddenException, Get, Headers, Param, Post, UnauthorizedException } from '@nestjs/common';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { IIOS_EVENTS } from '@insignia/iios-contracts';
|
import { IIOS_EVENTS } from '@insignia/iios-contracts';
|
||||||
import { hmacSign } from '@insignia/iios-adapter-sdk';
|
import { hmacSign } from '@insignia/iios-adapter-sdk';
|
||||||
@@ -8,6 +8,7 @@ import { AdapterRegistry } from '../adapters/adapter.registry';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import { RetentionService } from '../retention/retention.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production.
|
* Dev-only helpers (gated by IIOS_DEV_TOKENS=1). Never enable in production.
|
||||||
@@ -20,25 +21,64 @@ export class DevController {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly actors: ActorResolver,
|
private readonly actors: ActorResolver,
|
||||||
private readonly session: SessionVerifier,
|
private readonly session: SessionVerifier,
|
||||||
|
private readonly retention: RetentionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('token')
|
@Post('token')
|
||||||
token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } {
|
token(@Body() body: { appId: string; userId: string; name?: string; orgId?: string }): { token: string } {
|
||||||
this.assertDev();
|
this.assertDev();
|
||||||
|
return { token: this.signToken(body.appId, body.userId, body.name, body.orgId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev IdP: a credentialed login that mints the SAME JWT claims a real IdP / Session
|
||||||
|
* Broker would issue (sub/name/appId/orgId). Credentials come from DEV_USERS (JSON
|
||||||
|
* `{username: password}`; defaults to a few demo users). SessionVerifier is the stable
|
||||||
|
* verify seam — swapping in a real IdP means issuing these same claims, nothing else.
|
||||||
|
*/
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() body: { username: string; password: string; appId?: string }): { token: string; userId: string } {
|
||||||
|
this.assertDev();
|
||||||
|
const appId = body.appId ?? 'portal-demo';
|
||||||
|
let users: Record<string, string>;
|
||||||
|
try {
|
||||||
|
users = JSON.parse(process.env.DEV_USERS ?? '{"alice":"alice","bob":"bob","carol":"carol"}');
|
||||||
|
} catch {
|
||||||
|
users = {};
|
||||||
|
}
|
||||||
|
const expected = users[body.username];
|
||||||
|
if (expected === undefined || expected !== body.password) throw new UnauthorizedException('invalid username or password');
|
||||||
|
return { token: this.signToken(appId, body.username, body.username), userId: body.username };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dev directory: the known dev usernames (from DEV_USERS). A real app validates
|
||||||
|
* add-by-username against its user store / the MDM port; this stands in for it. */
|
||||||
|
@Get('users')
|
||||||
|
users(): { users: string[] } {
|
||||||
|
this.assertDev();
|
||||||
|
let map: Record<string, string>;
|
||||||
|
try {
|
||||||
|
map = JSON.parse(process.env.DEV_USERS ?? '{"alice":"alice","bob":"bob","carol":"carol"}');
|
||||||
|
} catch {
|
||||||
|
map = {};
|
||||||
|
}
|
||||||
|
return { users: Object.keys(map) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private signToken(appId: string, userId: string, name?: string, orgId?: string): string {
|
||||||
let secrets: Record<string, string>;
|
let secrets: Record<string, string>;
|
||||||
try {
|
try {
|
||||||
secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
||||||
} catch {
|
} catch {
|
||||||
secrets = {};
|
secrets = {};
|
||||||
}
|
}
|
||||||
const secret = secrets[body.appId];
|
const secret = secrets[appId];
|
||||||
if (!secret) throw new BadRequestException(`unknown app: ${body.appId}`);
|
if (!secret) throw new BadRequestException(`unknown app: ${appId}`);
|
||||||
const token = jwt.sign(
|
return jwt.sign(
|
||||||
{ sub: body.userId, name: body.name ?? body.userId, appId: body.appId, orgId: body.orgId ?? `org_${body.appId}` },
|
{ sub: userId, name: name ?? userId, appId, orgId: orgId ?? `org_${appId}` },
|
||||||
secret,
|
secret,
|
||||||
{ algorithm: 'HS256', expiresIn: '2h' },
|
{ algorithm: 'HS256', expiresIn: '2h' },
|
||||||
);
|
);
|
||||||
return { token };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server signs a sample provider payload and feeds it to the inbound pipeline. */
|
/** Server signs a sample provider payload and feeds it to the inbound pipeline. */
|
||||||
@@ -96,6 +136,13 @@ export class DevController {
|
|||||||
return { eventId: id, scopeId: scope.id };
|
return { eventId: id, scopeId: scope.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run the retention sweep on demand (drives the retention smoke). */
|
||||||
|
@Post('retention/sweep')
|
||||||
|
async retentionSweep() {
|
||||||
|
this.assertDev();
|
||||||
|
return this.retention.sweep();
|
||||||
|
}
|
||||||
|
|
||||||
private principal(authorization?: string): MessagePrincipal {
|
private principal(authorization?: string): MessagePrincipal {
|
||||||
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
||||||
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ export class ActorResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureParticipant(threadId: string, actorId: string): Promise<void> {
|
async ensureParticipant(threadId: string, actorId: string, participantRole = 'MEMBER'): Promise<void> {
|
||||||
await this.prisma.iiosThreadParticipant.upsert({
|
await this.prisma.iiosThreadParticipant.upsert({
|
||||||
where: { threadId_actorId: { threadId, actorId } },
|
where: { threadId_actorId: { threadId, actorId } },
|
||||||
create: { threadId, actorId },
|
create: { threadId, actorId, participantRole },
|
||||||
update: {},
|
update: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface MessageSentData {
|
|||||||
interactionId: string;
|
interactionId: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
senderActorId: string;
|
senderActorId: string;
|
||||||
|
mentions?: string[]; // opaque app notify-list (userIds); the kernel never parses "@"
|
||||||
}
|
}
|
||||||
interface MessageReadData {
|
interface MessageReadData {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -57,6 +58,60 @@ export class InboxProjector implements OnModuleInit {
|
|||||||
if (p.actorId === data.senderActorId) continue;
|
if (p.actorId === data.senderActorId) continue;
|
||||||
await this.upsertNeedsReply(thread.scopeId, p.actorId, data.threadId, data.interactionId, traceId, thread.subject);
|
await this.upsertNeedsReply(thread.scopeId, p.actorId, data.threadId, data.interactionId, traceId, thread.subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Targeted mention notifications from the app-supplied opaque notify-list.
|
||||||
|
const mentions = data.mentions ?? [];
|
||||||
|
if (mentions.length > 0) {
|
||||||
|
const src = await this.prisma.iiosInteraction.findUnique({
|
||||||
|
where: { id: data.interactionId },
|
||||||
|
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
|
||||||
|
});
|
||||||
|
const senderName = src?.actor?.sourceHandle?.externalId ?? 'Someone';
|
||||||
|
const text = src?.parts[0]?.bodyText ?? undefined;
|
||||||
|
for (const userId of mentions) {
|
||||||
|
await this.createMention(thread.scopeId, userId, data.threadId, data.interactionId, data.senderActorId, senderName, text, traceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One MENTION item per (owner, source message) for a mentioned participant (never the sender). */
|
||||||
|
private async createMention(
|
||||||
|
scopeId: string,
|
||||||
|
userId: string,
|
||||||
|
threadId: string,
|
||||||
|
sourceInteractionId: string,
|
||||||
|
senderActorId: string,
|
||||||
|
senderName: string,
|
||||||
|
summary: string | undefined,
|
||||||
|
traceId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
const handle = await this.prisma.iiosSourceHandle.findUnique({
|
||||||
|
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: userId } },
|
||||||
|
});
|
||||||
|
if (!handle) return;
|
||||||
|
const actor = await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } });
|
||||||
|
if (!actor || actor.id === senderActorId) return; // resolvable, and never notify the sender
|
||||||
|
const isMember = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } });
|
||||||
|
if (!isMember) return; // only thread participants get mention items
|
||||||
|
const existing = await this.prisma.iiosInboxItem.findFirst({ where: { ownerActorId: actor.id, sourceInteractionId, kind: 'MENTION' } });
|
||||||
|
if (existing) return; // idempotent per source message
|
||||||
|
|
||||||
|
const item = await this.prisma.iiosInboxItem.create({
|
||||||
|
data: {
|
||||||
|
scopeId,
|
||||||
|
ownerActorId: actor.id,
|
||||||
|
kind: 'MENTION',
|
||||||
|
title: `${senderName} mentioned you`,
|
||||||
|
summary,
|
||||||
|
priority: 'HIGH',
|
||||||
|
threadId,
|
||||||
|
sourceInteractionId,
|
||||||
|
traceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.iiosInboxItemStateHistory.create({
|
||||||
|
data: { inboxItemId: item.id, toState: 'OPEN', reasonCode: 'mentioned' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onMessageRead(event: CloudEvent): Promise<void> {
|
async onMessageRead(event: CloudEvent): Promise<void> {
|
||||||
@@ -67,21 +122,24 @@ export class InboxProjector implements OnModuleInit {
|
|||||||
|
|
||||||
private async applyMessageRead(event: CloudEvent): Promise<void> {
|
private async applyMessageRead(event: CloudEvent): Promise<void> {
|
||||||
const data = event.data as MessageReadData;
|
const data = event.data as MessageReadData;
|
||||||
const item = await this.prisma.iiosInboxItem.findFirst({
|
// Reading the thread clears the reader's activity AND mention items for it.
|
||||||
|
const items = await this.prisma.iiosInboxItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
threadId: data.threadId,
|
threadId: data.threadId,
|
||||||
ownerActorId: data.actorId,
|
ownerActorId: data.actorId,
|
||||||
kind: 'NEEDS_REPLY',
|
kind: { in: ['NEEDS_REPLY', 'MENTION'] },
|
||||||
state: { in: ['OPEN', 'SNOOZED'] },
|
state: { in: ['OPEN', 'SNOOZED'] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!item) return;
|
if (items.length === 0) return;
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction(
|
||||||
this.prisma.iiosInboxItem.update({ where: { id: item.id }, data: { state: 'DONE' } }),
|
items.flatMap((item) => [
|
||||||
this.prisma.iiosInboxItemStateHistory.create({
|
this.prisma.iiosInboxItem.update({ where: { id: item.id }, data: { state: 'DONE' } }),
|
||||||
data: { inboxItemId: item.id, fromState: item.state, toState: 'DONE', reasonCode: 'message_read' },
|
this.prisma.iiosInboxItemStateHistory.create({
|
||||||
}),
|
data: { inboxItemId: item.id, fromState: item.state, toState: 'DONE', reasonCode: 'message_read' },
|
||||||
]);
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async upsertNeedsReply(
|
private async upsertNeedsReply(
|
||||||
|
|||||||
@@ -88,6 +88,38 @@ describe('InboxProjector (P3 work surface)', () => {
|
|||||||
expect(await itemsFor('bob')).toHaveLength(1);
|
expect(await itemsFor('bob')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('message.sent with mentions → a MENTION item for the mentioned participant only', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await m.addParticipant(threadId, alice, 'carol');
|
||||||
|
await m.send(threadId, alice, { content: 'hey @bob look' }, 'k1', undefined, undefined, ['bob']);
|
||||||
|
|
||||||
|
await projector().onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
|
||||||
|
|
||||||
|
const bobMentions = (await itemsFor('bob')).filter((i) => i.kind === 'MENTION');
|
||||||
|
expect(bobMentions).toHaveLength(1);
|
||||||
|
expect(bobMentions[0]?.state).toBe('OPEN');
|
||||||
|
expect(bobMentions[0]?.sourceInteractionId).toBeTruthy();
|
||||||
|
expect((await itemsFor('carol')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // not mentioned
|
||||||
|
expect((await itemsFor('alice')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // never the sender
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reading the thread resolves the reader’s MENTION item to DONE', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
const sent = await m.send(threadId, alice, { content: '@bob ping' }, 'k1', undefined, undefined, ['bob']);
|
||||||
|
|
||||||
|
const proj = projector();
|
||||||
|
await proj.onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
|
||||||
|
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('OPEN');
|
||||||
|
|
||||||
|
await m.markRead(threadId, bob, sent.id);
|
||||||
|
await proj.onMessageRead((await eventsOf(IIOS_EVENTS.messageRead))[0]!);
|
||||||
|
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('DONE');
|
||||||
|
});
|
||||||
|
|
||||||
it('message.read → the recipient item goes DONE', async () => {
|
it('message.read → the recipient item goes DONE', async () => {
|
||||||
const m = ms();
|
const m = ms();
|
||||||
const { threadId } = await m.openThread(null, alice);
|
const { threadId } = await m.openThread(null, alice);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AiJobService } from './ai/ai.service';
|
|||||||
import { AiBudgetGuard } from './ai/ai-budget.guard';
|
import { AiBudgetGuard } from './ai/ai-budget.guard';
|
||||||
import { CalendarService } from './calendar/calendar.service';
|
import { CalendarService } from './calendar/calendar.service';
|
||||||
import { OutboundService } from './adapters/outbound.service';
|
import { OutboundService } from './adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from './idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from './capability/capability.broker';
|
import { CapabilityBroker } from './capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from './capability/capability.registry';
|
import { CapabilityProviderRegistry } from './capability/capability.registry';
|
||||||
import type { PrismaService } from './prisma/prisma.service';
|
import type { PrismaService } from './prisma/prisma.service';
|
||||||
@@ -27,7 +28,7 @@ const B: MessagePrincipal = { userId: 'b1', orgId: 'org_B', appId: 'portal-demo'
|
|||||||
const START = '2026-07-02T10:00:00.000Z';
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
|
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
|
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
|
||||||
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), outbound());
|
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), outbound());
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import './observability/tracing'; // MUST be first — auto-instrumentation patches modules on require
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { traceMiddleware } from './observability/trace.middleware';
|
import { traceMiddleware } from './observability/trace.middleware';
|
||||||
|
import { RedisIoAdapter } from './realtime/redis-io.adapter';
|
||||||
|
import { PolicyDeniedFilter } from './platform/policy-denied.filter';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||||
@@ -11,7 +14,17 @@ async function bootstrap(): Promise<void> {
|
|||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||||
);
|
);
|
||||||
|
app.useGlobalFilters(new PolicyDeniedFilter()); // fail-closed policy denials → 403
|
||||||
app.enableCors({ origin: true, credentials: true });
|
app.enableCors({ origin: true, credentials: true });
|
||||||
|
|
||||||
|
// Multi-replica realtime: fan socket.io room emits across instances via Redis.
|
||||||
|
// Opt-in — without REDIS_URL the default in-memory adapter is used (single instance).
|
||||||
|
if (process.env.REDIS_URL) {
|
||||||
|
const redisAdapter = new RedisIoAdapter(app, process.env.REDIS_URL);
|
||||||
|
await redisAdapter.connect();
|
||||||
|
app.useWebSocketAdapter(redisAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3200);
|
const port = Number(process.env.PORT ?? 3200);
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { StoragePort } from './storage.port';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev storage: bytes on local disk under MEDIA_DIR, with a sidecar `.meta.json`
|
||||||
|
* holding the mime/size/checksum. Object keys may contain a scope subdirectory.
|
||||||
|
* A drop-in for an S3/Supabase adapter in prod.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LocalDiskStorage implements StoragePort {
|
||||||
|
private readonly root = process.env.MEDIA_DIR?.trim() || path.join(os.tmpdir(), 'iios-media');
|
||||||
|
|
||||||
|
private full(objectKey: string): string {
|
||||||
|
// Prevent path traversal: keep everything under root.
|
||||||
|
const p = path.normalize(path.join(this.root, objectKey));
|
||||||
|
if (!p.startsWith(path.normalize(this.root))) throw new Error('invalid object key');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> {
|
||||||
|
const file = this.full(objectKey);
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
const checksumSha256 = createHash('sha256').update(data).digest('hex');
|
||||||
|
await fs.writeFile(file, data);
|
||||||
|
await fs.writeFile(`${file}.meta.json`, JSON.stringify({ mime, sizeBytes: data.length, checksumSha256 }));
|
||||||
|
return { sizeBytes: data.length, checksumSha256 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> {
|
||||||
|
const file = this.full(objectKey);
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(file);
|
||||||
|
const meta = JSON.parse(await fs.readFile(`${file}.meta.json`, 'utf8')) as { mime: string };
|
||||||
|
return { data, mime: meta.mime || 'application/octet-stream', sizeBytes: data.length };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(objectKey: string): Promise<void> {
|
||||||
|
const file = this.full(objectKey);
|
||||||
|
await fs.rm(file, { force: true });
|
||||||
|
await fs.rm(`${file}.meta.json`, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Put, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
|
||||||
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
|
||||||
|
@Controller('v1/media')
|
||||||
|
export class MediaController {
|
||||||
|
constructor(
|
||||||
|
private readonly media: MediaService,
|
||||||
|
private readonly session: SessionVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Authorize an upload → short-lived signed PUT url + object key. */
|
||||||
|
@Post('presign-upload')
|
||||||
|
async presignUpload(@Body() body: PresignUploadDto, @Headers('authorization') auth?: string) {
|
||||||
|
return this.media.presignUpload(this.principal(auth), { mime: body.mime, sizeBytes: body.sizeBytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize a download → short-lived signed GET url. */
|
||||||
|
@Post('presign-download')
|
||||||
|
async presignDownload(@Body() body: PresignDownloadDto, @Headers('authorization') auth?: string) {
|
||||||
|
return this.media.presignDownload(this.principal(auth), body.contentRef, body.mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The presigned PUT target — token IS the auth. Reads the raw binary body stream. */
|
||||||
|
@Put('upload/:token')
|
||||||
|
async upload(@Param('token') token: string, @Req() req: Request) {
|
||||||
|
const data = await readBody(req);
|
||||||
|
if (data.length === 0) throw new BadRequestException('empty upload body');
|
||||||
|
return this.media.put(token, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The presigned GET target — token IS the auth. Streams the bytes with their mime. */
|
||||||
|
@Get('blob/:token')
|
||||||
|
async blob(@Param('token') token: string, @Res() res: Response) {
|
||||||
|
const { data, mime } = await this.media.get(token);
|
||||||
|
res.setHeader('Content-Type', mime);
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
|
res.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private principal(auth?: string): MessagePrincipal {
|
||||||
|
const token = (auth ?? '').replace(/^Bearer\s+/i, '');
|
||||||
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||||
|
return this.session.verify(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect a request body stream into a Buffer (binary media upload; no body parser touches it). */
|
||||||
|
function readBody(req: Request): Promise<Buffer> {
|
||||||
|
const raw = (req as Request & { rawBody?: Buffer }).rawBody;
|
||||||
|
if (raw && raw.length) return Promise.resolve(raw);
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class PresignUploadDto {
|
||||||
|
@IsString() mime!: string;
|
||||||
|
@IsInt() @Min(1) @Max(26 * 1024 * 1024) sizeBytes!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PresignDownloadDto {
|
||||||
|
@IsString() contentRef!: string;
|
||||||
|
@IsOptional() @IsString() mime?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
|
import { MediaController } from './media.controller';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
import { LocalDiskStorage } from './local-disk.storage';
|
||||||
|
import { STORAGE_PORT } from './storage.port';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, IdentityModule],
|
||||||
|
controllers: [MediaController],
|
||||||
|
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter.
|
||||||
|
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { DevOpaPort } from '../platform/dev-opa.port';
|
||||||
|
import { LocalDiskStorage } from './local-disk.storage';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
const asService = prisma as unknown as PrismaService;
|
||||||
|
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
|
||||||
|
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService));
|
||||||
|
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||||
|
|
||||||
|
// point storage at an isolated temp dir for the test run
|
||||||
|
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
function tokenFrom(url: string): string {
|
||||||
|
return url.split('/').pop()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MediaService (presigned local storage)', () => {
|
||||||
|
it('presign → PUT → presign-download → GET round-trips the bytes with mime + checksum', async () => {
|
||||||
|
const s = svc();
|
||||||
|
const bytes = Buffer.from('hello-image-bytes');
|
||||||
|
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
|
||||||
|
expect(objectKey).toContain('/'); // scopeId/uuid
|
||||||
|
|
||||||
|
const put = await s.put(tokenFrom(uploadUrl), bytes);
|
||||||
|
expect(put.sizeBytes).toBe(bytes.length);
|
||||||
|
expect(put.checksumSha256).toHaveLength(64);
|
||||||
|
|
||||||
|
const { url } = await s.presignDownload(alice, objectKey, 'image/png');
|
||||||
|
const got = await s.get(tokenFrom(url));
|
||||||
|
expect(got.data.equals(bytes)).toBe(true);
|
||||||
|
expect(got.mime).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an over-the-cap upload (OPA policy)', async () => {
|
||||||
|
await expect(svc().presignUpload(alice, { mime: 'image/png', sizeBytes: 26 * 1024 * 1024 })).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a disallowed file type (OPA policy)', async () => {
|
||||||
|
await expect(svc().presignUpload(alice, { mime: 'application/x-msdownload', sizeBytes: 1000 })).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a PUT larger than the presigned size is refused', async () => {
|
||||||
|
const s = svc();
|
||||||
|
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
|
||||||
|
await expect(s.put(tokenFrom(uploadUrl), Buffer.from('way too many bytes'))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered / non-media token', async () => {
|
||||||
|
await expect(svc().get('not-a-real-token')).rejects.toThrow();
|
||||||
|
// an upload token cannot be used to download
|
||||||
|
const s = svc();
|
||||||
|
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
|
||||||
|
await expect(s.get(tokenFrom(uploadUrl))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tenant-fences downloads: an object outside my scope is forbidden', async () => {
|
||||||
|
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||||
|
import { decideOrThrow } from '../platform/fail-closed';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { STORAGE_PORT, type StoragePort } from './storage.port';
|
||||||
|
|
||||||
|
interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number }
|
||||||
|
interface DownloadToken { op: 'get'; objectKey: string; mime: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presigned media. IIOS never trusts the client with storage — it authorizes an
|
||||||
|
* upload (OPA: size/type/scope), then hands back a short-lived signed URL that
|
||||||
|
* points at its OWN storage endpoints. The bytes live behind the StoragePort; the
|
||||||
|
* kernel only ever stores the object key (contentRef) on a MessagePart.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MediaService {
|
||||||
|
private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret';
|
||||||
|
private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, '');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||||
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||||
|
private readonly actors: ActorResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Authorize an upload and return a short-lived signed PUT url + the object key. */
|
||||||
|
async presignUpload(
|
||||||
|
principal: MessagePrincipal,
|
||||||
|
input: { mime: string; sizeBytes: number },
|
||||||
|
): Promise<{ objectKey: string; uploadUrl: string }> {
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
await decideOrThrow(this.ports, { action: 'iios.media.upload', scopeId: scope.id, mime: input.mime, sizeBytes: input.sizeBytes });
|
||||||
|
const objectKey = `${scope.id}/${randomUUID()}`;
|
||||||
|
const token = jwt.sign({ op: 'put', objectKey, mime: input.mime, maxBytes: input.sizeBytes } satisfies UploadToken, this.secret, { expiresIn: '5m' });
|
||||||
|
return { objectKey, uploadUrl: `${this.publicUrl}/v1/media/upload/${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store bytes for a valid, unexpired upload token (enforcing the declared size cap). */
|
||||||
|
async put(token: string, data: Buffer): Promise<{ objectKey: string; sizeBytes: number; checksumSha256: string }> {
|
||||||
|
const t = this.verify<UploadToken>(token, 'put');
|
||||||
|
if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size');
|
||||||
|
const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime);
|
||||||
|
return { objectKey: t.objectKey, sizeBytes, checksumSha256 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize a download and return a short-lived signed GET url (tenant-fenced). */
|
||||||
|
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
if (!contentRef.startsWith(`${scope.id}/`)) throw new ForbiddenException('object not in your scope');
|
||||||
|
const token = jwt.sign({ op: 'get', objectKey: contentRef, mime } satisfies DownloadToken, this.secret, { expiresIn: '1h' });
|
||||||
|
return { url: `${this.publicUrl}/v1/media/blob/${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch bytes for a valid, unexpired download token. */
|
||||||
|
async get(token: string): Promise<{ data: Buffer; mime: string; sizeBytes: number }> {
|
||||||
|
const t = this.verify<DownloadToken>(token, 'get');
|
||||||
|
const obj = await this.storage.get(t.objectKey);
|
||||||
|
if (!obj) throw new BadRequestException('object not found');
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private verify<T extends { op: string }>(token: string, op: T['op']): T {
|
||||||
|
let payload: T;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, this.secret) as T;
|
||||||
|
} catch {
|
||||||
|
throw new ForbiddenException('invalid or expired media token');
|
||||||
|
}
|
||||||
|
if (payload.op !== op) throw new ForbiddenException('wrong media token');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Byte storage seam. The kernel stores a `contentRef` (an opaque object key) on a
|
||||||
|
* MessagePart; the actual bytes live behind this port. Dev binds LocalDiskStorage;
|
||||||
|
* prod swaps to S3/R2/Supabase Storage with zero changes to the media service or app.
|
||||||
|
*/
|
||||||
|
export interface StoragePort {
|
||||||
|
put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }>;
|
||||||
|
get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null>;
|
||||||
|
remove(objectKey: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORAGE_PORT = Symbol('STORAGE_PORT');
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ConnectedSocket,
|
ConnectedSocket,
|
||||||
MessageBody,
|
MessageBody,
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
OnGatewayInit,
|
OnGatewayInit,
|
||||||
SubscribeMessage,
|
SubscribeMessage,
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
@@ -15,6 +16,7 @@ import { logJson } from '../observability/logger';
|
|||||||
import { MessageService, type MessagePrincipal } from './message.service';
|
import { MessageService, type MessagePrincipal } from './message.service';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
import { OutboxBus } from '../outbox/outbox.bus';
|
import { OutboxBus } from '../outbox/outbox.bus';
|
||||||
|
import { PresenceService } from '../notifications/presence.service';
|
||||||
|
|
||||||
interface SocketState {
|
interface SocketState {
|
||||||
principal: MessagePrincipal;
|
principal: MessagePrincipal;
|
||||||
@@ -27,7 +29,7 @@ interface SocketState {
|
|||||||
* propagates messages persisted elsewhere (REST/ingest) to connected clients.
|
* propagates messages persisted elsewhere (REST/ingest) to connected clients.
|
||||||
*/
|
*/
|
||||||
@WebSocketGateway({ namespace: '/message', cors: { origin: true } })
|
@WebSocketGateway({ namespace: '/message', cors: { origin: true } })
|
||||||
export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
private readonly logger = new Logger(MessageGateway.name);
|
private readonly logger = new Logger(MessageGateway.name);
|
||||||
private readonly emittedLocally = new Set<string>();
|
private readonly emittedLocally = new Set<string>();
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
|||||||
private readonly messages: MessageService,
|
private readonly messages: MessageService,
|
||||||
private readonly session: SessionVerifier,
|
private readonly session: SessionVerifier,
|
||||||
private readonly bus: OutboxBus,
|
private readonly bus: OutboxBus,
|
||||||
|
private readonly presence: PresenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -65,31 +68,82 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket): void {
|
||||||
|
this.presence.clearSocket(client.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app reports which thread is in the foreground (or null when blurred) — presence. */
|
||||||
|
@SubscribeMessage('focus_thread')
|
||||||
|
focusThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string | null }): void {
|
||||||
|
const state = client.data as SocketState | undefined;
|
||||||
|
if (!state?.principal) return;
|
||||||
|
this.presence.setFocus(client.id, state.principal.userId, body?.threadId ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeMessage('open_thread')
|
@SubscribeMessage('open_thread')
|
||||||
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string }) {
|
async openThread(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId?: string; membership?: string; creatorRole?: string; subject?: string }) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
const result = await this.messages.openThread(body?.threadId ?? null, principal);
|
try {
|
||||||
await client.join(result.threadId);
|
const result = await this.messages.openThread(body?.threadId ?? null, principal, {
|
||||||
return result;
|
membership: body?.membership,
|
||||||
|
creatorRole: body?.creatorRole,
|
||||||
|
subject: body?.subject,
|
||||||
|
});
|
||||||
|
await client.join(result.threadId);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
// Fail to an ACK'd error instead of throwing (which never acks → client hangs on "loading").
|
||||||
|
return { error: (err as Error).message ?? 'could not open the conversation' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('add_participant')
|
||||||
|
async addParticipant(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; userId: string; role?: string }) {
|
||||||
|
const { principal } = client.data as SocketState;
|
||||||
|
return this.messages.addParticipant(body.threadId, principal, body.userId, body.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('send_message')
|
@SubscribeMessage('send_message')
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() body: { threadId: string; content: string; contentRef?: string },
|
@MessageBody()
|
||||||
|
body: { threadId: string; content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string; parentInteractionId?: string; mentions?: string[] },
|
||||||
) {
|
) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
const msg = await this.messages.send(
|
const msg = await this.messages.send(
|
||||||
body.threadId,
|
body.threadId,
|
||||||
principal,
|
principal,
|
||||||
{ content: body.content, contentRef: body.contentRef },
|
{ content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
|
undefined,
|
||||||
|
body.parentInteractionId,
|
||||||
|
body.mentions,
|
||||||
);
|
);
|
||||||
this.emittedLocally.add(msg.id);
|
this.emittedLocally.add(msg.id);
|
||||||
this.server.to(body.threadId).emit('message', msg);
|
this.server.to(body.threadId).emit('message', msg);
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('annotate')
|
||||||
|
async annotate(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() body: { threadId: string; interactionId: string; type: string; value: string },
|
||||||
|
) {
|
||||||
|
const { principal } = client.data as SocketState;
|
||||||
|
const r = await this.messages.toggleAnnotation(body.interactionId, principal, body.type, body.value);
|
||||||
|
// Broadcast the refreshed user list for this (interaction,type,value) so every client re-renders.
|
||||||
|
this.server.to(r.threadId).emit('annotation', {
|
||||||
|
threadId: r.threadId,
|
||||||
|
interactionId: r.interactionId,
|
||||||
|
type: r.type,
|
||||||
|
value: r.value,
|
||||||
|
op: r.op,
|
||||||
|
users: r.users,
|
||||||
|
userId: principal.userId,
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeMessage('read')
|
@SubscribeMessage('read')
|
||||||
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
|
|||||||
import { MessageService } from './message.service';
|
import { MessageService } from './message.service';
|
||||||
import { MessageGateway } from './message.gateway';
|
import { MessageGateway } from './message.gateway';
|
||||||
import { OutboxModule } from '../outbox/outbox.module';
|
import { OutboxModule } from '../outbox/outbox.module';
|
||||||
|
import { PresenceService } from '../notifications/presence.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
providers: [MessageService, MessageGateway],
|
providers: [MessageService, MessageGateway, PresenceService],
|
||||||
exports: [MessageService],
|
exports: [MessageService, PresenceService],
|
||||||
})
|
})
|
||||||
export class MessageModule {}
|
export class MessageModule {}
|
||||||
|
|||||||
@@ -9,16 +9,55 @@ import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver
|
|||||||
|
|
||||||
export type { MessagePrincipal };
|
export type { MessagePrincipal };
|
||||||
|
|
||||||
|
/** A generic annotation aggregate on a message (opaque type/value + who applied it). */
|
||||||
|
export interface AnnotationDto {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
users: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A media attachment on a message (the app renders it by `kind`). */
|
||||||
|
export interface AttachmentDto {
|
||||||
|
contentRef: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
kind: 'image' | 'video' | 'audio' | 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaKind(mime: string): AttachmentDto['kind'] {
|
||||||
|
if (mime.startsWith('image/')) return 'image';
|
||||||
|
if (mime.startsWith('video/')) return 'video';
|
||||||
|
if (mime.startsWith('audio/')) return 'audio';
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageDto {
|
export interface MessageDto {
|
||||||
id: string;
|
id: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
senderActorId: string;
|
senderActorId: string;
|
||||||
|
senderId: string; // the sender's stable externalId (email/username) — reliable "is this mine?"
|
||||||
|
senderName: string;
|
||||||
content: string;
|
content: string;
|
||||||
contentRef?: string;
|
contentRef?: string;
|
||||||
|
attachment?: AttachmentDto;
|
||||||
|
parentInteractionId?: string;
|
||||||
|
annotations: AnnotationDto[];
|
||||||
traceId: string;
|
traceId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThreadSummary {
|
||||||
|
threadId: string;
|
||||||
|
subject: string | null;
|
||||||
|
membership?: string;
|
||||||
|
participants: string[];
|
||||||
|
participantCount: number;
|
||||||
|
unread: number;
|
||||||
|
muted?: boolean;
|
||||||
|
lastMessage?: string;
|
||||||
|
lastAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenThreadResult {
|
export interface OpenThreadResult {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -39,16 +78,29 @@ export class MessageService {
|
|||||||
private readonly actors: ActorResolver,
|
private readonly actors: ActorResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Open an existing thread, or create one when no id is given. Joins as participant. */
|
/**
|
||||||
async openThread(threadId: string | null, principal: MessagePrincipal): Promise<OpenThreadResult> {
|
* Open an existing thread, or create one when no id is given. On create, an optional
|
||||||
|
* generic `membership` attribute is stamped on the thread's metadata (the app's DM/group
|
||||||
|
* hint — the kernel never branches on it; policy does), and the creator joins as ADMIN
|
||||||
|
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may
|
||||||
|
* re-open it (policy `iios.thread.join`) — new members enter via addParticipant.
|
||||||
|
*/
|
||||||
|
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> {
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
|
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
|
||||||
const scope = await this.actors.resolveScope(principal);
|
const scope = await this.actors.resolveScope(principal);
|
||||||
const actor = await this.actors.resolveActor(scope.id, principal);
|
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||||
|
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the
|
||||||
|
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app).
|
||||||
const thread = await this.prisma.iiosThread.create({
|
const thread = await this.prisma.iiosThread.create({
|
||||||
data: { scopeId: scope.id, createdByActorId: actor.id },
|
data: {
|
||||||
|
scopeId: scope.id,
|
||||||
|
createdByActorId: actor.id,
|
||||||
|
subject: opts?.subject?.trim() || undefined,
|
||||||
|
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await this.actors.ensureParticipant(thread.id, actor.id);
|
await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
|
||||||
return { threadId: thread.id, status: thread.status, history: [] };
|
return { threadId: thread.id, status: thread.status, history: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,16 +108,122 @@ export class MessageService {
|
|||||||
if (!thread) throw new NotFoundException('thread not found');
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
|
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
|
||||||
const actor = await this.actors.resolveActor(thread.scopeId, principal);
|
const actor = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
|
const alreadyMember =
|
||||||
|
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null;
|
||||||
|
// Join is governed ONLY for threads that opted into a membership model (chat dm/group);
|
||||||
|
// support/inbox/generic threads (no membership attr) keep open-join, unchanged.
|
||||||
|
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
await decideOrThrow(this.ports, { action: 'iios.thread.join', threadId, scopeId: thread.scopeId, membership, alreadyMember });
|
||||||
await this.actors.ensureParticipant(threadId, actor.id);
|
await this.actors.ensureParticipant(threadId, actor.id);
|
||||||
return { threadId, status: thread.status, history: await this.history(threadId) };
|
return { threadId, status: thread.status, history: await this.history(threadId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Governed thread membership (a member adds another user by userId). Fail-closed via
|
||||||
|
* policy: the DM cap / group-admin rule is enforced by OPA (dev stub now, real later);
|
||||||
|
* the kernel only supplies generic context and adds the participant. The target's actor
|
||||||
|
* is resolved-or-created so you can add someone who hasn't logged in yet.
|
||||||
|
*/
|
||||||
|
async addParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string, role = 'MEMBER'): Promise<{ threadId: string; participantCount: number }> {
|
||||||
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
|
const caller = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
|
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
|
||||||
|
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
|
||||||
|
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
|
||||||
|
await decideOrThrow(this.ports, {
|
||||||
|
action: 'iios.thread.participant.add',
|
||||||
|
threadId,
|
||||||
|
scopeId: thread.scopeId,
|
||||||
|
membership,
|
||||||
|
participantCount,
|
||||||
|
callerRole: callerP?.participantRole,
|
||||||
|
targetUserId,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
const target = await this.actors.resolveActor(scope.id, {
|
||||||
|
userId: targetUserId,
|
||||||
|
appId: principal.appId,
|
||||||
|
orgId: principal.orgId,
|
||||||
|
tenantId: principal.tenantId,
|
||||||
|
displayName: targetUserId,
|
||||||
|
});
|
||||||
|
await this.actors.ensureParticipant(threadId, target.id, role);
|
||||||
|
return { threadId, participantCount: participantCount + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle the caller's per-thread notification mute flag. */
|
||||||
|
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
||||||
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
|
const actor = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
|
await this.prisma.iiosThreadParticipant.update({
|
||||||
|
where: { threadId_actorId: { threadId, actorId: actor.id } },
|
||||||
|
data: { muted },
|
||||||
|
});
|
||||||
|
return { threadId, muted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic "my threads": every thread the caller participates in, with last message + unread. */
|
||||||
|
async listThreads(principal: MessagePrincipal): Promise<ThreadSummary[]> {
|
||||||
|
const scope = await this.actors.findScope(principal);
|
||||||
|
if (!scope) return [];
|
||||||
|
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||||
|
const memberships = await this.prisma.iiosThreadParticipant.findMany({ where: { actorId: actor.id }, select: { threadId: true, muted: true } });
|
||||||
|
const threadIds = memberships.map((m) => m.threadId);
|
||||||
|
if (threadIds.length === 0) return [];
|
||||||
|
const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted]));
|
||||||
|
|
||||||
|
const [threads, unreads, allParts] = await Promise.all([
|
||||||
|
this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }),
|
||||||
|
this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }),
|
||||||
|
this.prisma.iiosThreadParticipant.findMany({
|
||||||
|
where: { threadId: { in: threadIds } },
|
||||||
|
include: { actor: { include: { sourceHandle: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
|
||||||
|
const membersBy = new Map<string, string[]>();
|
||||||
|
for (const p of allParts) {
|
||||||
|
const name = p.actor?.sourceHandle?.externalId ?? p.actor?.displayName ?? p.actorId;
|
||||||
|
membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
threads.map(async (t) => {
|
||||||
|
const last = await this.prisma.iiosInteraction.findFirst({
|
||||||
|
where: { threadId: t.id },
|
||||||
|
orderBy: { occurredAt: 'desc' },
|
||||||
|
include: { parts: { where: { kind: 'TEXT' }, take: 1 } },
|
||||||
|
});
|
||||||
|
const members = membersBy.get(t.id) ?? [];
|
||||||
|
return {
|
||||||
|
threadId: t.id,
|
||||||
|
subject: t.subject,
|
||||||
|
membership: (t.metadata as { membership?: string } | null)?.membership,
|
||||||
|
participants: members,
|
||||||
|
participantCount: members.length,
|
||||||
|
unread: unreadBy.get(t.id) ?? 0,
|
||||||
|
muted: mutedBy.get(t.id) ?? false,
|
||||||
|
lastMessage: last?.parts[0]?.bodyText ?? undefined,
|
||||||
|
lastAt: last?.occurredAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
async send(
|
async send(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
principal: MessagePrincipal,
|
principal: MessagePrincipal,
|
||||||
body: { content: string; contentRef?: string },
|
body: { content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string },
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
traceId: string = randomUUID(),
|
traceId: string = randomUUID(),
|
||||||
|
parentInteractionId?: string,
|
||||||
|
mentions?: string[],
|
||||||
): Promise<MessageDto> {
|
): Promise<MessageDto> {
|
||||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||||
if (!thread) throw new NotFoundException('thread not found');
|
if (!thread) throw new NotFoundException('thread not found');
|
||||||
@@ -75,10 +233,15 @@ export class MessageService {
|
|||||||
const actor = await this.actors.resolveActor(thread.scopeId, principal);
|
const actor = await this.actors.resolveActor(thread.scopeId, principal);
|
||||||
await this.actors.ensureParticipant(threadId, actor.id);
|
await this.actors.ensureParticipant(threadId, actor.id);
|
||||||
|
|
||||||
|
// A reply links to its parent — but only if the parent is in the same thread (else ignored).
|
||||||
|
const parentRef = parentInteractionId
|
||||||
|
? (await this.prisma.iiosInteraction.findFirst({ where: { id: parentInteractionId, threadId }, select: { id: true } }))?.id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Idempotency: a repeat key returns the existing message (no re-increment).
|
// Idempotency: a repeat key returns the existing message (no re-increment).
|
||||||
const existing = await this.prisma.iiosInteraction.findUnique({
|
const existing = await this.prisma.iiosInteraction.findUnique({
|
||||||
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
|
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
|
||||||
include: { parts: { orderBy: { partIndex: 'asc' } } },
|
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
|
||||||
});
|
});
|
||||||
if (existing) return this.toDto(existing, threadId);
|
if (existing) return this.toDto(existing, threadId);
|
||||||
|
|
||||||
@@ -93,6 +256,7 @@ export class MessageService {
|
|||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
status: 'NORMALIZED',
|
status: 'NORMALIZED',
|
||||||
traceId,
|
traceId,
|
||||||
|
parentInteractionId: parentRef,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,7 +264,18 @@ export class MessageService {
|
|||||||
{ interactionId: created.id, partIndex: 0, kind: 'TEXT', bodyText: body.content },
|
{ interactionId: created.id, partIndex: 0, kind: 'TEXT', bodyText: body.content },
|
||||||
];
|
];
|
||||||
if (body.contentRef) {
|
if (body.contentRef) {
|
||||||
parts.push({ interactionId: created.id, partIndex: 1, kind: 'FILE_REF', contentRef: body.contentRef });
|
const mime = body.mimeType ?? '';
|
||||||
|
// Generic part kind from mime — the kernel stores media as opaque parts.
|
||||||
|
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
|
||||||
|
parts.push({
|
||||||
|
interactionId: created.id,
|
||||||
|
partIndex: 1,
|
||||||
|
kind,
|
||||||
|
contentRef: body.contentRef,
|
||||||
|
mimeType: body.mimeType,
|
||||||
|
sizeBytes: body.sizeBytes != null ? BigInt(body.sizeBytes) : undefined,
|
||||||
|
checksumSha256: body.checksumSha256,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await tx.iiosMessagePart.createMany({ data: parts });
|
await tx.iiosMessagePart.createMany({ data: parts });
|
||||||
|
|
||||||
@@ -114,7 +289,9 @@ export class MessageService {
|
|||||||
datacontenttype: 'application/json',
|
datacontenttype: 'application/json',
|
||||||
traceparent: `00-${traceId.replace(/-/g, '')}-0000000000000000-01`,
|
traceparent: `00-${traceId.replace(/-/g, '')}-0000000000000000-01`,
|
||||||
insignia: { scopeSnapshotId: thread.scopeId, correlationId: traceId, idempotencyKey, dataClass: 'internal' },
|
insignia: { scopeSnapshotId: thread.scopeId, correlationId: traceId, idempotencyKey, dataClass: 'internal' },
|
||||||
data: { interactionId: created.id, threadId, senderActorId: actor.id },
|
// `mentions` is an OPAQUE app-supplied notify-list (userIds) — the kernel never parses
|
||||||
|
// "@"; the inbox projector generically fans out a notification to those actors.
|
||||||
|
data: { interactionId: created.id, threadId, senderActorId: actor.id, mentions: mentions ?? [] },
|
||||||
};
|
};
|
||||||
await tx.iiosOutboxEvent.create({
|
await tx.iiosOutboxEvent.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -139,7 +316,7 @@ export class MessageService {
|
|||||||
|
|
||||||
return tx.iiosInteraction.findUniqueOrThrow({
|
return tx.iiosInteraction.findUniqueOrThrow({
|
||||||
where: { id: created.id },
|
where: { id: created.id },
|
||||||
include: { parts: { orderBy: { partIndex: 'asc' } } },
|
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,7 +325,7 @@ export class MessageService {
|
|||||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||||
const winner = await this.prisma.iiosInteraction.findUniqueOrThrow({
|
const winner = await this.prisma.iiosInteraction.findUniqueOrThrow({
|
||||||
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
|
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
|
||||||
include: { parts: { orderBy: { partIndex: 'asc' } } },
|
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
|
||||||
});
|
});
|
||||||
return this.toDto(winner, threadId);
|
return this.toDto(winner, threadId);
|
||||||
}
|
}
|
||||||
@@ -220,7 +397,7 @@ export class MessageService {
|
|||||||
async getMessageById(id: string, principal?: MessagePrincipal): Promise<MessageDto | null> {
|
async getMessageById(id: string, principal?: MessagePrincipal): Promise<MessageDto | null> {
|
||||||
const i = await this.prisma.iiosInteraction.findUnique({
|
const i = await this.prisma.iiosInteraction.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { parts: { orderBy: { partIndex: 'asc' } } },
|
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
|
||||||
});
|
});
|
||||||
if (!i || !i.threadId) return null;
|
if (!i || !i.threadId) return null;
|
||||||
if (principal) await this.actors.assertOwns(principal, i.scopeId); // tenant fence (KG-02) when called on behalf of a caller
|
if (principal) await this.actors.assertOwns(principal, i.scopeId); // tenant fence (KG-02) when called on behalf of a caller
|
||||||
@@ -231,25 +408,162 @@ export class MessageService {
|
|||||||
const interactions = await this.prisma.iiosInteraction.findMany({
|
const interactions = await this.prisma.iiosInteraction.findMany({
|
||||||
where: { threadId },
|
where: { threadId },
|
||||||
orderBy: { occurredAt: 'asc' },
|
orderBy: { occurredAt: 'asc' },
|
||||||
include: { parts: { orderBy: { partIndex: 'asc' } } },
|
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
|
||||||
});
|
});
|
||||||
return interactions.map((i) => this.toDto(i, threadId));
|
const annotations = await this.annotationsByInteraction(interactions.map((i) => i.id));
|
||||||
|
return interactions.map((i) => this.toDto(i, threadId, annotations.get(i.id) ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a generic annotation (actor attaches/removes an opaque label on an interaction).
|
||||||
|
* `annotationType`/`value` are app-supplied and never interpreted here (the chat app uses
|
||||||
|
* type "reaction" + an emoji value). Governed: only a thread participant may annotate.
|
||||||
|
* Returns the refreshed user list for that (type,value) so callers can broadcast it.
|
||||||
|
*/
|
||||||
|
async toggleAnnotation(
|
||||||
|
interactionId: string,
|
||||||
|
principal: MessagePrincipal,
|
||||||
|
annotationType: string,
|
||||||
|
value: string,
|
||||||
|
): Promise<{ interactionId: string; threadId: string; type: string; value: string; op: 'add' | 'remove'; users: string[] }> {
|
||||||
|
const target = await this.prisma.iiosInteraction.findUnique({
|
||||||
|
where: { id: interactionId },
|
||||||
|
select: { id: true, threadId: true, scopeId: true },
|
||||||
|
});
|
||||||
|
if (!target || !target.threadId) throw new NotFoundException('message not found');
|
||||||
|
const actor = await this.actors.resolveActor(target.scopeId, principal);
|
||||||
|
const isMember =
|
||||||
|
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId: target.threadId, actorId: actor.id } } })) !== null;
|
||||||
|
await decideOrThrow(this.ports, { action: 'iios.interaction.annotate', threadId: target.threadId, scopeId: target.scopeId, isMember });
|
||||||
|
|
||||||
|
const key = {
|
||||||
|
scopeId_targetInteractionId_actorId_annotationType_value: {
|
||||||
|
scopeId: target.scopeId,
|
||||||
|
targetInteractionId: interactionId,
|
||||||
|
actorId: actor.id,
|
||||||
|
annotationType,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const existing = await this.prisma.iiosInteractionAnnotation.findUnique({ where: key });
|
||||||
|
let op: 'add' | 'remove';
|
||||||
|
if (existing) {
|
||||||
|
await this.prisma.iiosInteractionAnnotation.delete({ where: { id: existing.id } });
|
||||||
|
op = 'remove';
|
||||||
|
} else {
|
||||||
|
await this.prisma.iiosInteractionAnnotation.create({
|
||||||
|
data: { scopeId: target.scopeId, targetInteractionId: interactionId, actorId: actor.id, annotationType, value },
|
||||||
|
});
|
||||||
|
op = 'add';
|
||||||
|
}
|
||||||
|
|
||||||
|
const users =
|
||||||
|
(await this.annotationsByInteraction([interactionId])).get(interactionId)?.find((a) => a.type === annotationType && a.value === value)?.users ?? [];
|
||||||
|
return { interactionId, threadId: target.threadId, type: annotationType, value, op, users };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic "my annotated interactions" — every message the caller annotated with a given
|
||||||
|
* type, newest first, with thread context. The app uses type "save" for a personal
|
||||||
|
* bookmarks list (and could use "pin" for a cross-thread pinned view). Generic: the kernel
|
||||||
|
* never interprets the type string.
|
||||||
|
*/
|
||||||
|
async listMyAnnotated(
|
||||||
|
principal: MessagePrincipal,
|
||||||
|
annotationType: string,
|
||||||
|
): Promise<Array<{ message: MessageDto; threadId: string; threadSubject: string | null }>> {
|
||||||
|
const scope = await this.actors.findScope(principal);
|
||||||
|
if (!scope) return [];
|
||||||
|
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||||
|
const anns = await this.prisma.iiosInteractionAnnotation.findMany({
|
||||||
|
where: { actorId: actor.id, annotationType },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { targetInteractionId: true },
|
||||||
|
});
|
||||||
|
const ids = anns.map((a) => a.targetInteractionId);
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const [interactions, agg] = await Promise.all([
|
||||||
|
this.prisma.iiosInteraction.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
include: {
|
||||||
|
parts: { orderBy: { partIndex: 'asc' } },
|
||||||
|
actor: { include: { sourceHandle: true } },
|
||||||
|
thread: { select: { subject: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.annotationsByInteraction(ids),
|
||||||
|
]);
|
||||||
|
const byId = new Map(interactions.map((i) => [i.id, i]));
|
||||||
|
return ids
|
||||||
|
.map((id) => byId.get(id))
|
||||||
|
.filter((i): i is NonNullable<typeof i> => Boolean(i && i.threadId))
|
||||||
|
.map((i) => ({
|
||||||
|
message: this.toDto(i, i.threadId as string, agg.get(i.id) ?? []),
|
||||||
|
threadId: i.threadId as string,
|
||||||
|
threadSubject: i.thread?.subject ?? null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Aggregate annotations for the given interactions into { type, value, users[] } groups. */
|
||||||
|
private async annotationsByInteraction(interactionIds: string[]): Promise<Map<string, AnnotationDto[]>> {
|
||||||
|
const map = new Map<string, AnnotationDto[]>();
|
||||||
|
if (interactionIds.length === 0) return map;
|
||||||
|
const rows = await this.prisma.iiosInteractionAnnotation.findMany({
|
||||||
|
where: { targetInteractionId: { in: interactionIds } },
|
||||||
|
include: { actor: { include: { sourceHandle: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
for (const r of rows) {
|
||||||
|
const user = r.actor?.sourceHandle?.externalId ?? r.actor?.displayName ?? r.actorId;
|
||||||
|
const list = map.get(r.targetInteractionId) ?? [];
|
||||||
|
let entry = list.find((e) => e.type === r.annotationType && e.value === r.value);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { type: r.annotationType, value: r.value, users: [] };
|
||||||
|
list.push(entry);
|
||||||
|
}
|
||||||
|
entry.users.push(user);
|
||||||
|
map.set(r.targetInteractionId, list);
|
||||||
|
}
|
||||||
|
for (const list of map.values()) for (const e of list) e.users.sort(); // deterministic order
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
private toDto(
|
private toDto(
|
||||||
interaction: { id: string; actorId: string | null; traceId: string | null; occurredAt: Date; parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }> },
|
interaction: {
|
||||||
|
id: string;
|
||||||
|
actorId: string | null;
|
||||||
|
actor?: { displayName: string | null; sourceHandle: { externalId: string } | null } | null;
|
||||||
|
parentInteractionId?: string | null;
|
||||||
|
traceId: string | null;
|
||||||
|
occurredAt: Date;
|
||||||
|
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null; mimeType?: string | null; sizeBytes?: bigint | null }>;
|
||||||
|
},
|
||||||
threadId: string,
|
threadId: string,
|
||||||
|
annotations: AnnotationDto[] = [],
|
||||||
): MessageDto {
|
): MessageDto {
|
||||||
const text = interaction.parts.find((p) => p.kind === 'TEXT');
|
const text = interaction.parts.find((p) => p.kind === 'TEXT');
|
||||||
const file = interaction.parts.find((p) => p.contentRef);
|
const file = interaction.parts.find((p) => p.contentRef);
|
||||||
|
const attachment: AttachmentDto | undefined = file?.contentRef
|
||||||
|
? {
|
||||||
|
contentRef: file.contentRef,
|
||||||
|
mimeType: file.mimeType ?? 'application/octet-stream',
|
||||||
|
sizeBytes: file.sizeBytes != null ? Number(file.sizeBytes) : 0,
|
||||||
|
kind: mediaKind(file.mimeType ?? ''),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
id: interaction.id,
|
id: interaction.id,
|
||||||
threadId,
|
threadId,
|
||||||
senderActorId: interaction.actorId ?? '',
|
senderActorId: interaction.actorId ?? '',
|
||||||
|
senderId: interaction.actor?.sourceHandle?.externalId ?? '',
|
||||||
|
senderName: interaction.actor?.displayName ?? interaction.actor?.sourceHandle?.externalId ?? 'unknown',
|
||||||
content: text?.bodyText ?? '',
|
content: text?.bodyText ?? '',
|
||||||
contentRef: file?.contentRef ?? undefined,
|
contentRef: file?.contentRef ?? undefined,
|
||||||
|
attachment,
|
||||||
|
parentInteractionId: interaction.parentInteractionId ?? undefined,
|
||||||
|
annotations,
|
||||||
traceId: interaction.traceId ?? '',
|
traceId: interaction.traceId ?? '',
|
||||||
createdAt: interaction.occurredAt,
|
createdAt: interaction.occurredAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
import { resetDb } from '../test-utils/reset-db';
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
import { makeFakePorts } from '@insignia/iios-testkit';
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
import { MessageService, type MessagePrincipal } from './message.service';
|
import { MessageService, type MessagePrincipal } from './message.service';
|
||||||
import { ActorResolver } from '../identity/actor.resolver';
|
import { ActorResolver } from '../identity/actor.resolver';
|
||||||
|
import { DevOpaPort } from '../platform/dev-opa.port';
|
||||||
import type { PrismaService } from '../prisma/prisma.service';
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
const asService = prisma as unknown as PrismaService;
|
const asService = prisma as unknown as PrismaService;
|
||||||
const svc = () => new MessageService(asService, makeFakePorts(), new ActorResolver(asService));
|
const svc = () => new MessageService(asService, makeFakePorts(), new ActorResolver(asService));
|
||||||
|
// A service whose OPA actually enforces the membership rules (real dev policy plane).
|
||||||
|
const gov = () =>
|
||||||
|
new MessageService(asService, { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts, new ActorResolver(asService));
|
||||||
|
|
||||||
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||||
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
|
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
|
||||||
@@ -101,3 +106,124 @@ describe('MessageService (P2 native messaging)', () => {
|
|||||||
expect(ce.insignia.correlationId).toBe(msg.traceId);
|
expect(ce.insignia.correlationId).toBe(msg.traceId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
||||||
|
it('a direct message is capped at two people (OPA policy)', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
|
||||||
|
expect((await s.addParticipant(threadId, alice, 'bob')).participantCount).toBe(2);
|
||||||
|
await expect(s.addParticipant(threadId, alice, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(2); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: the creator is ADMIN and can add; a plain member cannot', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await s.addParticipant(threadId, alice, 'bob'); // admin adds a member
|
||||||
|
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(2);
|
||||||
|
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
|
||||||
|
});
|
||||||
|
|
||||||
|
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await expect(s.openThread(threadId, bob)).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
await s.addParticipant(threadId, alice, 'bob');
|
||||||
|
expect((await s.openThread(threadId, bob)).threadId).toBe(threadId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listThreads returns the caller’s threads with membership, count, last message + unread', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await s.addParticipant(threadId, alice, 'bob');
|
||||||
|
await s.send(threadId, alice, { content: 'hello team' }, 'k1');
|
||||||
|
const mine = await s.listThreads(alice);
|
||||||
|
expect(mine).toHaveLength(1);
|
||||||
|
expect(mine[0]).toMatchObject({ membership: 'group', participantCount: 2, lastMessage: 'hello team' });
|
||||||
|
expect((await s.listThreads(bob))[0]?.unread).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
|
||||||
|
const first = await s.send(threadId, alice, { content: 'question?' }, 'k1');
|
||||||
|
const reply = await s.send(threadId, alice, { content: 'answer' }, 'k2', undefined, first.id);
|
||||||
|
expect(reply.parentInteractionId).toBe(first.id);
|
||||||
|
|
||||||
|
const { threadId: other } = await s.openThread(null, alice, { membership: 'dm' });
|
||||||
|
const cross = await s.send(other, alice, { content: 'x' }, 'k3', undefined, first.id);
|
||||||
|
expect(cross.parentInteractionId).toBeUndefined(); // parent not in this thread
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interaction annotations (generic reactions primitive)', () => {
|
||||||
|
it('toggleAnnotation adds then removes (toggle) and aggregates users into history', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await s.addParticipant(threadId, alice, 'bob');
|
||||||
|
await s.openThread(threadId, bob);
|
||||||
|
const msg = await s.send(threadId, alice, { content: 'ship it' }, 'k1');
|
||||||
|
|
||||||
|
const add = await s.toggleAnnotation(msg.id, bob, 'reaction', '👍');
|
||||||
|
expect(add.op).toBe('add');
|
||||||
|
expect(add.users).toEqual(['bob']);
|
||||||
|
|
||||||
|
const add2 = await s.toggleAnnotation(msg.id, alice, 'reaction', '👍'); // alice also 👍
|
||||||
|
expect(add2.op).toBe('add');
|
||||||
|
expect(add2.users).toEqual(['alice', 'bob']); // sorted, deterministic
|
||||||
|
|
||||||
|
const rem = await s.toggleAnnotation(msg.id, bob, 'reaction', '👍'); // bob toggles off
|
||||||
|
expect(rem.op).toBe('remove');
|
||||||
|
expect(rem.users).toEqual(['alice']);
|
||||||
|
|
||||||
|
const hist = await s.history(threadId);
|
||||||
|
const m = hist.find((x) => x.id === msg.id)!;
|
||||||
|
expect(m.annotations).toEqual([{ type: 'reaction', value: '👍', users: ['alice'] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different values coexist for the same actor (👍 and 🎉 both stick)', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
const msg = await s.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||||
|
await s.toggleAnnotation(msg.id, alice, 'reaction', '👍');
|
||||||
|
await s.toggleAnnotation(msg.id, alice, 'reaction', '🎉');
|
||||||
|
const m = (await s.history(threadId)).find((x) => x.id === msg.id)!;
|
||||||
|
expect(m.annotations).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ type: 'reaction', value: '👍', users: ['alice'] },
|
||||||
|
{ type: 'reaction', value: '🎉', users: ['alice'] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a non-member cannot annotate (governed by policy)', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
const msg = await s.send(threadId, alice, { content: 'secret' }, 'k1');
|
||||||
|
await expect(s.toggleAnnotation(msg.id, bob, 'reaction', '👍')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listMyAnnotated returns the caller’s saved messages with thread context; others see none', async () => {
|
||||||
|
const s = gov();
|
||||||
|
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
|
||||||
|
await s.addParticipant(threadId, alice, 'bob');
|
||||||
|
const msg = await s.send(threadId, alice, { content: 'save this' }, 'k1');
|
||||||
|
await s.toggleAnnotation(msg.id, alice, 'save', ''); // save is a personal annotation
|
||||||
|
|
||||||
|
const saved = await s.listMyAnnotated(alice, 'save');
|
||||||
|
expect(saved).toHaveLength(1);
|
||||||
|
expect(saved[0]).toMatchObject({ threadId, threadSubject: 'Design' });
|
||||||
|
expect(saved[0]?.message.content).toBe('save this');
|
||||||
|
expect(await s.listMyAnnotated(bob, 'save')).toHaveLength(0); // bob saved nothing
|
||||||
|
});
|
||||||
|
|
||||||
|
it('send carries an OPAQUE mentions[] into the message event (kernel never parses @)', async () => {
|
||||||
|
const s = svc();
|
||||||
|
const { threadId } = await s.openThread(null, alice);
|
||||||
|
await s.openThread(threadId, bob);
|
||||||
|
await s.send(threadId, alice, { content: 'hi @bob' }, 'k1', undefined, undefined, ['bob']);
|
||||||
|
const ev = await prisma.iiosOutboxEvent.findFirstOrThrow({ where: { eventType: 'com.insignia.iios.message.sent.v1' } });
|
||||||
|
const ce = ev.cloudEvent as { data: { mentions?: string[] } };
|
||||||
|
expect(ce.data.mentions).toEqual(['bob']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Delete, Get, Headers, Post } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import { SubscribeDto, UnsubscribeDto } from './notification.dto';
|
||||||
|
|
||||||
|
@Controller('v1/notifications')
|
||||||
|
export class NotificationController {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly actors: ActorResolver,
|
||||||
|
private readonly session: SessionVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The public VAPID key the client needs to create a push subscription. */
|
||||||
|
@Get('vapid-public-key')
|
||||||
|
vapidKey(): { key: string } {
|
||||||
|
return { key: process.env.VAPID_PUBLIC_KEY ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store (or refresh) a push subscription for the caller. */
|
||||||
|
@Post('subscribe')
|
||||||
|
async subscribe(@Body() body: SubscribeDto, @Headers('authorization') auth?: string): Promise<{ ok: true }> {
|
||||||
|
const principal = this.principal(auth);
|
||||||
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||||
|
await this.prisma.iiosNotificationSubscription.upsert({
|
||||||
|
where: { endpoint: body.endpoint },
|
||||||
|
create: { scopeId: scope.id, actorId: actor.id, kind: body.kind ?? 'webpush', endpoint: body.endpoint, p256dh: body.keys.p256dh, auth: body.keys.auth, userAgent: body.userAgent },
|
||||||
|
update: { actorId: actor.id, p256dh: body.keys.p256dh, auth: body.keys.auth, lastSeenAt: new Date() },
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('subscribe')
|
||||||
|
async unsubscribe(@Body() body: UnsubscribeDto): Promise<{ ok: true }> {
|
||||||
|
await this.prisma.iiosNotificationSubscription.deleteMany({ where: { endpoint: body.endpoint } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private principal(auth?: string): MessagePrincipal {
|
||||||
|
const token = (auth ?? '').replace(/^Bearer\s+/i, '');
|
||||||
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||||
|
return this.session.verify(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class SubscribeDto {
|
||||||
|
@IsOptional() @IsIn(['webpush']) kind?: string;
|
||||||
|
@IsString() endpoint!: string;
|
||||||
|
@IsObject() keys!: { p256dh: string; auth: string };
|
||||||
|
@IsOptional() @IsString() userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnsubscribeDto {
|
||||||
|
@IsString() endpoint!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { OutboxModule } from '../outbox/outbox.module';
|
||||||
|
import { MessageModule } from '../messaging/message.module';
|
||||||
|
import { NotificationController } from './notification.controller';
|
||||||
|
import { NotificationProjector } from './notification.projector';
|
||||||
|
import { WebPushDelivery } from './web-push.delivery';
|
||||||
|
import { NOTIFICATION_PORT } from './notification.port';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [OutboxModule, MessageModule], // OutboxModule → bus/dlq; MessageModule → PresenceService. Prisma/Projection/Identity are @Global.
|
||||||
|
controllers: [NotificationController],
|
||||||
|
providers: [
|
||||||
|
NotificationProjector,
|
||||||
|
{
|
||||||
|
// Dev binds Web Push (VAPID); prod can swap this to an email/FCM adapter.
|
||||||
|
provide: NOTIFICATION_PORT,
|
||||||
|
useFactory: () =>
|
||||||
|
new WebPushDelivery(
|
||||||
|
process.env.VAPID_PUBLIC_KEY
|
||||||
|
? { publicKey: process.env.VAPID_PUBLIC_KEY, privateKey: process.env.VAPID_PRIVATE_KEY ?? '', subject: process.env.VAPID_SUBJECT ?? 'mailto:dev@insignia' }
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class NotificationModule {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** The delivery seam. Dev = Web Push (VAPID); prod can swap to email/FCM/APNs. */
|
||||||
|
export interface PushSub {
|
||||||
|
endpoint: string;
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPayload {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
data: { threadId: string; interactionId?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPort {
|
||||||
|
deliver(sub: PushSub, payload: NotificationPayload): Promise<'sent' | 'gone' | 'failed'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOTIFICATION_PORT = Symbol('NOTIFICATION_PORT');
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
|
||||||
|
import { ActorResolver } from '../identity/actor.resolver';
|
||||||
|
import { OutboxBus } from '../outbox/outbox.bus';
|
||||||
|
import { DlqService } from '../outbox/dlq.service';
|
||||||
|
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
||||||
|
import { PresenceService } from './presence.service';
|
||||||
|
import { NotificationProjector } from './notification.projector';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
const asService = prisma as unknown as PrismaService;
|
||||||
|
const actors = new ActorResolver(asService);
|
||||||
|
const ms = () => new MessageService(asService, makeFakePorts(), actors);
|
||||||
|
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||||
|
|
||||||
|
async function actorIdFor(userId: string): Promise<string> {
|
||||||
|
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: userId } });
|
||||||
|
return (await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } })).id;
|
||||||
|
}
|
||||||
|
async function seedSub(userId: string): Promise<string> {
|
||||||
|
const actorId = await actorIdFor(userId);
|
||||||
|
const scope = await prisma.iiosScope.findFirstOrThrow();
|
||||||
|
await prisma.iiosNotificationSubscription.create({ data: { scopeId: scope.id, actorId, kind: 'webpush', endpoint: `https://push/${userId}`, p256dh: 'k', auth: 'a' } });
|
||||||
|
return actorId;
|
||||||
|
}
|
||||||
|
async function sentEvents(): Promise<CloudEvent[]> {
|
||||||
|
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
|
||||||
|
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
|
||||||
|
}
|
||||||
|
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
|
||||||
|
const deliver = vi.fn().mockResolvedValue(deliverResult);
|
||||||
|
const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
|
||||||
|
return { proj, deliver, presence };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
describe('NotificationProjector', () => {
|
||||||
|
it('DM: notifies the recipient (absent), never the sender', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
|
||||||
|
const { proj, deliver } = makeProjector();
|
||||||
|
await proj.onMessageSent((await sentEvents())[0]!);
|
||||||
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
|
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group without a mention: does NOT notify', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
await m.send(threadId, alice, { content: 'hello all' }, 'k1');
|
||||||
|
const { proj, deliver } = makeProjector();
|
||||||
|
await proj.onMessageSent((await sentEvents())[0]!);
|
||||||
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group WITH a mention: notifies the mentioned member', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
|
||||||
|
const { proj, deliver } = makeProjector();
|
||||||
|
await proj.onMessageSent((await sentEvents())[0]!);
|
||||||
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group reply-to-you: notifies the parent author even without a mention', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
const parent = await m.send(threadId, bob, { content: 'question?' }, 'k1'); // bob authors the parent
|
||||||
|
await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
|
||||||
|
const { proj, deliver } = makeProjector();
|
||||||
|
const evs = await sentEvents();
|
||||||
|
await proj.onMessageSent(evs[evs.length - 1]!); // project the reply
|
||||||
|
expect(deliver).toHaveBeenCalledOnce();
|
||||||
|
expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('presence gate: a recipient viewing the thread is NOT notified', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||||
|
const presence = new PresenceService();
|
||||||
|
presence.setFocus('sockB', 'bob', threadId);
|
||||||
|
const { proj, deliver } = makeProjector(presence);
|
||||||
|
await proj.onMessageSent((await sentEvents())[0]!);
|
||||||
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mute gate: a muted recipient is NOT notified', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
const bobActorId = await seedSub('bob');
|
||||||
|
await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
|
||||||
|
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||||
|
const { proj, deliver } = makeProjector();
|
||||||
|
await proj.onMessageSent((await sentEvents())[0]!);
|
||||||
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes a subscription that returns "gone"', async () => {
|
||||||
|
const m = ms();
|
||||||
|
const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
|
||||||
|
await m.addParticipant(threadId, alice, 'bob');
|
||||||
|
await seedSub('bob');
|
||||||
|
await m.send(threadId, alice, { content: 'hi' }, 'k1');
|
||||||
|
const { proj } = makeProjector(new PresenceService(), 'gone');
|
||||||
|
await proj.onMessageSent((await sentEvents())[0]!);
|
||||||
|
expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { OutboxBus } from '../outbox/outbox.bus';
|
||||||
|
import { DlqService } from '../outbox/dlq.service';
|
||||||
|
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
||||||
|
import { PresenceService } from './presence.service';
|
||||||
|
import { NOTIFICATION_PORT, type NotificationPort } from './notification.port';
|
||||||
|
|
||||||
|
interface MsgData {
|
||||||
|
interactionId: string;
|
||||||
|
threadId: string;
|
||||||
|
senderActorId: string;
|
||||||
|
mentions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns `message.sent` into push notifications for absent recipients. Three gates:
|
||||||
|
* 1. policy — DM always; group only if @mentioned or a reply to that recipient
|
||||||
|
* 2. presence — skip if the recipient is currently focused on that thread
|
||||||
|
* 3. mute — skip if the recipient muted the thread
|
||||||
|
* Then dispatches to each of the recipient's subscriptions; a 'gone' result prunes it.
|
||||||
|
* Idempotent per event id (same pattern as InboxProjector). Generic: DM-vs-group is read
|
||||||
|
* from the opaque `membership` thread attribute here in the notification *policy*, not the kernel.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationProjector implements OnModuleInit {
|
||||||
|
private readonly consumer = 'notification-projector';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly bus: OutboxBus,
|
||||||
|
private readonly dlq: DlqService,
|
||||||
|
private readonly cursor: ProjectionCursorService,
|
||||||
|
private readonly presence: PresenceService,
|
||||||
|
@Inject(NOTIFICATION_PORT) private readonly port: NotificationPort,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
this.dlq.registerHandler(this.consumer, (e) => this.onMessageSent(e));
|
||||||
|
this.bus.on(IIOS_EVENTS.messageSent, (p) =>
|
||||||
|
void this.onMessageSent(p as CloudEvent).catch((err) => this.dlq.onConsumerFailure(this.consumer, p as CloudEvent, err)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onMessageSent(event: CloudEvent): Promise<void> {
|
||||||
|
if (!(await this.claim(event.id))) return; // duplicate → no apply, no cursor advance
|
||||||
|
await this.apply(event);
|
||||||
|
await this.cursor.advance(this.consumer, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async apply(event: CloudEvent): Promise<void> {
|
||||||
|
const data = event.data as MsgData;
|
||||||
|
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
|
||||||
|
if (!thread) return;
|
||||||
|
const membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||||
|
|
||||||
|
const interaction = await this.prisma.iiosInteraction.findUnique({
|
||||||
|
where: { id: data.interactionId },
|
||||||
|
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
|
||||||
|
});
|
||||||
|
const senderName = interaction?.actor?.sourceHandle?.externalId ?? 'Someone';
|
||||||
|
const body = interaction?.parts[0]?.bodyText ?? 'sent an attachment';
|
||||||
|
const mentions = data.mentions ?? [];
|
||||||
|
|
||||||
|
// Parent author (for reply-to-you) — one lookup.
|
||||||
|
let parentAuthorActorId: string | null = null;
|
||||||
|
if (interaction?.parentInteractionId) {
|
||||||
|
const parent = await this.prisma.iiosInteraction.findUnique({
|
||||||
|
where: { id: interaction.parentInteractionId },
|
||||||
|
select: { actorId: true },
|
||||||
|
});
|
||||||
|
parentAuthorActorId = parent?.actorId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await this.prisma.iiosThreadParticipant.findMany({
|
||||||
|
where: { threadId: data.threadId },
|
||||||
|
include: { actor: { include: { sourceHandle: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const p of participants) {
|
||||||
|
if (p.actorId === data.senderActorId) continue; // never the sender
|
||||||
|
const userId = p.actor?.sourceHandle?.externalId;
|
||||||
|
|
||||||
|
// gate 1 — policy
|
||||||
|
const mentioned = userId != null && mentions.includes(userId);
|
||||||
|
const repliedToMe = parentAuthorActorId != null && parentAuthorActorId === p.actorId;
|
||||||
|
if (!(membership === 'dm' || mentioned || repliedToMe)) continue;
|
||||||
|
|
||||||
|
// gate 2 — presence
|
||||||
|
if (userId && this.presence.isViewing(userId, data.threadId)) continue;
|
||||||
|
|
||||||
|
// gate 3 — mute
|
||||||
|
if (p.muted) continue;
|
||||||
|
|
||||||
|
const subs = await this.prisma.iiosNotificationSubscription.findMany({ where: { actorId: p.actorId } });
|
||||||
|
for (const s of subs) {
|
||||||
|
const result = await this.port.deliver(
|
||||||
|
{ endpoint: s.endpoint, p256dh: s.p256dh, auth: s.auth },
|
||||||
|
{ title: senderName, body, data: { threadId: data.threadId, interactionId: data.interactionId } },
|
||||||
|
);
|
||||||
|
if (result === 'gone') await this.prisma.iiosNotificationSubscription.delete({ where: { id: s.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async claim(eventId: string): Promise<boolean> {
|
||||||
|
const res = await this.prisma.iiosProcessedEvent.createMany({
|
||||||
|
data: [{ consumerName: this.consumer, eventId }],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
return res.count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { PresenceService } from './presence.service';
|
||||||
|
|
||||||
|
describe('PresenceService', () => {
|
||||||
|
it('reports viewing only for the focused thread, and clears on disconnect', () => {
|
||||||
|
const p = new PresenceService();
|
||||||
|
expect(p.isViewing('alice', 'T1')).toBe(false);
|
||||||
|
p.setFocus('sock1', 'alice', 'T1');
|
||||||
|
expect(p.isViewing('alice', 'T1')).toBe(true);
|
||||||
|
expect(p.isViewing('alice', 'T2')).toBe(false); // joined-elsewhere ≠ viewing
|
||||||
|
p.setFocus('sock1', 'alice', 'T2'); // moved focus
|
||||||
|
expect(p.isViewing('alice', 'T1')).toBe(false);
|
||||||
|
expect(p.isViewing('alice', 'T2')).toBe(true);
|
||||||
|
p.clearSocket('sock1');
|
||||||
|
expect(p.isViewing('alice', 'T2')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('any of the actor’s sockets counts as viewing', () => {
|
||||||
|
const p = new PresenceService();
|
||||||
|
p.setFocus('sockA', 'bob', 'T9');
|
||||||
|
p.setFocus('sockB', 'bob', null); // a second tab, no focus
|
||||||
|
expect(p.isViewing('bob', 'T9')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory focus tracker (single instance). Keyed on userId (the stable externalId the
|
||||||
|
* gateway has as principal.userId). NOTE: room membership ≠ viewing — the sidebar joins
|
||||||
|
* every thread room for live updates, so presence uses an explicit `focus_thread` signal.
|
||||||
|
* Prod (multi-replica): back this with Redis.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PresenceService {
|
||||||
|
private readonly focus = new Map<string, { userId: string; threadId: string | null }>(); // socketId → focus
|
||||||
|
|
||||||
|
setFocus(socketId: string, userId: string, threadId: string | null): void {
|
||||||
|
this.focus.set(socketId, { userId, threadId });
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSocket(socketId: string): void {
|
||||||
|
this.focus.delete(socketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
isViewing(userId: string, threadId: string): boolean {
|
||||||
|
for (const f of this.focus.values()) if (f.userId === userId && f.threadId === threadId) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { WebPushDelivery } from './web-push.delivery';
|
||||||
|
|
||||||
|
const sub = { endpoint: 'https://push/x', p256dh: 'k', auth: 'a' };
|
||||||
|
const payload = { title: 'Bob', body: 'hi', data: { threadId: 'T1' } };
|
||||||
|
const vapid = { publicKey: 'p', privateKey: 'k', subject: 'mailto:x' };
|
||||||
|
|
||||||
|
describe('WebPushDelivery', () => {
|
||||||
|
it('returns "sent" on success', async () => {
|
||||||
|
const send = vi.fn().mockResolvedValue({ statusCode: 201 });
|
||||||
|
const d = new WebPushDelivery(vapid, send as never);
|
||||||
|
expect(await d.deliver(sub, payload)).toBe('sent');
|
||||||
|
expect(send).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "gone" on 404/410 so the caller prunes', async () => {
|
||||||
|
const d410 = new WebPushDelivery(vapid, vi.fn().mockRejectedValue({ statusCode: 410 }) as never);
|
||||||
|
expect(await d410.deliver(sub, payload)).toBe('gone');
|
||||||
|
const d404 = new WebPushDelivery(vapid, vi.fn().mockRejectedValue({ statusCode: 404 }) as never);
|
||||||
|
expect(await d404.deliver(sub, payload)).toBe('gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "failed" on other errors', async () => {
|
||||||
|
const d = new WebPushDelivery(vapid, vi.fn().mockRejectedValue({ statusCode: 500 }) as never);
|
||||||
|
expect(await d.deliver(sub, payload)).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is disabled (returns "failed", never calls send) without VAPID keys', async () => {
|
||||||
|
const send = vi.fn();
|
||||||
|
const d = new WebPushDelivery(undefined, send as never);
|
||||||
|
expect(await d.deliver(sub, payload)).toBe('failed');
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import webpush from 'web-push';
|
||||||
|
import type { NotificationPayload, NotificationPort, PushSub } from './notification.port';
|
||||||
|
|
||||||
|
type SendFn = typeof webpush.sendNotification;
|
||||||
|
export interface Vapid {
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web Push (VAPID) delivery. A dead subscription (404/410) → 'gone' so the caller prunes it. */
|
||||||
|
@Injectable()
|
||||||
|
export class WebPushDelivery implements NotificationPort {
|
||||||
|
private readonly vapid?: Vapid;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
vapid?: Vapid,
|
||||||
|
private readonly send: SendFn = webpush.sendNotification,
|
||||||
|
) {
|
||||||
|
// Store VAPID; pass it per-send (not global setVapidDetails) so construction never
|
||||||
|
// validates keys — keeps the adapter unit-testable with a mocked transport.
|
||||||
|
this.vapid = vapid?.publicKey ? vapid : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deliver(sub: PushSub, payload: NotificationPayload): Promise<'sent' | 'gone' | 'failed'> {
|
||||||
|
if (!this.vapid) return 'failed'; // push disabled (no VAPID keys)
|
||||||
|
try {
|
||||||
|
await this.send(
|
||||||
|
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ vapidDetails: this.vapid },
|
||||||
|
);
|
||||||
|
return 'sent';
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { statusCode?: number }).statusCode;
|
||||||
|
return code === 404 || code === 410 ? 'gone' : 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { RouteService } from '../routing/route.service';
|
|||||||
import { AiJobService } from '../ai/ai.service';
|
import { AiJobService } from '../ai/ai.service';
|
||||||
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
||||||
import { OutboundService } from '../adapters/outbound.service';
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { runWithTrace } from './trace-context';
|
import { runWithTrace } from './trace-context';
|
||||||
@@ -23,7 +24,7 @@ const actors = new ActorResolver(asService);
|
|||||||
const user: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo' };
|
const user: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo' };
|
||||||
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()));
|
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||||
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
|
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
|
||||||
|
|
||||||
async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> {
|
async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
import { SessionVerifier } from '../platform/session.verifier';
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
||||||
|
import { RetentionService } from '../retention/retention.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tenant-scoped metrics (P9 observability, JSON). Returns the CALLER's tenant
|
* Tenant-scoped metrics (P9 observability, JSON). Returns the CALLER's tenant
|
||||||
@@ -17,6 +18,7 @@ export class MetricsController {
|
|||||||
private readonly actors: ActorResolver,
|
private readonly actors: ActorResolver,
|
||||||
private readonly session: SessionVerifier,
|
private readonly session: SessionVerifier,
|
||||||
private readonly cursors: ProjectionCursorService,
|
private readonly cursors: ProjectionCursorService,
|
||||||
|
private readonly retention: RetentionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -59,6 +61,7 @@ export class MetricsController {
|
|||||||
tenant,
|
tenant,
|
||||||
relay: { outboxPending, oldestPendingMs },
|
relay: { outboxPending, oldestPendingMs },
|
||||||
projections,
|
projections,
|
||||||
|
retention: await this.retention.summary(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { trace } from '@opentelemetry/api';
|
||||||
import { runWithTrace, newTraceId, traceIdFromTraceparent } from './trace-context';
|
import { runWithTrace, newTraceId, traceIdFromTraceparent } from './trace-context';
|
||||||
import { logJson } from './logger';
|
import { logJson } from './logger';
|
||||||
|
|
||||||
@@ -9,8 +10,13 @@ import { logJson } from './logger';
|
|||||||
* the response finishes (method, path, status, ms). Wired via app.use() in main.ts.
|
* the response finishes (method, path, status, ms). Wired via app.use() in main.ts.
|
||||||
*/
|
*/
|
||||||
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
|
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
// Prefer the ACTIVE OpenTelemetry trace id: that is what makes this log line
|
||||||
|
// joinable to its distributed trace in Grafana (Loki -> Tempo). Falls back to
|
||||||
|
// the inbound header, then traceparent, then a generated id, so a correlation
|
||||||
|
// id is always present even when tracing is disabled.
|
||||||
|
const otelTraceId = trace.getActiveSpan()?.spanContext().traceId;
|
||||||
const headerTrace = (req.headers['x-trace-id'] as string | undefined)?.trim();
|
const headerTrace = (req.headers['x-trace-id'] as string | undefined)?.trim();
|
||||||
const traceId = headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
|
const traceId = otelTraceId || headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
|
||||||
res.setHeader('x-trace-id', traceId);
|
res.setHeader('x-trace-id', traceId);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
runWithTrace(traceId, () => {
|
runWithTrace(traceId, () => {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* OpenTelemetry bootstrap. MUST be imported before anything else in main.ts —
|
||||||
|
* auto-instrumentation works by patching modules (http, express, pg, redis, …)
|
||||||
|
* as they are require()d, so any module loaded before this runs is never traced.
|
||||||
|
*
|
||||||
|
* Env-driven on purpose: the OTel SDK reads OTEL_SERVICE_NAME,
|
||||||
|
* OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL itself, so the
|
||||||
|
* collector target is a deployment concern rather than a code change. When
|
||||||
|
* OTEL_EXPORTER_OTLP_ENDPOINT is unset the SDK never starts, so local dev and
|
||||||
|
* tests run with zero tracing overhead and no exporter errors.
|
||||||
|
*
|
||||||
|
* Traces land in Tempo and are viewable in Grafana. The existing P9 trace
|
||||||
|
* middleware adopts the active OTel trace id, so an `http.request` log line and
|
||||||
|
* its distributed trace share one id (Loki -> Tempo pivot).
|
||||||
|
*/
|
||||||
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||||
|
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||||
|
|
||||||
|
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||||
|
|
||||||
|
if (endpoint) {
|
||||||
|
const sdk = new NodeSDK({
|
||||||
|
instrumentations: [
|
||||||
|
getNodeAutoInstrumentations({
|
||||||
|
// Noisy and low value: every file read becomes a span.
|
||||||
|
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||||
|
// k8s probes hit /health constantly; tracing them would swamp Tempo
|
||||||
|
// and bury the real request traces.
|
||||||
|
'@opentelemetry/instrumentation-http': {
|
||||||
|
ignoreIncomingRequestHook: (req) => {
|
||||||
|
const url = req.url ?? '';
|
||||||
|
return ['/health', '/ready', '/healthz', '/readyz', '/metrics'].some((p) =>
|
||||||
|
url.startsWith(p),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.start();
|
||||||
|
|
||||||
|
const shutdown = (): void => {
|
||||||
|
// Flush buffered spans before exit, else the last requests before a
|
||||||
|
// rollout are lost.
|
||||||
|
void sdk.shutdown().finally(() => process.exit(0));
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DevOpaPort } from './dev-opa.port';
|
||||||
|
|
||||||
|
const opa = new DevOpaPort();
|
||||||
|
|
||||||
|
describe('DevOpaPort (dev policy plane — membership rules)', () => {
|
||||||
|
it('allows every existing action by default (thread create/read, message send)', async () => {
|
||||||
|
for (const action of ['iios.thread.create', 'iios.thread.read', 'iios.message.send', undefined]) {
|
||||||
|
expect((await opa.decide({ action })).allow).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DM is capped at two: adding a 2nd is allowed, a 3rd is denied', async () => {
|
||||||
|
const add = (participantCount: number) =>
|
||||||
|
opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount });
|
||||||
|
expect((await add(1)).allow).toBe(true); // 1 → adding the 2nd
|
||||||
|
const third = await add(2); // 2 → adding a 3rd
|
||||||
|
expect(third.allow).toBe(false);
|
||||||
|
expect(third.obligations[0]?.reason).toMatch(/two people/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adding a participant requires the caller be a member', async () => {
|
||||||
|
const d = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'NONE', participantCount: 3 });
|
||||||
|
expect(d.allow).toBe(false);
|
||||||
|
expect(d.obligations[0]?.reason).toMatch(/member/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group add/remove requires ADMIN', async () => {
|
||||||
|
const asMember = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'MEMBER', participantCount: 3 });
|
||||||
|
expect(asMember.allow).toBe(false);
|
||||||
|
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
|
||||||
|
const asAdmin = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'ADMIN', participantCount: 3 });
|
||||||
|
expect(asAdmin.allow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('self-join is governed only on membership threads', async () => {
|
||||||
|
// generic / support thread (no membership attr) → open join, unchanged
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
||||||
|
// a membership (chat) thread → existing member allowed, stranger denied
|
||||||
|
expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: true })).allow).toBe(true);
|
||||||
|
const stranger = await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false });
|
||||||
|
expect(stranger.allow).toBe(false);
|
||||||
|
expect(stranger.obligations[0]?.reason).toMatch(/not a member/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { PolicyDecision } from '@insignia/iios-contracts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev stand-in for the real OPA policy plane. It evaluates a small **policy table**
|
||||||
|
* against the generic `{ action, ...context }` the kernel passes to `opa.decide`.
|
||||||
|
* Everything defaults to ALLOW (so existing actions are unchanged); only the rules
|
||||||
|
* below deny. This is the *policy plane*, NOT the kernel — the chat meaning of "dm"
|
||||||
|
* vs "group" lives here as policy, and a real OPA drops in behind the same interface.
|
||||||
|
*/
|
||||||
|
export interface OpaInput {
|
||||||
|
action?: string;
|
||||||
|
membership?: string; // app-set generic thread attribute: 'dm' | 'group'
|
||||||
|
participantCount?: number;
|
||||||
|
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
||||||
|
alreadyMember?: boolean;
|
||||||
|
isMember?: boolean;
|
||||||
|
mime?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
|
const MEDIA_ALLOWED = /^(image|video|audio)\//;
|
||||||
|
const MEDIA_ALLOWED_DOCS = new Set([
|
||||||
|
'application/pdf', 'text/plain', 'application/zip',
|
||||||
|
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allow = (): PolicyDecision => ({ decisionRef: 'dev-allow', allow: true, obligations: [], ttlSeconds: 60 });
|
||||||
|
const deny = (reason: string): PolicyDecision => ({ decisionRef: 'dev-deny', allow: false, obligations: [{ kind: 'AUDIT', reason }], ttlSeconds: 0 });
|
||||||
|
|
||||||
|
export class DevOpaPort {
|
||||||
|
async decide(input: unknown): Promise<PolicyDecision> {
|
||||||
|
const i = (input ?? {}) as OpaInput;
|
||||||
|
switch (i.action) {
|
||||||
|
case 'iios.thread.participant.add': {
|
||||||
|
const count = i.participantCount ?? 0;
|
||||||
|
if (i.membership === 'dm' && count >= 2) return deny('a direct message is limited to two people');
|
||||||
|
if (i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can add participants');
|
||||||
|
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
case 'iios.thread.join': {
|
||||||
|
// Only threads that opted into a membership model (chat dm/group) are governed;
|
||||||
|
// generic/support threads (no membership attr) keep open-join. For a governed
|
||||||
|
// thread, new members must be added first (governed add-participant).
|
||||||
|
if (!i.membership) return allow();
|
||||||
|
return i.alreadyMember ? allow() : deny('you are not a member of this thread');
|
||||||
|
}
|
||||||
|
case 'iios.interaction.annotate': {
|
||||||
|
// Annotating (e.g. reacting to) a message requires being a participant of its thread.
|
||||||
|
return i.isMember ? allow() : deny('you are not a member of this thread');
|
||||||
|
}
|
||||||
|
case 'iios.media.upload': {
|
||||||
|
const mime = i.mime ?? '';
|
||||||
|
if ((i.sizeBytes ?? 0) > MEDIA_MAX_BYTES) return deny('file too large (max 25 MB)');
|
||||||
|
if (!MEDIA_ALLOWED.test(mime) && !MEDIA_ALLOWED_DOCS.has(mime)) return deny(`file type not allowed: ${mime || 'unknown'}`);
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
IiosPlatformPorts,
|
IiosPlatformPorts,
|
||||||
PlatformPrincipal,
|
PlatformPrincipal,
|
||||||
PolicyDecision,
|
|
||||||
ContextDecisionBundle,
|
ContextDecisionBundle,
|
||||||
SourceHandleResolution,
|
SourceHandleResolution,
|
||||||
} from '@insignia/iios-contracts';
|
} from '@insignia/iios-contracts';
|
||||||
|
import { DevOpaPort } from './dev-opa.port';
|
||||||
|
|
||||||
/** DI token for the platform ports (Session/OPA/CMP/MDM/CRRE/SAS/Capability). */
|
/** DI token for the platform ports (Session/OPA/CMP/MDM/CRRE/SAS/Capability). */
|
||||||
export const PLATFORM_PORTS = Symbol('PLATFORM_PORTS');
|
export const PLATFORM_PORTS = Symbol('PLATFORM_PORTS');
|
||||||
@@ -22,11 +22,8 @@ export class LocalDevPorts implements IiosPlatformPorts {
|
|||||||
return { principalRef: 'local-dev', orgId: 'org_demo', appId: 'portal-demo', assurance: 'service' };
|
return { principalRef: 'local-dev', orgId: 'org_demo', appId: 'portal-demo', assurance: 'service' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
opa = {
|
// Dev policy plane: default-allow + the membership rules (real OPA swaps in here).
|
||||||
async decide(_input: unknown): Promise<PolicyDecision> {
|
opa = new DevOpaPort();
|
||||||
return { decisionRef: 'local-allow', allow: true, obligations: [], ttlSeconds: 60 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
cmp = {
|
cmp = {
|
||||||
async checkPurpose(_input: unknown): Promise<{ receiptRef?: string; status: 'ALLOW' | 'DENY' | 'NOT_REQUIRED' }> {
|
async checkPurpose(_input: unknown): Promise<{ receiptRef?: string; status: 'ALLOW' | 'DENY' | 'NOT_REQUIRED' }> {
|
||||||
return { status: 'NOT_REQUIRED' };
|
return { status: 'NOT_REQUIRED' };
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ArgumentsHost, Catch, HttpStatus, type ExceptionFilter } from '@nestjs/common';
|
||||||
|
import { PolicyDeniedError } from '@insignia/iios-contracts';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a fail-closed `PolicyDeniedError` (from `decideOrThrow`) to HTTP 403 instead of a
|
||||||
|
* generic 500, so callers can tell "policy denied" (with the reason) from a server fault.
|
||||||
|
* Applies to every policy-gated REST endpoint (e.g. the DM-cap / membership rules).
|
||||||
|
*/
|
||||||
|
@Catch(PolicyDeniedError)
|
||||||
|
export class PolicyDeniedFilter implements ExceptionFilter {
|
||||||
|
catch(err: PolicyDeniedError, host: ArgumentsHost): void {
|
||||||
|
const res = host.switchToHttp().getResponse<Response>();
|
||||||
|
res.status(HttpStatus.FORBIDDEN).json({ statusCode: 403, code: err.code, message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { SessionVerifier } from './session.verifier';
|
||||||
|
|
||||||
|
// Two independent fake IdPs (each its own EC signing key + kid).
|
||||||
|
function makeIssuer(kid: string) {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||||
|
const jwk = { ...(publicKey.export({ format: 'jwk' }) as Record<string, unknown>), kid, use: 'sig', alg: 'ES256' };
|
||||||
|
return { privateKey, jwks: { keys: [jwk] } };
|
||||||
|
}
|
||||||
|
const chat = makeIssuer('chat-kid');
|
||||||
|
const support = makeIssuer('support-kid');
|
||||||
|
const CHAT_URL = 'https://chat-proj.example.co';
|
||||||
|
const SUPPORT_URL = 'https://support-proj.example.co';
|
||||||
|
|
||||||
|
function sat(priv: KeyObject, url: string, kid: string, email: string, name: string): string {
|
||||||
|
return jwt.sign(
|
||||||
|
{ email, user_metadata: { full_name: name }, role: 'authenticated' },
|
||||||
|
priv,
|
||||||
|
{ algorithm: 'ES256', issuer: `${url}/auth/v1`, audience: 'authenticated', subject: 'uuid-x', keyid: kid, expiresIn: '1h' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SessionVerifier — multi-issuer (OIDC/JWKS registry)', () => {
|
||||||
|
let verifier: SessionVerifier;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.AUTH_ISSUERS = JSON.stringify([
|
||||||
|
{ url: CHAT_URL, appId: 'portal-demo' },
|
||||||
|
{ url: SUPPORT_URL, appId: 'support-app' },
|
||||||
|
]);
|
||||||
|
// Serve each issuer's JWKS from its own well-known URL.
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (u: string) => {
|
||||||
|
const s = String(u);
|
||||||
|
const body = s.includes('chat-proj') ? chat.jwks : s.includes('support-proj') ? support.jwks : { keys: [] };
|
||||||
|
return { ok: true, json: async () => body } as unknown as Response;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
verifier = new SessionVerifier();
|
||||||
|
await verifier.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete process.env.AUTH_ISSUERS;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes a token to the appId of its own issuer', () => {
|
||||||
|
expect(verifier.verify(sat(chat.privateKey, CHAT_URL, 'chat-kid', 'Alice@x.com', 'Alice'))).toMatchObject({
|
||||||
|
userId: 'alice@x.com',
|
||||||
|
appId: 'portal-demo',
|
||||||
|
displayName: 'Alice',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a second issuer maps to a DIFFERENT appId — isolated scope', () => {
|
||||||
|
expect(verifier.verify(sat(support.privateKey, SUPPORT_URL, 'support-kid', 'Bob@x.com', 'Bob'))).toMatchObject({
|
||||||
|
userId: 'bob@x.com',
|
||||||
|
appId: 'support-app',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token from an untrusted issuer', () => {
|
||||||
|
const rogue = makeIssuer('rogue-kid');
|
||||||
|
expect(() => verifier.verify(sat(rogue.privateKey, 'https://evil.example.co', 'rogue-kid', 'e@x.com', 'E'))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a forgery: signed by issuer B but claiming issuer A + A’s kid', () => {
|
||||||
|
const forged = sat(support.privateKey, CHAT_URL, 'chat-kid', 'x@x.com', 'X'); // wrong key for the claimed issuer
|
||||||
|
expect(() => verifier.verify(forged)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,148 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { createPublicKey } from 'node:crypto';
|
||||||
|
import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import type { MessagePrincipal } from '../messaging/message.service';
|
import type { MessagePrincipal } from '../messaging/message.service';
|
||||||
|
|
||||||
|
/** One trusted OIDC issuer (a Supabase project / IdP) → the app scope it maps to. */
|
||||||
|
interface IssuerEntry {
|
||||||
|
issuer: string; // the token `iss` claim, e.g. https://<ref>.supabase.co/auth/v1
|
||||||
|
jwksUrl: string;
|
||||||
|
appId: string;
|
||||||
|
orgId: string;
|
||||||
|
audience: string;
|
||||||
|
kidToPem: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The real `session` port for P2: verifies a host app's HS256 token (reuses the
|
* The `session` port. Verifies whatever token a caller presents:
|
||||||
* support-service AppTokenVerifier pattern). Per-app secrets come from the
|
*
|
||||||
* APP_SECRETS env (JSON map keyed by appId). Expected claims: { sub, name?,
|
* 1. OIDC / JWKS (real IdPs) — a REGISTRY of trusted issuers (env `AUTH_ISSUERS`,
|
||||||
* appId, orgId?, tenantId? }.
|
* or the single `SUPABASE_URL` shorthand). A token is routed by its `iss` claim
|
||||||
|
* to that issuer's entry, verified against that issuer's public JWKS (ES256, no
|
||||||
|
* secret), and stamped with that entry's `appId`/`orgId`. So two projects/IdPs
|
||||||
|
* map to two isolated app scopes on one IIOS — app A's tokens can't reach app B.
|
||||||
|
*
|
||||||
|
* 2. App token (dev / HS256) — legacy per-app secrets from `APP_SECRETS`, keyed by
|
||||||
|
* the `appId` claim. Unchanged; used by the dev IdP + tests.
|
||||||
|
*
|
||||||
|
* A Session Broker would later collapse case 1 to a single issuer (the Broker's PAT).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionVerifier {
|
export class SessionVerifier implements OnModuleInit {
|
||||||
private readonly secrets: Record<string, string>;
|
private readonly appSecrets: Record<string, string>;
|
||||||
|
private readonly issuers = new Map<string, IssuerEntry>(); // keyed by issuer string
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
try {
|
try {
|
||||||
this.secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
this.appSecrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
||||||
} catch {
|
} catch {
|
||||||
this.secrets = {};
|
this.appSecrets = {};
|
||||||
|
}
|
||||||
|
for (const cfg of this.readIssuerConfig()) {
|
||||||
|
const url = this.normalize(cfg.url);
|
||||||
|
const appId = cfg.appId?.trim() || 'portal-demo';
|
||||||
|
const issuer = cfg.issuer?.trim() || `${url}/auth/v1`;
|
||||||
|
const jwksUrl = cfg.jwksUrl?.trim() || `${url}/auth/v1/.well-known/jwks.json`;
|
||||||
|
this.issuers.set(issuer, {
|
||||||
|
issuer,
|
||||||
|
jwksUrl,
|
||||||
|
appId,
|
||||||
|
orgId: cfg.orgId?.trim() || `org_${appId}`,
|
||||||
|
audience: cfg.audience?.trim() || 'authenticated',
|
||||||
|
kidToPem: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await Promise.all([...this.issuers.values()].map((e) => this.refreshJwks(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assemble the issuer registry from AUTH_ISSUERS (multi) or SUPABASE_URL (single, back-compat). */
|
||||||
|
private readIssuerConfig(): Array<{ url: string; appId?: string; orgId?: string; issuer?: string; jwksUrl?: string; audience?: string }> {
|
||||||
|
const out: Array<{ url: string; appId?: string; orgId?: string; issuer?: string; jwksUrl?: string; audience?: string }> = [];
|
||||||
|
try {
|
||||||
|
const raw = process.env.AUTH_ISSUERS;
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as Array<{ url: string; appId?: string; orgId?: string; issuer?: string; jwksUrl?: string; audience?: string }>;
|
||||||
|
if (Array.isArray(parsed)) out.push(...parsed.filter((e) => e && (e.url || e.issuer)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed AUTH_ISSUERS */
|
||||||
|
}
|
||||||
|
const single = process.env.SUPABASE_URL?.trim();
|
||||||
|
if (single && !out.length) {
|
||||||
|
out.push({ url: single, appId: process.env.SUPABASE_APP_ID?.trim(), orgId: process.env.SUPABASE_ORG_ID?.trim() });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch one issuer's JWKS and cache each key as PEM (so verify() stays synchronous). */
|
||||||
|
private async refreshJwks(entry: IssuerEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(entry.jwksUrl);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const { keys } = (await res.json()) as { keys: Array<Record<string, unknown>> };
|
||||||
|
const next = new Map<string, string>();
|
||||||
|
for (const jwk of keys ?? []) {
|
||||||
|
const kid = jwk.kid as string | undefined;
|
||||||
|
if (!kid) continue;
|
||||||
|
try {
|
||||||
|
next.set(kid, createPublicKey({ key: jwk as never, format: 'jwk' }).export({ type: 'spki', format: 'pem' }) as string);
|
||||||
|
} catch {
|
||||||
|
/* skip a key we can't import */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next.size) entry.kidToPem = next;
|
||||||
|
} catch {
|
||||||
|
/* keep whatever keys we already have */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(token: string): MessagePrincipal {
|
verify(token: string): MessagePrincipal {
|
||||||
const decoded = jwt.decode(token) as jwt.JwtPayload | null;
|
const decoded = jwt.decode(token, { complete: true }) as { header?: { alg?: string; kid?: string }; payload?: jwt.JwtPayload } | null;
|
||||||
const appId = decoded?.appId ? String(decoded.appId) : undefined;
|
if (!decoded?.payload) throw new UnauthorizedException('invalid token');
|
||||||
|
|
||||||
|
if (this.issuers.size && decoded.header?.alg === 'ES256') {
|
||||||
|
const iss = decoded.payload.iss ? String(decoded.payload.iss) : '';
|
||||||
|
const entry = this.issuers.get(iss);
|
||||||
|
if (!entry) throw new UnauthorizedException(`untrusted issuer: ${iss || '(none)'}`);
|
||||||
|
return this.verifyOidc(token, decoded.header.kid, entry);
|
||||||
|
}
|
||||||
|
return this.verifyAppToken(token, decoded.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify an ES256 SAT against its issuer's JWKS key, mapping to that issuer's app scope. */
|
||||||
|
private verifyOidc(token: string, kid: string | undefined, entry: IssuerEntry): MessagePrincipal {
|
||||||
|
const pem = kid ? entry.kidToPem.get(kid) : undefined;
|
||||||
|
if (!pem) {
|
||||||
|
void this.refreshJwks(entry); // rotated / not loaded — pull fresh for next time
|
||||||
|
throw new UnauthorizedException('unknown signing key — retry');
|
||||||
|
}
|
||||||
|
let payload: jwt.JwtPayload;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, pem, { algorithms: ['ES256'], issuer: entry.issuer, audience: entry.audience }) as jwt.JwtPayload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('invalid token');
|
||||||
|
}
|
||||||
|
const email = payload.email ? String(payload.email).toLowerCase() : undefined;
|
||||||
|
const meta = (payload.user_metadata ?? {}) as { full_name?: string; name?: string };
|
||||||
|
// userId = email (stable + human-readable → mentions/directory work); RealMDM canonicalises `sub` later.
|
||||||
|
const userId = email ?? String(payload.sub);
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
appId: entry.appId,
|
||||||
|
orgId: entry.orgId,
|
||||||
|
tenantId: undefined,
|
||||||
|
displayName: meta.full_name ?? meta.name ?? email ?? userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy per-app HS256 token (dev IdP / host apps). */
|
||||||
|
private verifyAppToken(token: string, decodedPayload: jwt.JwtPayload): MessagePrincipal {
|
||||||
|
const appId = decodedPayload.appId ? String(decodedPayload.appId) : undefined;
|
||||||
if (!appId) throw new UnauthorizedException('missing appId claim');
|
if (!appId) throw new UnauthorizedException('missing appId claim');
|
||||||
const secret = this.secrets[appId];
|
const secret = this.appSecrets[appId];
|
||||||
if (!secret) throw new UnauthorizedException(`unknown app: ${appId}`);
|
if (!secret) throw new UnauthorizedException(`unknown app: ${appId}`);
|
||||||
|
|
||||||
let payload: jwt.JwtPayload;
|
let payload: jwt.JwtPayload;
|
||||||
@@ -43,4 +161,9 @@ export class SessionVerifier {
|
|||||||
displayName: payload.name ? String(payload.name) : undefined,
|
displayName: payload.name ? String(payload.name) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accept a project URL in any shape (…/rest/v1, …/auth/v1, trailing slash). */
|
||||||
|
private normalize(url: string): string {
|
||||||
|
return url.trim().replace(/\/+$/, '').replace(/\/(rest|auth)\/v1$/, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Logger, type INestApplicationContext } from '@nestjs/common';
|
||||||
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||||
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import type { Server, ServerOptions } from 'socket.io';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Socket.io adapter backed by Redis pub/sub (P9 scaling). With the default in-memory
|
||||||
|
* adapter, `server.to(room).emit()` only reaches sockets on the SAME instance; behind a
|
||||||
|
* load balancer a client on replica B never sees a message emitted by replica A. This
|
||||||
|
* adapter fans room emits across every replica through Redis, so realtime works at N>1.
|
||||||
|
* Wired only when REDIS_URL is set (single-instance dev keeps the in-memory adapter).
|
||||||
|
*/
|
||||||
|
export class RedisIoAdapter extends IoAdapter {
|
||||||
|
private readonly logger = new Logger(RedisIoAdapter.name);
|
||||||
|
private adapterConstructor?: ReturnType<typeof createAdapter>;
|
||||||
|
private clients: Redis[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: INestApplicationContext,
|
||||||
|
private readonly url: string,
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Establish the pub/sub clients + the socket.io Redis adapter factory. */
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
const pubClient = new Redis(this.url, { maxRetriesPerRequest: null });
|
||||||
|
const subClient = pubClient.duplicate();
|
||||||
|
this.clients = [pubClient, subClient];
|
||||||
|
pubClient.on('error', (err) => this.logger.error(`redis pub error: ${err.message}`));
|
||||||
|
subClient.on('error', (err) => this.logger.error(`redis sub error: ${err.message}`));
|
||||||
|
this.adapterConstructor = createAdapter(pubClient, subClient);
|
||||||
|
this.logger.log('socket.io Redis adapter connected — realtime fans out across replicas');
|
||||||
|
}
|
||||||
|
|
||||||
|
createIOServer(port: number, options?: ServerOptions): Server {
|
||||||
|
const server = super.createIOServer(port, options) as Server;
|
||||||
|
if (this.adapterConstructor) server.adapter(this.adapterConstructor);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
await Promise.all(this.clients.map((c) => c.quit().catch(() => undefined)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { RetentionService } from './retention.service';
|
||||||
|
|
||||||
|
/** Data-retention sweep, global so the dev controller + /metrics can reach it. */
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [RetentionService],
|
||||||
|
exports: [RetentionService],
|
||||||
|
})
|
||||||
|
export class RetentionModule {}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { ActorResolver } from '../identity/actor.resolver';
|
||||||
|
import { IngestService } from '../interactions/ingest.service';
|
||||||
|
import { RetentionService } from './retention.service';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
const asService = prisma as unknown as PrismaService;
|
||||||
|
const actors = new ActorResolver(asService);
|
||||||
|
const svc = () => new RetentionService(asService);
|
||||||
|
|
||||||
|
async function ingest(text: string, orgId = 'org_demo'): Promise<{ interactionId: string; scopeId: string }> {
|
||||||
|
const res = await new IngestService(asService, makeFakePorts(), actors).ingest(
|
||||||
|
{
|
||||||
|
scope: { orgId, appId: 'portal-demo' },
|
||||||
|
channel: { type: 'WEBHOOK', externalChannelId: 'webhook' },
|
||||||
|
source: { handleKind: 'EXTERNAL_VISITOR', externalId: 'ext-1' },
|
||||||
|
kind: 'MESSAGE',
|
||||||
|
thread: { externalThreadId: `wh-${randomUUID().slice(0, 6)}` },
|
||||||
|
parts: [{ kind: 'TEXT', bodyText: text }],
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
providerEventId: `pe-${randomUUID()}`,
|
||||||
|
},
|
||||||
|
`ing-${randomUUID()}`,
|
||||||
|
);
|
||||||
|
const it = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId } });
|
||||||
|
return { interactionId: res.interactionId, scopeId: it.scopeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const past = new Date(Date.now() - 86_400_000);
|
||||||
|
const future = new Date(Date.now() + 86_400_000);
|
||||||
|
const setSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) =>
|
||||||
|
prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'interaction', targetId } }, data });
|
||||||
|
const bodyOf = async (interactionId: string) => (await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } })).bodyText;
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
describe('RetentionService (P9 — retention sweep)', () => {
|
||||||
|
it('ensureSnapshots captures one ACTIVE snapshot per interaction with archive < delete', async () => {
|
||||||
|
const { interactionId } = await ingest('hello');
|
||||||
|
await svc().ensureSnapshots();
|
||||||
|
const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'interaction', targetId: interactionId } } });
|
||||||
|
expect(snap.status).toBe('ACTIVE');
|
||||||
|
expect(snap.dataClass).toBe('internal');
|
||||||
|
expect(snap.archiveAfter.getTime()).toBeLessThan(snap.deleteAfter.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts an interaction whose delete window has passed', async () => {
|
||||||
|
const { interactionId } = await ingest('my secret');
|
||||||
|
await svc().ensureSnapshots();
|
||||||
|
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past });
|
||||||
|
|
||||||
|
const res = await svc().applySweep();
|
||||||
|
expect(res.redacted).toBe(1);
|
||||||
|
expect(await bodyOf(interactionId)).toBe('[redacted]');
|
||||||
|
expect((await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: interactionId } })).status).toBe('REDACTED');
|
||||||
|
expect((await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'interaction', targetId: interactionId } } })).status).toBe('REDACTED');
|
||||||
|
expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('archives (not deletes) when only the archive window has passed, then deletes later', async () => {
|
||||||
|
const { interactionId } = await ingest('still needed');
|
||||||
|
await svc().ensureSnapshots();
|
||||||
|
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: future });
|
||||||
|
|
||||||
|
let res = await svc().applySweep();
|
||||||
|
expect(res).toMatchObject({ archived: 1, redacted: 0 });
|
||||||
|
expect(await bodyOf(interactionId)).toBe('still needed'); // content untouched by archive
|
||||||
|
|
||||||
|
await setSnapshot(interactionId, { deleteAfter: past });
|
||||||
|
res = await svc().applySweep();
|
||||||
|
expect(res.redacted).toBe(1);
|
||||||
|
expect(await bodyOf(interactionId)).toBe('[redacted]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an active compliance hold blocks the sweep until released', async () => {
|
||||||
|
const { interactionId, scopeId } = await ingest('held content');
|
||||||
|
await svc().ensureSnapshots();
|
||||||
|
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past });
|
||||||
|
const hold = await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'message', targetId: interactionId, holdReason: 'legal' } });
|
||||||
|
|
||||||
|
let res = await svc().applySweep();
|
||||||
|
expect(res).toMatchObject({ redacted: 0, skippedHeld: 1 });
|
||||||
|
expect(await bodyOf(interactionId)).toBe('held content');
|
||||||
|
|
||||||
|
await prisma.iiosComplianceHold.update({ where: { id: hold.id }, data: { status: 'RELEASED' } });
|
||||||
|
res = await svc().applySweep();
|
||||||
|
expect(res.redacted).toBe(1);
|
||||||
|
expect(await bodyOf(interactionId)).toBe('[redacted]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — a REDACTED snapshot is terminal', async () => {
|
||||||
|
const { interactionId } = await ingest('x');
|
||||||
|
await svc().ensureSnapshots();
|
||||||
|
await setSnapshot(interactionId, { archiveAfter: past, deleteAfter: past });
|
||||||
|
await svc().applySweep();
|
||||||
|
expect(await svc().applySweep()).toMatchObject({ archived: 0, redacted: 0, skippedHeld: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweep is tenant-scoped', async () => {
|
||||||
|
const a = await ingest('tenant a', 'org_A');
|
||||||
|
const b = await ingest('tenant b', 'org_B');
|
||||||
|
await svc().ensureSnapshots();
|
||||||
|
await setSnapshot(a.interactionId, { archiveAfter: past, deleteAfter: past });
|
||||||
|
await setSnapshot(b.interactionId, { archiveAfter: past, deleteAfter: past });
|
||||||
|
|
||||||
|
const res = await svc().applySweep(a.scopeId);
|
||||||
|
expect(res.redacted).toBe(1);
|
||||||
|
expect(await bodyOf(a.interactionId)).toBe('[redacted]');
|
||||||
|
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { recordAudit } from '../observability/audit';
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
export interface SweepResult {
|
||||||
|
archived: number;
|
||||||
|
redacted: number;
|
||||||
|
skippedHeld: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data retention (P9). Each interaction gets a per-resource retention snapshot with
|
||||||
|
* frozen archive/delete timestamps; a scheduled sweep archives (status change) then
|
||||||
|
* deletes = redacts-in-place (reusing the DSR tombstone semantics) once a resource ages
|
||||||
|
* past its window. An active compliance hold blocks both. Never a hard delete.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RetentionService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RetentionService.name);
|
||||||
|
private timer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
const ms = Number(process.env.IIOS_RETENTION_SWEEP_INTERVAL_MS ?? 0);
|
||||||
|
if (ms > 0) {
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.sweep().catch((err) => this.logger.warn(`retention sweep failed: ${(err as Error).message}`));
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private windowDays(dataClass: string, kind: 'ARCHIVE' | 'DELETE'): number {
|
||||||
|
const cls = process.env[`IIOS_RETENTION_${kind}_DAYS_${dataClass.toUpperCase()}`];
|
||||||
|
const glob = process.env[`IIOS_RETENTION_${kind}_DAYS`];
|
||||||
|
return Number(cls ?? glob ?? (kind === 'ARCHIVE' ? 90 : 365));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazily capture a per-resource snapshot for any interaction that lacks one. */
|
||||||
|
async ensureSnapshots(scopeId?: string): Promise<number> {
|
||||||
|
const interactions = await this.prisma.iiosInteraction.findMany({
|
||||||
|
where: scopeId ? { scopeId } : {},
|
||||||
|
select: { id: true, scopeId: true, dataClass: true, receivedAt: true },
|
||||||
|
});
|
||||||
|
let created = 0;
|
||||||
|
for (const i of interactions) {
|
||||||
|
const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({
|
||||||
|
where: { targetType_targetId: { targetType: 'interaction', targetId: i.id } },
|
||||||
|
});
|
||||||
|
if (exists) continue;
|
||||||
|
const base = i.receivedAt.getTime();
|
||||||
|
await this.prisma.iiosRetentionPolicySnapshot.create({
|
||||||
|
data: {
|
||||||
|
policyKey: `default:${i.dataClass}:v1`,
|
||||||
|
scopeSnapshotId: i.scopeId,
|
||||||
|
targetType: 'interaction',
|
||||||
|
targetId: i.id,
|
||||||
|
dataClass: i.dataClass,
|
||||||
|
archiveAfter: new Date(base + this.windowDays(i.dataClass, 'ARCHIVE') * DAY_MS),
|
||||||
|
deleteAfter: new Date(base + this.windowDays(i.dataClass, 'DELETE') * DAY_MS),
|
||||||
|
sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
|
||||||
|
async applySweep(scopeId?: string): Promise<SweepResult> {
|
||||||
|
const now = new Date();
|
||||||
|
const due = await this.prisma.iiosRetentionPolicySnapshot.findMany({
|
||||||
|
where: { status: { in: ['ACTIVE', 'ARCHIVED'] }, archiveAfter: { lte: now }, ...(scopeId ? { scopeSnapshotId: scopeId } : {}) },
|
||||||
|
});
|
||||||
|
const result: SweepResult = { archived: 0, redacted: 0, skippedHeld: 0 };
|
||||||
|
|
||||||
|
for (const s of due) {
|
||||||
|
const held = await this.prisma.iiosComplianceHold.findFirst({
|
||||||
|
where: { targetId: s.targetId, status: 'ACTIVE', OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
|
||||||
|
});
|
||||||
|
if (held) {
|
||||||
|
result.skippedHeld++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.deleteAfter <= now) {
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }),
|
||||||
|
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
|
||||||
|
this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }),
|
||||||
|
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
|
||||||
|
]);
|
||||||
|
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
||||||
|
result.redacted++;
|
||||||
|
} else if (s.status === 'ACTIVE') {
|
||||||
|
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
|
||||||
|
await recordAudit(this.prisma, { action: 'retention.archived', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
|
||||||
|
result.archived++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full sweep: capture missing snapshots, then act on due ones. */
|
||||||
|
async sweep(scopeId?: string): Promise<SweepResult> {
|
||||||
|
await this.ensureSnapshots(scopeId);
|
||||||
|
return this.applySweep(scopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot lifecycle counts for /metrics (global ops). */
|
||||||
|
async summary(): Promise<{ total: number; byStatus: Record<string, number> }> {
|
||||||
|
const groups = await this.prisma.iiosRetentionPolicySnapshot.groupBy({ by: ['status'], _count: true });
|
||||||
|
const byStatus: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const g of groups) {
|
||||||
|
byStatus[g.status] = g._count;
|
||||||
|
total += g._count;
|
||||||
|
}
|
||||||
|
return { total, byStatus };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { RouteService } from './route.service';
|
|||||||
import { RouteProjector } from './route.projector';
|
import { RouteProjector } from './route.projector';
|
||||||
import { IngestService } from '../interactions/ingest.service';
|
import { IngestService } from '../interactions/ingest.service';
|
||||||
import { OutboundService } from '../adapters/outbound.service';
|
import { OutboundService } from '../adapters/outbound.service';
|
||||||
|
import { IdempotencyService } from '../idempotency/idempotency.service';
|
||||||
import { CapabilityBroker } from '../capability/capability.broker';
|
import { CapabilityBroker } from '../capability/capability.broker';
|
||||||
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
import { CapabilityProviderRegistry } from '../capability/capability.registry';
|
||||||
import { OutboxBus } from '../outbox/outbox.bus';
|
import { OutboxBus } from '../outbox/outbox.bus';
|
||||||
@@ -106,7 +107,7 @@ describe('Route simulator (P6, preview-first)', () => {
|
|||||||
|
|
||||||
describe('RouteService approve/deny → execute (P6)', () => {
|
describe('RouteService approve/deny → execute (P6)', () => {
|
||||||
const admin: MessagePrincipal = { userId: 'admin', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Admin' };
|
const admin: MessagePrincipal = { userId: 'admin', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Admin' };
|
||||||
const svc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())), actors);
|
const svc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)), actors);
|
||||||
|
|
||||||
it('approving a REVIEW decision executes the forward to the sandbox', async () => {
|
it('approving a REVIEW decision executes the forward to the sandbox', async () => {
|
||||||
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
|
const { interactionId, scopeId } = await makeInteraction('party at 9 PM');
|
||||||
@@ -160,7 +161,7 @@ describe('RouteService approve/deny → execute (P6)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('RouteProjector — AUTOMATIC forwarding (P6)', () => {
|
describe('RouteProjector — AUTOMATIC forwarding (P6)', () => {
|
||||||
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())), actors);
|
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)), actors);
|
||||||
const projector = () => new RouteProjector(asService, new OutboxBus(), new DlqService(asService), routeSvc(), new ProjectionCursorService(asService));
|
const projector = () => new RouteProjector(asService, new OutboxBus(), new DlqService(asService), routeSvc(), new ProjectionCursorService(asService));
|
||||||
|
|
||||||
// Ingest an inbound (channel-bearing) interaction — routable, unlike native DMs.
|
// Ingest an inbound (channel-bearing) interaction — routable, unlike native DMs.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { PrismaClient } from '@prisma/client';
|
|||||||
export async function resetDb(prisma: PrismaClient): Promise<void> {
|
export async function resetDb(prisma: PrismaClient): Promise<void> {
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRawUnsafe(
|
||||||
`TRUNCATE TABLE
|
`TRUNCATE TABLE
|
||||||
"IiosComplianceHold","IiosProjectionCursor","IiosIdempotencyCommand","IiosDlqItem","IiosAuditLink",
|
"IiosRetentionPolicySnapshot","IiosComplianceHold","IiosProjectionCursor","IiosIdempotencyCommand","IiosDlqItem","IiosAuditLink",
|
||||||
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
|
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
|
||||||
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
||||||
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsInt, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class SendMessageDto {
|
export class SendMessageDto {
|
||||||
@IsString() content!: string;
|
@IsString() content!: string;
|
||||||
@IsOptional() @IsString() contentRef?: string;
|
@IsOptional() @IsString() contentRef?: string;
|
||||||
|
@IsOptional() @IsString() mimeType?: string;
|
||||||
|
@IsOptional() @IsInt() sizeBytes?: number;
|
||||||
|
@IsOptional() @IsString() checksumSha256?: string;
|
||||||
|
@IsOptional() @IsString() parentInteractionId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ThreadsService } from './threads.service';
|
import { ThreadsService } from './threads.service';
|
||||||
import { MessageService } from '../messaging/message.service';
|
import { MessageService } from '../messaging/message.service';
|
||||||
@@ -22,11 +23,50 @@ export class ThreadsController {
|
|||||||
private readonly session: SessionVerifier,
|
private readonly session: SessionVerifier,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Generic "my threads" — every thread the caller participates in (last message + unread). */
|
||||||
|
@Get()
|
||||||
|
async listThreads(@Headers('authorization') auth?: string) {
|
||||||
|
return this.messages.listThreads(this.principal(auth));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
|
||||||
|
@Get('my-annotations')
|
||||||
|
async myAnnotations(@Headers('authorization') auth?: string, @Query('type') type = 'save') {
|
||||||
|
return this.messages.listMyAnnotated(this.principal(auth), type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */
|
||||||
|
@Post()
|
||||||
|
@HttpCode(201)
|
||||||
|
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) {
|
||||||
|
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
|
||||||
|
@Post(':id/participants')
|
||||||
|
@HttpCode(201)
|
||||||
|
async addParticipant(@Param('id') id: string, @Body() body: { userId: string; role?: string }, @Headers('authorization') auth?: string) {
|
||||||
|
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/messages')
|
@Get(':id/messages')
|
||||||
async listMessages(@Param('id') id: string) {
|
async listMessages(@Param('id') id: string) {
|
||||||
return this.threads.getMessages(id);
|
return this.threads.getMessages(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mute / unmute notifications from this thread for the caller. */
|
||||||
|
@Post(':id/mute')
|
||||||
|
@HttpCode(200)
|
||||||
|
async mute(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||||
|
return this.messages.muteThread(id, this.principal(auth), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/unmute')
|
||||||
|
@HttpCode(200)
|
||||||
|
async unmute(@Param('id') id: string, @Headers('authorization') auth?: string) {
|
||||||
|
return this.messages.muteThread(id, this.principal(auth), false);
|
||||||
|
}
|
||||||
|
|
||||||
/** REST/polling fallback for native send (same write as the socket path). */
|
/** REST/polling fallback for native send (same write as the socket path). */
|
||||||
@Post(':id/messages')
|
@Post(':id/messages')
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
@@ -36,14 +76,19 @@ export class ThreadsController {
|
|||||||
@Headers('authorization') authorization?: string,
|
@Headers('authorization') authorization?: string,
|
||||||
@Headers('idempotency-key') idempotencyKey?: string,
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
) {
|
) {
|
||||||
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
|
||||||
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
|
||||||
const principal = this.session.verify(token);
|
|
||||||
return this.messages.send(
|
return this.messages.send(
|
||||||
id,
|
id,
|
||||||
principal,
|
this.principal(authorization),
|
||||||
{ content: body.content, contentRef: body.contentRef },
|
{ content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
|
||||||
idempotencyKey ?? randomUUID(),
|
idempotencyKey ?? randomUUID(),
|
||||||
|
undefined,
|
||||||
|
body.parentInteractionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private principal(authorization?: string) {
|
||||||
|
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
||||||
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||||
|
return this.session.verify(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1997
-38
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
// Tests TRUNCATE between cases, so they MUST NOT touch the dev database. Run them
|
||||||
|
// against an isolated `iios_test` DB (created + migrated here, once, before the suite).
|
||||||
|
const TEST_URL = 'postgresql://iios:iios@localhost:5434/iios_test?schema=public';
|
||||||
|
|
||||||
|
export default function setup() {
|
||||||
|
try {
|
||||||
|
execSync(`docker exec iios-db psql -U iios -d postgres -c "CREATE DATABASE iios_test"`, { stdio: 'pipe' });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e.stderr ?? e.stdout ?? e);
|
||||||
|
if (!/already exists/i.test(msg)) {
|
||||||
|
console.warn(`[vitest] could not create iios_test (is the iios-db container up?): ${msg.slice(0, 160)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
execSync('pnpm --filter @insignia/iios-service exec prisma migrate deploy', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, DATABASE_URL: TEST_URL },
|
||||||
|
});
|
||||||
|
}
|
||||||
+7
-3
@@ -15,9 +15,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'],
|
include: ['packages/**/src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'],
|
||||||
// DB-backed specs share one Postgres and TRUNCATE between tests. Run every
|
// DB-backed specs TRUNCATE between tests, so they run against an ISOLATED
|
||||||
// file in a single worker process, sequentially, so there is no cross-file
|
// `iios_test` database (created + migrated by the global setup) — never the dev
|
||||||
// race on the shared database.
|
// DB. This env overrides any DATABASE_URL from the shell.
|
||||||
|
env: { DATABASE_URL: 'postgresql://iios:iios@localhost:5434/iios_test?schema=public' },
|
||||||
|
globalSetup: ['./scripts/vitest-global-setup.mjs'],
|
||||||
|
// Run every file in a single worker process, sequentially, so there is no
|
||||||
|
// cross-file race on the shared database.
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
sequence: { concurrent: false },
|
sequence: { concurrent: false },
|
||||||
pool: 'forks',
|
pool: 'forks',
|
||||||
|
|||||||
Reference in New Issue
Block a user