Compare commits
10 Commits
611ca74cea
...
f0ea1ca553
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
@@ -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,105 @@
|
|||||||
|
# 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 JWT signing keys), `ADAPTER_SECRETS` (webhook HMAC keys).
|
||||||
|
- **⚠️ `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**.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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,24 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@insignia/iios-testkit": "workspace:*",
|
"@insignia/iios-testkit": "workspace:*",
|
||||||
@@ -35,7 +38,6 @@
|
|||||||
"@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",
|
|
||||||
"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");
|
||||||
@@ -435,6 +435,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?
|
||||||
@@ -493,6 +494,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,53 @@
|
|||||||
|
// 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; Bob joins 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 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);
|
||||||
@@ -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,35 +24,41 @@ 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();
|
||||||
|
|
||||||
|
// 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;
|
if (existing) return existing;
|
||||||
|
|
||||||
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
|
||||||
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey },
|
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' } });
|
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmd = await this.prisma.iiosOutboundCommand.create({
|
const cmd = await this.prisma.iiosOutboundCommand.create({
|
||||||
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey },
|
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key },
|
||||||
});
|
});
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId, purpose });
|
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey: key, scopeId, purpose });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
|
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
@@ -72,6 +79,12 @@ export class OutboundService {
|
|||||||
]);
|
]);
|
||||||
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
|
await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId });
|
||||||
return updated;
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opt-in idempotency: an explicit key routes through the ledger (request-hash conflict → 409,
|
||||||
|
// response replay); a keyless send just runs with a fresh uuid (old behavior).
|
||||||
|
if (!idempotencyKey) return body();
|
||||||
|
return this.idempotency.run({ scopeId, commandName: 'channel.send', key: idempotencyKey, request: { channelType, target, payload, purpose } }, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
|
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
|
||||||
|
|||||||
@@ -16,6 +16,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';
|
||||||
@@ -39,6 +40,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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +21,7 @@ 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')
|
||||||
@@ -96,6 +98,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');
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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';
|
||||||
|
|
||||||
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 });
|
||||||
@@ -12,6 +13,15 @@ async function bootstrap(): Promise<void> {
|
|||||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||||
);
|
);
|
||||||
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
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+100
-3
@@ -352,6 +352,9 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^6.2.1
|
specifier: ^6.2.1
|
||||||
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
|
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
|
||||||
|
'@socket.io/redis-adapter':
|
||||||
|
specifier: ^8.3.0
|
||||||
|
version: 8.3.0(socket.io-adapter@2.5.8)
|
||||||
class-transformer:
|
class-transformer:
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.1
|
version: 0.5.1
|
||||||
@@ -361,9 +364,15 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.7
|
specifier: ^16.4.7
|
||||||
version: 16.6.1
|
version: 16.6.1
|
||||||
|
ioredis:
|
||||||
|
specifier: ^5.11.1
|
||||||
|
version: 5.11.1
|
||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
|
prisma:
|
||||||
|
specifier: ^6.2.1
|
||||||
|
version: 6.19.3(typescript@5.9.3)
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
@@ -392,9 +401,6 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^26.0.1
|
specifier: ^26.0.1
|
||||||
version: 26.0.1
|
version: 26.0.1
|
||||||
prisma:
|
|
||||||
specifier: ^6.2.1
|
|
||||||
version: 6.19.3(typescript@5.9.3)
|
|
||||||
socket.io-client:
|
socket.io-client:
|
||||||
specifier: ^4.8.3
|
specifier: ^4.8.3
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
@@ -1164,6 +1170,9 @@ packages:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@ioredis/commands@1.10.0':
|
||||||
|
resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -1426,6 +1435,12 @@ packages:
|
|||||||
'@socket.io/component-emitter@3.1.2':
|
'@socket.io/component-emitter@3.1.2':
|
||||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||||
|
|
||||||
|
'@socket.io/redis-adapter@8.3.0':
|
||||||
|
resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
socket.io-adapter: ^2.5.4
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@@ -1834,6 +1849,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.1:
|
||||||
|
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -1908,6 +1927,15 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
debug@4.3.7:
|
||||||
|
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1935,6 +1963,10 @@ packages:
|
|||||||
defu@6.1.7:
|
defu@6.1.7:
|
||||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||||
|
|
||||||
|
denque@2.1.0:
|
||||||
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2200,6 +2232,10 @@ packages:
|
|||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
ioredis@5.11.1:
|
||||||
|
resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
|
||||||
|
engines: {node: '>=12.22.0'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -2452,6 +2488,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
notepack.io@3.0.1:
|
||||||
|
resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==}
|
||||||
|
|
||||||
nypm@0.6.8:
|
nypm@0.6.8:
|
||||||
resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==}
|
resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2620,6 +2659,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
|
||||||
|
redis-errors@1.2.0:
|
||||||
|
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
redis-parser@3.0.0:
|
||||||
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
reflect-metadata@0.2.2:
|
reflect-metadata@0.2.2:
|
||||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||||
|
|
||||||
@@ -2754,6 +2801,9 @@ packages:
|
|||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
|
||||||
statuses@2.0.2:
|
statuses@2.0.2:
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2953,6 +3003,10 @@ packages:
|
|||||||
ufo@1.6.4:
|
ufo@1.6.4:
|
||||||
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
|
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
|
||||||
|
|
||||||
|
uid2@1.0.0:
|
||||||
|
resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==}
|
||||||
|
engines: {node: '>= 4.0.0'}
|
||||||
|
|
||||||
uid@2.0.2:
|
uid@2.0.2:
|
||||||
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
|
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3716,6 +3770,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 26.0.1
|
'@types/node': 26.0.1
|
||||||
|
|
||||||
|
'@ioredis/commands@1.10.0': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -3968,6 +4024,15 @@ snapshots:
|
|||||||
|
|
||||||
'@socket.io/component-emitter@3.1.2': {}
|
'@socket.io/component-emitter@3.1.2': {}
|
||||||
|
|
||||||
|
'@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.8)':
|
||||||
|
dependencies:
|
||||||
|
debug: 4.3.7
|
||||||
|
notepack.io: 3.0.1
|
||||||
|
socket.io-adapter: 2.5.8
|
||||||
|
uid2: 1.0.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@tokenizer/inflate@0.4.1':
|
'@tokenizer/inflate@0.4.1':
|
||||||
@@ -4446,6 +4511,8 @@ snapshots:
|
|||||||
|
|
||||||
clone@1.0.4: {}
|
clone@1.0.4: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -4504,6 +4571,10 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
debug@4.3.7:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -4520,6 +4591,8 @@ snapshots:
|
|||||||
|
|
||||||
defu@6.1.7: {}
|
defu@6.1.7: {}
|
||||||
|
|
||||||
|
denque@2.1.0: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
@@ -4900,6 +4973,18 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
ioredis@5.11.1:
|
||||||
|
dependencies:
|
||||||
|
'@ioredis/commands': 1.10.0
|
||||||
|
cluster-key-slot: 1.1.1
|
||||||
|
debug: 4.4.3
|
||||||
|
denque: 2.1.0
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
redis-parser: 3.0.0
|
||||||
|
standard-as-callback: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
is-arrayish@0.2.1: {}
|
is-arrayish@0.2.1: {}
|
||||||
@@ -5105,6 +5190,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.50: {}
|
node-releases@2.0.50: {}
|
||||||
|
|
||||||
|
notepack.io@3.0.1: {}
|
||||||
|
|
||||||
nypm@0.6.8:
|
nypm@0.6.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
citty: 0.2.2
|
citty: 0.2.2
|
||||||
@@ -5258,6 +5345,12 @@ snapshots:
|
|||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
|
redis-errors@1.2.0: {}
|
||||||
|
|
||||||
|
redis-parser@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
|
||||||
reflect-metadata@0.2.2: {}
|
reflect-metadata@0.2.2: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
@@ -5460,6 +5553,8 @@ snapshots:
|
|||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
@@ -5624,6 +5719,8 @@ snapshots:
|
|||||||
|
|
||||||
ufo@1.6.4: {}
|
ufo@1.6.4: {}
|
||||||
|
|
||||||
|
uid2@1.0.0: {}
|
||||||
|
|
||||||
uid@2.0.2:
|
uid@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lukeed/csprng': 1.1.0
|
'@lukeed/csprng': 1.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user