diff --git a/docs/IIOS_API_AND_SDK_GUIDE.md b/docs/IIOS_API_AND_SDK_GUIDE.md index 5f33815..ebd9115 100644 --- a/docs/IIOS_API_AND_SDK_GUIDE.md +++ b/docs/IIOS_API_AND_SDK_GUIDE.md @@ -58,6 +58,8 @@ Use it on every call: `-H "authorization: Bearer "`. - **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 | |---|---| @@ -179,6 +181,52 @@ Grouped by domain. All paths are relative to the base URL. **Auth = Bearer token `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):** + +```mermaid +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 @@ -191,7 +239,8 @@ 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, {contentRef?, idempotencyKey?})` +- **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)` @@ -269,7 +318,7 @@ Run against a live service (from `packages/iios-service`, `node scripts/`) | `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` (114 tests). Import-boundary check: `pnpm boundary`. +Automated unit/integration suite: `pnpm test` (192 tests). Import-boundary check: `pnpm boundary`. --- @@ -289,7 +338,11 @@ Automated unit/integration suite: `pnpm test` (114 tests). Import-boundary check |---|---|---| | `PORT` | `3200` | HTTP port | | `DATABASE_URL` | `postgresql://iios:iios@localhost:5434/iios?schema=public` | Postgres | -| `APP_SECRETS` | `{"portal-demo":"dev-secret"}` | per-app JWT secrets (`{appId: secret}`) | +| `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` | `/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 |