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>
This commit is contained in:
@@ -58,6 +58,8 @@ 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 |
|
||||
|---|---|
|
||||
@@ -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/<name>`)
|
||||
| `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` | `<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 |
|
||||
|
||||
Reference in New Issue
Block a user