Compare commits
2 Commits
3298401772
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eb385707fa | |||
| 0bdae453fc |
@@ -1,12 +0,0 @@
|
||||
# IIOS Project Memory — Index
|
||||
|
||||
Repo-local, versioned project memory. The full canonical rules are in the repo root
|
||||
**`CLAUDE.md`**; these files are granular, per-topic memories that accumulate across sessions.
|
||||
Read the relevant ones before working in that area; add/update files as the project evolves.
|
||||
|
||||
- [project_iios_overview.md](project_iios_overview.md) — what IIOS is (generic interaction OS; kernel + specializations + platform ports)
|
||||
- [feedback_generic_safety.md](feedback_generic_safety.md) — THE #1 rule: no chat/domain vocabulary in the kernel
|
||||
- [reference_run_and_test.md](reference_run_and_test.md) — run command, isolated `iios_test` DB, and the `replay.spec` flake workaround
|
||||
- [project_recent_features.md](project_recent_features.md) — the "real-providers" era: Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications
|
||||
- [reference_chat_web_consumer.md](reference_chat_web_consumer.md) — chat-web is the reference app driving IIOS feature work
|
||||
- [feedback_workflow.md](feedback_workflow.md) — commits (email + co-author), IIOS_DEV_TOKENS off in prod, TDD, build-before-restart
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: feedback_generic_safety
|
||||
description: THE #1 locked rule — the kernel must never hardcode chat/domain vocabulary
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
**The kernel must stay generic.** No `'dm'` / `'group'` / `'reaction'` / `'emoji'` / `'mention'`
|
||||
literals in kernel or messaging *logic*. If a reviewer asked "is this a chat backend now?" the
|
||||
answer must stay **no**.
|
||||
|
||||
**Why:** IIOS is a generic interaction OS; chat is one consumer. Baking chat meaning into the
|
||||
kernel destroys reuse (support/community/meetings share the same core).
|
||||
|
||||
**How to apply:** domain meaning lives in exactly three places, never the kernel —
|
||||
1. **OPA policy** (`DevOpaPort` → real OPA): DM-cap, group-admin, governed-join, media limits,
|
||||
notification triggers.
|
||||
2. **Opaque attributes** the kernel stores but never interprets: `thread.metadata.membership`
|
||||
(`dm`/`group`), interaction annotations (opaque `annotationType`+`value` → app writes
|
||||
`reaction`/`pin`/`save`), `mentions[]` (opaque userId notify-list; kernel never parses `@`).
|
||||
3. **The app** (chat-web): rendering + product semantics.
|
||||
|
||||
Reading an opaque attr inside a **policy/notification gate** (`membership === 'dm'` in
|
||||
`DevOpaPort` or `notification.projector.ts`) is OK — that file IS the policy plane. Everywhere
|
||||
else, keep generic. Verify before committing:
|
||||
`grep -rniE "'dm'|'group'|reaction|emoji" packages/iios-service/src | grep -v spec` — hits only
|
||||
in policy/notification/app-facing layers or comments. Also run `pnpm boundary`.
|
||||
Related: [[project_iios_overview]].
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: feedback_workflow
|
||||
description: Working conventions — commits, prod flags, TDD, rebuild-before-restart
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email
|
||||
**`maaz@insigniaconsultancy.com`**, co-author every commit with Claude. The user commits
|
||||
directly to `main` in this project (fine); branch only if asked.
|
||||
- **⚠ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token
|
||||
minting, chaos, retention sweep). The single most important prod-hardening flag.
|
||||
- **TDD** — write the failing spec first; verify it fails; implement; verify it passes.
|
||||
- **Rebuild before restart** — after backend changes, `nest build` then restart from `dist`;
|
||||
`lsof -ti :3200 | xargs kill -9` first or the old build keeps serving (stale-dist bites).
|
||||
- **New kernel capability = a generic primitive only** — add domain meaning in OPA policy + the
|
||||
app, never the kernel. See [[feedback_generic_safety]].
|
||||
- The user prefers **direct, fast iteration** (implement → verify end-to-end → commit), not
|
||||
heavyweight multi-agent/spec ceremony. Include "how to test" in summaries; report failures honestly.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: project_iios_overview
|
||||
description: What IIOS is — a generic multi-tenant interaction OS; kernel + specializations + swappable platform ports
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
IIOS (Insignia Interaction OS) = one NestJS service (`@insignia/iios-service`) + SDKs, in a
|
||||
pnpm monorepo (`packages/*`). Every interaction (chat message, ticket, routed post, AI
|
||||
suggestion, meeting) is the **same kernel object** inside a **tenant scope** (`IiosScope`:
|
||||
org/app/tenant/…), behind the **same fail-closed gates**, emitting the **same audit trail**.
|
||||
|
||||
Products (messaging, inbox, support, routing, AI, calendar, media, notifications) are thin
|
||||
**specializations** on top of a tiny kernel — never the reverse (`pnpm boundary` enforces it).
|
||||
|
||||
Platform seams are **ports** (`IiosPlatformPorts`, DI token `PLATFORM_PORTS`; dev = permissive
|
||||
`LocalDevPorts`): session, opa, cmp (consent), mdm, sas, capability, plus `StoragePort` (media)
|
||||
and `NotificationPort` (push). **Dev stubs swap to real adapters with zero consumer changes.**
|
||||
Every op passes `decideOrThrow(ports, {action,…})` fail-closed. Events go through a
|
||||
transactional outbox → `OutboxBus` → idempotent projectors (inbox, notifications).
|
||||
|
||||
See the repo `CLAUDE.md` and `docs/IIOS_API_AND_SDK_GUIDE.md` (as-built reference) for detail.
|
||||
Related: [[feedback_generic_safety]].
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: project_recent_features
|
||||
description: The "real-providers" era — Supabase auth, reactions/pins/saves, mentions→inbox, media, notifications
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
Beyond P0–P8 (kernel → messaging → inbox → support → adapters → routing → AI →
|
||||
calendar/meetings), recent work (mostly driven by the chat app, the start of P9 "real
|
||||
providers"):
|
||||
|
||||
- **Real Supabase auth** — `SessionVerifier` verifies real OIDC tokens against issuer JWKS
|
||||
(ES256, no secret), a **multi-issuer registry** routed by the `iss` claim → per-issuer `appId`
|
||||
scope (`AUTH_ISSUERS` JSON, or `SUPABASE_URL` single-issuer shorthand). `userId = email`.
|
||||
Legacy HS256 app-token path (`APP_SECRETS`) stays for dev/tests.
|
||||
- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive (opaque
|
||||
`annotationType`+`value`); socket `annotate` event → `annotation` broadcast;
|
||||
`GET /v1/threads/my-annotations?type=save`.
|
||||
- **@mentions → Inbox** — `send(... mentions[])` (opaque userId list) → `InboxProjector` fans out
|
||||
a `MENTION` inbox item to mentioned participants; reading resolves it.
|
||||
- **Media** — `StoragePort` (dev = local disk `MEDIA_DIR`; prod swap to S3/Supabase), presigned
|
||||
upload/download (signed HS256 tokens, OPA-gated size/type, tenant-fenced), `attachment` on
|
||||
`MessageDto`; parts use generic `MEDIA_REF/VOICE_REF/FILE_REF`.
|
||||
- **Notifications** — presence-gated Web Push: `NotificationProjector` runs 3 gates
|
||||
(policy=DM/mention/reply-to-you · presence=`focus_thread` signal · per-thread `muted`) →
|
||||
swappable `NotificationPort` (Web Push/VAPID); dead sub (410) pruned. Presence is in-memory
|
||||
(single-instance) → Redis for multi-replica.
|
||||
- `senderId` (stable externalId) on `MessageDto` for reliable "is this mine?".
|
||||
|
||||
~205 tests. Keep `docs/IIOS_API_AND_SDK_GUIDE.md` current when adding endpoints.
|
||||
Related: [[reference_chat_web_consumer]], [[feedback_generic_safety]].
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: reference_chat_web_consumer
|
||||
description: chat-web is the reference consumer app that drives IIOS feature work
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
**chat-web** (separate repo, `~/Documents/insignia-work/chat-web`) is the reference app on IIOS —
|
||||
a 1:1 + group chat UI (Vite + React + TanStack Router/Query + socket.io-client + supabase-js).
|
||||
It's frontend-only; IIOS provides identity, threads, messages, realtime, reactions, mentions,
|
||||
media, notifications.
|
||||
|
||||
Feature work usually spans **both repos**: a generic primitive/port in iios + the app UI in
|
||||
chat-web. The layer split we follow: **service** owns storage/auth/governance, the **SDK layer**
|
||||
(`chat-web/src/lib/*`, mirrors `@insignia/iios-kernel-client`) owns client plumbing
|
||||
(e.g. `uploadMedia`/`mediaUrl`, `registerPush`), the **app** owns rendering.
|
||||
|
||||
chat-web uses real Supabase login (`VITE_SUPABASE_URL` + anon key in its `.env`); identity =
|
||||
email. Run it with `pnpm dev`. Commit both repos with git email `maaz@insigniaconsultancy.com`.
|
||||
Related: [[project_recent_features]].
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
name: reference_run_and_test
|
||||
description: How to run the service + the test-DB isolation and the replay.spec flake workaround
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
**Infra:** Postgres in docker `iios-db` on **:5434** (db `iios`), Redis on :6379. If Docker is
|
||||
down: `open -a OrbStack` then `docker start iios-db`.
|
||||
|
||||
**Run** (dev auth via Supabase; media + push enabled):
|
||||
```
|
||||
pnpm --filter @insignia/iios-service exec nest build # rebuild after backend changes
|
||||
SUPABASE_URL=https://<ref>.supabase.co REDIS_URL=redis://localhost:6379 PORT=3200 \
|
||||
APP_SECRETS='{"portal-demo":"dev-secret"}' MEDIA_DIR=/tmp/iios-media \
|
||||
VAPID_PUBLIC_KEY=… VAPID_PRIVATE_KEY=… VAPID_SUBJECT=mailto:dev@insignia \
|
||||
node packages/iios-service/dist/main.js # :3200 ; GET /health
|
||||
```
|
||||
Restarting: `lsof -ti :3200 | xargs kill -9` first (stale instance → EADDRINUSE / old build served).
|
||||
|
||||
**Tests:** `pnpm test` (Vitest) runs against an **isolated `iios_test` DB** (globalSetup creates
|
||||
+ migrates it; `DATABASE_URL` overridden) — it **never wipes the dev `iios` DB**. TDD: spec next
|
||||
to code; DB specs use `resetDb()`.
|
||||
|
||||
**⚠ Known flake — `outbox/replay.spec`:** clock/ordering-sensitive, pre-existing. If it fails in a
|
||||
full run, it's stale `iios_test` state, NOT a regression. Fix:
|
||||
`docker exec iios-db psql -U iios -d postgres -c "DROP DATABASE IF EXISTS iios_test WITH (FORCE)"`
|
||||
then re-run. Prove it's not yours by stashing changes and re-running.
|
||||
|
||||
`pnpm boundary` = import-boundary check (must stay OK). Smokes: `packages/iios-service/scripts/smoke-*.mjs`.
|
||||
@@ -1,27 +0,0 @@
|
||||
name: publish-sdks
|
||||
|
||||
# Publish the @insignia/iios-* SDK packages to the Gitea npm registry on a version tag.
|
||||
# Requires: Gitea Actions enabled + a runner, and a repo secret GITEA_PUBLISH_TOKEN
|
||||
# (a token with `write:package` scope for the `insignia` org).
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r build
|
||||
- run: pnpm -r publish --no-git-checks
|
||||
env:
|
||||
# maps to ${GITEA_TOKEN} in .npmrc; private packages (service, testkit) are skipped
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_PUBLISH_TOKEN }}
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
|
||||
- name: Bump k8s-pods image tag (ArgoCD deploys)
|
||||
run: |
|
||||
rm -rf /tmp/kp # dind-builder is a persistent host: clear any stale checkout from a prior run
|
||||
git clone --depth 1 -b main \
|
||||
"https://mcp-bot:${{ secrets.K8S_PODS_TOKEN }}@git.lynkedup.cloud/platform-engineering/k8s-pods.git" /tmp/kp
|
||||
cd /tmp/kp
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# @insignia SDK packages publish to / install from the Gitea package registry.
|
||||
# Auth comes from the GITEA_TOKEN env var (never commit the token itself).
|
||||
# publish → token needs the `write:package` scope
|
||||
# install → token needs `read:package`
|
||||
@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/
|
||||
//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN}
|
||||
@@ -1,122 +0,0 @@
|
||||
# IIOS — Claude Code Rules
|
||||
|
||||
> **Project memory:** granular, versioned per-topic notes live in **`.claude/memory/`** — read
|
||||
> `.claude/memory/MEMORY.md` (the index) and the relevant entries before working in an area, and
|
||||
> add/update memories as the project evolves. This file is the canonical rules; those are the
|
||||
> accumulating notes.
|
||||
|
||||
## What IIOS is
|
||||
|
||||
**IIOS (Insignia Interaction OS)** is a **generic, multi-tenant "interaction OS"** — one
|
||||
NestJS service (`@insignia/iios-service`) + a family of SDKs. Every interaction (a chat
|
||||
message, a support ticket, a routed post, an AI suggestion, a meeting) is the **same kernel
|
||||
object** inside a **tenant scope**, behind the **same fail-closed gates** (policy/consent),
|
||||
emitting the **same audit trail**. Products (messaging, support, community, AI, meetings) are
|
||||
thin **specializations** on top of the kernel — never the other way around.
|
||||
|
||||
`chat-web` (separate repo) is the reference consumer app.
|
||||
|
||||
## THE #1 LOCKED RULE — generic-safety (read this before touching the kernel)
|
||||
|
||||
**The kernel must never hardcode chat/domain vocabulary.** No `'dm'` / `'group'` /
|
||||
`'reaction'` / `'emoji'` / `'mention'` literals in kernel or messaging *logic*. If a reviewer
|
||||
asked *"is this a chat backend now?"* the answer must stay **no**.
|
||||
|
||||
Domain meaning lives in **three** places, never the kernel:
|
||||
1. **OPA policy** (the policy plane — `DevOpaPort` now, real OPA later). The DM-cap,
|
||||
group-admin, governed-join, media-limit, and notification-trigger rules live here.
|
||||
2. **Opaque thread/interaction attributes** the kernel stores but never interprets:
|
||||
`thread.metadata.membership` (`'dm'|'group'`), interaction annotations (opaque
|
||||
`annotationType` + `value` → the app writes `reaction`/`pin`/`save`), `mentions[]` (an
|
||||
opaque userId notify-list the kernel fans out; it never parses `@`).
|
||||
3. **The app** (chat-web) — rendering + product semantics.
|
||||
|
||||
Reading an opaque attribute inside a **policy/notification gate** (e.g. `membership === 'dm'`
|
||||
in `DevOpaPort` or `notification.projector.ts`) is allowed — that file *is* the policy plane,
|
||||
not the kernel. Everywhere else, keep it generic. Verify with a grep before committing:
|
||||
`grep -rniE "'dm'|'group'|reaction|emoji" packages/iios-service/src | grep -v spec` — hits
|
||||
should only be in the policy/notification/app-facing layers or comments.
|
||||
|
||||
`pnpm boundary` enforces the layer dependency law (specializations import the kernel, never
|
||||
the reverse). Run it; don't break it.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Kernel primitives** (generic): `IiosScope` (six-vector: org/app/tenant/bu/…), `IiosSourceHandle`
|
||||
(externalId = userId; stays `UNVERIFIED` until MDM resolves → `canonicalEntityId`), `IiosActorRef`,
|
||||
`IiosThread` (subject/metadata), `IiosThreadParticipant`, `IiosInteraction` (+ `parentInteractionId`
|
||||
reply link), `IiosMessagePart` (media as `contentRef`), `IiosInteractionAnnotation` (generic).
|
||||
- **Platform ports** (`IiosPlatformPorts`, DI token `PLATFORM_PORTS`; dev = `LocalDevPorts`):
|
||||
session, opa, cmp (consent), mdm, sas, capability — plus a `StoragePort` (media) and
|
||||
`NotificationPort` (push). **Dev stubs → real adapters with zero consumer changes.** Every
|
||||
op passes `decideOrThrow(ports, {action,…})` **fail-closed**.
|
||||
- **Session (auth):** `SessionVerifier` verifies (a) real OIDC tokens (Supabase/`AUTH_ISSUERS`)
|
||||
against the issuer JWKS (ES256, no secret), routed by `iss` → per-issuer `appId` scope; or
|
||||
(b) legacy dev HS256 app tokens (`APP_SECRETS`, keyed by `appId`). `userId = email` for OIDC.
|
||||
- **Events:** transactional outbox → `OutboxBus` → **projectors** (inbox, notifications).
|
||||
Projectors are **idempotent** (`claim()` on `IiosProcessedEvent` + projection cursor).
|
||||
Delivery is at-least-once → clients dedupe by message `id`.
|
||||
- **Scope isolation:** every row is tagged by `scopeId` (org+app+tenant). By-id ops call
|
||||
`assertOwns` (tenant fence → 403). Always `select`/scope Prisma queries.
|
||||
|
||||
## Tech stack
|
||||
|
||||
NestJS 11 · Prisma 6 / PostgreSQL 16 (docker `iios-db` on **:5434**, db `iios`) · Redis
|
||||
(socket.io adapter, multi-replica) · socket.io (`/message` namespace) · Vitest · pnpm
|
||||
monorepo (`packages/*`). `iios-service` is a **modular monolith** (HTTP + WS + relay +
|
||||
projectors in one process).
|
||||
|
||||
## Running & testing
|
||||
|
||||
```bash
|
||||
docker start iios-db # Postgres :5434 (OrbStack; `open -a OrbStack` if down)
|
||||
pnpm --filter @insignia/iios-service exec nest build
|
||||
# run (dev auth via Supabase; media + notifications enabled):
|
||||
SUPABASE_URL=https://<ref>.supabase.co REDIS_URL=redis://localhost:6379 PORT=3200 \
|
||||
APP_SECRETS='{"portal-demo":"dev-secret"}' MEDIA_DIR=/tmp/iios-media \
|
||||
VAPID_PUBLIC_KEY=… VAPID_PRIVATE_KEY=… VAPID_SUBJECT=mailto:dev@insignia \
|
||||
node packages/iios-service/dist/main.js # → :3200 ; GET /health
|
||||
```
|
||||
|
||||
- **`pnpm test`** — Vitest. Runs against an **isolated `iios_test` DB** (globalSetup creates +
|
||||
migrates it; `DATABASE_URL` overridden). **It never wipes the dev `iios` DB.** ~205 tests.
|
||||
- **TDD**: write the failing spec first (see `*.spec.ts` next to the code). DB specs use
|
||||
`resetDb()` + real Postgres.
|
||||
- **Flaky `outbox/replay.spec`**: it's clock/ordering-sensitive and pre-existing. If it fails
|
||||
in a full run, `docker exec iios-db psql -U iios -d postgres -c "DROP DATABASE IF EXISTS iios_test WITH (FORCE)"`
|
||||
then re-run — it's stale test-DB state, not a regression.
|
||||
- **`pnpm boundary`** — import-boundary check (must stay OK).
|
||||
- Smokes: `packages/iios-service/scripts/smoke-*.mjs` (run against a live service).
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Commits:** conventional (`feat:`/`fix:`/`docs:`/`chore:`), git email
|
||||
**`maaz@insigniaconsultancy.com`**, and co-author every commit with Claude. Branch off `main`
|
||||
before committing if asked; otherwise the session has committed directly to `main`.
|
||||
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production** — it exposes `/v1/dev/*` (unauth token
|
||||
minting). Single most important prod flag.
|
||||
- New kernel capability = a **generic primitive** only (see the #1 rule). Add domain meaning in
|
||||
policy + app.
|
||||
- Prisma: always `select` to avoid leaking `passwordHash`/PII; org/tenant scope every `where`.
|
||||
|
||||
## What's built (state)
|
||||
|
||||
P0–P8: kernel → messaging → inbox → support → adapters → routing → AI → calendar/meetings.
|
||||
Recent (the "P9 real-providers" era, mostly driven by the chat app):
|
||||
- **Real Supabase auth** — multi-issuer JWKS verification (`SessionVerifier`); first stubbed
|
||||
port turned real.
|
||||
- **Reactions / pins / saves** — one generic `IiosInteractionAnnotation` primitive
|
||||
(opaque type/value), `annotate` socket event, `GET /v1/threads/my-annotations`.
|
||||
- **@mentions → Inbox** — `mentions[]` on send → `MENTION` inbox item (projector).
|
||||
- **Media** — `StoragePort` (dev local disk → prod S3/Supabase), presigned upload/download,
|
||||
attachment on `MessageDto`.
|
||||
- **Notifications** — presence-gated Web Push: `NotificationProjector` (policy/presence/mute
|
||||
gates) + swappable `NotificationPort`, `focus_thread` presence signal, per-thread mute.
|
||||
- `senderId` (stable externalId) on messages for reliable "is this mine?".
|
||||
|
||||
## Docs (as-built)
|
||||
|
||||
- `docs/IIOS_API_AND_SDK_GUIDE.md` — the **as-built REST/socket/SDK reference** (endpoints,
|
||||
shapes, env, vocab). Keep it current when adding endpoints.
|
||||
- `docs/DEPLOYMENT.md` — deploy/topology/env/scaling.
|
||||
- `docs/IIOS_OVERVIEW_FOR_CEO.md` — plain-language capability tour.
|
||||
@@ -1,53 +0,0 @@
|
||||
# Publishing the IIOS SDKs (Gitea package registry)
|
||||
|
||||
The `@insignia/iios-*` **frontend SDKs** publish to our self-hosted Gitea npm registry:
|
||||
|
||||
```
|
||||
https://git.lynkedup.cloud/api/packages/insignia/npm/
|
||||
```
|
||||
|
||||
**Published (public in the registry):** `iios-contracts`, `iios-kernel-client`, `iios-adapter-sdk`,
|
||||
and the React hook packages `iios-message-web`, `iios-inbox-web`, `iios-support-web`,
|
||||
`iios-ai-web`, `iios-community-web`, `iios-meeting-web`.
|
||||
**Kept private (never published):** `iios-service` (the deployed backend) and `iios-testkit` (dev fakes).
|
||||
|
||||
> be-crm does **not** consume these — it talks to IIOS over REST. The SDKs are a **frontend** concern
|
||||
> (chat-web, the CRM support UI, mobile).
|
||||
|
||||
## One-time: get a token
|
||||
Gitea → **Settings → Applications → Generate Token**:
|
||||
- to **publish**: scope `write:package`
|
||||
- to **install** (private packages): scope `read:package`
|
||||
|
||||
Export it (never commit it):
|
||||
```bash
|
||||
export GITEA_TOKEN=<your-gitea-token>
|
||||
```
|
||||
The repo `.npmrc` already routes the `@insignia` scope to Gitea and reads `${GITEA_TOKEN}`.
|
||||
|
||||
## Publish
|
||||
```bash
|
||||
pnpm release:dry # build all + pack (no upload) — verify the 9 packages pack cleanly
|
||||
pnpm release # build all + publish the non-private packages to Gitea
|
||||
```
|
||||
`pnpm -r publish` automatically **skips** `private` packages, so only the 9 SDKs go out.
|
||||
Versions are **immutable** — bump before re-publishing (edit `version`, or adopt `changesets`).
|
||||
|
||||
Or push a tag and let CI do it (see `.gitea/workflows/publish-sdks.yml`; needs Gitea Actions +
|
||||
a runner + the `GITEA_PUBLISH_TOKEN` secret):
|
||||
```bash
|
||||
git tag v0.1.0 && git push origin v0.1.0
|
||||
```
|
||||
|
||||
## Consume (in chat-web / the CRM front-end)
|
||||
Add an `.npmrc` to the consuming repo:
|
||||
```ini
|
||||
@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/
|
||||
//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN}
|
||||
```
|
||||
Then:
|
||||
```bash
|
||||
export GITEA_TOKEN=<read-token>
|
||||
pnpm add @insignia/iios-kernel-client @insignia/iios-contracts
|
||||
```
|
||||
This replaces the **vendored** client that chat-web copies today — one source of truth for all frontends.
|
||||
@@ -54,10 +54,6 @@ for the full, commented list. Highlights:
|
||||
app scopes. The dev HS256 path (`APP_SECRETS`) stays for local/tests.
|
||||
- **Media storage:** `MEDIA_DIR` + `PUBLIC_URL` configure the **dev** local-disk store; for
|
||||
prod, bind the `StoragePort` to object storage (see topology) — the API/SDK don't change.
|
||||
- **Notifications (Web Push):** set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT`
|
||||
to enable push (unset → the engine no-ops). ⚠️ Presence (the "are you viewing this thread?"
|
||||
gate) is **in-memory / single-instance** today — with N>1 replicas, back `PresenceService`
|
||||
with Redis so the projector on one replica sees focus from another.
|
||||
- **⚠️ `IIOS_DEV_TOKENS` MUST be `0`/unset in production.** It exposes `/v1/dev/*`
|
||||
(unauthenticated token minting, webhook injection, chaos, retention sweep). This is the
|
||||
single most important prod-hardening flag.
|
||||
|
||||
@@ -243,54 +243,6 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
### 5.11 Notifications (push, presence-gated)
|
||||
|
||||
The engine reacts to `message.sent` and pushes to **absent** recipients through a swappable
|
||||
**NotificationPort** (Web Push/VAPID now; email/FCM later). The DB holds only push
|
||||
subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays `IiosInboxItem`.
|
||||
|
||||
| Method | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/v1/notifications/vapid-public-key` | — | `{key}` — the public VAPID key the client needs to subscribe |
|
||||
| POST | `/v1/notifications/subscribe` | `{kind?, endpoint, keys:{p256dh, auth}, userAgent?}` | `{ok:true}` — store/refresh the caller's push subscription |
|
||||
| DELETE | `/v1/notifications/subscribe` | `{endpoint}` | `{ok:true}` |
|
||||
| POST | `/v1/threads/:id/mute` · `/unmute` | — | `{threadId, muted}` — per-thread notification mute for the caller |
|
||||
|
||||
**Presence (socket):** the app emits `focus_thread` `{threadId | null}` on the `/message`
|
||||
namespace whenever the foreground conversation changes (or the tab blurs). This is how the
|
||||
engine knows you're *viewing* a thread — **room membership ≠ viewing**, because the sidebar
|
||||
joins every thread room for live updates. `GET /v1/threads` returns each thread's `muted`.
|
||||
|
||||
**The three gates (fail-closed, in the notification policy — not the kernel):**
|
||||
1. **Policy** — DM always; a group message only if you were `@`-mentioned **or** it's a reply to you.
|
||||
2. **Presence** — skip if you're currently focused on that thread (you saw it live).
|
||||
3. **Mute** — skip if you muted the thread.
|
||||
A dead subscription (Web Push `404/410`) is pruned. DM-vs-group is read from the opaque
|
||||
`membership` thread attribute here in the *notification policy*; the kernel never branches on it.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"]
|
||||
PROJ --> LOOP{{"for each recipient (never the sender)"}}
|
||||
LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"}
|
||||
G1 -->|no| X1["skip"]
|
||||
G1 -->|yes| G2{"PRESENCE: focused on this thread?"}
|
||||
G2 -->|yes| X2["skip — seen live"]
|
||||
G2 -->|no| G3{"MUTE: thread muted?"}
|
||||
G3 -->|yes| X3["skip"]
|
||||
G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"]
|
||||
PORT --> WP["Web Push (VAPID)"]
|
||||
WP -->|sent| OUT["→ browser push service → service worker → OS notification"]
|
||||
WP -->|"gone (404/410)"| PRUNE["prune dead subscription"]
|
||||
```
|
||||
|
||||
**Client (SDK layer, `lib/notifications.ts` → belongs in `@insignia/iios-kernel-client`):**
|
||||
`registerPush()` (permission → register service worker → `PushManager.subscribe` with the
|
||||
VAPID key → POST the subscription), `muteThread(threadId, muted)`, and `MessageSocket.focus(threadId)`.
|
||||
A service worker renders the OS notification on `push` and deep-links to the thread on click.
|
||||
|
||||
---
|
||||
|
||||
## 6. SDK reference
|
||||
|
||||
Two layers: a low-level `RestClient` (+ socket) and per-domain React hook packages.
|
||||
@@ -304,7 +256,6 @@ Methods (all return typed promises):
|
||||
- **Messaging:** `listThreads()`, `createThread({membership?, creatorRole?, subject?})`, `addParticipant(threadId, userId, role?)`, `getThreadMessages(threadId)`, `sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?})`
|
||||
- **Reactions / pins / saves (annotations):** `MessageSocket.react(threadId, interactionId, emoji)` · `pin(...)` · `save(...)` (generic `annotate` under the hood); `listMyAnnotated('save')` for a cross-thread saved list. Subscribe to the `annotation` event for live updates.
|
||||
- **Media:** `uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}` (presign → PUT-with-progress → normalized ref); `mediaUrl(contentRef) → signed view URL` (cached, short-lived). *This plumbing is identical for every app, so it lives in the SDK; the app only renders by `kind`.*
|
||||
- **Notifications:** `registerPush()` (service worker + `PushManager.subscribe` + POST subscription), `muteThread(threadId, muted)`, `MessageSocket.focus(threadId)` (presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering.
|
||||
- **Inbox:** `listInboxItems(state?)`, `patchInboxItem(id, {state, reason?})`
|
||||
- **Support:** `createTicket({subject, priority?, threadId?})`, `escalate(threadId, subject?)`, `listTickets('mine'|'assigned')`, `patchTicket(id, state)`, `requestCallback({...})`, `createQueue(name)`, `joinQueue(id)`, `joinDefaultQueue()`, `setAvailability(state)`
|
||||
- **Routing:** `createBinding(input)`, `listBindings()`, `simulateRoute({interactionId, originChannelType, originRef?})`, `listRouteDecisions(state?)`, `approveDecision(id)`, `denyDecision(id)`
|
||||
@@ -382,7 +333,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/<name>`)
|
||||
| `smoke-capability.mjs` | governed egress + real HTTP provider (needs `IIOS_PROVIDER_URL_EMAIL`) |
|
||||
| `smoke-tenant.mjs` | cross-tenant 403 + list isolation |
|
||||
|
||||
Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check: `pnpm boundary`.
|
||||
Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check: `pnpm boundary`.
|
||||
|
||||
---
|
||||
|
||||
@@ -407,7 +358,6 @@ Automated unit/integration suite: `pnpm test` (205 tests). Import-boundary check
|
||||
| `MEDIA_DIR` | `<tmp>/iios-media` | local media storage dir (dev `StoragePort`) |
|
||||
| `MEDIA_SECRET` | `dev-media-secret` | signs media upload/download URLs |
|
||||
| `PUBLIC_URL` | `http://localhost:$PORT` | base used to build presigned media URLs |
|
||||
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | — | Web Push (notifications). Unset → push disabled (engine no-ops). Generate once: `node -e "console.log(require('web-push').generateVAPIDKeys())"` |
|
||||
| `IIOS_DEV_TOKENS` | `0` | set `1` to enable `/v1/dev/*` |
|
||||
| `ADAPTER_SECRETS` | `{}` | per-channel HMAC secrets (default `dev-adapter-secret`) |
|
||||
| `IIOS_OUTBOUND_LIMIT` / `_WINDOW_MS` | `5` / `60000` | per-(channel,target) rate limit |
|
||||
|
||||
@@ -159,9 +159,9 @@ Each product SDK is a handful of React hooks — a front-end dev wires the UI, t
|
||||
| Calendar/Zoom providers | Simulated sync | P9 |
|
||||
| Multi-tenant scale, retention, SLOs | Not yet | P9 |
|
||||
|
||||
**Proof it works:** 205 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
|
||||
**Proof it works:** 192 automated tests pass; every capability has a runnable demo and an end-to-end smoke script; the layer-boundary check enforces the architecture.
|
||||
|
||||
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, media sharing** (images/video/audio/docs on a swappable storage port), and **presence-gated push notifications** (Web Push via a swappable notification port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off.
|
||||
**P9 has begun (real providers).** A production chat app (`chat-web`) now runs on IIOS with **real Supabase login** (the session port verifies real OIDC tokens via JWKS — the first stubbed port turned real), plus richer chat built generically on the kernel: **emoji reactions, pinned & saved messages, @mentions → inbox, and media sharing** (images/video/audio/docs on a swappable storage port). Each was a thin, generic addition — no chat-specific logic in the kernel — which is the reuse thesis paying off.
|
||||
|
||||
---
|
||||
|
||||
@@ -177,4 +177,4 @@ Narrative arc for the CEO: **one engine → chat → inbox → support → chann
|
||||
|
||||
---
|
||||
|
||||
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 54 Postgres tables across 20 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media → notifications). Boundary-enforced dependency law; 205 passing tests; 8+ end-to-end smoke scripts. First real-provider swaps live: Supabase auth (JWKS-verified), a media storage port, and Web Push notifications.*
|
||||
*Appendix — repo facts: 11 packages, 6 demo apps, one NestJS service, 53 Postgres tables across 19 migrations (kernel → messaging → inbox → support → adapters → routing → ai → calendar → annotations/mentions → media). Boundary-enforced dependency law; 192 passing tests; 8+ end-to-end smoke scripts. First real-provider swap live: Supabase auth (JWKS-verified) + a media storage port.*
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# Insignia Platform — Live State (from the mesh-verify probe, 2026-07-10)
|
||||
|
||||
> Distilled from the authenticated `mesh-verify.lynkedup.cloud` dashboard (`/api/results` +
|
||||
> `/api/journey`). This is the **real** platform IIOS is meant to plug into — service inventory,
|
||||
> the identity/session/governance flow, and the exact contracts to wire IIOS's platform ports.
|
||||
> Tokens redacted (the raw JSON dumps contain live SAT/PAT/refresh tokens — do not commit them).
|
||||
|
||||
## Cluster / mesh
|
||||
- **Cluster:** `lynkedup-tech` (NYC2 / DigitalOcean). **Istio** (istio-envoy) + **SPIFFE/SPIRE**,
|
||||
trust domain **`spiffe://insignia.tech`** (SVIDs like `spiffe://insignia.tech/ns/sre/sa/default`,
|
||||
`.../sa/realmdm-sas`). mTLS **PERMISSIVE**. Reached by ClusterIP DNS.
|
||||
- **Namespaces:** `sre` (MDM, OPA, misc), `cmp` (consent platform), `insignia` (identity/session/
|
||||
app-facing services), `istio-system`.
|
||||
- Public edges: `*.lynkedup.cloud` (behind oauth2-proxy → Keycloak).
|
||||
|
||||
## The identity/session/governance flow (7 steps — the doctrine)
|
||||
> **Separate authorities:** Session Broker *authenticates*, OPA *authorizes*, CMP decides *purpose*,
|
||||
> RealMDM *resolves identity*. Purpose-proof ≠ authorization. The PAT carries the external `sub`
|
||||
> only as a **SHA-256 hash**, never raw; `canonical_person_id` is null until MDM VERIFIES (MDM never
|
||||
> blocks login).
|
||||
|
||||
1. **Anonymous consent (CMP Edge)** — browser CMP SDK → `POST /edge/v1/cache-policy` → EdDSA-signed
|
||||
cache-category manifest; a ConsentReceipt goes to CMP over gRPC. Purpose proof only.
|
||||
2. **External login (Supabase)** → **SAT** (ES256 JWT). Claims: `iss` (project `/auth/v1`), opaque
|
||||
UUID `sub`, `aud=authenticated`, `email`, `user_metadata` (full_name, avatar_url), `aal`, `amr`.
|
||||
Proves the *session*, not the person/permission. Verified via Supabase **JWKS** (kid-selected).
|
||||
3. **Session Broker exchange SAT → PAT** — `POST /v1/sessions/exchange` (Bearer SAT +
|
||||
`X-Client-Authorization` = BFF Keycloak client-creds, aud=session-broker). Broker verifies the
|
||||
SAT, calls the MDM bridge, resolves scope from **memberships**, mints the **PAT** (~5 min).
|
||||
3b. **Workload identity (SPIFFE/mTLS)** — each meshed pod gets an X.509-SVID; OPA receives **both**
|
||||
the user (`principal`) and the caller (`caller.spiffe_id`) — different layers, never merged.
|
||||
4. **MDM Auth-Subject Bridge** — `POST /v1/auth-subjects/resolve {issuer, subject}` → `{platform_
|
||||
principal_id, canonical_person_id (null until VERIFIED), link_state (PENDING/VERIFIED),
|
||||
link_version, match_method}`. Keyed on **iss+sub** (never email). Idempotent.
|
||||
5. **OPA decision** — `POST /v1/decisions` → `{decision_id, allow, reason_codes, obligations,
|
||||
policy_version}`. Obligations = masks / row-filters / denied fields / audit level / ttl. A
|
||||
decision, not a 50-line entitlement JWT — the PEP MUST enforce every obligation.
|
||||
6. **AppShell assembles the ACE** — combines PAT + OPA obligations + CMP consent into an App
|
||||
Context Envelope (HttpOnly cookie): `capabilities[]` (policy-derived), `ui_obligations`
|
||||
(hide/mask/step_up), `consent`. **No tokens, no raw PII in the browser.**
|
||||
7. **CRM renders** — applies OPA `row_filter` (SQL WHERE) + `mask_fields` + `deny_fields`
|
||||
server-side; rows reference `canonical_person_id`, not email.
|
||||
|
||||
## Service inventory + real endpoints
|
||||
**Identity / session (`insignia` ns):**
|
||||
- **Session Broker** `session-broker.insignia:80` — `POST /v1/sessions/exchange` (SAT→PAT).
|
||||
- **Memberships** `memberships.insignia` (public `insignia-memberships.lynkedup.cloud`) —
|
||||
`POST /v1/internal/resolve`, `GET /v1/memberships` (Bearer PAT) → scope tuple + `allowed[]`.
|
||||
- **AppShell BFF** `appshell-bff.insignia:80` (public `insignia-appshell.lynkedup.cloud`) —
|
||||
`GET /apps/crm-web/bootstrap` (Bearer PAT) → ACE.
|
||||
- **Profile** `profile.insignia:80` — `GET /v1/profile`, `GET /v1/stats`. Backed by **sqlite3**
|
||||
(`/data/profiles.db`, PVC `insignia-profile-data`, WAL, persistent, single-replica RWO).
|
||||
- **CRM** `crm.insignia:80` — `GET /v1/leads?view=list` (applies obligations).
|
||||
- **Policy Gateway** `policy-gateway.insignia:80` — `POST /v1/decisions`, `POST /v1/decisions/batch`.
|
||||
|
||||
**MDM (`sre` ns):** `mdm-kernel.sre:80` (`/v1/auth-subjects/resolve`, `/healthz`, `/readyz`; auth =
|
||||
Keycloak service token **aud=realmdm**) · `mdm-ai.sre:80` · `mdm-sas.sre:9090` (**gRPC** — tokenization/SAS).
|
||||
|
||||
**OPA / policy (`sre` ns):** `opa.sre:8181` (`/health`, `GET /v1/data` = live policy tree;
|
||||
default-deny) · `realmdm-opa.sre:8181` · `opal-server.sre:7002` (OPAL policy distribution).
|
||||
|
||||
**CMP (`cmp` ns), backed by Postgres + NATS + Redis:** `cmp-core:8080` (real `CheckConsent` gRPC;
|
||||
service token aud=cmp) · `cmp-admin:8086` · `cmp-evidence:8081` · `cmp-sync:8085` ·
|
||||
`cmp-tollgate:8082` (NATS JetStream gating) · `cmp-media:8083` · `cmp-worker:8084`. Plus
|
||||
`insignia-consent-edge.insignia` (`POST /edge/v1/cache-policy`) and `insignia-consent-adapter.insignia`
|
||||
(`POST /v1/consent/evaluate {purpose}` → `{permitted, legal_basis, state, consent_epoch}`).
|
||||
|
||||
**Other (`sre` ns):** `poi-api:5000` · `roof:8002` (YOLO segmentation) · `commit:8081` ·
|
||||
`presign:8080` · `egs:8090` · `artifact-retrieval:8082` (was 503 on probe day) · `cmp-docs`.
|
||||
|
||||
## The two contracts IIOS must match
|
||||
|
||||
### PAT (Platform Access Token) — what IIOS should verify
|
||||
`iss = https://identity.insignia.internal` · **ES256** (EC P-256), verify via the broker **JWKS**
|
||||
(`.../.well-known/jwks.json`, kid-selected — **no shared secret**) · `aud` includes the app (e.g.
|
||||
`crm-web`) · `lifetime ~300s`. Claims:
|
||||
```
|
||||
sub = platform_principal_id app_id tenant_id org_id bu_id
|
||||
region environment role aal amr auth_source
|
||||
external_subject_hash = sha256:… (NOT the raw external sub)
|
||||
canonical_person_id (null until VERIFIED)
|
||||
session_epoch · policy_epoch · consent_epoch (stale-detection)
|
||||
sid (platform_session_id) · typ = platform-access+jwt
|
||||
```
|
||||
|
||||
### OPA decision — `POST http://policy-gateway.insignia.svc.cluster.local/v1/decisions`
|
||||
Request `{ input: { principal{platform_principal_id, canonical_person_id, auth_source, aal, roles,
|
||||
memberships[]}, caller{spiffe_id}, resource{type, id, tenant_id, classification[]}, action,
|
||||
context{purpose, device_trust, consent_receipt_ids[], network_zone, time} } }`
|
||||
Response `{ decision_id, allow, reason_codes[], obligations{ row_filter, allow_fields[], mask_fields{},
|
||||
deny_fields[], audit, decision_ttl_seconds }, policy_version }`.
|
||||
|
||||
## What this means for wiring IIOS's ports
|
||||
1. **Auth — verify the PAT, not the Supabase SAT.** IIOS today verifies the Supabase SAT directly
|
||||
(a dev shortcut). In the real platform the **Session Broker** does SAT→PAT; a platform workload
|
||||
verifies the **PAT**. The PAT already carries the full scope tuple + `platform_principal_id`, so
|
||||
`MessagePrincipal` maps ~1:1: `userId = platform_principal_id` (→ `canonical_person_id` once
|
||||
VERIFIED), `appId = app_id`, `orgId = org_id`, `tenantId = tenant_id`, `+ buId`. Wire it by adding
|
||||
the broker as an `AUTH_ISSUERS` entry (iss `https://identity.insignia.internal`, ES256, its JWKS).
|
||||
2. **OPA — point `OpaPort` at the Policy Gateway** (`POST /v1/decisions`). Build `{principal, caller.
|
||||
spiffe_id, resource, action, context}` from the PAT + the op; `decideOrThrow` maps `allow` → proceed
|
||||
and **must enforce the obligations** (masks/row-filter/deny). The gateway's input is richer than
|
||||
IIOS's current `{action,…}` — that's the adapter's job to assemble.
|
||||
3. **MDM — usually don't call it.** The PAT already carries `platform_principal_id`/`canonical_person_id`
|
||||
(the broker resolved at login). Only call `/v1/auth-subjects/resolve` if IIOS is the identity-exchange
|
||||
edge (it isn't — the Broker is).
|
||||
4. **CMP — consent gate** via `insignia-consent-adapter /v1/consent/evaluate {purpose}` when processing
|
||||
content for AI/analytics/marketing.
|
||||
5. **SAS — tokenization/masking** is `mdm-sas` (gRPC) — the "tokenize sensitive parts before storage"
|
||||
requirement.
|
||||
|
||||
*Source: two API payloads captured 2026-07-10 (`result.json` = probes, `journey.json` = the CRM
|
||||
first-vertical-slice journey). Re-capture from the authenticated dashboard to refresh.*
|
||||
+1
-3
@@ -7,9 +7,7 @@
|
||||
"build": "pnpm -r build",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test": "vitest run",
|
||||
"boundary": "node scripts/check-import-boundary.mjs",
|
||||
"release:dry": "pnpm -r build && pnpm -r publish --dry-run --no-git-checks",
|
||||
"release": "pnpm -r build && pnpm -r publish --no-git-checks"
|
||||
"boundary": "node scripts/check-import-boundary.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.0.1",
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"name": "@insignia/iios-adapter-sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@insignia/iios-contracts": "workspace:*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
{
|
||||
"name": "@insignia/iios-ai-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -29,8 +23,5 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
{
|
||||
"name": "@insignia/iios-community-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -29,8 +23,5 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
{
|
||||
"name": "@insignia/iios-contracts",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
{
|
||||
"name": "@insignia/iios-inbox-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -29,8 +23,5 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
{
|
||||
"name": "@insignia/iios-kernel-client",
|
||||
"version": "0.1.4",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -25,8 +19,5 @@
|
||||
"devDependencies": {
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@ export interface MessageSocketConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io
|
||||
* types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens
|
||||
* all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI
|
||||
* that watches multiple conversations keeps receiving live messages after a drop.
|
||||
* Framework-agnostic facade over the `/message` Socket.io namespace (ports the
|
||||
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck.
|
||||
* On reconnect it re-opens the current thread so subscriptions resume with no
|
||||
* lost messages (the docs' disconnect/reconnect requirement).
|
||||
*/
|
||||
export class MessageSocket {
|
||||
private readonly socket: SocketLike;
|
||||
private readonly joined = new Set<string>();
|
||||
private currentThreadId: string | null = null;
|
||||
|
||||
constructor(config: MessageSocketConfig, socket?: SocketLike) {
|
||||
this.socket =
|
||||
@@ -26,9 +26,9 @@ export class MessageSocket {
|
||||
autoConnect: config.autoConnect ?? true,
|
||||
}) as unknown as SocketLike);
|
||||
|
||||
// Re-subscribe to every joined thread after a reconnect.
|
||||
// Re-open the active thread after a reconnect.
|
||||
this.socket.on('connect', () => {
|
||||
for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
|
||||
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,70 +40,27 @@ export class MessageSocket {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
/** Run `handler` on every (re)connect, and immediately if already connected. */
|
||||
onConnected(handler: () => void): () => void {
|
||||
this.socket.on('connect', handler);
|
||||
if (this.socket.connected) handler();
|
||||
return () => this.socket.off('connect', handler);
|
||||
}
|
||||
|
||||
/** Subscribe to a server event; returns an unsubscribe fn. */
|
||||
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
|
||||
const fn = handler as (...args: unknown[]) => void;
|
||||
this.socket.on(event as string, fn);
|
||||
return () => this.socket.off(event as string, fn);
|
||||
this.socket.on(event, fn);
|
||||
return () => this.socket.off(event, fn);
|
||||
}
|
||||
|
||||
async openThread(
|
||||
threadId?: string,
|
||||
opts?: { membership?: string; creatorRole?: string; subject?: string },
|
||||
): Promise<OpenThreadResult> {
|
||||
// Timeout (when the transport supports it) so a server error that never acks
|
||||
// can't hang the caller forever.
|
||||
const ack = this.socket.timeout ? this.socket.timeout(8000) : this.socket;
|
||||
const result = (await ack.emitWithAck('open_thread', { threadId, ...opts })) as OpenThreadResult & { error?: string };
|
||||
if (result?.error) throw new Error(result.error);
|
||||
this.joined.add(result.threadId);
|
||||
async openThread(threadId?: string): Promise<OpenThreadResult> {
|
||||
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult;
|
||||
this.currentThreadId = result.threadId;
|
||||
return result;
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
threadId: string,
|
||||
content: string,
|
||||
opts?: {
|
||||
contentRef?: string;
|
||||
parentInteractionId?: string;
|
||||
mentions?: string[];
|
||||
attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string };
|
||||
},
|
||||
): Promise<Message> {
|
||||
async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise<Message> {
|
||||
return (await this.socket.emitWithAck('send_message', {
|
||||
threadId,
|
||||
content,
|
||||
contentRef: opts?.attachment?.contentRef ?? opts?.contentRef,
|
||||
mimeType: opts?.attachment?.mimeType,
|
||||
sizeBytes: opts?.attachment?.sizeBytes,
|
||||
checksumSha256: opts?.attachment?.checksumSha256,
|
||||
parentInteractionId: opts?.parentInteractionId,
|
||||
mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel
|
||||
contentRef: opts?.contentRef,
|
||||
})) as Message;
|
||||
}
|
||||
|
||||
/** Pin a message in the thread (shared, generic annotation type "pin"). */
|
||||
async pin(threadId: string, interactionId: string): Promise<void> {
|
||||
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'pin', value: '' });
|
||||
}
|
||||
|
||||
/** Save a message for myself (personal, generic annotation type "save"). */
|
||||
async save(threadId: string, interactionId: string): Promise<void> {
|
||||
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'save', value: '' });
|
||||
}
|
||||
|
||||
/** Toggle an emoji reaction on a message (a generic annotation of type "reaction"). */
|
||||
async react(threadId: string, interactionId: string, value: string): Promise<void> {
|
||||
await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'reaction', value });
|
||||
}
|
||||
|
||||
async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
|
||||
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
|
||||
}
|
||||
@@ -111,9 +68,4 @@ export class MessageSocket {
|
||||
typing(threadId: string): void {
|
||||
this.socket.emit('typing', { threadId });
|
||||
}
|
||||
|
||||
/** Tell the server which thread is in the foreground (or null when blurred) — drives presence. */
|
||||
focus(threadId: string | null): void {
|
||||
this.socket.emit('focus_thread', { threadId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
|
||||
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types';
|
||||
|
||||
export interface RestConfig {
|
||||
serviceUrl: string;
|
||||
token?: string;
|
||||
/**
|
||||
* Extra headers for every request — e.g. `x-context-attestation` (the July 12 trust proof).
|
||||
* Pass a FUNCTION to mint fresh headers per request: a context attestation carries a single-use
|
||||
* nonce, so a static header would be replay-rejected on the 2nd call. The function is invoked
|
||||
* once per request.
|
||||
*/
|
||||
headers?: Record<string, string> | (() => Record<string, string>);
|
||||
}
|
||||
|
||||
/** REST/polling client for kernel reads and the native-send fallback. */
|
||||
@@ -22,8 +15,7 @@ export class RestClient {
|
||||
}
|
||||
|
||||
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||
const custom = typeof this.config.headers === 'function' ? this.config.headers() : this.config.headers;
|
||||
const h: Record<string, string> = { 'content-type': 'application/json', ...custom, ...extra };
|
||||
const h: Record<string, string> = { 'content-type': 'application/json', ...extra };
|
||||
if (this.config.token) h.authorization = `Bearer ${this.config.token}`;
|
||||
return h;
|
||||
}
|
||||
@@ -65,73 +57,12 @@ export class RestClient {
|
||||
return (await r.json()) as InboxItem;
|
||||
}
|
||||
|
||||
// ─── auth + threads (app surface) ─────────────────────────────
|
||||
/** Dev IdP login (POST /v1/dev/login). A real IdP issues the same JWT — this is the swap point. */
|
||||
async login(username: string, password: string): Promise<LoginResult> {
|
||||
const r = await fetch(this.url('/v1/dev/login'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (r.status === 401) throw new Error('invalid username or password');
|
||||
if (!r.ok) throw new Error(`login failed (${r.status}) — is the service running with IIOS_DEV_TOKENS=1?`);
|
||||
return (await r.json()) as LoginResult;
|
||||
}
|
||||
|
||||
/** Dev directory: known usernames. A real app validates against its user store / MDM. */
|
||||
async listUsers(): Promise<string[]> {
|
||||
const r = await fetch(this.url('/v1/dev/users'), { headers: this.headers() });
|
||||
if (!r.ok) return [];
|
||||
return ((await r.json()) as { users: string[] }).users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-authoritative conversation list (works cross-device, shows unread + members).
|
||||
* `filter.metadata` narrows to threads whose opaque attribute bag matches every key/value.
|
||||
*/
|
||||
async listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
|
||||
const qs = filter?.metadata
|
||||
? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
|
||||
: '';
|
||||
const r = await fetch(this.url(`/v1/threads${qs}`), { headers: this.headers() });
|
||||
if (!r.ok) throw new Error(`listThreads ${r.status}`);
|
||||
return (await r.json()) as ThreadSummary[];
|
||||
}
|
||||
|
||||
/** Create a thread with generic app attributes (membership/creator role/subject + an opaque metadata bag). */
|
||||
async createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }> {
|
||||
return this.post<{ threadId: string }>('/v1/threads', opts);
|
||||
}
|
||||
|
||||
/** My saved messages (personal bookmarks), newest first, with thread context. */
|
||||
async listSaved(): Promise<SavedItem[]> {
|
||||
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
|
||||
if (!r.ok) throw new Error(`listSaved ${r.status}`);
|
||||
return (await r.json()) as SavedItem[];
|
||||
}
|
||||
|
||||
/** Governed add-participant. A policy 403 (e.g. DM cap) surfaces its reason. */
|
||||
async addParticipant(threadId: string, userId: string): Promise<void> {
|
||||
const r = await fetch(this.url(`/v1/threads/${threadId}/participants`), {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
if (r.ok) return;
|
||||
const body = (await r.json().catch(() => ({}))) as { message?: string };
|
||||
throw new Error(body.message ?? `could not add member (${r.status})`);
|
||||
}
|
||||
|
||||
// ─── support ──────────────────────────────────────────────────
|
||||
async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record<string, unknown> }): Promise<Ticket> {
|
||||
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> {
|
||||
return this.post<Ticket>('/v1/support/tickets', body);
|
||||
}
|
||||
async escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
|
||||
return this.post<Ticket>('/v1/support/escalate', { threadId, subject, metadata });
|
||||
}
|
||||
/** Manually assign a ticket to a specific user (generic assignment override). */
|
||||
async assignTicket(id: string, userId: string): Promise<Ticket> {
|
||||
return this.post<Ticket>(`/v1/support/tickets/${id}/assignee`, { userId });
|
||||
async escalate(threadId: string, subject?: string): Promise<Ticket> {
|
||||
return this.post<Ticket>('/v1/support/escalate', { threadId, subject });
|
||||
}
|
||||
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
|
||||
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
/** A media/file part attached to a message (contentRef points at object storage). */
|
||||
export interface Attachment {
|
||||
contentRef: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
kind: 'image' | 'video' | 'audio' | 'file';
|
||||
}
|
||||
|
||||
/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */
|
||||
export interface AnnotationGroup {
|
||||
type: string;
|
||||
value: string;
|
||||
users: string[]; // usernames who applied it
|
||||
}
|
||||
|
||||
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */
|
||||
export interface Message {
|
||||
id: string;
|
||||
threadId: string;
|
||||
senderActorId: string;
|
||||
senderId: string; // sender's email/username — reliable "is this mine?" check
|
||||
senderName: string;
|
||||
content: string;
|
||||
contentRef?: string;
|
||||
attachment?: Attachment;
|
||||
parentInteractionId?: string;
|
||||
annotations?: AnnotationGroup[];
|
||||
traceId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -46,50 +26,10 @@ export interface TypingEvent {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** Broadcast when someone toggles an annotation — carries the refreshed user list. */
|
||||
export interface AnnotationEvent {
|
||||
threadId: string;
|
||||
interactionId: string;
|
||||
type: string;
|
||||
value: string;
|
||||
op: 'add' | 'remove';
|
||||
users: string[];
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface MessageEvents {
|
||||
message: (m: Message) => void;
|
||||
receipt: (e: ReceiptEvent) => void;
|
||||
typing: (e: TypingEvent) => void;
|
||||
annotation: (e: AnnotationEvent) => void;
|
||||
}
|
||||
|
||||
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
|
||||
export interface ThreadSummary {
|
||||
threadId: string;
|
||||
subject: string | null;
|
||||
membership?: string; // 'dm' | 'group' (opaque app attribute)
|
||||
/** The thread's opaque, app-supplied attribute bag — echoed verbatim; the kernel never interprets it. */
|
||||
metadata?: Record<string, unknown> | null;
|
||||
participants: string[]; // member usernames
|
||||
participantCount: number;
|
||||
unread: number;
|
||||
muted?: boolean;
|
||||
lastMessage?: string;
|
||||
lastAt?: string;
|
||||
}
|
||||
|
||||
/** A personally-saved message with its thread context (GET /v1/threads/my-annotations?type=save). */
|
||||
export interface SavedItem {
|
||||
message: Message;
|
||||
threadId: string;
|
||||
threadSubject: string | null;
|
||||
}
|
||||
|
||||
/** Result of the dev IdP login (POST /v1/dev/login). A real IdP issues the same claims. */
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
|
||||
@@ -128,8 +68,6 @@ export interface Ticket {
|
||||
assignedActorId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Opaque, app-supplied attribute bag on the ticket — echoed verbatim; the kernel never interprets it. */
|
||||
metadata?: Record<string, unknown> | null;
|
||||
threadLinks?: Array<{ threadId: string; relationKind: string }>;
|
||||
}
|
||||
|
||||
@@ -250,8 +188,4 @@ export interface SocketLike {
|
||||
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
|
||||
connect(): unknown;
|
||||
disconnect(): unknown;
|
||||
/** True while the underlying transport is connected (socket.io exposes this). */
|
||||
connected?: boolean;
|
||||
/** Per-call ack timeout (socket.io). Optional so fakes can omit it. */
|
||||
timeout?(ms: number): { emitWithAck(event: string, ...args: unknown[]): Promise<unknown> };
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
{
|
||||
"name": "@insignia/iios-meeting-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -29,8 +23,5 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
{
|
||||
"name": "@insignia/iios-message-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -29,8 +23,5 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,13 +49,3 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
|
||||
# ── Capability providers (governed egress targets) ───────────────────────────
|
||||
# Per-channel provider endpoint the CapabilityBroker calls, e.g.:
|
||||
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email
|
||||
|
||||
# ── Context attestation (July 12 trust layer) ──
|
||||
# Audience IIOS requires on attestations addressed to it.
|
||||
IIOS_ATTESTATION_AUDIENCE=iios-core
|
||||
# Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the
|
||||
# appshell-crm client into the registry when IIOS_DEV_TOKENS=1).
|
||||
# IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key
|
||||
# When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403).
|
||||
# Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless.
|
||||
IIOS_REQUIRE_ATTESTATION=0
|
||||
|
||||
@@ -26,12 +26,14 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"ioredis": "^5.11.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^4.1.0",
|
||||
"prisma": "^6.2.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io": "^4.8.3",
|
||||
"web-push": "^3.6.7"
|
||||
"web-push": "^3.6.7",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/sdk-node": "^0.220.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.78.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@insignia/iios-testkit": "workspace:*",
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
-- Trust plane (July 12): context attestation client registry + nonce replay ledger.
|
||||
CREATE TABLE "IiosClientRegistry" (
|
||||
"clientId" TEXT NOT NULL,
|
||||
"clientType" TEXT NOT NULL,
|
||||
"ownerService" TEXT,
|
||||
"allowedAppIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"attestSecret" TEXT,
|
||||
"jwksUri" TEXT,
|
||||
"spiffeId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "IiosClientRegistry_pkey" PRIMARY KEY ("clientId")
|
||||
);
|
||||
|
||||
CREATE TABLE "IiosAttestationNonce" (
|
||||
"nonce" TEXT NOT NULL,
|
||||
"clientId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "IiosAttestationNonce_pkey" PRIMARY KEY ("nonce")
|
||||
);
|
||||
|
||||
CREATE INDEX "IiosAttestationNonce_expiresAt_idx" ON "IiosAttestationNonce"("expiresAt");
|
||||
@@ -1321,31 +1321,3 @@ model IiosDlqItem {
|
||||
@@unique([consumerName, sourceId])
|
||||
@@index([scopeId, status])
|
||||
}
|
||||
|
||||
// ─── Trust plane (July 12) — context attestation ──────────────────
|
||||
// Registered clients/BFFs/adapters allowed to call IIOS and attest context on
|
||||
// behalf of an app. A valid actor token is NOT enough; the caller must present a
|
||||
// context attestation signed by one of these registered clients.
|
||||
model IiosClientRegistry {
|
||||
clientId String @id
|
||||
clientType String // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
|
||||
ownerService String?
|
||||
allowedAppIds String[] // which app_ids this client may attest for
|
||||
attestSecret String? // dev: shared HS256 signing key (AppShell's key stand-in)
|
||||
jwksUri String? // prod: verify the attestation signature via JWKS
|
||||
spiffeId String? // prod: workload identity for mTLS proof
|
||||
status String @default("ACTIVE") // ACTIVE | DISABLED
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Single-use nonce ledger: once a context attestation's nonce is seen it can
|
||||
// never be replayed. Reserve-if-absent (unique PK) makes the check atomic.
|
||||
model IiosAttestationNonce {
|
||||
nonce String @id
|
||||
clientId String
|
||||
expiresAt DateTime
|
||||
firstSeenAt DateTime @default(now())
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { AttestationModule } from './platform/attestation.module';
|
||||
import { IdentityModule } from './identity/identity.module';
|
||||
import { InteractionsModule } from './interactions/interactions.module';
|
||||
import { IdempotencyModule } from './idempotency/idempotency.module';
|
||||
@@ -28,7 +27,6 @@ import { DevController } from './dev/dev.controller';
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PlatformModule,
|
||||
AttestationModule,
|
||||
IdentityModule,
|
||||
InteractionsModule,
|
||||
IdempotencyModule,
|
||||
|
||||
@@ -8,9 +8,6 @@ export interface MessagePrincipal {
|
||||
appId: string;
|
||||
tenantId?: string;
|
||||
displayName?: string;
|
||||
/** The token's audience (`aud`). Used to enforce realtime delegation: a socket-scoped
|
||||
* token (`iios-message`) must not be a full REST/actor token (`iios-core`), and vice-versa. */
|
||||
audience?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './observability/tracing'; // MUST be first — auto-instrumentation patches modules on require
|
||||
import 'dotenv/config';
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
@@ -9,10 +10,6 @@ import { PolicyDeniedFilter } from './platform/policy-denied.filter';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
// Express 5 defaults to the 'simple' query parser, which ignores nested params like
|
||||
// ?metadata[key]=value — so generic metadata filters would be silently dropped. Use the
|
||||
// qs-based 'extended' parser so those parse into a nested object.
|
||||
app.getHttpAdapter().getInstance().set('query parser', 'extended');
|
||||
app.use(traceMiddleware); // request-scoped trace context + x-trace-id header (P9)
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { Socket } from 'socket.io';
|
||||
import { MessageGateway } from './message.gateway';
|
||||
import type { MessageService, MessagePrincipal } from './message.service';
|
||||
import { SessionVerifier } from '../platform/session.verifier';
|
||||
import type { OutboxBus } from '../outbox/outbox.bus';
|
||||
import type { PresenceService } from '../notifications/presence.service';
|
||||
|
||||
// Realtime delegation: the browser opens the socket with a short-lived token minted for the
|
||||
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
|
||||
// that audience — a full REST/actor token (iios-core) or an un-scoped token can't open the stream.
|
||||
|
||||
const APP = 'crm-web';
|
||||
const SECRET = 'dev-crm-secret';
|
||||
|
||||
// ─── the real SessionVerifier must expose `aud` so the gateway can enforce it ───
|
||||
describe('SessionVerifier — app-token audience surfacing', () => {
|
||||
let v: SessionVerifier;
|
||||
beforeAll(async () => {
|
||||
delete process.env.AUTH_ISSUERS;
|
||||
delete process.env.SUPABASE_URL;
|
||||
process.env.APP_SECRETS = JSON.stringify({ [APP]: SECRET });
|
||||
v = new SessionVerifier();
|
||||
await v.onModuleInit();
|
||||
});
|
||||
afterAll(() => { delete process.env.APP_SECRETS; });
|
||||
|
||||
it('surfaces the aud claim of a realtime-scoped token', () => {
|
||||
const tok = jwt.sign({ appId: APP, aud: 'iios-message' }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
|
||||
expect(v.verify(tok).audience).toBe('iios-message');
|
||||
});
|
||||
|
||||
it('leaves audience undefined for an un-scoped token (a REST/actor token)', () => {
|
||||
const tok = jwt.sign({ appId: APP }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
|
||||
expect(v.verify(tok).audience).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the gateway enforces the realtime audience on connect ───
|
||||
function fakeSocket(): { sock: Socket; disconnected: () => boolean } {
|
||||
let disconnected = false;
|
||||
const sock = {
|
||||
handshake: { auth: { token: 'tok' } },
|
||||
disconnect: () => { disconnected = true; },
|
||||
data: undefined as unknown,
|
||||
} as unknown as Socket;
|
||||
return { sock, disconnected: () => disconnected };
|
||||
}
|
||||
|
||||
function gatewayReturning(principal: MessagePrincipal): MessageGateway {
|
||||
const session = { verify: () => principal } as unknown as SessionVerifier;
|
||||
return new MessageGateway(
|
||||
undefined as unknown as MessageService,
|
||||
session,
|
||||
undefined as unknown as OutboxBus,
|
||||
undefined as unknown as PresenceService,
|
||||
);
|
||||
}
|
||||
|
||||
const principal = (audience?: string): MessagePrincipal => ({ userId: 'u', appId: APP, orgId: 'org', audience });
|
||||
|
||||
describe('MessageGateway — realtime audience enforcement', () => {
|
||||
afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; });
|
||||
|
||||
it('accepts any valid token when enforcement is OFF (env unset)', () => {
|
||||
const { sock, disconnected } = fakeSocket();
|
||||
gatewayReturning(principal(undefined)).handleConnection(sock);
|
||||
expect(disconnected()).toBe(false);
|
||||
expect((sock.data as { principal: MessagePrincipal }).principal.userId).toBe('u');
|
||||
});
|
||||
|
||||
it('accepts a token whose aud matches the required realtime audience', () => {
|
||||
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
|
||||
const { sock, disconnected } = fakeSocket();
|
||||
gatewayReturning(principal('iios-message')).handleConnection(sock);
|
||||
expect(disconnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a full REST/actor token (aud=iios-core) on the socket', () => {
|
||||
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
|
||||
const { sock, disconnected } = fakeSocket();
|
||||
gatewayReturning(principal('iios-core')).handleConnection(sock);
|
||||
expect(disconnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an un-scoped token (no aud) when enforcement is on', () => {
|
||||
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
|
||||
const { sock, disconnected } = fakeSocket();
|
||||
gatewayReturning(principal(undefined)).handleConnection(sock);
|
||||
expect(disconnected()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -61,14 +61,6 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
||||
try {
|
||||
const token = String(client.handshake.auth?.token ?? '');
|
||||
const principal = this.session.verify(token);
|
||||
// Realtime delegation (least privilege): when IIOS_REALTIME_AUDIENCE is set, the socket
|
||||
// accepts ONLY a token minted for that audience (a short-lived `iios-message` delegate) —
|
||||
// a full REST/actor token (`iios-core`) or an un-scoped token cannot open the stream. So a
|
||||
// leaked socket token can't drive privileged REST, and vice-versa. Unset → no enforcement.
|
||||
const required = process.env.IIOS_REALTIME_AUDIENCE?.trim();
|
||||
if (required && principal.audience !== required) {
|
||||
throw new Error(`realtime audience "${principal.audience ?? '(none)'}" != required "${required}"`);
|
||||
}
|
||||
client.data = { principal } satisfies SocketState;
|
||||
} catch (err) {
|
||||
this.logger.warn(`rejecting socket: ${(err as Error).message}`);
|
||||
|
||||
@@ -50,8 +50,6 @@ export interface ThreadSummary {
|
||||
threadId: string;
|
||||
subject: string | null;
|
||||
membership?: string;
|
||||
/** The thread's opaque, app-supplied attribute bag — echoed back verbatim; the kernel never interprets it. */
|
||||
metadata?: Record<string, unknown> | null;
|
||||
participants: string[];
|
||||
participantCount: number;
|
||||
unread: number;
|
||||
@@ -87,21 +85,19 @@ export class MessageService {
|
||||
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may
|
||||
* re-open it (policy `iios.thread.join`) — new members enter via addParticipant.
|
||||
*/
|
||||
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<OpenThreadResult> {
|
||||
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> {
|
||||
if (!threadId) {
|
||||
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
|
||||
const scope = await this.actors.resolveScope(principal);
|
||||
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||
// `membership`/`creatorRole`/`subject`/`metadata` are generic, app-supplied thread attributes —
|
||||
// the kernel stores/echoes them as an opaque bag but never branches on their meaning (that lives
|
||||
// in policy + the app). `membership` is folded into the same bag for back-compat.
|
||||
const merged = { ...(opts?.metadata ?? {}), ...(opts?.membership ? { membership: opts.membership } : {}) };
|
||||
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the
|
||||
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app).
|
||||
const thread = await this.prisma.iiosThread.create({
|
||||
data: {
|
||||
scopeId: scope.id,
|
||||
createdByActorId: actor.id,
|
||||
subject: opts?.subject?.trim() || undefined,
|
||||
metadata: Object.keys(merged).length > 0 ? (merged as Prisma.InputJsonValue) : undefined,
|
||||
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined,
|
||||
},
|
||||
});
|
||||
await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
|
||||
@@ -171,12 +167,8 @@ export class MessageService {
|
||||
return { threadId, muted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic "my threads": every thread the caller participates in, with last message + unread.
|
||||
* An optional `filter.metadata` narrows to threads whose opaque attribute bag matches ALL of the
|
||||
* given key/values (an equality match on the JSON bag — the kernel does not interpret the keys).
|
||||
*/
|
||||
async listThreads(principal: MessagePrincipal, filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
|
||||
/** Generic "my threads": every thread the caller participates in, with last message + unread. */
|
||||
async listThreads(principal: MessagePrincipal): Promise<ThreadSummary[]> {
|
||||
const scope = await this.actors.findScope(principal);
|
||||
if (!scope) return [];
|
||||
const actor = await this.actors.resolveActor(scope.id, principal);
|
||||
@@ -185,7 +177,7 @@ export class MessageService {
|
||||
if (threadIds.length === 0) return [];
|
||||
const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted]));
|
||||
|
||||
const [allThreads, unreads, allParts] = await Promise.all([
|
||||
const [threads, unreads, allParts] = await Promise.all([
|
||||
this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }),
|
||||
this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }),
|
||||
this.prisma.iiosThreadParticipant.findMany({
|
||||
@@ -193,14 +185,6 @@ export class MessageService {
|
||||
include: { actor: { include: { sourceHandle: true } } },
|
||||
}),
|
||||
]);
|
||||
// Opaque equality filter on the metadata bag (every requested key must match).
|
||||
const metaFilter = filter?.metadata;
|
||||
const threads = metaFilter
|
||||
? allThreads.filter((t) => {
|
||||
const bag = (t.metadata as Record<string, unknown> | null) ?? {};
|
||||
return Object.entries(metaFilter).every(([k, v]) => bag[k] === v);
|
||||
})
|
||||
: allThreads;
|
||||
const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
|
||||
const membersBy = new Map<string, string[]>();
|
||||
for (const p of allParts) {
|
||||
@@ -208,41 +192,27 @@ export class MessageService {
|
||||
membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]);
|
||||
}
|
||||
|
||||
// Latest message per thread in ONE query (Postgres DISTINCT ON) instead of one findFirst
|
||||
// per thread — a caller with many threads no longer fans out N parallel queries and
|
||||
// exhausts the connection pool.
|
||||
const lastRows =
|
||||
threads.length === 0
|
||||
? []
|
||||
: await this.prisma.$queryRaw<Array<{ threadId: string; occurredAt: Date; lastMessage: string | null }>>(Prisma.sql`
|
||||
SELECT DISTINCT ON (i."threadId")
|
||||
i."threadId" AS "threadId",
|
||||
i."occurredAt" AS "occurredAt",
|
||||
(SELECT p."bodyText" FROM "IiosMessagePart" p
|
||||
WHERE p."interactionId" = i.id AND p.kind::text = 'TEXT'
|
||||
ORDER BY p."partIndex" ASC LIMIT 1) AS "lastMessage"
|
||||
FROM "IiosInteraction" i
|
||||
WHERE i."threadId" IN (${Prisma.join(threads.map((t) => t.id))})
|
||||
ORDER BY i."threadId", i."occurredAt" DESC
|
||||
`);
|
||||
const lastBy = new Map(lastRows.map((r) => [r.threadId, r]));
|
||||
|
||||
const summaries = threads.map((t) => {
|
||||
const last = lastBy.get(t.id);
|
||||
const members = membersBy.get(t.id) ?? [];
|
||||
return {
|
||||
threadId: t.id,
|
||||
subject: t.subject,
|
||||
membership: (t.metadata as { membership?: string } | null)?.membership,
|
||||
metadata: (t.metadata as Record<string, unknown> | null) ?? null,
|
||||
participants: members,
|
||||
participantCount: members.length,
|
||||
unread: unreadBy.get(t.id) ?? 0,
|
||||
muted: mutedBy.get(t.id) ?? false,
|
||||
lastMessage: last?.lastMessage ?? undefined,
|
||||
lastAt: last?.occurredAt,
|
||||
};
|
||||
});
|
||||
const summaries = await Promise.all(
|
||||
threads.map(async (t) => {
|
||||
const last = await this.prisma.iiosInteraction.findFirst({
|
||||
where: { threadId: t.id },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
include: { parts: { where: { kind: 'TEXT' }, take: 1 } },
|
||||
});
|
||||
const members = membersBy.get(t.id) ?? [];
|
||||
return {
|
||||
threadId: t.id,
|
||||
subject: t.subject,
|
||||
membership: (t.metadata as { membership?: string } | null)?.membership,
|
||||
participants: members,
|
||||
participantCount: members.length,
|
||||
unread: unreadBy.get(t.id) ?? 0,
|
||||
muted: mutedBy.get(t.id) ?? false,
|
||||
lastMessage: last?.parts[0]?.bodyText ?? undefined,
|
||||
lastAt: last?.occurredAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0));
|
||||
}
|
||||
|
||||
|
||||
@@ -143,23 +143,6 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
|
||||
expect((await s.listThreads(bob))[0]?.unread).toBe(1);
|
||||
});
|
||||
|
||||
it('opaque metadata: stored on create, echoed in listThreads, and a metadata filter narrows the list', async () => {
|
||||
const s = svc();
|
||||
// Two threads with different opaque app attributes — the kernel never interprets these values.
|
||||
await s.openThread(null, alice, { metadata: { source: 'crm-support', crmCustomerId: 'cust_1' } });
|
||||
await s.openThread(null, alice, { metadata: { source: 'other' } });
|
||||
|
||||
const all = await s.listThreads(alice);
|
||||
expect(all).toHaveLength(2);
|
||||
const support = all.find((t) => (t.metadata as { source?: string } | null)?.source === 'crm-support');
|
||||
expect(support?.metadata).toMatchObject({ source: 'crm-support', crmCustomerId: 'cust_1' });
|
||||
|
||||
// Equality filter on the opaque bag returns only the matching thread.
|
||||
const filtered = await s.listThreads(alice, { metadata: { source: 'crm-support' } });
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0]?.metadata).toMatchObject({ crmCustomerId: 'cust_1' });
|
||||
});
|
||||
|
||||
it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import { runWithTrace, newTraceId, traceIdFromTraceparent } from './trace-context';
|
||||
import { logJson } from './logger';
|
||||
|
||||
@@ -9,8 +10,13 @@ import { logJson } from './logger';
|
||||
* the response finishes (method, path, status, ms). Wired via app.use() in main.ts.
|
||||
*/
|
||||
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
// Prefer the ACTIVE OpenTelemetry trace id: that is what makes this log line
|
||||
// joinable to its distributed trace in Grafana (Loki -> Tempo). Falls back to
|
||||
// the inbound header, then traceparent, then a generated id, so a correlation
|
||||
// id is always present even when tracing is disabled.
|
||||
const otelTraceId = trace.getActiveSpan()?.spanContext().traceId;
|
||||
const headerTrace = (req.headers['x-trace-id'] as string | undefined)?.trim();
|
||||
const traceId = headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
|
||||
const traceId = otelTraceId || headerTrace || traceIdFromTraceparent(req.headers['traceparent'] as string | undefined) || newTraceId();
|
||||
res.setHeader('x-trace-id', traceId);
|
||||
const startedAt = Date.now();
|
||||
runWithTrace(traceId, () => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* OpenTelemetry bootstrap. MUST be imported before anything else in main.ts —
|
||||
* auto-instrumentation works by patching modules (http, express, pg, redis, …)
|
||||
* as they are require()d, so any module loaded before this runs is never traced.
|
||||
*
|
||||
* Env-driven on purpose: the OTel SDK reads OTEL_SERVICE_NAME,
|
||||
* OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL itself, so the
|
||||
* collector target is a deployment concern rather than a code change. When
|
||||
* OTEL_EXPORTER_OTLP_ENDPOINT is unset the SDK never starts, so local dev and
|
||||
* tests run with zero tracing overhead and no exporter errors.
|
||||
*
|
||||
* Traces land in Tempo and are viewable in Grafana. The existing P9 trace
|
||||
* middleware adopts the active OTel trace id, so an `http.request` log line and
|
||||
* its distributed trace share one id (Loki -> Tempo pivot).
|
||||
*/
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||
|
||||
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
|
||||
if (endpoint) {
|
||||
const sdk = new NodeSDK({
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
// Noisy and low value: every file read becomes a span.
|
||||
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||
// k8s probes hit /health constantly; tracing them would swamp Tempo
|
||||
// and bury the real request traces.
|
||||
'@opentelemetry/instrumentation-http': {
|
||||
ignoreIncomingRequestHook: (req) => {
|
||||
const url = req.url ?? '';
|
||||
return ['/health', '/ready', '/healthz', '/readyz', '/metrics'].some((p) =>
|
||||
url.startsWith(p),
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
|
||||
const shutdown = (): void => {
|
||||
// Flush buffered spans before exit, else the last requests before a
|
||||
// rollout are lost.
|
||||
void sdk.shutdown().finally(() => process.exit(0));
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { JwksClient } from 'jwks-rsa';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type {
|
||||
ClientRegistryEntry,
|
||||
ClientRegistryPort,
|
||||
NonceStorePort,
|
||||
PublicKeyResolverPort,
|
||||
} from './context-attestation';
|
||||
|
||||
/** Client registry backed by Postgres (IiosClientRegistry). */
|
||||
@Injectable()
|
||||
export class PrismaClientRegistry implements ClientRegistryPort {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async find(clientId: string): Promise<ClientRegistryEntry | null> {
|
||||
const r = await this.prisma.iiosClientRegistry.findUnique({ where: { clientId } });
|
||||
if (!r) return null;
|
||||
return {
|
||||
clientId: r.clientId,
|
||||
clientType: r.clientType,
|
||||
allowedAppIds: r.allowedAppIds,
|
||||
attestSecret: r.attestSecret ?? undefined,
|
||||
jwksUri: r.jwksUri ?? undefined,
|
||||
status: r.status === 'ACTIVE' ? 'ACTIVE' : 'DISABLED',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nonce ledger backed by Postgres (IiosAttestationNonce). Reserve-if-absent is atomic via the
|
||||
* unique primary key: a duplicate insert (P2002) means the nonce was already used → replay.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaNonceStore implements NonceStorePort {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean> {
|
||||
try {
|
||||
await this.prisma.iiosAttestationNonce.create({ data: input });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') return false; // already seen
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a client's public signing key from its JWKS (prod ES256 path). Keeps ONE JwksClient
|
||||
* per jwksUri — the client caches keys and refetches on a cache miss (so key rotation is picked up
|
||||
* without a restart). Returns null on any failure so the verifier fails closed with NO_KEY.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwksPublicKeyResolver implements PublicKeyResolverPort {
|
||||
private readonly clients = new Map<string, JwksClient>();
|
||||
|
||||
private clientFor(jwksUri: string): JwksClient {
|
||||
let c = this.clients.get(jwksUri);
|
||||
if (!c) {
|
||||
c = new JwksClient({ jwksUri, cache: true, cacheMaxEntries: 8, cacheMaxAge: 10 * 60_000, rateLimit: true, jwksRequestsPerMinute: 12 });
|
||||
this.clients.set(jwksUri, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
async resolve(jwksUri: string, kid: string): Promise<string | null> {
|
||||
try {
|
||||
const key = await this.clientFor(jwksUri).getSigningKey(kid);
|
||||
return key.getPublicKey(); // PEM (SPKI) — works for EC (ES256) and RSA keys
|
||||
} catch {
|
||||
return null; // unknown kid / unreachable JWKS / malformed key → fail closed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Global, Module, type OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ContextAttestationVerifier } from './context-attestation';
|
||||
import { PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver } from './attestation-stores';
|
||||
import { ContextAttestationGuard } from './context-attestation.guard';
|
||||
|
||||
/**
|
||||
* Wires the context-attestation trust layer into DI and exposes the guard globally so any
|
||||
* controller can enforce the three-proof gate. Also dev-seeds a registered client so locally
|
||||
* minted attestations verify (prod registers clients out-of-band).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
PrismaClientRegistry,
|
||||
PrismaNonceStore,
|
||||
JwksPublicKeyResolver,
|
||||
{
|
||||
provide: ContextAttestationVerifier,
|
||||
useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore, keys: JwksPublicKeyResolver) =>
|
||||
new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }, keys),
|
||||
inject: [PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver],
|
||||
},
|
||||
ContextAttestationGuard,
|
||||
],
|
||||
exports: [ContextAttestationVerifier, ContextAttestationGuard],
|
||||
})
|
||||
export class AttestationModule implements OnModuleInit {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Dev seed: register the CRM support client so an attestation signed with the shared dev
|
||||
* secret verifies locally. Gated by IIOS_DEV_TOKENS + IIOS_ATTESTATION_DEV_SECRET; best-effort. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (process.env.IIOS_DEV_TOKENS !== '1') return;
|
||||
const secret = process.env.IIOS_ATTESTATION_DEV_SECRET;
|
||||
if (!secret) return;
|
||||
await this.prisma.iiosClientRegistry
|
||||
.upsert({
|
||||
where: { clientId: 'appshell-crm' },
|
||||
create: { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' },
|
||||
update: { clientType: 'APPSHELL', attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
|
||||
import { ContextAttestationGuard } from './context-attestation.guard';
|
||||
import {
|
||||
ContextAttestationVerifier,
|
||||
signDevAttestation,
|
||||
type ClientRegistryPort,
|
||||
type NonceStorePort,
|
||||
} from './context-attestation';
|
||||
import type { SessionVerifier } from './session.verifier';
|
||||
|
||||
const SECRET = 'appshell-dev-signing-key';
|
||||
|
||||
// SessionVerifier stand-in: the token always resolves to app "crm-web".
|
||||
const fakeSession = {
|
||||
verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }),
|
||||
} as unknown as SessionVerifier;
|
||||
|
||||
const registryFor = (): ClientRegistryPort => ({
|
||||
find: async (id) =>
|
||||
id === 'appshell-crm'
|
||||
? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' }
|
||||
: null,
|
||||
});
|
||||
const freshNonces = (): NonceStorePort => {
|
||||
const seen = new Set<string>();
|
||||
return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) };
|
||||
};
|
||||
|
||||
function makeGuard(): ContextAttestationGuard {
|
||||
const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' });
|
||||
return new ContextAttestationGuard(fakeSession, verifier);
|
||||
}
|
||||
|
||||
// A guard whose actor token resolves to `tokenApp` — used to test attestation↔token app binding.
|
||||
function makeGuardForApp(tokenApp: string): ContextAttestationGuard {
|
||||
const session = {
|
||||
verify: () => ({ userId: 'agent_1', appId: tokenApp, orgId: 'org', tenantId: 'tnt', displayName: 'A' }),
|
||||
} as unknown as SessionVerifier;
|
||||
const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' });
|
||||
return new ContextAttestationGuard(session, verifier);
|
||||
}
|
||||
|
||||
function ctxWith(headers: Record<string, string>): ExecutionContext {
|
||||
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function att(over: Record<string, unknown> = {}, secret = SECRET): string {
|
||||
return signDevAttestation(
|
||||
{ issuer: 'appshell.crm', audience: 'iios-core', clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over },
|
||||
secret,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ContextAttestationGuard (three-proof gate on the request path)', () => {
|
||||
afterEach(() => { delete process.env.IIOS_REQUIRE_ATTESTATION; });
|
||||
|
||||
it('allows a request with no attestation when NOT required (dev / zero-trust)', async () => {
|
||||
expect(await makeGuard().canActivate(ctxWith({}))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a request with no attestation when IIOS_REQUIRE_ATTESTATION=1', async () => {
|
||||
process.env.IIOS_REQUIRE_ATTESTATION = '1';
|
||||
await expect(makeGuard().canActivate(ctxWith({}))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('allows a valid attestation bound to the token app', async () => {
|
||||
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att() };
|
||||
expect(await makeGuard().canActivate(ctxWith(headers))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a forged attestation (wrong signing key) — stolen-context defense', async () => {
|
||||
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_forge' }, 'attacker-key') };
|
||||
await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects a replayed attestation (same nonce twice)', async () => {
|
||||
const guard = makeGuard();
|
||||
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_replay' }) };
|
||||
expect(await guard.canActivate(ctxWith(headers))).toBe(true);
|
||||
await expect(guard.canActivate(ctxWith({ ...headers }))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
// ── doc rejection matrix: "valid token + wrong client/context → reject" ──
|
||||
it('rejects a valid attestation whose app binding != the actor token app (stolen context reused by another app)', async () => {
|
||||
// Attestation is validly signed for app crm-web, but the presented actor token is for a different app.
|
||||
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_appmix' }) };
|
||||
await expect(makeGuardForApp('other-app').canActivate(ctxWith(headers))).rejects.toThrow(/APP_MISMATCH/);
|
||||
});
|
||||
|
||||
// ── doc rejection matrix: "valid token + wrong audience → reject before business logic" ──
|
||||
it('rejects an attestation minted for another service (audience != iios-core) replayed at IIOS', async () => {
|
||||
const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_aud', audience: 'some-other-service' }) };
|
||||
await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toThrow(/WRONG_AUDIENCE/);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { type CanActivate, type ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { SessionVerifier } from './session.verifier';
|
||||
import { ContextAttestationVerifier } from './context-attestation';
|
||||
|
||||
/**
|
||||
* The July 12 three-proof gate on the request path. Proof #1 (a valid actor token) is done by
|
||||
* the controllers; proof #2 (workload/mTLS) is the mesh's job in prod. This guard adds proof #3:
|
||||
* a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.).
|
||||
*
|
||||
* Behaviour (safe rollout):
|
||||
* • attestation header present → verify it; the attestation's app_id must match the actor
|
||||
* token's app_id. Any failure → 403.
|
||||
* • attestation header absent → allowed ONLY when not required (dev / a zero-trust standalone
|
||||
* caller that IIOS checks fully itself). With `IIOS_REQUIRE_ATTESTATION=1`, absence is a 403.
|
||||
*
|
||||
* The requirement is read per-request so it can be toggled without restarts and stays OFF by
|
||||
* default — existing callers that don't send an attestation keep working until the rollout flips.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContextAttestationGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly session: SessionVerifier,
|
||||
private readonly attest: ContextAttestationVerifier,
|
||||
) {}
|
||||
|
||||
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||
const required = process.env.IIOS_REQUIRE_ATTESTATION === '1';
|
||||
const req = ctx.switchToHttp().getRequest<Request>();
|
||||
const raw = req.headers['x-context-attestation'];
|
||||
const attestation = Array.isArray(raw) ? raw[0] : raw;
|
||||
|
||||
if (!attestation) {
|
||||
if (required) throw new ForbiddenException('context attestation required');
|
||||
return true; // dev / zero-trust: the actor token is still enforced downstream
|
||||
}
|
||||
|
||||
// Bind the attestation to the token's app_id, so a stolen attestation for another app fails.
|
||||
const auth = req.headers['authorization'] ?? '';
|
||||
const token = String(auth).replace(/^Bearer\s+/i, '');
|
||||
let appId: string;
|
||||
try {
|
||||
appId = this.session.verify(token).appId ?? '';
|
||||
} catch {
|
||||
throw new ForbiddenException('invalid actor token');
|
||||
}
|
||||
|
||||
const result = await this.attest.verify(attestation, appId);
|
||||
if (!result.ok) throw new ForbiddenException(`context attestation rejected: ${result.reason}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
ContextAttestationVerifier,
|
||||
signDevAttestation,
|
||||
signDevAttestationES256,
|
||||
type ClientRegistryEntry,
|
||||
type ClientRegistryPort,
|
||||
type NonceStorePort,
|
||||
type PublicKeyResolverPort,
|
||||
} from './context-attestation';
|
||||
import { PrismaNonceStore } from './attestation-stores';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const APPSHELL_SECRET = 'appshell-dev-signing-key';
|
||||
const AUD = 'iios-core';
|
||||
const NOW = new Date('2026-07-13T12:00:00Z');
|
||||
|
||||
// ─── in-memory fakes (fast, deterministic — no DB needed) ─────────
|
||||
function fakeRegistry(over: Partial<ClientRegistryEntry> = {}): ClientRegistryPort {
|
||||
const entry: ClientRegistryEntry = {
|
||||
clientId: 'appshell-crm',
|
||||
clientType: 'SUPPORT_BFF',
|
||||
allowedAppIds: ['crm-web'],
|
||||
attestSecret: APPSHELL_SECRET,
|
||||
status: 'ACTIVE',
|
||||
...over,
|
||||
};
|
||||
return { find: async (id) => (id === entry.clientId ? entry : null) };
|
||||
}
|
||||
|
||||
function fakeNonces(): NonceStorePort {
|
||||
const seen = new Set<string>();
|
||||
return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) };
|
||||
}
|
||||
|
||||
const verifier = (reg: ClientRegistryPort = fakeRegistry(), nonces: NonceStorePort = fakeNonces()) =>
|
||||
new ContextAttestationVerifier(reg, nonces, { audience: AUD });
|
||||
|
||||
/** A well-formed AppShell attestation, overridable per test. */
|
||||
function attest(over: Record<string, unknown> = {}, secret = APPSHELL_SECRET, at = NOW): string {
|
||||
return signDevAttestation(
|
||||
{ issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: 'n_default', ...over },
|
||||
secret,
|
||||
at,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ContextAttestationVerifier (July 12 trust proof)', () => {
|
||||
it('accepts a valid attestation from a registered client for the matching app', async () => {
|
||||
const res = await verifier().verify(attest({ nonce: 'n_ok' }), 'crm-web', NOW);
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm');
|
||||
});
|
||||
|
||||
// ── the five rejection cases the CEO named ──
|
||||
it('rejects an unknown / unregistered client (wrong client)', async () => {
|
||||
const res = await verifier().verify(attest({ clientId: 'hacker-app', nonce: 'n1' }), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'UNKNOWN_CLIENT' });
|
||||
});
|
||||
|
||||
it('rejects the wrong audience (token minted for another service)', async () => {
|
||||
const res = await verifier().verify(attest({ audience: 'some-other-service', nonce: 'n2' }), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' });
|
||||
});
|
||||
|
||||
it('rejects an expired attestation', async () => {
|
||||
// signed 10 min ago with a 5 min TTL → already expired at NOW
|
||||
const stale = attest({ nonce: 'n3', ttlSeconds: 300 }, APPSHELL_SECRET, new Date(NOW.getTime() - 600_000));
|
||||
const res = await verifier().verify(stale, 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'EXPIRED' });
|
||||
});
|
||||
|
||||
it('rejects a replayed nonce (same attestation used twice)', async () => {
|
||||
const v = verifier();
|
||||
const token = attest({ nonce: 'n_replay' });
|
||||
const first = await v.verify(token, 'crm-web', NOW);
|
||||
const second = await v.verify(token, 'crm-web', NOW);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second).toMatchObject({ ok: false, reason: 'REPLAY' });
|
||||
});
|
||||
|
||||
it('rejects app mismatch: token app != attestation app', async () => {
|
||||
const res = await verifier().verify(attest({ appId: 'crm-web', nonce: 'n4' }), 'some-other-app', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
|
||||
});
|
||||
|
||||
// ── extra hardening ──
|
||||
it('rejects a forged signature (signed with the wrong key)', async () => {
|
||||
const res = await verifier().verify(attest({ nonce: 'n5' }, 'attacker-key'), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' });
|
||||
});
|
||||
|
||||
it('rejects a disabled client', async () => {
|
||||
const res = await verifier(fakeRegistry({ status: 'DISABLED' })).verify(attest({ nonce: 'n6' }), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'CLIENT_DISABLED' });
|
||||
});
|
||||
|
||||
it('rejects an app the client is not allowed to attest for', async () => {
|
||||
const reg = fakeRegistry({ allowedAppIds: ['other-app'] });
|
||||
const res = await verifier(reg).verify(attest({ appId: 'crm-web', nonce: 'n7' }), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── prod asymmetric path: ES256 verified via the client's JWKS ───
|
||||
describe('ContextAttestationVerifier — JWKS / ES256 (production signing path)', () => {
|
||||
const JWKS = 'https://appshell.example/.well-known/jwks.json';
|
||||
const KID = 'appshell-key-1';
|
||||
|
||||
// A real EC P-256 keypair (what AppShell would hold; only the public half is published as JWKS).
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
|
||||
const pubPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
|
||||
const { privateKey: otherPriv } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const otherPrivPem = otherPriv.export({ type: 'pkcs8', format: 'pem' }) as string;
|
||||
|
||||
// Registry entry that verifies via JWKS (no shared secret) + a resolver that maps KID → pubkey.
|
||||
const jwksRegistry: ClientRegistryPort = {
|
||||
find: async (id) =>
|
||||
id === 'appshell-crm'
|
||||
? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], jwksUri: JWKS, status: 'ACTIVE' }
|
||||
: null,
|
||||
};
|
||||
const resolver: PublicKeyResolverPort = { resolve: async (_uri, kid) => (kid === KID ? pubPem : null) };
|
||||
const v = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD }, resolver);
|
||||
// No 4th arg → no key resolver wired at all (distinct from passing undefined, which hits the default).
|
||||
const vNoResolver = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD });
|
||||
|
||||
function es256(over: Record<string, unknown> = {}, priv = privPem, kid = KID): string {
|
||||
return signDevAttestationES256(
|
||||
{ issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over },
|
||||
priv,
|
||||
kid,
|
||||
NOW,
|
||||
);
|
||||
}
|
||||
|
||||
it('accepts an ES256 attestation whose signature verifies against the JWKS', async () => {
|
||||
const res = await v().verify(es256({ nonce: 'es_ok' }), 'crm-web', NOW);
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm');
|
||||
});
|
||||
|
||||
it('rejects an ES256 attestation signed with a DIFFERENT private key (forged)', async () => {
|
||||
const res = await v().verify(es256({ nonce: 'es_forged' }, otherPrivPem), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' });
|
||||
});
|
||||
|
||||
it('rejects when the JWT header carries an unknown kid (no matching JWKS key)', async () => {
|
||||
const res = await v().verify(es256({ nonce: 'es_kid' }, privPem, 'rotated-away-kid'), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' });
|
||||
});
|
||||
|
||||
it('rejects when a JWKS client is configured but no key resolver is wired', async () => {
|
||||
const res = await vNoResolver().verify(es256({ nonce: 'es_nores' }), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' });
|
||||
});
|
||||
|
||||
it('still enforces audience / app / replay on the ES256 path', async () => {
|
||||
const vv = v();
|
||||
expect(await vv.verify(es256({ nonce: 'es_aud', audience: 'other' }), 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' });
|
||||
expect(await vv.verify(es256({ nonce: 'es_app' }), 'some-other-app', NOW)).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
|
||||
const tok = es256({ nonce: 'es_replay' });
|
||||
expect((await vv.verify(tok, 'crm-web', NOW)).ok).toBe(true);
|
||||
expect(await vv.verify(tok, 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'REPLAY' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DB-level atomicity of the nonce ledger ───────────────────────
|
||||
describe('PrismaNonceStore (atomic replay defense)', () => {
|
||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
const store = new PrismaNonceStore(prisma as unknown as PrismaService);
|
||||
let dbUp = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
try { await prisma.$connect(); dbUp = true; } catch { dbUp = false; }
|
||||
});
|
||||
afterAll(async () => { if (dbUp) await prisma.$disconnect(); });
|
||||
|
||||
it('reserves a nonce once; a second reserve of the same nonce is rejected', async (ctx) => {
|
||||
if (!dbUp) return ctx.skip(); // DB unreachable in this environment — the in-memory REPLAY test covers the logic
|
||||
const nonce = `n_db_${NOW.getTime()}_${Math.floor(Math.random() * 1e9)}`;
|
||||
const expiresAt = new Date(NOW.getTime() + 300_000);
|
||||
const first = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt });
|
||||
const second = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt });
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(false);
|
||||
await prisma.iiosAttestationNonce.delete({ where: { nonce } }).catch(() => undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* The July 12 "third proof". A cryptographically valid actor token is NOT enough — a hacked
|
||||
* app can replay a stolen one. Before IIOS processes a privileged request it must also verify
|
||||
* a CONTEXT ATTESTATION: a short-lived, single-use, signed statement from an authorized parent
|
||||
* (AppShell for CRM, the adapter registry for channels, Calendar for calendar) that says
|
||||
* "this request, this client, this app, this context is genuinely mine."
|
||||
*
|
||||
* This module is the verifier + its two ports (client registry, nonce ledger) + a dev signer.
|
||||
* It is intentionally decoupled from the request path so it can be unit-tested in isolation and
|
||||
* wired into the messaging guard as a separate, gated step.
|
||||
*/
|
||||
|
||||
/** The subset of AppShell's context-attestation envelope that IIOS verifies. */
|
||||
export interface ContextAttestation {
|
||||
attestationId?: string;
|
||||
issuer: string;
|
||||
issuerType?: string; // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
|
||||
audience: string; // must equal the configured IIOS audience
|
||||
clientId: string; // must be a registered, ACTIVE client
|
||||
appId: string; // must match the actor token's app_id AND be allowed for this client
|
||||
scope?: { orgId?: string; appId?: string; tenantId?: string; buId?: string };
|
||||
nonce: string; // single-use
|
||||
iat?: number;
|
||||
exp?: number; // short-lived
|
||||
}
|
||||
|
||||
/** A registered caller allowed to attest context on behalf of one or more apps. */
|
||||
export interface ClientRegistryEntry {
|
||||
clientId: string;
|
||||
clientType: string;
|
||||
allowedAppIds: string[];
|
||||
attestSecret?: string; // dev: shared HS256 signing key (AppShell's key stand-in)
|
||||
jwksUri?: string; // prod: verify the attestation signature asymmetrically (ES256) via this JWKS
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
}
|
||||
|
||||
export interface ClientRegistryPort {
|
||||
find(clientId: string): Promise<ClientRegistryEntry | null>;
|
||||
}
|
||||
|
||||
export interface NonceStorePort {
|
||||
/** Atomically reserve a nonce. Returns true if fresh, false if it was already seen (replay). */
|
||||
reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the PUBLIC key for a JWKS uri + key id, for the prod asymmetric (ES256) path.
|
||||
* A port so the verifier stays network-free and unit-testable — the real impl fetches +
|
||||
* caches the client's JWKS; tests inject a fake that returns a known key.
|
||||
*/
|
||||
export interface PublicKeyResolverPort {
|
||||
/** Return the signing key (PEM) for `kid` at `jwksUri`, or null if it can't be resolved. */
|
||||
resolve(jwksUri: string, kid: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
export type AttestationReason =
|
||||
| 'MALFORMED'
|
||||
| 'UNKNOWN_CLIENT'
|
||||
| 'CLIENT_DISABLED'
|
||||
| 'NO_KEY'
|
||||
| 'BAD_SIGNATURE'
|
||||
| 'WRONG_AUDIENCE'
|
||||
| 'APP_MISMATCH'
|
||||
| 'EXPIRED'
|
||||
| 'REPLAY';
|
||||
|
||||
export type AttestationResult =
|
||||
| { ok: true; attestation: ContextAttestation }
|
||||
| { ok: false; reason: AttestationReason; detail: string };
|
||||
|
||||
export interface AttestationConfig {
|
||||
/** The audience IIOS requires on attestations addressed to it (e.g. 'iios-core'). */
|
||||
audience: string;
|
||||
}
|
||||
|
||||
const deny = (reason: AttestationReason, detail: string): AttestationResult => ({ ok: false, reason, detail });
|
||||
|
||||
export class ContextAttestationVerifier {
|
||||
constructor(
|
||||
private readonly registry: ClientRegistryPort,
|
||||
private readonly nonces: NonceStorePort,
|
||||
private readonly config: AttestationConfig,
|
||||
/** Optional — required only for clients that verify via a JWKS (prod ES256). */
|
||||
private readonly keys?: PublicKeyResolverPort,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Verify an attestation against an already-verified actor token. Fail-closed: any doubt → deny.
|
||||
* @param attestationJwt the signed attestation the parent issued
|
||||
* @param tokenAppId the `app_id` from the verified actor token (Level-1 proof)
|
||||
* @param now current time — injected so tests are deterministic
|
||||
*/
|
||||
async verify(attestationJwt: string, tokenAppId: string, now: Date = new Date()): Promise<AttestationResult> {
|
||||
// 1. Decode WITHOUT verifying, only to learn who claims to have signed it.
|
||||
const claimed = jwt.decode(attestationJwt, { json: true }) as (ContextAttestation & jwt.JwtPayload) | null;
|
||||
if (!claimed || typeof claimed !== 'object' || !claimed.clientId || !claimed.nonce) {
|
||||
return deny('MALFORMED', 'attestation missing clientId/nonce');
|
||||
}
|
||||
|
||||
// 2. The claimed client must be registered and ACTIVE.
|
||||
const client = await this.registry.find(claimed.clientId);
|
||||
if (!client) return deny('UNKNOWN_CLIENT', `client "${claimed.clientId}" is not registered`);
|
||||
if (client.status !== 'ACTIVE') return deny('CLIENT_DISABLED', `client "${claimed.clientId}" is ${client.status}`);
|
||||
|
||||
// 3. Verify the signature with the client's key. A client verifies EITHER asymmetrically via
|
||||
// its published JWKS (prod: AppShell signs ES256 with a private key) OR with a shared HS256
|
||||
// secret (dev stand-in). ignoreExpiration so we can return a precise EXPIRED (step 6) rather
|
||||
// than BAD_SIGNATURE.
|
||||
const key = await this.resolveVerificationKey(attestationJwt, client);
|
||||
if (!key.ok) return deny(key.reason, key.detail);
|
||||
let att: ContextAttestation & jwt.JwtPayload;
|
||||
try {
|
||||
att = jwt.verify(attestationJwt, key.pem, { algorithms: [key.alg], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload;
|
||||
} catch (e) {
|
||||
return deny('BAD_SIGNATURE', (e as Error).message);
|
||||
}
|
||||
|
||||
// 4. Audience must be IIOS — a token for another service can't be replayed at IIOS.
|
||||
if (att.audience !== this.config.audience) return deny('WRONG_AUDIENCE', `audience "${att.audience}" != "${this.config.audience}"`);
|
||||
|
||||
// 5. app binding: the attestation's app must match the token's app AND be allowed for this client.
|
||||
if (att.appId !== tokenAppId) return deny('APP_MISMATCH', `attestation app "${att.appId}" != token app "${tokenAppId}"`);
|
||||
if (!client.allowedAppIds.includes(att.appId)) return deny('APP_MISMATCH', `client "${client.clientId}" not allowed for app "${att.appId}"`);
|
||||
|
||||
// 6. Freshness — attestations are short-lived.
|
||||
const expMs = (att.exp ?? 0) * 1000;
|
||||
if (!expMs || expMs <= now.getTime()) return deny('EXPIRED', 'attestation expired or missing exp');
|
||||
|
||||
// 7. Single-use — reserve the nonce. A replayed (stolen) attestation loses here. Fail-closed.
|
||||
const fresh = await this.nonces.reserve({ nonce: att.nonce, clientId: client.clientId, expiresAt: new Date(expMs) });
|
||||
if (!fresh) return deny('REPLAY', `nonce "${att.nonce}" already used`);
|
||||
|
||||
return { ok: true, attestation: att };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the algorithm + verification key for a client. JWKS (ES256) wins when configured:
|
||||
* read the `kid` from the JWT header and resolve the public key from the client's JWKS.
|
||||
* Otherwise fall back to the shared HS256 dev secret. Either way returns a precise NO_KEY
|
||||
* reason when no usable key can be obtained (fail-closed).
|
||||
*/
|
||||
private async resolveVerificationKey(
|
||||
attestationJwt: string,
|
||||
client: ClientRegistryEntry,
|
||||
): Promise<{ ok: true; pem: string; alg: 'ES256' | 'HS256' } | { ok: false; reason: AttestationReason; detail: string }> {
|
||||
if (client.jwksUri) {
|
||||
if (!this.keys) return { ok: false, reason: 'NO_KEY', detail: `client "${client.clientId}" uses JWKS but no key resolver is wired` };
|
||||
const decoded = jwt.decode(attestationJwt, { complete: true });
|
||||
const kid = decoded && typeof decoded === 'object' ? (decoded.header?.kid as string | undefined) : undefined;
|
||||
if (!kid) return { ok: false, reason: 'NO_KEY', detail: 'attestation header missing kid (required for JWKS)' };
|
||||
const pem = await this.keys.resolve(client.jwksUri, kid).catch(() => null);
|
||||
if (!pem) return { ok: false, reason: 'NO_KEY', detail: `no JWKS key for kid "${kid}"` };
|
||||
return { ok: true, pem, alg: 'ES256' };
|
||||
}
|
||||
if (client.attestSecret) return { ok: true, pem: client.attestSecret, alg: 'HS256' };
|
||||
return { ok: false, reason: 'NO_KEY', detail: `no verification key configured for "${client.clientId}"` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV/TEST helper: mint a signed attestation the way AppShell would (HS256 stand-in for its
|
||||
* signing key). Production AppShell signs asymmetrically and IIOS verifies via the client's JWKS.
|
||||
*/
|
||||
export function signDevAttestation(
|
||||
claims: Omit<ContextAttestation, 'iat' | 'exp'> & { ttlSeconds?: number },
|
||||
secret: string,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const { ttlSeconds = 300, ...rest } = claims;
|
||||
const iat = Math.floor(now.getTime() / 1000);
|
||||
return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, secret, { algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV/TEST helper: mint an attestation signed ASYMMETRICALLY (ES256), the way production AppShell
|
||||
* does. `privateKeyPem` is a PKCS#8 EC private key; `kid` is stamped into the JWT header so the
|
||||
* verifier can pick the matching public key from the client's JWKS.
|
||||
*/
|
||||
export function signDevAttestationES256(
|
||||
claims: Omit<ContextAttestation, 'iat' | 'exp'> & { ttlSeconds?: number },
|
||||
privateKeyPem: string,
|
||||
kid: string,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const { ttlSeconds = 300, ...rest } = claims;
|
||||
const iat = Math.floor(now.getTime() / 1000);
|
||||
return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, privateKeyPem, { algorithm: 'ES256', keyid: kid });
|
||||
}
|
||||
@@ -135,7 +135,6 @@ export class SessionVerifier implements OnModuleInit {
|
||||
orgId: entry.orgId,
|
||||
tenantId: undefined,
|
||||
displayName: meta.full_name ?? meta.name ?? email ?? userId,
|
||||
audience: typeof payload.aud === 'string' ? payload.aud : entry.audience,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,7 +159,6 @@ export class SessionVerifier implements OnModuleInit {
|
||||
orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`,
|
||||
tenantId: payload.tenantId ? String(payload.tenantId) : undefined,
|
||||
displayName: payload.name ? String(payload.name) : undefined,
|
||||
audience: typeof payload.aud === 'string' ? payload.aud : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { IiosTicketState } from '@prisma/client';
|
||||
import { SupportService } from './support.service';
|
||||
import { AssignmentService } from './assignment.service';
|
||||
import { SessionVerifier } from '../platform/session.verifier';
|
||||
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
|
||||
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||
import {
|
||||
AssignTicketDto,
|
||||
AvailabilityDto,
|
||||
CallbackDto,
|
||||
CreateQueueDto,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
} from './support.dto';
|
||||
|
||||
@Controller('v1/support')
|
||||
@UseGuards(ContextAttestationGuard)
|
||||
export class SupportController {
|
||||
constructor(
|
||||
private readonly support: SupportService,
|
||||
@@ -35,7 +32,7 @@ export class SupportController {
|
||||
|
||||
@Post('escalate')
|
||||
async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) {
|
||||
return this.support.escalate(body.threadId, this.principal(auth), body.subject, body.metadata);
|
||||
return this.support.escalate(body.threadId, this.principal(auth), body.subject);
|
||||
}
|
||||
|
||||
@Get('tickets')
|
||||
@@ -48,12 +45,6 @@ export class SupportController {
|
||||
return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason);
|
||||
}
|
||||
|
||||
/** Manually assign a ticket to a specific actor (by userId) — generic assignment override. */
|
||||
@Post('tickets/:id/assignee')
|
||||
async assign(@Param('id') id: string, @Body() body: AssignTicketDto, @Headers('authorization') auth?: string) {
|
||||
return this.support.assignTo(id, this.principal(auth), body.userId);
|
||||
}
|
||||
|
||||
@Post('callbacks')
|
||||
async callback(
|
||||
@Body() body: CallbackDto,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const;
|
||||
const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const;
|
||||
@@ -7,18 +7,11 @@ export class CreateTicketDto {
|
||||
@IsString() subject!: string;
|
||||
@IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number];
|
||||
@IsOptional() @IsString() threadId?: string;
|
||||
/** Opaque, app-supplied attribute bag — stored on the ticket, never interpreted by the kernel. */
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class EscalateDto {
|
||||
@IsString() threadId!: string;
|
||||
@IsOptional() @IsString() subject?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class AssignTicketDto {
|
||||
@IsString() userId!: string;
|
||||
}
|
||||
|
||||
export class PatchTicketDto {
|
||||
|
||||
@@ -35,7 +35,7 @@ export class SupportService {
|
||||
|
||||
async createTicket(
|
||||
principal: MessagePrincipal,
|
||||
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string; metadata?: Record<string, unknown> },
|
||||
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string },
|
||||
idempotencyKey?: string,
|
||||
) {
|
||||
await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal });
|
||||
@@ -63,8 +63,6 @@ export class SupportService {
|
||||
subject: input.subject,
|
||||
priority: input.priority ?? 'P3',
|
||||
traceId,
|
||||
// Opaque, app-supplied attribute bag (e.g. a caller's external ref) — stored, never interpreted.
|
||||
metadata: input.metadata ? (input.metadata as Prisma.InputJsonValue) : undefined,
|
||||
},
|
||||
});
|
||||
await tx.iiosTicketStateHistory.create({
|
||||
@@ -101,17 +99,11 @@ export class SupportService {
|
||||
return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body);
|
||||
}
|
||||
|
||||
/** Escalate a chat thread to support: create a ticket linked to that thread. `metadata` is opaque. */
|
||||
async escalate(threadId: string, principal: MessagePrincipal, subject?: string, metadata?: Record<string, unknown>) {
|
||||
/** Escalate a chat thread to support: create a ticket linked to that thread. */
|
||||
async escalate(threadId: string, principal: MessagePrincipal, subject?: string) {
|
||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||
if (!thread) throw new NotFoundException('thread not found');
|
||||
// Inherit the thread's opaque bag so the ticket carries the same app context, unless overridden.
|
||||
const inherited = { ...((thread.metadata as Record<string, unknown> | null) ?? {}), ...(metadata ?? {}) };
|
||||
return this.createTicket(principal, {
|
||||
subject: subject ?? thread.subject ?? 'Support request',
|
||||
threadId,
|
||||
metadata: Object.keys(inherited).length > 0 ? inherited : undefined,
|
||||
});
|
||||
return this.createTicket(principal, { subject: subject ?? thread.subject ?? 'Support request', threadId });
|
||||
}
|
||||
|
||||
async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise<void> {
|
||||
@@ -128,53 +120,6 @@ export class SupportService {
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually assign a ticket to a specific actor (by userId) — a generic override of the
|
||||
* event-driven auto-assignment. The target is resolved-or-created as an actor in the ticket's
|
||||
* scope (so you can assign someone who hasn't logged in yet) and joined to the linked thread(s)
|
||||
* so they can reply over the socket. Fail-closed via policy `iios.support.ticket.assign`.
|
||||
*/
|
||||
async assignTo(ticketId: string, principal: MessagePrincipal, targetUserId: string) {
|
||||
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId }, include: { threadLinks: true } });
|
||||
if (!ticket) throw new NotFoundException('ticket not found');
|
||||
await decideOrThrow(this.ports, { action: 'iios.support.ticket.assign', ticketId, scopeId: ticket.scopeId, targetUserId });
|
||||
|
||||
const target = await this.actors.resolveActor(ticket.scopeId, {
|
||||
userId: targetUserId,
|
||||
appId: principal.appId,
|
||||
orgId: principal.orgId,
|
||||
tenantId: principal.tenantId,
|
||||
displayName: targetUserId,
|
||||
});
|
||||
const toState: IiosTicketState = ticket.state === 'NEW' ? 'OPEN' : ticket.state;
|
||||
const event: CloudEvent = {
|
||||
specversion: '1.0',
|
||||
id: `evt_assign_${ticketId}_${target.id}`,
|
||||
type: IIOS_EVENTS.ticketStateChanged,
|
||||
source: `iios/support/${ticket.scopeId}`,
|
||||
subject: `ticket/${ticketId}`,
|
||||
time: new Date().toISOString(),
|
||||
datacontenttype: 'application/json',
|
||||
insignia: { scopeSnapshotId: ticket.scopeId, correlationId: ticket.traceId ?? undefined, idempotencyKey: `assign:${ticketId}:${target.id}`, dataClass: 'internal' },
|
||||
data: { ticketId, fromState: ticket.state, toState, assignedActorId: target.id },
|
||||
};
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.iiosTicket.update({ where: { id: ticketId }, data: { assignedActorId: target.id, state: toState } }),
|
||||
this.prisma.iiosTicketStateHistory.create({ data: { ticketId, fromState: ticket.state, toState, actorId: target.id, reasonCode: 'assigned' } }),
|
||||
this.prisma.iiosOutboxEvent.create({
|
||||
data: {
|
||||
aggregateType: 'ticket',
|
||||
aggregateId: ticketId,
|
||||
eventType: IIOS_EVENTS.ticketStateChanged,
|
||||
cloudEvent: event as unknown as Prisma.InputJsonValue,
|
||||
partitionKey: `${ticket.scopeId}:${ticketId}`,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
for (const link of ticket.threadLinks) await this.actors.ensureParticipant(link.threadId, target.id);
|
||||
return this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
|
||||
}
|
||||
|
||||
async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) {
|
||||
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
|
||||
if (!ticket) throw new NotFoundException('ticket not found');
|
||||
|
||||
@@ -57,19 +57,6 @@ describe('SupportService (P4)', () => {
|
||||
expect(links[0]?.threadId).toBe(threadId);
|
||||
});
|
||||
|
||||
it('stores an opaque metadata bag on the ticket; assignTo assigns to a target actor and opens it', async () => {
|
||||
const t = await support().createTicket(cust, { subject: 'help', metadata: { crmCustomerId: 'cust_1', source: 'crm-support' } });
|
||||
expect(t.metadata).toMatchObject({ crmCustomerId: 'cust_1', source: 'crm-support' });
|
||||
expect(t.state).toBe('NEW');
|
||||
|
||||
const assigned = await support().assignTo(t.id, cust, 'agent_1');
|
||||
expect(assigned?.state).toBe('OPEN');
|
||||
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'agent_1' } });
|
||||
const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } });
|
||||
expect(assigned?.assignedActorId).toBe(actor.id);
|
||||
expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id, reasonCode: 'assigned' } })).toBe(1);
|
||||
});
|
||||
|
||||
it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => {
|
||||
const t = await support().createTicket(cust, { subject: 's' });
|
||||
await support().transition(t.id, cust, 'OPEN');
|
||||
|
||||
@@ -9,16 +9,13 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ThreadsService } from './threads.service';
|
||||
import { MessageService } from '../messaging/message.service';
|
||||
import { SessionVerifier } from '../platform/session.verifier';
|
||||
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
|
||||
import { SendMessageDto } from './send-message.dto';
|
||||
|
||||
@Controller('v1/threads')
|
||||
@UseGuards(ContextAttestationGuard)
|
||||
export class ThreadsController {
|
||||
constructor(
|
||||
private readonly threads: ThreadsService,
|
||||
@@ -26,14 +23,10 @@ export class ThreadsController {
|
||||
private readonly session: SessionVerifier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generic "my threads" — every thread the caller participates in (last message + unread).
|
||||
* `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key).
|
||||
*/
|
||||
/** Generic "my threads" — every thread the caller participates in (last message + unread). */
|
||||
@Get()
|
||||
async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
|
||||
const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
|
||||
return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
|
||||
async listThreads(@Headers('authorization') auth?: string) {
|
||||
return this.messages.listThreads(this.principal(auth));
|
||||
}
|
||||
|
||||
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
|
||||
@@ -42,11 +35,11 @@ export class ThreadsController {
|
||||
return this.messages.listMyAnnotated(this.principal(auth), type);
|
||||
}
|
||||
|
||||
/** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
|
||||
/** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */
|
||||
@Post()
|
||||
@HttpCode(201)
|
||||
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }, @Headers('authorization') auth?: string) {
|
||||
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata });
|
||||
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) {
|
||||
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject });
|
||||
}
|
||||
|
||||
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
{
|
||||
"name": "@insignia/iios-support-web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit"
|
||||
@@ -30,8 +24,5 @@
|
||||
"react": "^19.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1824
-74
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user