- New §5.10 Media: presign-upload/download + upload/blob endpoints, attachment shape, governance notes (25MB + type allowlist via OPA, signed tokens, tenant fence), and a sequence diagram of the presign→upload→send→signed-download flow with the OPA gate. - SDK: uploadMedia/mediaUrl; send() attachment/mentions. - Auth §3: real OIDC (Supabase/AUTH_ISSUERS) JWKS verification alongside dev HS256. - Env: MEDIA_DIR/MEDIA_SECRET/PUBLIC_URL/SUPABASE_URL/AUTH_ISSUERS; test count 192. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 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/:id/messages |
— | {threadId, messages[]} |
| POST | /v1/threads/:id/messages |
{content, contentRef?} + idempotency-key? header |
Message (201) |
Realtime (Socket.IO, namespace /message): connect, then emit client→server events:
open_thread{threadId?|otherUserId},send_message{threadId, content, idempotencyKey?},read{threadId},delivered{threadId}.- Server emits to the thread room:
message(a Message) andreceipt{interactionId, actorId, kind: READ|DELIVERED}.
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 |
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
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:
getThreadMessages(threadId),sendMessage(threadId, content, {attachment?, parentInteractionId?, mentions?, idempotencyKey?}) - 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. - 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 (192 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 |
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, SUPPORT_UPDATE, MEETING_FOLLOWUP, DIGEST, SYSTEM_ALERT, CRM_OWNER_INTEREST
- 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.