Files
iios/docs/IIOS_API_AND_SDK_GUIDE.md
T
maaz519 ac7f790303 docs(iios): media section (endpoints + governed flow diagram), real-IdP auth, media env
- 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>
2026-07-09 01:38:37 +05:30

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 with org_A / org_B to 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-key header; adapter send takes idempotencyKey in 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 scopeId in requests (except the raw /v1/interactions/ingest and 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) and receipt {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 by kind.
  • 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 BLOCKED unless every attendee consented; an outbound send to an opted-out recipient is BLOCKED (consent gate).
  • Per-tenant quota — one tenant flooding outbound gets RATE_LIMITED without 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.