10 Commits

Author SHA1 Message Date
maaz519 3298401772 feat: enforce realtime delegation audience on the message socket
The socket used to accept any valid token, so a full REST/actor token could open the
live stream. Now it can require a narrowly-scoped delegated token (aud=iios-message),
so a leaked socket token can't drive privileged REST, and vice-versa.

- MessagePrincipal gains `audience`, surfaced from both verify paths (OIDC aud, and the
  app-token `aud` claim).
- message.gateway: when IIOS_REALTIME_AUDIENCE is set, handleConnection accepts only a
  token whose aud matches (opt-in, like IIOS_REQUIRE_ATTESTATION; unset = no change).
- spec: verifier surfaces aud; gateway accepts iios-message, rejects iios-core / no-aud
  when enforcing, passes through when off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:56:19 +05:30
maaz519 e39caa3c80 feat: verify context attestations via ES256/JWKS + rename client to appshell-crm
Proof #3 now supports the production asymmetric path, not just the dev shared secret.

- context-attestation.ts: PublicKeyResolverPort seam; verify() picks ES256 (JWKS by
  kid) when the client has a jwksUri, else HS256 (dev). signDevAttestationES256 helper.
- attestation-stores.ts: JwksPublicKeyResolver (jwks-rsa, one cached client per URI,
  refetch on rotation, fail-closed to NO_KEY).
- attestation.module.ts: inject the resolver into the verifier; dev-seed the client as
  clientType APPSHELL (it is the CRM browser-flow parent per the July-12 notes).
- rename the registered client crm-support-widget -> appshell-crm (the name reflects the
  attesting parent, not a widget).
- specs: ES256 (valid via JWKS, wrong key -> BAD_SIGNATURE, unknown kid -> NO_KEY, no
  resolver -> NO_KEY) + two stolen-token gate cases (wrong app binding -> APP_MISMATCH,
  wrong audience -> WRONG_AUDIENCE).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:56:10 +05:30
maaz519 85a78eb21e feat(iios-kernel-client): per-request header factory → 0.1.4
RestConfig.headers now accepts a function, invoked once per request, so a caller can
mint a FRESH context attestation (new single-use nonce) each call. A static header was
replay-rejected on the 2nd request of a multi-call operation. Published 0.1.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:26:33 +05:30
maaz519 3e66c7a5db perf(iios): listThreads fetches last-message-per-thread in one query
Replaced the per-thread findFirst inside Promise.all (N parallel queries) with a
single Postgres DISTINCT ON query. A caller with many threads no longer fans out
and exhausts the connection pool (the conversation.list 500 under accumulated data).
message.spec 16/16 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:28:37 +05:30
maaz519 9e0411bfe6 feat(iios-kernel-client): RestClient custom headers passthrough → 0.1.3
Adds optional RestConfig.headers, merged into every request, so a server-side
caller (be-crm's glue) can carry X-Context-Attestation (the July 12 proof #3)
alongside the bearer token. Backward compatible. Published 0.1.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:03:45 +05:30
maaz519 f2d590b04d feat(iios): wire the three-proof gate into the request path (§1b)
Applies the context-attestation verifier at the request boundary via a guard, so a
stolen/forged/replayed attestation is rejected before IIOS acts — while staying safe
to roll out.

- ContextAttestationGuard: if an X-Context-Attestation header is present, verify it
  (its app_id must match the actor token's app_id) → 403 on any failure; if absent,
  allow only when IIOS_REQUIRE_ATTESTATION != 1 (dev/zero-trust), else 403. The flag is
  read per-request and defaults OFF, so existing callers keep working until AppShell/
  be-crm start forwarding attestations.
- AttestationModule (@Global): provides the verifier + Prisma stores + guard, and
  dev-seeds the crm-support-widget client so locally minted attestations verify.
- Guard applied to ThreadsController + SupportController.
- Tests (5 pass, no DB): not-required-allows, required-rejects, valid, forged, replayed.

Workload/mTLS (proof #2) stays a mesh concern. Next: SDK header passthrough + demote
be-crm IiosClient to forward an attestation, then flip the flag to prove end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:49:40 +05:30
maaz519 dd6a4cd4fa feat(iios): context-attestation verifier — the July 12 stolen-token proof (§1)
A valid actor token is no longer enough. This adds the trust-layer core so IIOS
can require a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.)
before a privileged request — defeating a hacked app replaying a stolen token.

- prisma: IiosClientRegistry (registered clients allowed to attest, per app) +
  IiosAttestationNonce (single-use replay ledger). Migration applied.
- ContextAttestationVerifier + ports (ClientRegistryPort, NonceStorePort) + a dev
  signer. Verifies: registered+active client, signature, aud=iios, app_id matches
  the token AND is allowed for the client, freshness, single-use nonce. Fail-closed.
- Prisma-backed stores; nonce reserve is atomic via the unique PK (P2002 = replay).
- Tests (9 pass): happy path + the five rejection cases the CEO named
  (unknown client, wrong audience, expired, replayed nonce, app-mismatch) +
  forged-signature + disabled-client; a DB atomicity test (skips if engine offline).

Decoupled from the request path on purpose — wiring it into the messaging guard
(the three-proof gate) + demoting be-crm to an attestation forwarder is the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:11:04 +05:30
maaz519 77dd5bac82 fix(iios): use extended query parser so nested metadata filters parse
NestJS 11 runs Express 5, whose default 'simple' query parser ignores nested
params like ?metadata[key]=value — so GET /v1/threads silently dropped the
metadata filter, causing e.g. an app's "reuse existing thread for this customer"
check to match ANY thread. Set the qs-based 'extended' parser.

Verified: filtering by a non-existent metadata value now returns 0 (was returning
all); the be-crm support smoke goes 12/12 (was 10/12 once a prior thread existed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:33:28 +05:30
maaz519 b918a21083 feat(iios): generic opaque metadata + manual ticket assign (glue prereqs)
Generic kernel primitives so app backends (e.g. a CRM support glue) can link
their domain to interactions WITHOUT the kernel learning any app vocabulary:

- thread: accept an opaque `metadata` bag on create (merged with membership),
  echo it in listThreads, and filter listThreads by `?metadata[key]=value`
  (equality on the opaque bag — the kernel never interprets the keys).
- support: accept opaque `metadata` on createTicket/escalate (thread bag
  inherited); add SupportService.assignTo — a policy-gated manual assignment
  of a ticket to a target actor (POST /v1/support/tickets/:id/assignee).
- SDK @insignia/iios-kernel-client 0.1.2: createThread(metadata),
  listThreads({metadata}) filter, createTicket(metadata), escalate(metadata),
  assignTicket; ThreadSummary/Ticket expose the opaque bag.

Generic-safety gate holds: no crm/customer/lead in kernel code (opaque values
only). Tests: +2 (metadata create/filter, ticket metadata + assignTo); 31 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:39:30 +05:30
maaz519 8c70b6d31f feat(iios-kernel-client): reconcile to chat-web surface, publish 0.1.1
Brings the shared SDK up to the fuller client chat-web had vendored, so
frontends consume one source of truth instead of copying it:

- types: Message gains senderId/senderName/attachment/parentInteractionId/
  annotations; add Attachment, AnnotationGroup, AnnotationEvent, ThreadSummary,
  SavedItem, LoginResult; MessageEvents gains `annotation`; SocketLike gains
  connected + optional timeout.
- MessageSocket: onConnected, openThread(opts), richer sendMessage(opts)
  (attachment/mentions/parentInteractionId, back-compatible with contentRef),
  pin/save/react via annotate, focus, reconnect-re-subscribe-all.
- RestClient: login, listUsers, listThreads, createThread, listSaved,
  addParticipant.

Bump 0.1.0 -> 0.1.1. Whole monorepo still builds (Message change is additive
for iios-message-web/iios-support-web/demos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:49:25 +05:30
28 changed files with 1250 additions and 61 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@insignia/iios-kernel-client", "name": "@insignia/iios-kernel-client",
"version": "0.1.0", "version": "0.1.4",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
@@ -8,14 +8,14 @@ export interface MessageSocketConfig {
} }
/** /**
* Framework-agnostic facade over the `/message` Socket.io namespace (ports the * Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io
* support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck. * types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens
* On reconnect it re-opens the current thread so subscriptions resume with no * all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI
* lost messages (the docs' disconnect/reconnect requirement). * that watches multiple conversations keeps receiving live messages after a drop.
*/ */
export class MessageSocket { export class MessageSocket {
private readonly socket: SocketLike; private readonly socket: SocketLike;
private currentThreadId: string | null = null; private readonly joined = new Set<string>();
constructor(config: MessageSocketConfig, socket?: SocketLike) { constructor(config: MessageSocketConfig, socket?: SocketLike) {
this.socket = this.socket =
@@ -26,9 +26,9 @@ export class MessageSocket {
autoConnect: config.autoConnect ?? true, autoConnect: config.autoConnect ?? true,
}) as unknown as SocketLike); }) as unknown as SocketLike);
// Re-open the active thread after a reconnect. // Re-subscribe to every joined thread after a reconnect.
this.socket.on('connect', () => { this.socket.on('connect', () => {
if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId }); for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
}); });
} }
@@ -40,27 +40,70 @@ export class MessageSocket {
this.socket.disconnect(); 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. */ /** Subscribe to a server event; returns an unsubscribe fn. */
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void { on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
const fn = handler as (...args: unknown[]) => void; const fn = handler as (...args: unknown[]) => void;
this.socket.on(event, fn); this.socket.on(event as string, fn);
return () => this.socket.off(event, fn); return () => this.socket.off(event as string, fn);
} }
async openThread(threadId?: string): Promise<OpenThreadResult> { async openThread(
const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult; threadId?: string,
this.currentThreadId = result.threadId; opts?: { membership?: string; creatorRole?: string; subject?: string },
): Promise<OpenThreadResult> {
// Timeout (when the transport supports it) so a server error that never acks
// can't hang the caller forever.
const ack = this.socket.timeout ? this.socket.timeout(8000) : this.socket;
const result = (await ack.emitWithAck('open_thread', { threadId, ...opts })) as OpenThreadResult & { error?: string };
if (result?.error) throw new Error(result.error);
this.joined.add(result.threadId);
return result; return result;
} }
async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise<Message> { async sendMessage(
threadId: string,
content: string,
opts?: {
contentRef?: string;
parentInteractionId?: string;
mentions?: string[];
attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string };
},
): Promise<Message> {
return (await this.socket.emitWithAck('send_message', { return (await this.socket.emitWithAck('send_message', {
threadId, threadId,
content, content,
contentRef: opts?.contentRef, contentRef: opts?.attachment?.contentRef ?? opts?.contentRef,
mimeType: opts?.attachment?.mimeType,
sizeBytes: opts?.attachment?.sizeBytes,
checksumSha256: opts?.attachment?.checksumSha256,
parentInteractionId: opts?.parentInteractionId,
mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel
})) as Message; })) 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 }> { async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> {
return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean }; return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean };
} }
@@ -68,4 +111,9 @@ export class MessageSocket {
typing(threadId: string): void { typing(threadId: string): void {
this.socket.emit('typing', { threadId }); this.socket.emit('typing', { threadId });
} }
/** Tell the server which thread is in the foreground (or null when blurred) — drives presence. */
focus(threadId: string | null): void {
this.socket.emit('focus_thread', { threadId });
}
} }
+74 -5
View File
@@ -1,9 +1,16 @@
import type { IngestInteractionRequest } from '@insignia/iios-contracts'; import type { IngestInteractionRequest } from '@insignia/iios-contracts';
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem } from './types'; import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
export interface RestConfig { export interface RestConfig {
serviceUrl: string; serviceUrl: string;
token?: 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. */ /** REST/polling client for kernel reads and the native-send fallback. */
@@ -15,7 +22,8 @@ export class RestClient {
} }
private headers(extra: Record<string, string> = {}): Record<string, string> { private headers(extra: Record<string, string> = {}): Record<string, string> {
const h: Record<string, string> = { 'content-type': 'application/json', ...extra }; const custom = typeof this.config.headers === 'function' ? this.config.headers() : this.config.headers;
const h: Record<string, string> = { 'content-type': 'application/json', ...custom, ...extra };
if (this.config.token) h.authorization = `Bearer ${this.config.token}`; if (this.config.token) h.authorization = `Bearer ${this.config.token}`;
return h; return h;
} }
@@ -57,12 +65,73 @@ export class RestClient {
return (await r.json()) as InboxItem; 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 ────────────────────────────────────────────────── // ─── support ──────────────────────────────────────────────────
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> { async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record<string, unknown> }): Promise<Ticket> {
return this.post<Ticket>('/v1/support/tickets', body); return this.post<Ticket>('/v1/support/tickets', body);
} }
async escalate(threadId: string, subject?: string): Promise<Ticket> { async escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
return this.post<Ticket>('/v1/support/escalate', { threadId, subject }); return this.post<Ticket>('/v1/support/escalate', { threadId, subject, metadata });
}
/** Manually assign a ticket to a specific user (generic assignment override). */
async assignTicket(id: string, userId: string): Promise<Ticket> {
return this.post<Ticket>(`/v1/support/tickets/${id}/assignee`, { userId });
} }
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> { async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() }); const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
+66
View File
@@ -1,10 +1,30 @@
/** A media/file part attached to a message (contentRef points at object storage). */
export interface Attachment {
contentRef: string;
mimeType: string;
sizeBytes: number;
kind: 'image' | 'video' | 'audio' | 'file';
}
/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */
export interface AnnotationGroup {
type: string;
value: string;
users: string[]; // usernames who applied it
}
/** Wire shapes the kernel emits over socket / returns over REST (align with service). */ /** Wire shapes the kernel emits over socket / returns over REST (align with service). */
export interface Message { export interface Message {
id: string; id: string;
threadId: string; threadId: string;
senderActorId: string; senderActorId: string;
senderId: string; // sender's email/username — reliable "is this mine?" check
senderName: string;
content: string; content: string;
contentRef?: string; contentRef?: string;
attachment?: Attachment;
parentInteractionId?: string;
annotations?: AnnotationGroup[];
traceId?: string; traceId?: string;
createdAt: string; createdAt: string;
} }
@@ -26,10 +46,50 @@ export interface TypingEvent {
userId: string; 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 { export interface MessageEvents {
message: (m: Message) => void; message: (m: Message) => void;
receipt: (e: ReceiptEvent) => void; receipt: (e: ReceiptEvent) => void;
typing: (e: TypingEvent) => 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'; export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
@@ -68,6 +128,8 @@ export interface Ticket {
assignedActorId?: string | null; assignedActorId?: string | null;
createdAt: string; createdAt: string;
updatedAt: 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 }>; threadLinks?: Array<{ threadId: string; relationKind: string }>;
} }
@@ -188,4 +250,8 @@ export interface SocketLike {
emitWithAck(event: string, ...args: unknown[]): Promise<unknown>; emitWithAck(event: string, ...args: unknown[]): Promise<unknown>;
connect(): unknown; connect(): unknown;
disconnect(): 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> };
} }
+10
View File
@@ -49,3 +49,13 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12)
# ── Capability providers (governed egress targets) ─────────────────────────── # ── Capability providers (governed egress targets) ───────────────────────────
# Per-channel provider endpoint the CapabilityBroker calls, e.g.: # Per-channel provider endpoint the CapabilityBroker calls, e.g.:
# IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email # 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
+1
View File
@@ -26,6 +26,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0",
"prisma": "^6.2.1", "prisma": "^6.2.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
@@ -0,0 +1,24 @@
-- Trust plane (July 12): context attestation client registry + nonce replay ledger.
CREATE TABLE "IiosClientRegistry" (
"clientId" TEXT NOT NULL,
"clientType" TEXT NOT NULL,
"ownerService" TEXT,
"allowedAppIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"attestSecret" TEXT,
"jwksUri" TEXT,
"spiffeId" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IiosClientRegistry_pkey" PRIMARY KEY ("clientId")
);
CREATE TABLE "IiosAttestationNonce" (
"nonce" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosAttestationNonce_pkey" PRIMARY KEY ("nonce")
);
CREATE INDEX "IiosAttestationNonce_expiresAt_idx" ON "IiosAttestationNonce"("expiresAt");
@@ -1321,3 +1321,31 @@ model IiosDlqItem {
@@unique([consumerName, sourceId]) @@unique([consumerName, sourceId])
@@index([scopeId, status]) @@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])
}
+2
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { AttestationModule } from './platform/attestation.module';
import { IdentityModule } from './identity/identity.module'; import { IdentityModule } from './identity/identity.module';
import { InteractionsModule } from './interactions/interactions.module'; import { InteractionsModule } from './interactions/interactions.module';
import { IdempotencyModule } from './idempotency/idempotency.module'; import { IdempotencyModule } from './idempotency/idempotency.module';
@@ -27,6 +28,7 @@ import { DevController } from './dev/dev.controller';
imports: [ imports: [
PrismaModule, PrismaModule,
PlatformModule, PlatformModule,
AttestationModule,
IdentityModule, IdentityModule,
InteractionsModule, InteractionsModule,
IdempotencyModule, IdempotencyModule,
@@ -8,6 +8,9 @@ export interface MessagePrincipal {
appId: string; appId: string;
tenantId?: string; tenantId?: string;
displayName?: 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;
} }
/** /**
+4
View File
@@ -9,6 +9,10 @@ import { PolicyDeniedFilter } from './platform/policy-denied.filter';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { rawBody: true }); const app = await NestFactory.create(AppModule, { rawBody: true });
// 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.use(traceMiddleware); // request-scoped trace context + x-trace-id header (P9)
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
@@ -0,0 +1,93 @@
import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest';
import jwt from 'jsonwebtoken';
import type { Socket } from 'socket.io';
import { MessageGateway } from './message.gateway';
import type { MessageService, MessagePrincipal } from './message.service';
import { SessionVerifier } from '../platform/session.verifier';
import type { OutboxBus } from '../outbox/outbox.bus';
import type { PresenceService } from '../notifications/presence.service';
// Realtime delegation: the browser opens the socket with a short-lived token minted for the
// realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY
// that audience — a full REST/actor token (iios-core) or an un-scoped token can't open the stream.
const APP = 'crm-web';
const SECRET = 'dev-crm-secret';
// ─── the real SessionVerifier must expose `aud` so the gateway can enforce it ───
describe('SessionVerifier — app-token audience surfacing', () => {
let v: SessionVerifier;
beforeAll(async () => {
delete process.env.AUTH_ISSUERS;
delete process.env.SUPABASE_URL;
process.env.APP_SECRETS = JSON.stringify({ [APP]: SECRET });
v = new SessionVerifier();
await v.onModuleInit();
});
afterAll(() => { delete process.env.APP_SECRETS; });
it('surfaces the aud claim of a realtime-scoped token', () => {
const tok = jwt.sign({ appId: APP, aud: 'iios-message' }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
expect(v.verify(tok).audience).toBe('iios-message');
});
it('leaves audience undefined for an un-scoped token (a REST/actor token)', () => {
const tok = jwt.sign({ appId: APP }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' });
expect(v.verify(tok).audience).toBeUndefined();
});
});
// ─── the gateway enforces the realtime audience on connect ───
function fakeSocket(): { sock: Socket; disconnected: () => boolean } {
let disconnected = false;
const sock = {
handshake: { auth: { token: 'tok' } },
disconnect: () => { disconnected = true; },
data: undefined as unknown,
} as unknown as Socket;
return { sock, disconnected: () => disconnected };
}
function gatewayReturning(principal: MessagePrincipal): MessageGateway {
const session = { verify: () => principal } as unknown as SessionVerifier;
return new MessageGateway(
undefined as unknown as MessageService,
session,
undefined as unknown as OutboxBus,
undefined as unknown as PresenceService,
);
}
const principal = (audience?: string): MessagePrincipal => ({ userId: 'u', appId: APP, orgId: 'org', audience });
describe('MessageGateway — realtime audience enforcement', () => {
afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; });
it('accepts any valid token when enforcement is OFF (env unset)', () => {
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal(undefined)).handleConnection(sock);
expect(disconnected()).toBe(false);
expect((sock.data as { principal: MessagePrincipal }).principal.userId).toBe('u');
});
it('accepts a token whose aud matches the required realtime audience', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal('iios-message')).handleConnection(sock);
expect(disconnected()).toBe(false);
});
it('rejects a full REST/actor token (aud=iios-core) on the socket', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal('iios-core')).handleConnection(sock);
expect(disconnected()).toBe(true);
});
it('rejects an un-scoped token (no aud) when enforcement is on', () => {
process.env.IIOS_REALTIME_AUDIENCE = 'iios-message';
const { sock, disconnected } = fakeSocket();
gatewayReturning(principal(undefined)).handleConnection(sock);
expect(disconnected()).toBe(true);
});
});
@@ -61,6 +61,14 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
try { try {
const token = String(client.handshake.auth?.token ?? ''); const token = String(client.handshake.auth?.token ?? '');
const principal = this.session.verify(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; client.data = { principal } satisfies SocketState;
} catch (err) { } catch (err) {
this.logger.warn(`rejecting socket: ${(err as Error).message}`); this.logger.warn(`rejecting socket: ${(err as Error).message}`);
@@ -50,6 +50,8 @@ export interface ThreadSummary {
threadId: string; threadId: string;
subject: string | null; subject: string | null;
membership?: string; 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[]; participants: string[];
participantCount: number; participantCount: number;
unread: number; unread: number;
@@ -85,19 +87,21 @@ export class MessageService {
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may * 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. * re-open it (policy `iios.thread.join`) — new members enter via addParticipant.
*/ */
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> { async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<OpenThreadResult> {
if (!threadId) { if (!threadId) {
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal }); await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
const scope = await this.actors.resolveScope(principal); const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal); const actor = await this.actors.resolveActor(scope.id, principal);
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the // `membership`/`creatorRole`/`subject`/`metadata` are generic, app-supplied thread attributes —
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app). // 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 } : {}) };
const thread = await this.prisma.iiosThread.create({ const thread = await this.prisma.iiosThread.create({
data: { data: {
scopeId: scope.id, scopeId: scope.id,
createdByActorId: actor.id, createdByActorId: actor.id,
subject: opts?.subject?.trim() || undefined, subject: opts?.subject?.trim() || undefined,
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined, metadata: Object.keys(merged).length > 0 ? (merged as Prisma.InputJsonValue) : undefined,
}, },
}); });
await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER'); await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
@@ -167,8 +171,12 @@ export class MessageService {
return { threadId, muted }; return { threadId, muted };
} }
/** Generic "my threads": every thread the caller participates in, with last message + unread. */ /**
async listThreads(principal: MessagePrincipal): Promise<ThreadSummary[]> { * 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[]> {
const scope = await this.actors.findScope(principal); const scope = await this.actors.findScope(principal);
if (!scope) return []; if (!scope) return [];
const actor = await this.actors.resolveActor(scope.id, principal); const actor = await this.actors.resolveActor(scope.id, principal);
@@ -177,7 +185,7 @@ export class MessageService {
if (threadIds.length === 0) return []; if (threadIds.length === 0) return [];
const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted])); const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted]));
const [threads, unreads, allParts] = await Promise.all([ const [allThreads, unreads, allParts] = await Promise.all([
this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }), this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }),
this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }), this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }),
this.prisma.iiosThreadParticipant.findMany({ this.prisma.iiosThreadParticipant.findMany({
@@ -185,6 +193,14 @@ export class MessageService {
include: { actor: { include: { sourceHandle: true } } }, 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 unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
const membersBy = new Map<string, string[]>(); const membersBy = new Map<string, string[]>();
for (const p of allParts) { for (const p of allParts) {
@@ -192,27 +208,41 @@ export class MessageService {
membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]); membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]);
} }
const summaries = await Promise.all( // Latest message per thread in ONE query (Postgres DISTINCT ON) instead of one findFirst
threads.map(async (t) => { // per thread — a caller with many threads no longer fans out N parallel queries and
const last = await this.prisma.iiosInteraction.findFirst({ // exhausts the connection pool.
where: { threadId: t.id }, const lastRows =
orderBy: { occurredAt: 'desc' }, threads.length === 0
include: { parts: { where: { kind: 'TEXT' }, take: 1 } }, ? []
}); : await this.prisma.$queryRaw<Array<{ threadId: string; occurredAt: Date; lastMessage: string | null }>>(Prisma.sql`
const members = membersBy.get(t.id) ?? []; SELECT DISTINCT ON (i."threadId")
return { i."threadId" AS "threadId",
threadId: t.id, i."occurredAt" AS "occurredAt",
subject: t.subject, (SELECT p."bodyText" FROM "IiosMessagePart" p
membership: (t.metadata as { membership?: string } | null)?.membership, WHERE p."interactionId" = i.id AND p.kind::text = 'TEXT'
participants: members, ORDER BY p."partIndex" ASC LIMIT 1) AS "lastMessage"
participantCount: members.length, FROM "IiosInteraction" i
unread: unreadBy.get(t.id) ?? 0, WHERE i."threadId" IN (${Prisma.join(threads.map((t) => t.id))})
muted: mutedBy.get(t.id) ?? false, ORDER BY i."threadId", i."occurredAt" DESC
lastMessage: last?.parts[0]?.bodyText ?? undefined, `);
lastAt: last?.occurredAt, 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,
};
});
return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0)); return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0));
} }
@@ -143,6 +143,23 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect((await s.listThreads(bob))[0]?.unread).toBe(1); 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 () => { it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => {
const s = gov(); const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'dm' }); const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
@@ -0,0 +1,76 @@
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
}
}
}
@@ -0,0 +1,45 @@
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);
}
}
@@ -0,0 +1,96 @@
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/);
});
});
@@ -0,0 +1,52 @@
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;
}
}
@@ -0,0 +1,193 @@
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);
});
});
@@ -0,0 +1,190 @@
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,6 +135,7 @@ export class SessionVerifier implements OnModuleInit {
orgId: entry.orgId, orgId: entry.orgId,
tenantId: undefined, tenantId: undefined,
displayName: meta.full_name ?? meta.name ?? email ?? userId, displayName: meta.full_name ?? meta.name ?? email ?? userId,
audience: typeof payload.aud === 'string' ? payload.aud : entry.audience,
}; };
} }
@@ -159,6 +160,7 @@ export class SessionVerifier implements OnModuleInit {
orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`, orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`,
tenantId: payload.tenantId ? String(payload.tenantId) : undefined, tenantId: payload.tenantId ? String(payload.tenantId) : undefined,
displayName: payload.name ? String(payload.name) : undefined, displayName: payload.name ? String(payload.name) : undefined,
audience: typeof payload.aud === 'string' ? payload.aud : undefined,
}; };
} }
@@ -1,10 +1,12 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { IiosTicketState } from '@prisma/client'; import { IiosTicketState } from '@prisma/client';
import { SupportService } from './support.service'; import { SupportService } from './support.service';
import { AssignmentService } from './assignment.service'; import { AssignmentService } from './assignment.service';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
import type { MessagePrincipal } from '../identity/actor.resolver'; import type { MessagePrincipal } from '../identity/actor.resolver';
import { import {
AssignTicketDto,
AvailabilityDto, AvailabilityDto,
CallbackDto, CallbackDto,
CreateQueueDto, CreateQueueDto,
@@ -14,6 +16,7 @@ import {
} from './support.dto'; } from './support.dto';
@Controller('v1/support') @Controller('v1/support')
@UseGuards(ContextAttestationGuard)
export class SupportController { export class SupportController {
constructor( constructor(
private readonly support: SupportService, private readonly support: SupportService,
@@ -32,7 +35,7 @@ export class SupportController {
@Post('escalate') @Post('escalate')
async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) { async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) {
return this.support.escalate(body.threadId, this.principal(auth), body.subject); return this.support.escalate(body.threadId, this.principal(auth), body.subject, body.metadata);
} }
@Get('tickets') @Get('tickets')
@@ -45,6 +48,12 @@ export class SupportController {
return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason); 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') @Post('callbacks')
async callback( async callback(
@Body() body: CallbackDto, @Body() body: CallbackDto,
@@ -1,4 +1,4 @@
import { IsIn, IsOptional, IsString } from 'class-validator'; import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const; const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const;
const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const; const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const;
@@ -7,11 +7,18 @@ export class CreateTicketDto {
@IsString() subject!: string; @IsString() subject!: string;
@IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number]; @IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number];
@IsOptional() @IsString() threadId?: string; @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 { export class EscalateDto {
@IsString() threadId!: string; @IsString() threadId!: string;
@IsOptional() @IsString() subject?: string; @IsOptional() @IsString() subject?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class AssignTicketDto {
@IsString() userId!: string;
} }
export class PatchTicketDto { export class PatchTicketDto {
@@ -35,7 +35,7 @@ export class SupportService {
async createTicket( async createTicket(
principal: MessagePrincipal, principal: MessagePrincipal,
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string }, input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string; metadata?: Record<string, unknown> },
idempotencyKey?: string, idempotencyKey?: string,
) { ) {
await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal }); await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal });
@@ -63,6 +63,8 @@ export class SupportService {
subject: input.subject, subject: input.subject,
priority: input.priority ?? 'P3', priority: input.priority ?? 'P3',
traceId, 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({ await tx.iiosTicketStateHistory.create({
@@ -99,11 +101,17 @@ export class SupportService {
return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body); 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. */ /** Escalate a chat thread to support: create a ticket linked to that thread. `metadata` is opaque. */
async escalate(threadId: string, principal: MessagePrincipal, subject?: string) { async escalate(threadId: string, principal: MessagePrincipal, subject?: string, metadata?: Record<string, unknown>) {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found'); if (!thread) throw new NotFoundException('thread not found');
return this.createTicket(principal, { subject: subject ?? thread.subject ?? 'Support request', threadId }); // 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,
});
} }
async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise<void> { async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise<void> {
@@ -120,6 +128,53 @@ export class SupportService {
.catch(() => undefined); .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) { async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) {
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } }); const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
if (!ticket) throw new NotFoundException('ticket not found'); if (!ticket) throw new NotFoundException('ticket not found');
@@ -57,6 +57,19 @@ describe('SupportService (P4)', () => {
expect(links[0]?.threadId).toBe(threadId); 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 () => { it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => {
const t = await support().createTicket(cust, { subject: 's' }); const t = await support().createTicket(cust, { subject: 's' });
await support().transition(t.id, cust, 'OPEN'); await support().transition(t.id, cust, 'OPEN');
@@ -9,13 +9,16 @@ import {
Param, Param,
Post, Post,
Query, Query,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ThreadsService } from './threads.service'; import { ThreadsService } from './threads.service';
import { MessageService } from '../messaging/message.service'; import { MessageService } from '../messaging/message.service';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import { ContextAttestationGuard } from '../platform/context-attestation.guard';
import { SendMessageDto } from './send-message.dto'; import { SendMessageDto } from './send-message.dto';
@Controller('v1/threads') @Controller('v1/threads')
@UseGuards(ContextAttestationGuard)
export class ThreadsController { export class ThreadsController {
constructor( constructor(
private readonly threads: ThreadsService, private readonly threads: ThreadsService,
@@ -23,10 +26,14 @@ export class ThreadsController {
private readonly session: SessionVerifier, private readonly session: SessionVerifier,
) {} ) {}
/** Generic "my threads" — every thread the caller participates in (last message + unread). */ /**
* Generic "my threads" every thread the caller participates in (last message + unread).
* `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key).
*/
@Get() @Get()
async listThreads(@Headers('authorization') auth?: string) { async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
return this.messages.listThreads(this.principal(auth)); const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
} }
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */ /** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
@@ -35,11 +42,11 @@ export class ThreadsController {
return this.messages.listMyAnnotated(this.principal(auth), type); return this.messages.listMyAnnotated(this.principal(auth), type);
} }
/** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */ /** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
@Post() @Post()
@HttpCode(201) @HttpCode(201)
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) { 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 }); return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata });
} }
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */ /** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */
+41
View File
@@ -370,6 +370,9 @@ importers:
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.3 specifier: ^9.0.3
version: 9.0.3 version: 9.0.3
jwks-rsa:
specifier: ^4.1.0
version: 4.1.0
prisma: prisma:
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.19.3(typescript@5.9.3) version: 6.19.3(typescript@5.9.3)
@@ -2297,6 +2300,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
joycon@3.1.1: joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2343,6 +2349,10 @@ packages:
jwa@2.0.1: jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jwks-rsa@4.1.0:
resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0}
jws@4.0.1: jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
@@ -2353,6 +2363,9 @@ packages:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'} engines: {node: '>=14'}
limiter@1.1.5:
resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -2368,6 +2381,9 @@ packages:
resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==}
engines: {node: '>=6.11.5'} engines: {node: '>=6.11.5'}
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.includes@4.3.0: lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
@@ -2406,6 +2422,9 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lru-memoizer@3.0.0:
resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==}
magic-string@0.30.17: magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
@@ -5066,6 +5085,8 @@ snapshots:
jiti@2.7.0: {} jiti@2.7.0: {}
jose@6.2.3: {}
joycon@3.1.1: {} joycon@3.1.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -5113,6 +5134,17 @@ snapshots:
ecdsa-sig-formatter: 1.0.11 ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1 safe-buffer: 5.2.1
jwks-rsa@4.1.0:
dependencies:
'@types/jsonwebtoken': 9.0.10
debug: 4.4.3
jose: 6.2.3
limiter: 1.1.5
lru-cache: 11.5.1
lru-memoizer: 3.0.0
transitivePeerDependencies:
- supports-color
jws@4.0.1: jws@4.0.1:
dependencies: dependencies:
jwa: 2.0.1 jwa: 2.0.1
@@ -5122,6 +5154,8 @@ snapshots:
lilconfig@3.1.3: {} lilconfig@3.1.3: {}
limiter@1.1.5: {}
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
load-esm@1.0.3: {} load-esm@1.0.3: {}
@@ -5130,6 +5164,8 @@ snapshots:
loader-runner@4.3.2: {} loader-runner@4.3.2: {}
lodash.clonedeep@4.5.0: {}
lodash.includes@4.3.0: {} lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {} lodash.isboolean@3.0.3: {}
@@ -5159,6 +5195,11 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lru-memoizer@3.0.0:
dependencies:
lodash.clonedeep: 4.5.0
lru-cache: 11.5.1
magic-string@0.30.17: magic-string@0.30.17:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5