- API guide §5.11 Notifications: vapid-public-key / subscribe / unsubscribe / thread mute endpoints, the focus_thread presence signal, the three gates, and the engine flow diagram; SDK reference (registerPush/muteThread/focus); VAPID env; tests 205. - DEPLOYMENT: VAPID env + the in-memory-presence → Redis (multi-replica) caveat. - CEO overview: counts (205 tests, 54 tables / 20 migrations) + notifications in the "P9 has begun" note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
28 KiB
IIOS — API & SDK Guide (for QA and Developers)
Everything you need to run, test, and build against the IIOS service and SDKs. One NestJS API (
iios-service) + a set of browser SDKs. This doc is generated from the current code — endpoints, request/response shapes, SDK methods, auth, and env are all as-built.
1. Quick facts
| Thing | Value |
|---|---|
| Base URL (dev) | http://localhost:3200 (env PORT, default 3200) |
| Auth | Authorization: Bearer <JWT> — HS256, per-app secret |
| Content type | application/json |
| Validation | Global — unknown body fields are rejected (400); types are coerced |
| CORS | Enabled (origin: true) |
| Realtime | Socket.IO namespace /message |
| DB | PostgreSQL (dev: localhost:5434, db iios) |
Everything is scoped to a tenant. A caller can only see/act on data in its own scope (orgId + appId + optional tenantId, derived from the token). Cross-tenant access returns 403.
2. Getting started (run it locally)
# 1. Postgres (docker-compose in the repo root brings up 'iios-db' on :5434)
docker compose up -d
# 2. Install + generate + migrate
pnpm install
pnpm -F @insignia/iios-service prisma:generate
pnpm -F @insignia/iios-service prisma:migrate
# 3. Run the API (dev tokens ON so you can mint test tokens)
cd packages/iios-service && IIOS_DEV_TOKENS=1 pnpm start
# → iios-service listening on :3200
Health check: GET http://localhost:3200/health → { "status": "ok", "db": true, "egressProviders": { ... } }
3. Auth model (how to get a token)
Tokens are per-app JWTs signed with a secret from the APP_SECRETS env map ({"portal-demo":"dev-secret"} by default). The token carries sub (userId), appId, orgId, optional tenantId.
Get a dev token (only when IIOS_DEV_TOKENS=1):
curl -s -X POST http://localhost:3200/v1/dev/token \
-H 'content-type: application/json' \
-d '{"appId":"portal-demo","userId":"alice","orgId":"org_A"}'
# → { "token": "eyJhbGc..." }
Use it on every call: -H "authorization: Bearer <token>".
- Different
orgId= different tenant. Mint two tokens withorg_A/org_Bto test isolation. - Token TTL: 2h.
Real IdP tokens (production path). Beyond the dev HS256 tokens, SessionVerifier also verifies real OIDC access tokens (ES256) against a trusted issuer's public JWKS — set SUPABASE_URL (single issuer) or AUTH_ISSUERS (a registry mapping many issuers → app scopes). The verifier routes a token by its iss claim, so two projects/IdPs map to two isolated appId scopes on one service, and app A's tokens can't reach app B. No shared secret needed. The dev token path stays for tests/local.
Error responses (what QA will see)
| Status | When |
|---|---|
400 |
Missing Authorization header, unknown appId, or invalid/unknown body fields |
401 |
Invalid/expired token, or bad webhook signature |
403 |
Cross-tenant access (resource not in your scope) |
404 |
Resource id not found (within your scope) |
201 |
Created (POST) · 200 others |
4. Conventions
- Idempotency — native message send accepts an
idempotency-keyheader; adapter send takesidempotencyKeyin the body; AI/route/meeting derive their own keys. Re-sending the same key returns the same record (no duplicate). - Scopes/tenancy — inferred from the token; you never pass
scopeIdin requests (except the raw/v1/interactions/ingestand adapter ingest which carry a full scope object). - Fail-closed — reads/writes/sends pass through policy (OPA) + consent (CMP) gates; a denial blocks the action rather than silently allowing it.
- Preview / propose first — routing simulates before sending; AI proposes (never auto-decides); meetings gate recording on consent.
5. REST API reference
Grouped by domain. All paths are relative to the base URL. Auth = Bearer token required unless noted.
5.1 Health & Dev
| Method | Path | Auth | Body / Query | Returns |
|---|---|---|---|---|
| GET | /health |
none | — | {status, db, egressProviders} |
| POST | /v1/dev/token |
none* | {appId, userId, name?, orgId?} |
{token} |
| POST | /v1/dev/webhook/:channelType |
none* | {from?, text?} |
raw event (simulated signed inbound) |
*Dev endpoints require IIOS_DEV_TOKENS=1; never enabled in prod.
5.2 Interactions (kernel ingest)
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /v1/interactions/ingest |
IngestRequestDto |
{interactionId, ...} |
IngestRequestDto: { scope:{orgId,appId,buId?,tenantId?,workspaceId?,projectId?,userScopeId?}, channel:{type,externalChannelId?,capabilityContract?}, source:{handleKind,externalId,displayName?}, kind?, thread?:{externalThreadId?,subject?}, parts:[{kind,bodyText?,contentRef?,mimeType?}], occurredAt (ISO8601), providerEventId?, metadata? }. Send providerEventId twice → deduped.
5.3 Threads & Messaging (native chat)
| Method | Path | Body / Headers | Returns |
|---|---|---|---|
| GET | /v1/threads |
— | ThreadSummary[] — your threads (members, unread, last message, membership) |
| POST | /v1/threads |
{membership?, creatorRole?, subject?} |
{threadId, status, history} (201) — membership/creatorRole/subject are opaque app attributes the kernel stores but never interprets |
| POST | /v1/threads/:id/participants |
{userId, role?} |
{threadId, participantCount} (201) — governed (DM cap / group-admin via OPA) |
| GET | /v1/threads/:id/messages |
— | {threadId, messages[]} |
| POST | /v1/threads/:id/messages |
{content, contentRef?, mimeType?, sizeBytes?, checksumSha256?, parentInteractionId?} + idempotency-key? header |
Message (201) |
| GET | /v1/threads/my-annotations |
?type=save |
{message, threadId, threadSubject}[] — messages you annotated (e.g. saved) |
A Message carries {id, threadId, senderId, senderName, content, attachment?, parentInteractionId?, annotations[], traceId, createdAt}. senderId is the sender's stable externalId (email/username) — the reliable "is this mine?" check. attachment = {contentRef, mimeType, sizeBytes, kind} (§5.10). annotations = [{type, value, users[]}] — the reaction/pin/save aggregate.
Realtime (Socket.IO, namespace /message). Token verified on connect (auth.token), then emit client→server:
open_thread{threadId?, membership?, creatorRole?, subject?}— opens, or creates when no id; a governed join for membership threads. Returns{threadId, status, history}or{error}(acked, never a throw — clients don't hang).send_message{threadId, content, contentRef?, mimeType?, sizeBytes?, parentInteractionId?, mentions?}—mentionsis an opaque userId notify-list (the app parses@; the kernel never does).add_participant{threadId, userId, role?}·read{threadId, interactionId}·delivered{…}·typing{threadId}.annotate{threadId, interactionId, type, value}— toggle a generic annotation (chat app usestype: reaction|pin|save;value= emoji, or empty).
Server → thread room: message (a Message), receipt {interactionId, actorId, kind: READ|DELIVERED}, typing {threadId, userId}, annotation {interactionId, type, value, op: add|remove, users[], userId}.
Governance & primitives (all fail-closed via OPA). Membership (iios.thread.participant.add, iios.thread.join), annotating (iios.interaction.annotate — participant-only), and send all gate on policy. Reactions/pins/saves are one generic primitive — an interaction annotation the kernel stores + aggregates as opaque (type, value) but never interprets (the same primitive backs pins/saves/flags/tags). Mentions → Inbox: mentions[] flows into the message event; the inbox projector fans out a MENTION inbox item to each mentioned participant (never the sender); reading the thread resolves it.
5.4 Inbox (work queue)
| Method | Path | Query / Body | Returns |
|---|---|---|---|
| GET | /v1/inbox/items |
`?state=OPEN | SNOOZED |
| PATCH | /v1/inbox/items/:id |
{state, reason?} |
InboxItem |
Item kinds include NEEDS_REPLY (unreplied thread activity, one per owner+thread) and MENTION (someone @-mentioned you; priority: HIGH, one per source message). Reading the thread resolves both to DONE.
5.5 Support (tickets, escalation, callbacks, queues, agents)
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | /v1/support/tickets |
{subject, priority?, threadId?} |
Ticket |
| POST | /v1/support/escalate |
{threadId, subject?} |
Ticket (opens customer↔agent thread) |
| GET | /v1/support/tickets |
`?scope=mine | assigned` |
| PATCH | /v1/support/tickets/:id |
{state, reason?} |
Ticket |
| POST | /v1/support/callbacks |
{preferChannel, preferredTime?, notes?, ticketId?} |
CallbackRequest |
| POST | /v1/support/queues |
{name} |
{id} |
| POST | /v1/support/queues/:id/members |
— | membership |
| POST | /v1/support/agents/me/join |
— | joins default queue (go online) |
| PATCH | /v1/support/members/me/availability |
{state} |
membership |
priority ∈ {LOW, NORMAL, HIGH, URGENT}; ticket state ∈ {NEW, OPEN, PENDING, RESOLVED, CLOSED} (transitions validated).
5.6 Adapters (channels — inbound/outbound)
| Method | Path | Auth | Body / Headers | Returns |
|---|---|---|---|---|
| POST | /v1/adapters/:channelType/webhook |
signature | raw provider body + x-iios-signature (HMAC) |
202 (async normalize) |
| POST | /v1/adapters/:channelType/send |
Bearer | {target, payload?, idempotencyKey?} |
OutboundCommand |
| GET | /v1/adapters/inbound |
Bearer | — | raw inbound events (+ interactionId once normalized) |
| GET | /v1/adapters/outbound |
Bearer | — | outbound commands (+ delivery attempts) |
channelType ∈ {WEBHOOK, EMAIL, WHATSAPP, PORTAL}. Outbound goes through the Capability Broker (policy + consent + provider); a blocked send → command status BLOCKED, a tenant over quota → RATE_LIMITED.
5.7 Routing / rules (community)
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | /v1/routes/bindings |
CreateBindingDto |
RouteBinding |
| GET | /v1/routes/bindings |
— | RouteBinding[] (your scope) |
| POST | /v1/routes/simulate |
{interactionId, originChannelType, originRef?} |
{simulationId, decisions[]} (no send) |
| GET | /v1/routes/decisions |
`?state=ALLOW | DENY |
| POST | /v1/routes/decisions/:id/approve |
— | RouteDecision (executes to sandbox) |
| POST | /v1/routes/decisions/:id/deny |
— | RouteDecision |
| POST | /v1/routes/bindings/:id/flush-digest |
— | {forwarded} |
CreateBindingDto: {originChannelType, originRef?, destinationChannelType, destinationRef?, restrictionProfile?, mode?, outputFormat?, frequency?, includeAttachments?, requiresReview?, enabled?}. mode ∈ {MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY}; outputFormat ∈ {FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT}. Restricted destinations are deny-by-default for flagged content.
5.8 AI (proposal layer)
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | /v1/ai/jobs |
{interactionId, jobType} |
{job, artifact} |
| GET | /v1/ai/artifacts |
?interactionId=&status= |
AiArtifact[] (your scope) |
| GET | /v1/ai/artifacts/:id |
— | AiArtifact (claims + evidence + modelRun) |
| POST | /v1/ai/artifacts/:id/accept |
— | AiArtifact (ACCEPTED) |
| POST | /v1/ai/artifacts/:id/reject |
— | AiArtifact (REJECTED) |
jobType ∈ {CLASSIFY, SUMMARIZE, EXTRACT}. Artifacts carry confidence, evidence (citations), and abstentionReason when the model declines. AI proposes; a human accepts.
5.9 Calendar & Meetings
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | /v1/calendar/meetings |
ScheduleMeetingDto |
Meeting (SCHEDULED) |
| POST | /v1/calendar/requests |
RequestMeetingDto + ?fromCallback=&fromClaim= |
meeting request |
| GET | /v1/calendar/meetings |
— | Meeting[] (your scope) |
| GET | /v1/calendar/meetings/:id |
— | Meeting (participants, transcripts, summaries, action items) |
| POST | /v1/calendar/meetings/:id/consent |
{actorRefId, status} |
participant |
| POST | /v1/calendar/meetings/:id/transcript |
— | transcript (READY or BLOCKED) |
| POST | /v1/calendar/meetings/:id/summarize |
— | Meeting (summary + action items) |
| GET | /v1/calendar/meetings/:id/action-items |
— | MeetingActionItem[] |
| POST | /v1/calendar/providers |
— | provider account (simulated) |
| POST | /v1/calendar/providers/:id/sync |
— | {pulled, cursor} |
ScheduleMeetingDto: {meetingType, title, startAt, endAt?, timezone?, attendees?:[{userId, displayName?, role?, visibility?}], requestId?}. meetingType ∈ {ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL}; consent status ∈ {UNKNOWN, GRANTED, DENIED, REVOKED}. Transcript/summary is BLOCKED until every attendee has GRANTED consent.
5.10 Media (attachments)
The DB stores a reference; the bytes live behind a storage port (dev = local disk MEDIA_DIR; prod = swap to S3/R2/Supabase). Bytes go client ↔ storage directly via short-lived signed URLs — they never pass through the kernel.
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
| POST | /v1/media/presign-upload |
Bearer | {mime, sizeBytes} |
{objectKey, uploadUrl} — OPA-gated (size/type) |
| PUT | /v1/media/upload/:token |
token in URL | raw bytes | {objectKey, sizeBytes, checksumSha256} |
| POST | /v1/media/presign-download |
Bearer | {contentRef, mime?} |
{url} — tenant-fenced, signed 1h |
| GET | /v1/media/blob/:token |
token in URL | — | streams the bytes with their Content-Type |
Attaching to a message: send/send_message accept {contentRef, mimeType, sizeBytes, checksumSha256?}. The stored Message then carries attachment: { contentRef, mimeType, sizeBytes, kind } where kind ∈ {image, video, audio, file} (a friendly view of the generic MEDIA_REF/VOICE_REF/FILE_REF part the kernel writes).
Governance (fail-closed, built in):
- Upload policy
iios.media.upload— ≤ 25 MB and an allowlist (image/*,video/*,audio/*,application/pdf, common Office/text/zip). A violation → 403 before any bytes are sent. - Signed tokens (HS256,
MEDIA_SECRET) — upload URL lives 5 min, download URL 1 h; a tampered/expired token → 403. - Tenant fence — object keys are prefixed with the caller's
scopeId; a download for an object outside your scope → 403.
The flow (with governance):
sequenceDiagram
autonumber
participant C as Client (SDK uploadMedia)
participant API as IIOS Media API
participant OPA as OPA policy
participant ST as StoragePort (disk → S3)
participant K as Message kernel
C->>API: POST /v1/media/presign-upload {mime, sizeBytes}
API->>OPA: decide iios.media.upload (≤25MB? type allowed?)
alt denied
OPA-->>C: 403 (too large / type not allowed)
else allowed
API-->>C: { objectKey, uploadUrl } (signed, 5-min)
C->>ST: PUT bytes → uploadUrl (direct, not via kernel)
ST-->>C: { sizeBytes, checksumSha256 }
C->>K: send_message { contentRef, mime, size }
K-->>C: Message { attachment }
C->>API: POST /v1/media/presign-download { contentRef }
API->>API: tenant-fence (objectKey in my scope?)
API-->>C: { url } (signed, 1-hour)
C->>ST: GET url → bytes (stream, inline render / download)
end
5.11 Notifications (push, presence-gated)
The engine reacts to message.sent and pushes to absent recipients through a swappable
NotificationPort (Web Push/VAPID now; email/FCM later). The DB holds only push
subscriptions + a per-thread mute flag; the reusable "who has an unread" feed stays IiosInboxItem.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /v1/notifications/vapid-public-key |
— | {key} — the public VAPID key the client needs to subscribe |
| POST | /v1/notifications/subscribe |
{kind?, endpoint, keys:{p256dh, auth}, userAgent?} |
{ok:true} — store/refresh the caller's push subscription |
| DELETE | /v1/notifications/subscribe |
{endpoint} |
{ok:true} |
| POST | /v1/threads/:id/mute · /unmute |
— | {threadId, muted} — per-thread notification mute for the caller |
Presence (socket): the app emits focus_thread {threadId | null} on the /message
namespace whenever the foreground conversation changes (or the tab blurs). This is how the
engine knows you're viewing a thread — room membership ≠ viewing, because the sidebar
joins every thread room for live updates. GET /v1/threads returns each thread's muted.
The three gates (fail-closed, in the notification policy — not the kernel):
- Policy — DM always; a group message only if you were
@-mentioned or it's a reply to you. - Presence — skip if you're currently focused on that thread (you saw it live).
- Mute — skip if you muted the thread.
A dead subscription (Web Push
404/410) is pruned. DM-vs-group is read from the opaquemembershipthread attribute here in the notification policy; the kernel never branches on it.
flowchart TB
SENT["message.sent (outbox → bus)"] --> PROJ["NotificationProjector"]
PROJ --> LOOP{{"for each recipient (never the sender)"}}
LOOP --> G1{"POLICY: DM? · @mention? · reply-to-you?"}
G1 -->|no| X1["skip"]
G1 -->|yes| G2{"PRESENCE: focused on this thread?"}
G2 -->|yes| X2["skip — seen live"]
G2 -->|no| G3{"MUTE: thread muted?"}
G3 -->|yes| X3["skip"]
G3 -->|no| SUBS["load subscriptions"] --> PORT["NotificationPort.deliver()"]
PORT --> WP["Web Push (VAPID)"]
WP -->|sent| OUT["→ browser push service → service worker → OS notification"]
WP -->|"gone (404/410)"| PRUNE["prune dead subscription"]
Client (SDK layer, lib/notifications.ts → belongs in @insignia/iios-kernel-client):
registerPush() (permission → register service worker → PushManager.subscribe with the
VAPID key → POST the subscription), muteThread(threadId, muted), and MessageSocket.focus(threadId).
A service worker renders the OS notification on push and deep-links to the thread on click.
6. SDK reference
Two layers: a low-level RestClient (+ socket) and per-domain React hook packages.
6.1 Low-level client — @insignia/iios-kernel-client
import { RestClient } from '@insignia/iios-kernel-client';
const client = new RestClient({ serviceUrl: 'http://localhost:3200', token });
Methods (all return typed promises):
- Messaging:
listThreads(),createThread({membership?, creatorRole?, subject?}),addParticipant(threadId, userId, role?),getThreadMessages(threadId),sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?}) - Reactions / pins / saves (annotations):
MessageSocket.react(threadId, interactionId, emoji)·pin(...)·save(...)(genericannotateunder the hood);listMyAnnotated('save')for a cross-thread saved list. Subscribe to theannotationevent for live updates. - Media:
uploadMedia(file, {onProgress?}) → {contentRef, mimeType, sizeBytes, checksumSha256, kind}(presign → PUT-with-progress → normalized ref);mediaUrl(contentRef) → signed view URL(cached, short-lived). This plumbing is identical for every app, so it lives in the SDK; the app only renders bykind. - Notifications:
registerPush()(service worker +PushManager.subscribe+ POST subscription),muteThread(threadId, muted),MessageSocket.focus(threadId)(presence signal). The engine (projector + Web Push port) is server-side; the SDK/app own registration + rendering. - Inbox:
listInboxItems(state?),patchInboxItem(id, {state, reason?}) - Support:
createTicket({subject, priority?, threadId?}),escalate(threadId, subject?),listTickets('mine'|'assigned'),patchTicket(id, state),requestCallback({...}),createQueue(name),joinQueue(id),joinDefaultQueue(),setAvailability(state) - Routing:
createBinding(input),listBindings(),simulateRoute({interactionId, originChannelType, originRef?}),listRouteDecisions(state?),approveDecision(id),denyDecision(id) - AI:
runAiJob({interactionId, jobType}),listArtifacts({interactionId?, status?}),getArtifact(id),acceptArtifact(id),rejectArtifact(id) - Calendar:
scheduleMeeting(input),requestMeeting(input, {fromCallback?, fromClaim?}),listMeetings(),getMeeting(id),setConsent(meetingId, actorRefId, status),generateTranscript(meetingId),summarizeMeeting(meetingId),listActionItems(meetingId),connectCalendarProvider(),syncCalendarProvider(id) - Ingest:
ingest(body, idempotencyKey) - Realtime:
MessageSocket(Socket.IO facade for/message).
6.2 React hook packages (front-end teams)
Each package exports a Provider (wrap your app with serviceUrl + token) plus hooks:
| Package | Provider | Hooks |
|---|---|---|
@insignia/iios-message-web |
MessageProvider |
useThread, useMessages |
@insignia/iios-inbox-web |
InboxProvider |
useInbox |
@insignia/iios-support-web |
SupportProvider |
useEscalate, useTickets, useAssignedTickets, useCallbackRequest, useAvailability, useGoOnline (+ re-exports useThread/useMessages) |
@insignia/iios-community-web |
CommunityProvider |
useBindings, useRoutePreview, useRouteDecisions |
@insignia/iios-ai-web |
AiProvider |
useRunJob, useAiProposals, useArtifact |
@insignia/iios-meeting-web |
MeetingProvider |
useMeetings, useMeeting, useSchedule, useConsent, useSummarize, useActionItems |
// Example: an AI-assist panel
import { AiProvider, useRunJob, useAiProposals } from '@insignia/iios-ai-web';
<AiProvider serviceUrl={SERVICE} token={token}>
<Panel interactionId={id} />
</AiProvider>;
function Panel({ interactionId }: { interactionId: string }) {
const run = useRunJob();
const { artifacts, accept, reject } = useAiProposals({ interactionId, pollMs: 2500 });
return <>
<button onClick={() => run(interactionId, 'SUMMARIZE')}>Summarize</button>
{artifacts.map(a => <ProposalCard key={a.id} a={a} onAccept={() => accept(a.id)} onReject={() => reject(a.id)} />)}
</>;
}
7. QA quickstart (curl walkthrough)
BASE=http://localhost:3200
TOKEN=$(curl -s -X POST $BASE/v1/dev/token -H 'content-type: application/json' \
-d '{"appId":"portal-demo","userId":"alice","orgId":"org_A"}' | jq -r .token)
AUTH="authorization: Bearer $TOKEN"
# schedule a meeting
MID=$(curl -s -X POST $BASE/v1/calendar/meetings -H "$AUTH" -H 'content-type: application/json' \
-d '{"meetingType":"INTERNAL","title":"QA test","startAt":"2026-07-02T10:00:00.000Z"}' | jq -r .id)
# transcript is BLOCKED (no consent yet)
curl -s -X POST $BASE/v1/calendar/meetings/$MID/transcript -H "$AUTH" | jq .status # "BLOCKED"
# tenant isolation: a second tenant cannot read it → 403
TOKEN_B=$(curl -s -X POST $BASE/v1/dev/token -H 'content-type: application/json' \
-d '{"appId":"portal-demo","userId":"bob","orgId":"org_B"}' | jq -r .token)
curl -s -o /dev/null -w "%{http_code}\n" $BASE/v1/calendar/meetings/$MID \
-H "authorization: Bearer $TOKEN_B" # 403
Ready-made end-to-end tests (smoke scripts)
Run against a live service (from packages/iios-service, node scripts/<name>):
| Script | Proves |
|---|---|
smoke-realtime.mjs |
native chat + realtime + unread (P2) |
smoke-inbox.mjs |
needs-reply queue + auto-resolve (P3) |
smoke-support.mjs (+ seed-support.mjs) |
ticket assign + escalate + live reply (P4) |
smoke-adapter.mjs |
signed webhook ingest + sandbox send (P5) |
smoke-route.mjs |
preview-first routing, deny-by-default, approve→send (P6) |
smoke-ai.mjs |
AI classify/summarize/extract, abstain, KG-05 (P7) |
smoke-calendar.mjs |
consent-gated transcript, summary, action items (P8) |
smoke-capability.mjs |
governed egress + real HTTP provider (needs IIOS_PROVIDER_URL_EMAIL) |
smoke-tenant.mjs |
cross-tenant 403 + list isolation |
Automated unit/integration suite: pnpm test (205 tests). Import-boundary check: pnpm boundary.
8. Tenancy & security notes for QA
- Cross-tenant — any by-id read/mutate of another tenant's resource → 403; lists only ever return your own scope's data. Test with two
orgIds. - Consent — a meeting transcript/summary is
BLOCKEDunless every attendee consented; an outbound send to an opted-out recipient isBLOCKED(consent gate). - Per-tenant quota — one tenant flooding outbound gets
RATE_LIMITEDwithout affecting others. - AI safety — AI outputs are proposals (
PROPOSED) with confidence/evidence; they never auto-send or auto-approve; low-confidence → abstains. - Idempotency — replaying a webhook/message/job with the same key yields one record.
9. Environment variables
| Var | Default | Purpose |
|---|---|---|
PORT |
3200 |
HTTP port |
DATABASE_URL |
postgresql://iios:iios@localhost:5434/iios?schema=public |
Postgres |
APP_SECRETS |
{"portal-demo":"dev-secret"} |
per-app HS256 JWT secrets ({appId: secret}) |
SUPABASE_URL / AUTH_ISSUERS |
— | trusted OIDC issuer(s) — verify real IdP tokens (ES256) via JWKS. AUTH_ISSUERS is a JSON array [{url, appId, orgId?}] mapping issuers → app scopes; SUPABASE_URL is the single-issuer shorthand |
MEDIA_DIR |
<tmp>/iios-media |
local media storage dir (dev StoragePort) |
MEDIA_SECRET |
dev-media-secret |
signs media upload/download URLs |
PUBLIC_URL |
http://localhost:$PORT |
base used to build presigned media URLs |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT |
— | Web Push (notifications). Unset → push disabled (engine no-ops). Generate once: node -e "console.log(require('web-push').generateVAPIDKeys())" |
IIOS_DEV_TOKENS |
0 |
set 1 to enable /v1/dev/* |
ADAPTER_SECRETS |
{} |
per-channel HMAC secrets (default dev-adapter-secret) |
IIOS_OUTBOUND_LIMIT / _WINDOW_MS |
5 / 60000 |
per-(channel,target) rate limit |
IIOS_TENANT_OUTBOUND_LIMIT / _WINDOW_MS |
10000 / 60000 |
per-tenant egress quota |
IIOS_AI_BUDGET_UNITS |
1000000 |
per-tenant AI cost budget |
IIOS_CELL_ID |
cell-default |
cell assignment for new scopes |
IIOS_PROVIDER_URL_<CHANNEL> |
— | set to route that channel's egress to a real HTTP provider |
IIOS_RELAY_INTERVAL_MS |
500 |
outbox → event relay poll interval |
10. Vocabularies (enums)
- Interaction kind: MESSAGE, EMAIL, SYSTEM_NOTICE, INBOX_WORK, SUPPORT_CASE, MEETING_REQUEST, DIGEST, SUMMARY, NOTIFICATION
- Channel types: WEBHOOK, EMAIL, WHATSAPP, PORTAL
- Inbox state: OPEN, SNOOZED, DONE, ARCHIVED, CANCELLED, STALE · Inbox kind: NEEDS_REPLY, NEEDS_REVIEW, NEEDS_APPROVAL, MENTION, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
- Message part kind: TEXT, HTML, MARKDOWN, MEDIA_REF, FILE_REF, VOICE_REF, LOCATION, STRUCTURED_JSON · Attachment kind (DTO): image, video, audio, file
- Interaction annotation (app-level, opaque to the kernel):
type= reaction | pin | save … ;value= emoji (reactions) or empty - Ticket state: NEW, OPEN, PENDING, RESOLVED, CLOSED · priority: LOW, NORMAL, HIGH, URGENT
- Route mode: MANUAL, AUTOMATIC, HYBRID, SIMULATION_ONLY · output format: FORWARD, THREADED, DIGEST, SUMMARY, TRANSCRIPT · decision: ALLOW, DENY, REVIEW, SUPPRESS, SIMULATED
- AI job: CLASSIFY, SUMMARIZE, EXTRACT · artifact: CLASSIFICATION, SUMMARY, TRANSCRIPT, DIGEST, EXTRACTION · artifact status: PROPOSED, ACCEPTED, REJECTED, SUPERSEDED
- Meeting type: ZOOM, PHONE, IN_PERSON, CALLBACK, INTERNAL · status: REQUESTED, SCHEDULED, CONFIRMED, CANCELLED, COMPLETED, NO_SHOW · consent: UNKNOWN, GRANTED, DENIED, REVOKED · participant visibility: FULL, LIMITED, NONE
Base URL, ports, and demos: the service is http://localhost:3200; runnable demo UIs live under apps/ (message-demo 5173, agent-demo 5174, adapter-inspector 5175, route-admin 5176, ai-studio 5177, meeting-studio 5178). For architecture/what-each-SDK-is-for, see docs/IIOS_OVERVIEW_FOR_CEO.md.