# Castio — Platform Design Document **Date**: 2026-03-06 **Status**: Draft — pending user approval --- ## Table of Contents 1. [Product Overview](#1-product-overview) 2. [System Architecture](#2-system-architecture) 3. [Diagnostics & Observability](#3-diagnostics--observability) 4. [User System & Authentication](#section-4-user-system--authentication) 5. [Role-to-Preset Mapping](#section-5-role-to-preset-mapping) 6. [Meeting Lifecycle](#section-6-meeting-lifecycle) 7. [RTK Feature Mapping](#section-7-rtk-feature-mapping-exhaustive) 8. [Post-Meeting Experience](#section-8-post-meeting-experience) 9. [Webhook & Analytics Pipeline](#section-9-webhook--analytics-pipeline) 10. [App Pages & Navigation](#section-10-app-pages--navigation) 11. [Branding & Theming](#section-11-branding--theming) 12. [Organization Configuration](#section-12-organization-configuration) 13. [Future Features](#section-13-future-features) 14. [Deployment App (Separate Product)](#section-14-deployment-app-separate-product) --- ## 1. Product Overview ### 1.1 What This Is A self-hosted, white-label video/voice collaboration platform deployed entirely within the customer's own Cloudflare account. The platform supports three meeting types — **group video calls**, **audio-only rooms**, and **webinars with stage management** — plus RTMP/HLS livestreaming. All real-time media, participant state, recording, transcription, chat, and polls are handled by Cloudflare RealtimeKit. We build everything RealtimeKit does not provide: user management, scheduling, analytics, diagnostics, branding, and abuse protection. ### 1.2 Deployment Model - **Self-hosted**: Runs on the customer's Cloudflare account (Workers, D1, R2, KV) - **Zero vendor connection**: No traffic routes to our infrastructure at runtime - **Optional phone-home diagnostics**: Health telemetry can be sent to a vendor endpoint; enabled by default (pre-configured by deployment app), toggled per-org - **Single-command deployment**: `wrangler deploy` from repo (deployment app is a separate future product) ### 1.3 What RealtimeKit Handles (We Do NOT Rebuild) | Capability | RTK Component | Our Role | |---|---|---| | Media routing (video/audio/screen share) | SFU + Core SDK | Wire up SDK, expose in UI | | Participant state & permissions | Presets + REST API | Create presets matching our roles, call REST API | | Meeting lifecycle (join/leave/active/inactive) | REST API + SDK events | Proxy REST calls, listen to SDK events | | Chat (public + private DMs + pin) | Core SDK + UI Kit | Render `rtk-chat` components | | Polls | Core SDK + UI Kit | Render `rtk-polls` components | | Stage management (webinars) | Presets + UI Kit | Render stage components, configure presets | | Waiting rooms | Presets + UI Kit | Configure preset, render waiting screen | | Breakout rooms | Connected Meetings API | Render `rtk-breakout-rooms-manager` (web only, beta) | | Recording (composite) | REST API + Recording SDK | Start/stop via API, configure cloud storage | | Transcription (Whisper Large v3 Turbo) | AI feature + webhooks | Enable via preset, display `rtk-ai-transcriptions` | | AI meeting summaries | AI feature + webhooks | Consume `meeting.summary` webhook | | Virtual backgrounds | `@cloudflare/realtimekit-virtual-background` | Include addon | | Plugins (whiteboard, doc sharing) | Plugin system + UI Kit | Enable via preset | | Simulcast | Core SDK config | Set simulcast options | | TURN relay (ICE fallback) | Managed by RTK internally | Transparent — no developer action needed | | Message broadcasting | Server-side API | Call from Worker when needed | | Collaborative stores | Server-side API | Use for custom real-time state sync | ### 1.4 What We Build (Not in RTK) | Feature | Why Custom | Storage | |---|---|---| | **User system** (accounts, auth, roles) | RTK has participants, not users | D1 | | **Meeting scheduling** (instant, scheduled, recurring, permanent rooms) | RTK meetings have no time concept | D1 | | **Post-meeting experience** (recording playback, transcript viewer, AI summary display) | RTK stores data 7 days, we persist references | D1 + R2 | | **Analytics** (session counts, durations, participant stats, recording stats) | RTK provides basic daywise/livestream analytics via REST API; we supplement with granular per-meeting and custom-dimension analytics | D1 (webhook-fed + RTK analytics API) | | **Diagnostics & observability** (error tracking, health checks, Sentry) | Enterprise requirement, not in RTK | Sentry + Workers Observability + D1 (2 tables) | | **Org configuration** (settings, branding, policies) | Platform-level concern | D1 + R2 | | **Abuse protection** (rate limiting, capacity caps, guest controls) | Platform-level concern | KV + D1 | ### 1.5 Meeting Types and Preset Mapping Meeting type is determined by which **preset** is applied to participants at join time. Our platform defines these presets: | Our Role | RTK Preset Type | Key Permissions | |---|---|---| | **Owner** | Video + all host controls | Full admin: kick, mute others, manage stage, create polls, manage breakouts | | **Admin** | Video + all host controls | Same as Owner (platform-level distinction only — RTK treats identically) | | **Host** | Video + host controls | Kick, mute others, manage stage, admit from waiting room | | **Member** | Video + standard | Audio, video, screen share, chat, polls, waiting room bypass | | **Guest** | Video + restricted | Audio only, public chat only, no screen share, must go through waiting room | | **Webinar Viewer** | Webinar + viewer | View stage, public chat, polls — no media publishing | | **Webinar Panelist** | Webinar + stage | Can be added to stage by host, audio/video when on stage | | **Livestream Viewer** | Livestream + viewer | HLS playback only, chat | ### 1.6 Scope Boundaries **In scope (v1, web-first)**: - All Tier 1 features from gap analysis (meeting lifecycle, chat, polls, stage, recording, transcription, AI summaries, virtual backgrounds, plugins, simulcast, webhooks) - RTMP/HLS livestreaming (sparse docs — discovery risk accepted) - Breakout rooms (beta, web only) - Full diagnostics/observability layer **Low priority (groundwork only)**: - Realtime Agents (DO binding + route stubs only; SDK is experimental/transitional) **Needs research**: - ~~Per-track recording~~ — MOVED TO BUILD. `POST /recordings/track` API is documented with layers system for mapping audio/video tracks to output destinations. Not raw RTP export. **Future / dropped**: - Noise cancellation (third-party client-side lib — future) - SIP interconnect (does not exist in RTK — dropped) --- ## 2. System Architecture ### 2.1 High-Level Component Diagram ``` ┌─────────────────────────────────────────────────────────────┐ │ Customer's Cloudflare Account │ │ │ │ ┌──────────────┐ ┌────────────────────────────────────┐ │ │ │ React SPA │◄──►│ Cloudflare Worker (API + Webhooks)│ │ │ │ (Pages/R2) │ │ routes: /api/*, /webhooks/* │ │ │ │ RTK UI Kit │ └──────┬──────┬──────┬───────┬───────┘ │ │ │ RTK Core SDK│ │ │ │ │ │ │ └──────┬───────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ │ │ │ ┌─────┐ ┌────┐ ┌────┐ ┌──────┐ │ │ │ │ D1 │ │ R2 │ │ KV │ │ DO │ │ │ │ │(DB) │ │(fs)│ │(ch)│ │(agt) │ │ │ │ └─────┘ └────┘ └────┘ └──────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────┐ │ │ │ RealtimeKit │ ◄── SDK connects directly from │ │ │ (SFU + services)│ browser to RTK infrastructure │ │ └──────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ (optional) ▼ ┌──────────────────┐ │ Sentry (SaaS) │ ◄── Error tracking from Worker + SPA (vendor DSN + optional customer DSN) └──────────────────┘ ``` ### 2.2 Cloudflare Worker — Route Architecture Single Worker handling all server-side logic. Routes organized by concern: #### Auth Routes | Method | Route | Purpose | |---|---|---| | POST | `/api/auth/register` | Create user account | | POST | `/api/auth/login` | Authenticate, return JWT | | POST | `/api/auth/refresh` | Refresh JWT | | POST | `/api/auth/logout` | Invalidate session | | GET | `/api/auth/me` | Current user profile | #### User & Org Routes | Method | Route | Purpose | |---|---|---| | GET | `/api/users` | List org users (admin+) | | PATCH | `/api/users/:userId` | Update user role/profile | | DELETE | `/api/users/:userId` | Remove user from org | | GET | `/api/org/settings` | Get org configuration | | PATCH | `/api/org/settings` | Update org settings (owner/admin) | | POST | `/api/org/branding` | Upload logo/assets to R2 | #### Meeting Routes | Method | Route | Purpose | |---|---|---| | POST | `/api/meetings` | Create meeting (calls RTK REST: `POST /meetings`) | | GET | `/api/meetings` | List meetings with filters | | GET | `/api/meetings/:meetingId` | Get meeting details | | PATCH | `/api/meetings/:meetingId` | Update meeting (title, schedule, settings) | | DELETE | `/api/meetings/:meetingId` | Deactivate meeting | | POST | `/api/meetings/:meetingId/join` | Create RTK participant, return `authToken` + meeting config | | GET | `/api/meetings/:meetingId/participants` | List participants | | POST | `/api/meetings/:meetingId/recording/start` | Start composite recording via RTK API | | POST | `/api/meetings/:meetingId/recording/stop` | Stop recording via RTK API | | GET | `/api/meetings/:meetingId/recordings` | List recordings for meeting | | GET | `/api/meetings/:meetingId/transcripts` | Get transcripts (from RTK or cached) | | GET | `/api/meetings/:meetingId/summary` | Get AI summary | #### Scheduling Routes | Method | Route | Purpose | |---|---|---| | POST | `/api/schedule` | Create scheduled meeting (stores time + recurrence in D1) | | GET | `/api/schedule` | List upcoming scheduled meetings | | PATCH | `/api/schedule/:scheduleId` | Update schedule | | DELETE | `/api/schedule/:scheduleId` | Cancel scheduled meeting | #### Analytics Routes | Method | Route | Purpose | |---|---|---| | GET | `/api/analytics/overview` | Dashboard stats (sessions, participants, duration) | | GET | `/api/analytics/meetings` | Per-meeting analytics | | GET | `/api/analytics/usage` | Usage trends over time | #### Diagnostics Routes | Method | Route | Purpose | |---|---|---| | GET | `/api/diagnostics/health` | System health check (probe results) | | GET | `/api/diagnostics/webhooks` | Webhook delivery status/failures (paginated) | | GET | `/api/diagnostics/webhooks/:id` | Single webhook delivery detail | | GET | `/api/diagnostics/active-meetings` | Currently active meetings | | POST | `/api/diagnostics/phone-home` | Manual trigger for phone-home report | | PATCH | `/api/diagnostics/settings` | Update diagnostics settings (toggle Sentry, phone-home, customer DSN) | #### Webhook Receiver Route | Method | Route | Purpose | |---|---|---| | POST | `/webhooks/rtk` | Receive all RTK webhook events | #### Agent Routes (Groundwork Only) | Method | Route | Purpose | |---|---|---| | POST | `/api/agents/:meetingId/init` | Initialize agent DO for meeting | | POST | `/api/agents/:meetingId/deinit` | Tear down agent | | ALL | `/agentsInternal/*` | DO internal pipeline (forwarded to DO fetch) | ### 2.3 D1 Database Schema #### `users` ```sql CREATE TABLE users ( id TEXT PRIMARY KEY, -- ulid org_id TEXT NOT NULL, -- tenant isolation email TEXT NOT NULL, display_name TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('owner','admin','host','member','guest')), password_hash TEXT NOT NULL, avatar_url TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), last_login_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, UNIQUE(org_id, email) ); CREATE INDEX idx_users_org ON users(org_id); CREATE INDEX idx_users_email ON users(org_id, email); ``` #### `meetings` ```sql CREATE TABLE meetings ( id TEXT PRIMARY KEY, -- ulid org_id TEXT NOT NULL, rtk_meeting_id TEXT, -- RealtimeKit meeting ID (from REST API response) title TEXT NOT NULL, description TEXT, meeting_type TEXT NOT NULL CHECK(meeting_type IN ('video','audio','webinar','livestream')), status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('active','inactive')), created_by TEXT NOT NULL REFERENCES users(id), is_permanent_room INTEGER NOT NULL DEFAULT 0, max_participants INTEGER, waiting_room_enabled INTEGER NOT NULL DEFAULT 1, recording_auto_start INTEGER NOT NULL DEFAULT 0, transcription_enabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_meetings_org ON meetings(org_id); CREATE INDEX idx_meetings_status ON meetings(org_id, status); CREATE INDEX idx_meetings_rtk ON meetings(rtk_meeting_id); ``` #### `schedules` ```sql CREATE TABLE schedules ( id TEXT PRIMARY KEY, -- ulid org_id TEXT NOT NULL, meeting_id TEXT NOT NULL REFERENCES meetings(id), starts_at TEXT NOT NULL, -- ISO 8601 ends_at TEXT, -- ISO 8601, nullable for open-ended timezone TEXT NOT NULL DEFAULT 'UTC', recurrence_rule TEXT, -- iCalendar RRULE string, null = one-time created_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_schedules_org_time ON schedules(org_id, starts_at); CREATE INDEX idx_schedules_meeting ON schedules(meeting_id); ``` #### `sessions` ```sql CREATE TABLE sessions ( id TEXT PRIMARY KEY, -- ulid org_id TEXT NOT NULL, meeting_id TEXT NOT NULL REFERENCES meetings(id), rtk_session_id TEXT, -- RTK session ID from webhook/SDK started_at TEXT NOT NULL DEFAULT (datetime('now')), ended_at TEXT, participant_count INTEGER NOT NULL DEFAULT 0, peak_participant_count INTEGER NOT NULL DEFAULT 0, duration_seconds INTEGER, -- computed on session end created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_sessions_org ON sessions(org_id); CREATE INDEX idx_sessions_meeting ON sessions(meeting_id); CREATE INDEX idx_sessions_started ON sessions(org_id, started_at); ``` #### `participants` ```sql CREATE TABLE participants ( id TEXT PRIMARY KEY, -- ulid org_id TEXT NOT NULL, session_id TEXT NOT NULL REFERENCES sessions(id), meeting_id TEXT NOT NULL REFERENCES meetings(id), user_id TEXT REFERENCES users(id), -- null for guests rtk_participant_id TEXT, -- RTK participant ID display_name TEXT NOT NULL, role TEXT NOT NULL, joined_at TEXT NOT NULL DEFAULT (datetime('now')), left_at TEXT, duration_seconds INTEGER, is_guest INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_participants_session ON participants(session_id); CREATE INDEX idx_participants_meeting ON participants(meeting_id); CREATE INDEX idx_participants_user ON participants(user_id); ``` #### `recordings` ```sql CREATE TABLE recordings ( id TEXT PRIMARY KEY, -- ulid org_id TEXT NOT NULL, meeting_id TEXT NOT NULL REFERENCES meetings(id), session_id TEXT REFERENCES sessions(id), rtk_recording_id TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','recording','processing','ready','failed','expired')), storage_provider TEXT NOT NULL DEFAULT 'r2' CHECK(storage_provider IN ('r2','aws','azure','digitalocean','gcs','sftp')), storage_url TEXT, -- R2 key or external URL file_size_bytes INTEGER, duration_seconds INTEGER, codec TEXT, started_at TEXT, stopped_at TEXT, expires_at TEXT, -- RTK default: 7 days created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_recordings_org ON recordings(org_id); CREATE INDEX idx_recordings_meeting ON recordings(meeting_id); CREATE INDEX idx_recordings_status ON recordings(org_id, status); ``` #### `transcripts` ```sql CREATE TABLE transcripts ( id TEXT PRIMARY KEY, -- ulid org_id TEXT NOT NULL, meeting_id TEXT NOT NULL REFERENCES meetings(id), session_id TEXT REFERENCES sessions(id), content TEXT NOT NULL, -- full transcript text or JSON format TEXT NOT NULL DEFAULT 'text' CHECK(format IN ('text','json','vtt')), language TEXT DEFAULT 'en', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_transcripts_meeting ON transcripts(meeting_id); ``` #### `meeting_summaries` ```sql CREATE TABLE meeting_summaries ( id TEXT PRIMARY KEY, -- ulid org_id TEXT NOT NULL, meeting_id TEXT NOT NULL REFERENCES meetings(id), session_id TEXT REFERENCES sessions(id), summary_text TEXT NOT NULL, summary_type TEXT, -- e.g., 'action_items', 'key_points', 'full' created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_summaries_meeting ON meeting_summaries(meeting_id); ``` #### `org_settings` > **Note**: This is a simplified overview. See [Section 12.3](#123-d1-schema-org_settings) for the full canonical schema with all branding, policy, and watermark columns. ```sql CREATE TABLE org_settings ( id TEXT PRIMARY KEY DEFAULT 'default', -- single-row table org_name TEXT NOT NULL, org_slug TEXT NOT NULL UNIQUE, rtk_app_id TEXT, -- RealtimeKit App ID logo_url TEXT, -- R2 key brand_color TEXT DEFAULT '#2563EB', default_meeting_type TEXT DEFAULT 'video', max_meeting_duration_minutes INTEGER DEFAULT 480, max_participants_per_meeting INTEGER DEFAULT 200, guest_access_enabled INTEGER NOT NULL DEFAULT 1, waiting_room_default INTEGER NOT NULL DEFAULT 1, recording_storage_provider TEXT DEFAULT 'r2', recording_storage_config TEXT, -- JSON: bucket, credentials (encrypted) transcription_default INTEGER NOT NULL DEFAULT 0, phone_home_enabled INTEGER NOT NULL DEFAULT 0, phone_home_url TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); ``` #### Diagnostics tables (2 tables) — see [Section 3](#3-diagnostics--observability) for full schema. - `diagnostic_webhook_deliveries` — webhook processing status tracking - `diagnostic_health_snapshots` — periodic health probe results ### 2.4 R2 Storage | Bucket | Contents | Access Pattern | |---|---|---| | `{org}-branding` | Logos, custom backgrounds, favicon | Read on page load, write on settings update | | `{org}-recordings` | Composite recordings (if using R2 over external) | Write from RTK, read on playback | | `{org}-exports` | Analytics exports, transcript downloads | Write on demand, read once | ### 2.5 KV Namespaces | Namespace | Key Pattern | Value | TTL | |---|---|---|---| | `sessions` | `session:{jwt_id}` | `{userId, orgId, role, exp}` | JWT expiry (e.g., 24h) | | `rate-limits` | `rl:{orgId}:{route}:{minute}` | Request count (integer) | 60s | | `meeting-state` | `active:{orgId}:{meetingId}` | `{sessionId, participantCount, startedAt}` | None (deleted on session end) | | `cache` | `cache:{orgId}:{resource}:{id}` | Cached API responses | 30-300s depending on resource | ### 2.6 Durable Objects (Groundwork Only) Reserved for Realtime Agents. Not built in v1 but wrangler config includes the binding: ```jsonc // wrangler.jsonc (relevant section) { "durable_objects": { "bindings": [ { "class_name": "RealtimeAgent", "name": "REALTIME_AGENT" } ] }, "migrations": [ { "new_sqlite_classes": ["RealtimeAgent"], "tag": "v1" } ] } ``` ### 2.7 Worker Bindings Summary ```jsonc // wrangler.jsonc bindings { "d1_databases": [ { "binding": "DB", "database_name": "rtk-platform", "database_id": "..." } ], "r2_buckets": [ { "binding": "R2_BRANDING", "bucket_name": "rtk-branding" }, { "binding": "R2_RECORDINGS", "bucket_name": "rtk-recordings" }, { "binding": "R2_EXPORTS", "bucket_name": "rtk-exports" } ], "kv_namespaces": [ { "binding": "KV_SESSIONS", "id": "..." }, { "binding": "KV_RATE_LIMITS", "id": "..." }, { "binding": "KV_MEETING_STATE", "id": "..." }, { "binding": "KV_CACHE", "id": "..." } ], "vars": { "RTK_API_BASE": "https://api.cloudflare.com/client/v4/accounts", "PHONE_HOME_URL": "" }, "secrets": ["ACCOUNT_ID", "RTK_API_TOKEN", "JWT_SECRET", "SENTRY_DSN_VENDOR", "SENTRY_DSN_CUSTOMER", "SENTRY_RELEASE"] } ``` ### 2.8 Frontend Architecture - **React SPA** deployed to Cloudflare Pages (or served from R2 behind Worker) - **RTK UI Kit** (`@cloudflare/realtimekit-react-ui`) for all meeting UI — 136 pre-built components - **RTK Core SDK** (`@cloudflare/realtimekit-react`) for programmatic control and custom UI where needed - **Routing**: Client-side (React Router). Meeting room is a single route (`/meeting/:meetingId`) that loads the full RTK UI Kit experience - **State management**: RTK SDK manages all meeting state; React context for app-level state (auth, settings) - **Addons loaded**: Virtual background, recording, AI transcription ### 2.9 Data Flow: Meeting Join Sequence ``` User clicks "Join Meeting" → SPA calls POST /api/meetings/:meetingId/join (with JWT) → Worker validates JWT, checks user role + meeting permissions → Worker calls RTK REST API: POST /realtime/kit/{APP_ID}/meetings/{MEETING_ID}/participants with preset matching user's role → RTK returns { participantId, authToken } → Worker stores participant record in D1 → Worker updates KV meeting-state (increment participant count) → Worker returns { authToken, meetingConfig, preset_name } to SPA → SPA initializes RTK SDK: RealtimeKitClient.init({ authToken }) → SDK connects to RTK SFU (direct browser → Cloudflare edge) → Meeting UI renders via RTK UI Kit components ``` ### 2.10 Data Flow: Webhook Processing ``` RTK fires webhook event (e.g., recording.statusUpdate) → POST /webhooks/rtk → Worker validates webhook signature → Worker classifies event type → Worker routes to handler: ├── recording.statusUpdate → update recordings table, notify via WS if active ├── meeting.chatSynced → store chat export reference ├── meeting.summary → store summary in D1 ├── meeting.started → create session record, update KV meeting-state ├── meeting.ended → close session record, compute duration, update analytics ├── meeting.participantJoined → update participant count ├── meeting.participantLeft → update participant record, compute duration ├── meeting.transcript → store transcript in D1 └── livestreaming.statusUpdate → update livestream state → Handler writes to D1 + KV as needed → Handler writes to diagnostic_webhook_deliveries (success or failure); errors captured by Sentry automatically → Worker returns 200 OK (or 500 + logs failure for retry) ``` --- ## 3. Diagnostics & Observability ### 3.1 Design Philosophy Diagnostics is a **core architectural layer**, not an afterthought. The guiding principle: **know about issues before customers call.** The code is always present — orgs can disable reporting but the instrumentation is never stripped. The ultimate test: when a customer says "my meeting was laggy" or "I couldn't join" — we must be able to look up that specific meeting, that specific user, and see exactly what happened without ever asking the customer to reproduce the problem on a screenshare. We achieve this by layering four systems that each do what they're best at: | Layer | Tool | What it handles | Why not something else | |---|---|---|---| | **Error tracking & investigation** | Sentry (`@sentry/cloudflare` + `@sentry/react`) | Error capture, deduplication, grouping, stack traces, release tracking, alerting | Purpose-built for this; replacing it with D1 tables would be rebuilding Sentry poorly | | **Request tracing & I/O monitoring** | Cloudflare Workers Observability | Auto-traces every `fetch()`, KV, R2, D1, DO call. Zero code needed. OpenTelemetry-compliant. | Built into the platform — literally one config line. Traces exported to Sentry via OTLP. | | **Post-meeting quality investigation** | RTK Peer Report API (`GET /sessions/peer-report/{peer_id}`) | Per-participant quality stats (MOS, jitter, packet loss, RTT), device info, ICE candidates, TURN usage, IP geolocation, timestamped events | RTK already captures this server-side for every participant — we just query it on demand | | **Operational data we own** | D1 tables (2 tables) | Webhook delivery tracking, health check snapshots | Only we know about webhook processing flows and health probe results — no external tool captures this | What we do NOT build: - ~~D1 error tables~~ — Sentry is the error store. No `diagnostic_errors` table. - ~~D1 RTK API call logs~~ — Workers Observability auto-traces every `fetch()` to RTK API with latency, status, headers. No `diagnostic_rtk_api_calls` table. - ~~Custom RTK API call wrapper for diagnostics~~ — Workers Observability instruments `fetch()` automatically. Our RTK API client is a thin wrapper focused on auth/retries, not diagnostic logging. ### 3.2 Sentry Integration Sentry is the backbone of error tracking. Two SDKs, two contexts, one Sentry project. #### Packages & Versions - **Worker**: `@sentry/cloudflare` (v10.x, production-ready, official Sentry package) - **SPA**: `@sentry/react` (mature, full-featured including Session Replay) - **Replaces**: toucan-js (deprecated; `@hono/sentry` also deprecated in favor of `@sentry/cloudflare`) - **Requirement**: `compatibility_flags = ["nodejs_compat"]` in wrangler.toml (for AsyncLocalStorage) #### DSN Ownership Model Each deployment has up to two Sentry DSNs: | DSN | Secret Name | Purpose | Default | |---|---|---|---| | **Vendor DSN** | `SENTRY_DSN_VENDOR` | Our (platform vendor) Sentry project. We see errors across ALL customer deployments. | Pre-configured by deployment app. Enabled by default. | | **Customer DSN** | `SENTRY_DSN_CUSTOMER` | Customer's own Sentry project. They see only their own errors. | Empty. Customer adds via org settings. | **Behavior:** - If both DSNs are set → errors go to both (dual-client pattern) - If customer disables vendor DSN → errors go only to customer's Sentry (or nowhere if they have no DSN) - If neither is set → errors log to `console.error` only (captured by Workers Observability logs) - Customer can toggle vendor DSN on/off from org settings page at any time - Customer can add/change their own DSN from org settings page at any time - DSNs are write-only (can only submit events, cannot read/modify/delete) — safe to embed **Sentry project structure (vendor side):** - ONE Sentry project for all customer deployments (not per-customer) - Events tagged with `deployment_id` and `org_id` for filtering - Use Sentry's search/filter to isolate per-deployment views - Sentry Team plan ($26/mo base, ~$0.00015/event overage) — handles scale well #### Worker-Side Init ```typescript import * as Sentry from "@sentry/cloudflare"; // The entire Worker handler is wrapped with Sentry's withSentry() export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN_VENDOR, environment: env.ENVIRONMENT || "production", release: env.SENTRY_RELEASE, // git SHA, set at deploy time tracesSampleRate: 0.1, // 10% of transactions beforeSend(event) { // Strip PII: redact email, IP, auth tokens event = redactPII(event); // Forward to customer DSN if configured if (env.SENTRY_DSN_CUSTOMER && env.SENTRY_CUSTOMER_ENABLED !== "false") { forwardToCustomerSentry(event, env.SENTRY_DSN_CUSTOMER); } // Check if vendor DSN is disabled by org if (env.SENTRY_VENDOR_ENABLED === "false") return null; // drop event return event; }, }), { async fetch(request, env, ctx) { // Sentry automatically captures unhandled exceptions, // attaches request data, and traces fetch() sub-requests return router.handle(request, env, ctx); }, async scheduled(event, env, ctx) { ctx.waitUntil(runHealthCheck(env)); ctx.waitUntil(cleanupOldDiagnostics(env)); }, } ); ``` **What `withSentry()` gives us automatically (zero additional code):** - Unhandled exception capture with full stack traces - Request context (URL, method, headers) attached to every event - Breadcrumbs for `console.log/warn/error` calls - Distributed tracing across fetch sub-requests #### SPA-Side Init ```typescript import * as Sentry from "@sentry/react"; Sentry.init({ dsn: config.sentryDsnVendor, // fetched from Worker config endpoint environment: config.environment, release: config.release, integrations: [ Sentry.browserTracingIntegration(), Sentry.replayIntegration({ maskAllText: false, blockAllMedia: false }), ], tracesSampleRate: 0.1, replaysSessionSampleRate: 0.1, // 10% of sessions replaysOnErrorSampleRate: 1.0, // 100% of sessions with errors beforeSend(event) { event = redactPII(event); if (config.sentryDsnCustomer) { forwardToCustomerSentry(event, config.sentryDsnCustomer); } if (!config.sentryVendorEnabled) return null; return event; }, }); ``` **Dual-DSN implementation (SPA):** Use a custom transport wrapper that POSTs the event envelope to both DSN endpoints. This is simpler and more reliable than running two `BrowserClient` instances (which Sentry docs call "not recommended" due to integration conflicts). ```typescript function makeDualTransport(vendorDsn: string, customerDsn?: string) { const vendorTransport = Sentry.makeFetchTransport({ dsn: vendorDsn }); const customerTransport = customerDsn ? Sentry.makeFetchTransport({ dsn: customerDsn }) : null; return (options: Sentry.TransportOptions) => ({ send: async (envelope: Sentry.Envelope) => { const results = await Promise.allSettled([ vendorTransport(options).send(envelope), customerTransport ? customerTransport(options).send(envelope) : Promise.resolve(), ]); return results[0].status === "fulfilled" ? results[0].value : results[1].value; }, flush: (timeout?: number) => vendorTransport(options).flush(timeout), }); } ``` #### Sentry Tags (applied to every event) | Tag | Value | Purpose | |---|---|---| | `deployment_id` | Unique per deployment | Filter vendor Sentry by deployment | | `org_id` | Org identifier | Group errors by customer org | | `user_id` | Platform user ID | "Show me all errors for user X" — set via `Sentry.setUser({ id })` | | `participant_id` | RTK participant/peer ID (set on meeting join) | Cross-reference with peer-report API for quality investigation | | `session_id` | RTK session ID (set on meeting join) | Correlate errors to specific meeting sessions | | `component` | `worker`, `spa`, `rtk-sdk`, `webhook-handler` | Error source | | `meeting_id` | RTK meeting ID (when in meeting context) | Correlate errors to specific meetings | | `route` | e.g., `POST /api/meetings/:id/join` | Which API route errored | #### Source Maps ```bash # Deploy script uploads source maps to Sentry SENTRY_RELEASE=$(sentry-cli releases propose-version) wrangler deploy --var SENTRY_RELEASE:$SENTRY_RELEASE sentry-cli sourcemaps upload --release=$SENTRY_RELEASE dist/ ``` - Requires `sentry-cli >= 2.17.0` and `wrangler >= 3.x` - Worker source maps: `upload_source_maps = true` in wrangler.toml - SPA source maps: Vite plugin or manual upload #### What Gets Reported to Sentry | Source | What | Automatic? | |---|---|---| | Worker unhandled exceptions | All crashes, binding errors, OOM | Yes (`withSentry` catches them) | | Worker `fetch()` failures | RTK API 4xx/5xx, timeout, network errors | Yes (Workers Observability traces + Sentry captures thrown errors) | | Webhook handler failures | Processing errors, invalid payloads | Yes (thrown errors within `withSentry`) | | D1/KV/R2 failures | Query errors, constraint violations, permission errors | Yes (thrown errors) | | SPA React error boundaries | Component crashes | Yes (`Sentry.ErrorBoundary` wraps RTK UI Kit components) | | SPA RTK SDK errors | See section 3.5 below | Manual (event listeners → `Sentry.captureException`) | | SPA network errors | Failed API calls to our Worker | Yes (`browserTracingIntegration` traces fetch) | ### 3.3 Cloudflare Workers Observability Workers Observability provides auto-instrumentation of all Worker I/O with zero code changes. It captures what we would otherwise need custom wrapper code to log. #### Configuration ```toml # wrangler.toml [observability] enabled = true [observability.logs] head_sampling_rate = 1 # 1.0 = 100% of requests logged (adjust for cost) [observability.traces] enabled = true head_sampling_rate = 0.01 # 1% of requests traced (adjust for cost vs visibility) ``` That's it. No code changes. Note: logs and traces are separate opt-ins. This enables: 1. **Workers Logs** — all `console.log/warn/error` calls are collected, queryable in Cloudflare dashboard, searchable 2. **Workers Traces** — automatic OpenTelemetry-compliant traces for every `fetch()`, KV `get/put`, R2 `get/put`, D1 `prepare/run`, and Durable Object calls. Shows latency, status, and call hierarchy per request. 3. **Metrics Dashboard** (Beta) — single view of all Worker metrics + logs #### What this replaces The old spec had a `diagnostic_rtk_api_calls` D1 table and a custom `rtkApiCall()` wrapper that manually logged every RTK API call's method, endpoint, duration, and status. **Workers Observability does this automatically** for every `fetch()` the Worker makes — including RTK API calls, webhook deliveries, and any external HTTP calls. With full OpenTelemetry trace context. **One exception**: Workers Observability captures URL, method, status code, and latency — but NOT response bodies. Our RTK API client wrapper adds error-response logging so we know *why* a call failed, not just that it did: ```typescript // In the RTK API client wrapper (auth/retries layer) if (!res.ok) { const body = await res.text(); console.error(JSON.stringify({ rtk_api_error: { method, endpoint, status: res.status, body: body.slice(0, 500) } })); // Workers Observability captures this console.error, making it searchable } ``` #### OTel Export to Sentry Workers Observability can export traces to Sentry's OTLP endpoint, connecting Cloudflare's auto-instrumented traces with Sentry's error tracking: ```toml # wrangler.toml (if using direct OTel export — alternative to SDK-based tracing) [observability] enabled = true [observability.logs] enabled = true invocation_logs = true # log start/end of each invocation # Export config is set via Cloudflare dashboard or API: # Destination: Sentry OTLP endpoint # Protocol: OTLP/HTTP ``` **Note:** When using `@sentry/cloudflare` with `withSentry()`, Sentry already captures traces via the SDK. OTel export provides a complementary view in Sentry (infrastructure-level traces vs application-level). Both can coexist. #### Pricing (customer pays — runs on their Cloudflare account) | Feature | Free | Workers Paid | |---|---|---| | Logs | 200K events/day, 3-day retention | 20M/month, 7-day retention, $0.60/M overage | | Traces | Free during beta (no published pricing yet) | Free during beta | For most deployments, the included quotas are more than sufficient. The `head_sampling_rate` can be lowered to reduce volume. #### Logpush (optional, for long-term retention) Customers who want logs retained beyond 7 days can enable Logpush to push Worker logs to external storage: Supported destinations: R2, S3, Google Cloud Storage, Azure Blob, Datadog, Elastic, Splunk, Sumo Logic, New Relic, any HTTP endpoint. Config: `logpush = true` in wrangler.toml. Workers Paid plan only. ### 3.4 D1 Diagnostics Tables (2 tables) We only store data in D1 that no external tool captures: webhook processing status and health check results. #### `diagnostic_webhook_deliveries` Sentry captures errors, but it doesn't know about webhook *processing flows* (received → processing → processed/failed). Only we track this. ```sql CREATE TABLE diagnostic_webhook_deliveries ( id TEXT PRIMARY KEY, -- ulid (time-sortable) org_id TEXT NOT NULL, webhook_event_type TEXT NOT NULL, -- e.g., recording.statusUpdate, meeting.summary rtk_event_id TEXT, -- event ID from RTK payload meeting_id TEXT, session_id TEXT, status TEXT NOT NULL CHECK(status IN ('received','processing','processed','failed','retry_exhausted')), processing_duration_ms INTEGER, error_message TEXT, -- null if success payload_summary TEXT, -- truncated/redacted payload for debugging received_at TEXT NOT NULL DEFAULT (datetime('now')), processed_at TEXT ); CREATE INDEX idx_webhook_org_time ON diagnostic_webhook_deliveries(org_id, received_at DESC); CREATE INDEX idx_webhook_status ON diagnostic_webhook_deliveries(org_id, status); CREATE INDEX idx_webhook_event ON diagnostic_webhook_deliveries(org_id, webhook_event_type, received_at DESC); CREATE INDEX idx_webhook_meeting ON diagnostic_webhook_deliveries(meeting_id, received_at DESC); ``` #### `diagnostic_health_snapshots` Periodic health probe results. No external tool runs these probes — we do. ```sql CREATE TABLE diagnostic_health_snapshots ( id TEXT PRIMARY KEY, -- ulid org_id TEXT NOT NULL, check_type TEXT NOT NULL CHECK(check_type IN ('scheduled','manual','phone_home')), status TEXT NOT NULL CHECK(status IN ('healthy','degraded','unhealthy')), d1_latency_ms INTEGER, kv_latency_ms INTEGER, r2_latency_ms INTEGER, rtk_api_latency_ms INTEGER, rtk_api_reachable INTEGER NOT NULL DEFAULT 1, active_meetings_count INTEGER NOT NULL DEFAULT 0, active_participants_count INTEGER NOT NULL DEFAULT 0, details TEXT, -- JSON: per-check details created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_health_org_time ON diagnostic_health_snapshots(org_id, created_at DESC); CREATE INDEX idx_health_status ON diagnostic_health_snapshots(org_id, status); ``` ### 3.5 Client-Side RTK SDK Error Capture The RTK SDK emits events that `@sentry/react` doesn't automatically capture (they're not unhandled exceptions — they're SDK-specific events). We listen for them and forward to Sentry. #### RTK SDK Events to Capture | Event | Source | What it means | |---|---|---| | `mediaPermissionError` | `meeting.self` | Camera/mic denied: `DENIED`, `SYS_DENIED`, `UNAVAILABLE`, `CANCELED`. **UNVERIFIED** — exact event name not confirmed in RTK docs, verify at build time | | `mediaConnectionUpdate` | `meeting.meta` | WebRTC media connection state changed (connected/disconnected/failed) | | `socketConnectionUpdate` | `meeting.meta` | Signaling WebSocket state changed. **UNVERIFIED** — verify exact event name at build time | | `networkQualityScore` | `meeting.self` + remote participants | Per-participant network quality score. **UNVERIFIED** — verify exact event name at build time | | Recording `ERRORED` state | `meeting.recording` | Recording failed (irrecoverable) | | Screenshare errors | UI Kit language pack | Max screenshare limit reached, unknown error | | Livestream errors | UI Kit language pack | Not supported, not found, sync error, start/stop failure | #### Implementation Pattern ```typescript // In the React meeting component, after RTK meeting is initialized: meeting.self.on("mediaPermissionError", (error) => { Sentry.captureException(new Error(`Media permission: ${error.kind} - ${error.message}`), { tags: { component: "rtk-sdk", error_type: "media_permission", meeting_id: meeting.meta.meetingId }, extra: { permission: error.mediaPermissions }, }); }); meeting.meta.on("mediaConnectionUpdate", ({ state }) => { if (state === "failed") { Sentry.captureException(new Error("WebRTC media connection failed"), { tags: { component: "rtk-sdk", error_type: "media_connection", meeting_id: meeting.meta.meetingId }, }); } // Non-error states logged as breadcrumbs for context Sentry.addBreadcrumb({ category: "rtk", message: `Media connection: ${state}`, level: "info" }); }); meeting.self.on("networkQualityScore", (score) => { // Not an error — logged as breadcrumb so it's available if an error occurs later Sentry.addBreadcrumb({ category: "rtk", message: `Network quality: ${score}`, level: "info" }); }); ``` #### Join Flow Tracing "I couldn't join the meeting" is the #1 support complaint in any video product. The join flow crosses client→Worker→RTK→WebRTC, and failure at any step looks the same to the user: nothing happens. We trace every step with structured Sentry breadcrumbs so that if any step fails, the full trace is captured automatically. ```typescript // Join flow — each step adds a breadcrumb, failure at any step fires captureException with full trail async function joinMeeting(meetingId: string) { const addStep = (step: string) => Sentry.addBreadcrumb({ category: "join_flow", message: step, level: "info" }); try { addStep("join_flow:requesting_token"); const { authToken, participantId } = await api.post(`/meetings/${meetingId}/join`); Sentry.setTag("participant_id", participantId); addStep("join_flow:token_received"); addStep("join_flow:sdk_init"); const meeting = await RTKClient.init({ authToken }); addStep("join_flow:connecting"); await meeting.join(); // Track room state transitions meeting.self.on("roomStateUpdate", (state: string) => { Sentry.addBreadcrumb({ category: "rtk", message: `Room state: ${state}`, level: "info" }); if (state === "disconnected" || state === "kicked" || state === "rejected") { Sentry.captureMessage(`Meeting ended unexpectedly: ${state}`, { level: "warning", tags: { component: "rtk-sdk", meeting_id: meetingId }, }); } }); addStep("join_flow:joined"); } catch (err) { // All breadcrumbs from prior steps are attached automatically Sentry.captureException(err, { tags: { component: "join_flow", meeting_id: meetingId }, }); throw err; } } ``` States tracked via `meeting.self.roomState`: `init` → `joined` | `waitlisted` | `rejected` | `kicked` | `left` | `ended` | `disconnected`. All transitions are captured as breadcrumbs; abnormal exits (`disconnected`, `kicked`, `rejected`) fire Sentry events. #### React Error Boundaries Wrap RTK UI Kit components in Sentry error boundaries to catch component crashes: ```tsx } showDialog> ``` #### RTK Debugger Components (built-in quality UI) RTK provides 6 debugger components that display real-time quality metrics. We include these in the meeting UI behind a toggle — no custom code needed: | Component | Shows | |---|---| | `rtk-debugger` | Parent container for all debug panels | | `rtk-debugger-audio` | Audio bitrate, packet loss, jitter, CPU limitations | | `rtk-debugger-video` | Video bitrate, packet loss, jitter, bandwidth limitations | | `rtk-debugger-screenshare` | Screenshare network & media stats | | `rtk-debugger-system` | Battery level, battery charging status | | `rtk-debugger-toggle` | Button to show/hide the debugger panel | These components display quality ratings (Good / Average / Poor) with detailed stats. We render them as-is — RTK handles the data collection and display. ### 3.6 RTK Peer Report API (Post-Meeting Quality Investigation) RTK captures detailed per-participant quality data server-side for every session. This is the primary tool for investigating "my meeting was laggy" complaints — no client-side instrumentation needed. **Endpoint**: `GET /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/sessions/peer-report/{PEER_ID}?filters=device_info,ip_information,precall_network_information,events,quality_stats` **What it returns per participant:** | Category | Data | Investigation Use | |---|---|---| | `quality.audio_producer` | Timestamped: jitter, packet loss, RTT, MOS score, bytes sent | "Audio kept cutting out" → check packet loss spikes over time | | `quality.video_producer` | Same metrics for video | "Video was frozen" → check if participant stopped producing or quality dropped | | `quality.*_cumulative` | Aggregates: avg packet loss, p50/p75/p90 MOS, RTT distribution | Quick quality score for the session — was it good, mediocre, or bad? | | `metadata.device_info` | OS, browser, browser version, CPU count, mobile flag, SDK version | "It only happens on Safari" → filter by browser across sessions | | `metadata.ip_information` | City, country, region, ASN, timezone | "Our London office has problems" → check if issues correlate with location/ISP | | `metadata.candidate_pairs` | ICE candidates (local/remote type, protocol, RTT), producing + consuming transports | "Behind corporate firewall" → see if TURN relay was used, check relay RTT | | `metadata.pc_metadata` | `effective_network_type`, `reflexive_connectivity`, `relay_connectivity`, `turn_connectivity` | Pre-call network assessment — was the user's network even viable? | | `metadata.events` | Timestamped peer events throughout session | Full timeline of what happened from RTK's perspective | **Usage model**: On-demand, not automated. When a complaint arrives: 1. Look up the meeting → get session ID from D1 or RTK sessions API 2. Get participant list: `GET /sessions/{SESSION_ID}/participants` 3. For the complaining user, call peer-report with all filters 4. Compare their quality stats with other participants in the same session: - If **only they** had bad MOS/high packet loss → their network - If **everyone** had issues → RTK/regional problem - If **specific participants** had issues → check their shared characteristics (location, ISP, browser) **"Always laggy" user**: Pull peer reports across multiple sessions for the same `custom_participant_id`. If quality is consistently bad regardless of meeting → it's their network/device. The data answers the question definitively. ### 3.7 Diagnostic Data Flows #### Flow 1: Worker Error (automatic) ``` Request arrives → Sentry withSentry() wrapper → Handler executes → On unhandled error: ├── Sentry captures automatically (stack trace, request context, breadcrumbs) ├── Workers Observability logs the error + full fetch trace └── Worker returns error response → No custom error logging code needed ``` #### Flow 2: Webhook Processing (custom — we track the flow) ``` POST /webhooks/rtk arrives → Validate webhook signature ├── Invalid: Sentry.captureMessage("Invalid webhook signature"), return 401 └── Valid: continue → Parse event type from payload → INSERT INTO diagnostic_webhook_deliveries (status: 'processing') → Route to event handler → On success: ├── UPDATE webhook delivery (status: 'processed', processing_duration_ms) └── Return 200 → On failure: ├── Sentry.captureException(err, { tags: { webhook_event_type, meeting_id } }) ├── UPDATE webhook delivery (status: 'failed', error_message) └── Return 500 (RTK will retry) ``` #### Flow 3: Health Check (custom — periodic probes) ``` Scheduled CRON trigger (every 5 minutes) OR GET /api/diagnostics/health → Run parallel health probes: ├── D1: SELECT 1 → measure latency ├── KV: get test key → measure latency ├── R2: head test object → measure latency └── RTK API: GET /meetings?limit=1 → measure latency + confirm auth → Compute status: ├── healthy: all probes pass ├── degraded: any probe > 2s └── unhealthy: any probe fails → INSERT INTO diagnostic_health_snapshots → If phone_home_enabled AND (degraded OR unhealthy): ├── POST to vendor phone-home endpoint └── Log delivery result → Return health JSON ``` #### Flow 4: Client-Side Errors (Sentry SDK handles it) ``` RTK SDK error event fires in browser → Our event listener calls Sentry.captureException() with tags → Sentry SDK sends to vendor DSN (and customer DSN if configured) → No Worker round-trip needed — Sentry SDK sends directly to Sentry React component crash in meeting UI → Sentry.ErrorBoundary catches it → Sentry SDK sends crash report with component tree → Fallback UI shown to user ``` ### 3.8 Phone-Home Diagnostics Optional vendor telemetry. **Enabled by default** (pre-configured by deployment app). Org can disable from settings. ``` When enabled (org_settings.phone_home_enabled = 1): → On every health check (scheduled or manual): POST {PHONE_HOME_URL}/api/telemetry/health Body: { orgId (SHA-256 hashed), status, // healthy | degraded | unhealthy activeMeetings, activeParticipants, d1Latency, kvLatency, r2Latency, rtkApiLatency, workerVersion, sdkVersion, timestamp } → On FATAL Sentry event (via beforeSend hook): POST {PHONE_HOME_URL}/api/telemetry/alert Body: { orgId (SHA-256 hashed), severity: 'fatal', errorMessage (truncated, no PII), component, timestamp } ``` **Privacy guarantees:** - Org ID is SHA-256 hashed before transmission - No user data, meeting content, chat messages, participant names, emails, or IPs ever transmitted - Only aggregate counts and system health metrics - All phone-home calls logged in `diagnostic_health_snapshots` for transparency - Can be disabled at any time; disabling immediately stops all outbound telemetry ### 3.9 Diagnostics Routes | Method | Route | Purpose | Auth | |---|---|---|---| | GET | `/api/diagnostics/health` | System health check (probe results) | Owner/Admin | | GET | `/api/diagnostics/webhooks` | Webhook delivery status/failures (paginated) | Owner/Admin | | GET | `/api/diagnostics/webhooks/:id` | Single webhook delivery detail | Owner/Admin | | GET | `/api/diagnostics/active-meetings` | Currently active meetings | Owner/Admin | | GET | `/api/diagnostics/meetings/:meetingId/investigate` | Meeting investigation report (see below) | Owner/Admin | | POST | `/api/diagnostics/phone-home` | Manual trigger for phone-home report | Owner only | | PATCH | `/api/diagnostics/settings` | Update diagnostics settings (toggle vendor Sentry, phone-home, set customer DSN) | Owner only | **Removed routes** (now handled by Sentry): - ~~`GET /api/diagnostics/errors`~~ → Use Sentry dashboard - ~~`GET /api/diagnostics/errors/:errorId`~~ → Use Sentry dashboard - ~~`GET /api/diagnostics/trends`~~ → Use Sentry dashboard #### Meeting Investigation Endpoint `GET /api/diagnostics/meetings/:meetingId/investigate` — the "never need a screenshare" tool. Aggregates all diagnostic data for a specific meeting into one response. **What it calls (server-side, on demand):** 1. `GET /sessions?meeting_id={meetingId}` → session details (start/end time, status) 2. `GET /sessions/{sessionId}/participants` → participant list with join/leave times 3. `GET /sessions/peer-report/{peerId}?filters=device_info,ip_information,quality_stats,events,precall_network_information` → per-participant quality (called for each participant) 4. `SELECT * FROM diagnostic_webhook_deliveries WHERE meeting_id = ?` → webhook processing history **Response structure:** ```json { "meeting_id": "...", "session": { "id": "...", "started_at": "...", "ended_at": "...", "duration_seconds": 3600 }, "participants": [ { "id": "...", "display_name": "...", "custom_participant_id": "...", "joined_at": "...", "left_at": "...", "duration": 3540, "device": { "os": "macOS", "browser": "Chrome 120", "is_mobile": false }, "network": { "city": "London", "country": "GB", "asn": "AS2856", "effective_type": "4g" }, "quality_summary": { "audio_mos_avg": 4.2, "audio_packet_loss_avg": 0.3, "audio_rtt_avg": 45, "video_mos_avg": 3.8, "video_packet_loss_avg": 1.2, "ice_candidate_type": "relay", "turn_used": true } } ], "webhook_deliveries": [ { "event_type": "recording.statusUpdate", "status": "processed", "...": "..." } ], "sentry_link": "https://sentry.io/organizations/.../issues/?query=meeting_id:..." } ``` This single endpoint answers: who was there, when they joined/left, what device/browser/network they were on, what their quality was like, whether TURN was needed, and what webhooks fired. If quality was bad for one participant but fine for others — it's their network. If bad for everyone — it's RTK or regional. ### 3.10 Diagnostics Dashboard (Admin UI) The SPA includes a `/admin/diagnostics` page (Owner/Admin role only): | Panel | Data Source | Content | |---|---|---| | **System Health** | `diagnostic_health_snapshots` | Status badge (healthy/degraded/unhealthy), D1/KV/R2/RTK API latency gauges, last check time | | **Webhook Deliveries** | `diagnostic_webhook_deliveries` | Recent deliveries with status badges, failure rate %, event types | | **Active Meetings** | Worker API (KV + RTK) | Currently active meetings with participant count and duration | | **Meeting Investigation** | `/api/diagnostics/meetings/:id/investigate` | Enter a meeting ID → see full investigation report: participants, quality per user, devices, network, webhook history, Sentry link | | **Sentry Link** | External | "View Errors in Sentry" button linking to the Sentry project (filtered by deployment_id) | | **Settings** | `org_settings` | Toggle vendor Sentry on/off, add/edit customer Sentry DSN, toggle phone-home | ### 3.11 Data Retention & Cleanup Scheduled cleanup via Worker CRON: | Table | Retention | Schedule | |---|---|---| | `diagnostic_webhook_deliveries` | 30 days | Daily at 03:00 UTC | | `diagnostic_health_snapshots` | 90 days | Weekly | Sentry retention is managed by the Sentry plan (90 days on Team plan). Workers Observability logs/traces retain for 7 days (Cloudflare managed). No cleanup code needed for either. CRON trigger in wrangler config: ```toml [triggers] crons = ["0 3 * * *"] # Daily at 03:00 UTC ``` ### 3.12 Wrangler Configuration for Observability ```toml # Observability — logs + traces, zero code [observability] enabled = true [observability.logs] head_sampling_rate = 1 invocation_logs = true [observability.traces] enabled = true head_sampling_rate = 0.01 ``` Required secrets (set via `wrangler secret put`): ``` SENTRY_DSN_VENDOR # Vendor's Sentry DSN (set by deployment app) SENTRY_DSN_CUSTOMER # Customer's Sentry DSN (optional, set via org settings) SENTRY_RELEASE # Git SHA (set at deploy time) PHONE_HOME_URL # Vendor telemetry endpoint (set by deployment app) ``` ### 3.13 Alerting (Launch Minimum) "Know about issues before customers call" requires minimum alerting at launch. We use Sentry's built-in alert rules (zero code, configured in Sentry dashboard) plus phone-home: **Launch alerts (Sentry dashboard config, no custom code):** | Alert | Condition | Action | |---|---|---| | Error spike | >10 errors in 5 minutes | Sentry notification (email/Slack/PagerDuty via Sentry integrations) | | Fatal error | Any `level: fatal` event | Immediate Sentry notification + phone-home alert | | Recording failure | Events tagged `component: rtk-sdk` + `error_type: recording` | Sentry notification | | Media connection failure | Events tagged `error_type: media_connection` | Sentry notification | | New issue | First occurrence of a new error type | Sentry notification | **Launch alerts (phone-home, built into health check CRON):** - Health degraded/unhealthy → phone-home alert (already in Section 3.8) - Webhook failure rate >20% in last 15 minutes → phone-home alert with severity `warning` (query `diagnostic_webhook_deliveries` in health check CRON) **FUTURE**: Org-facing alerting (email, webhook to Slack/Teams/PagerDuty) for health status changes, webhook failure spikes, and meeting quality degradation. Will be built as a separate notification system when the core platform is stable. --- *Sections 4+ (Frontend Architecture, Feature Implementation, Deployment, Testing) to be written by other team members.* # Sections 4-6: User System, Role-to-Preset Mapping, Meeting Lifecycle --- ## Section 4: User System & Authentication ### 4.1 User Model The platform defines five roles in a strict hierarchy: | Role | Level | Description | |------|-------|-------------| | **Owner** | 5 | Organization creator. One per org. Cannot be demoted. Full control over billing, settings, and all users. | | **Admin** | 4 | Organization-level administrator. Manages users, settings, presets. Cannot delete org or transfer ownership. | | **Host** | 3 | Can create and manage meetings, admit waiting room participants, control stage, kick users. Default role for new members with meeting creation privileges. | | **Member** | 2 | Standard participant. Can join meetings they're invited to or that are open. Cannot create meetings unless promoted. | | **Guest** | 1 | Unauthenticated or externally-authenticated user. Joins via meeting link. Controlled by guest access policies. No org-level visibility. | ### 4.2 D1 Schema: Users & Organizations ```sql -- Organizations table CREATE TABLE organizations ( id TEXT PRIMARY KEY, -- nanoid name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, -- URL-safe org identifier (e.g., "acme-corp") rtk_app_id TEXT NOT NULL, -- RealtimeKit App ID for this org owner_id TEXT NOT NULL, -- references users.id settings_json TEXT DEFAULT '{}', -- org-wide settings (JSON blob) guest_access_default TEXT NOT NULL DEFAULT 'link_only', -- 'disabled' | 'link_only' | 'open' created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Users table CREATE TABLE users ( id TEXT PRIMARY KEY, -- nanoid org_id TEXT NOT NULL REFERENCES organizations(id), email TEXT NOT NULL, name TEXT NOT NULL, password_hash TEXT, -- null for SSO/guest users role TEXT NOT NULL DEFAULT 'member', -- 'owner' | 'admin' | 'host' | 'member' avatar_url TEXT, is_active INTEGER NOT NULL DEFAULT 1, last_login_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(org_id, email) ); -- Guest access tokens (for unauthenticated meeting access) CREATE TABLE guest_tokens ( id TEXT PRIMARY KEY, -- nanoid meeting_id TEXT NOT NULL REFERENCES meetings(id), display_name TEXT NOT NULL, email TEXT, -- optional, for follow-up token_hash TEXT NOT NULL UNIQUE, -- hashed access token rtk_participant_id TEXT, -- RTK participant ID once joined expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_users_org ON users(org_id); CREATE INDEX idx_users_email ON users(org_id, email); CREATE INDEX idx_guest_tokens_meeting ON guest_tokens(meeting_id); CREATE INDEX idx_guest_tokens_hash ON guest_tokens(token_hash); ``` ### 4.3 Authentication Flow This is a self-hosted Worker app — no third-party auth provider required. JWT-based auth with refresh tokens. #### Registration / Org Creation Flow ``` 1. POST /api/auth/register Body: { email, password, name, orgName, orgSlug } → Create organization row (generate rtk_app_id via RTK REST API: POST /realtime/kit/apps) → Create user row with role='owner' → Create default presets in RTK for this app (video_host, video_participant, etc.) → Return { accessToken, refreshToken, user, org } ``` #### Login Flow ``` 1. POST /api/auth/login Body: { email, password, orgSlug } → Lookup user by (org.slug → org.id, email) → Verify password_hash (argon2id) → Return { accessToken, refreshToken, user } 2. Access token: JWT, 15-minute expiry Payload: { sub: user.id, org: org.id, role: user.role, iat, exp } Signed with: HS256 using Worker secret (RTK_JWT_SECRET env var) 3. Refresh token: opaque token, 30-day expiry, stored hashed in D1 POST /api/auth/refresh → new access token + rotated refresh token ``` #### Guest Join Flow ``` 1. User visits meeting link: /m/{meetingSlug} or /{orgSlug}/m/{meetingSlug} 2. If not authenticated → show guest join form (display name, optional email) 3. POST /api/meetings/{meetingId}/guest-join Body: { displayName, email? } → Check meeting exists and is ACTIVE → Check org guest_access_default and meeting-level guest_access override → If guest access disabled → 403 → Create guest_tokens row → Create RTK participant via REST API (preset = guest preset for this meeting type) → Return { guestToken, rtk_authToken, meetingConfig } 4. Client SDK initializes with rtk_authToken 5. If waiting room enabled → guest enters waiting room; host admits/rejects ``` #### JWT Middleware (Worker) ```typescript // Every authenticated route runs through this middleware async function authMiddleware(request: Request, env: Env): Promise { const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) throw new HTTPError(401, 'Missing token'); const payload = await verifyJWT(token, env.RTK_JWT_SECRET); const user = await env.DB.prepare('SELECT * FROM users WHERE id = ?') .bind(payload.sub).first(); if (!user || !user.is_active) throw new HTTPError(401, 'Invalid user'); return { userId: user.id, orgId: user.org_id, role: user.role }; } ``` ### 4.4 Refresh Token Storage ```sql CREATE TABLE refresh_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash); ``` Token rotation: each refresh issues a new token and invalidates the old one. If a previously-rotated token is reused (replay detection), all tokens for that user are revoked. ### 4.5 Role-Based Access Control (RBAC) Authorization checks happen at the Worker API layer, not in RTK: | Action | Owner | Admin | Host | Member | Guest | |--------|-------|-------|------|--------|-------| | Manage org settings | Yes | Yes | - | - | - | | Manage users (invite, promote, deactivate) | Yes | Yes | - | - | - | | Create meetings | Yes | Yes | Yes | - | - | | Edit/delete any meeting | Yes | Yes | Own only | - | - | | Start/stop recording | Yes | Yes | Yes (own) | - | - | | Join meetings | Yes | Yes | Yes | Invited/Open | Link | | Configure presets | Yes | Yes | - | - | - | | View analytics | Yes | Yes | Yes (own) | - | - | | Delete organization | Yes | - | - | - | - | --- ## Section 5: Role-to-Preset Mapping ### 5.1 Core Principle RTK presets are the enforcement layer for in-meeting permissions. Our platform roles (Owner, Admin, Host, Member, Guest) map to RTK presets at **meeting join time**. The Worker selects the correct preset when calling the RTK "Add Participant" API. ``` Platform Role + Meeting Type → RTK Preset Name → Participant Created with That Preset ``` ### 5.2 Preset Naming Convention Format: `{meeting_type}_{participant_role}` | Meeting Type | Host Preset | Participant Preset | Guest Preset | Viewer Preset | |-------------|-------------|-------------------|--------------|---------------| | **Video** (group call) | `video_host` | `video_participant` | `video_guest` | — | | **Audio-Only** | `audio_host` | `audio_participant` | `audio_guest` | — | | **Webinar** | `webinar_host` | `webinar_participant` | `webinar_guest` | `webinar_viewer` | | **Livestream** | `livestream_host` | `livestream_participant` | — | `livestream_viewer` | - **Owner and Admin** always get the `*_host` preset (maximum in-meeting control) - **Host** gets the `*_host` preset for meetings they created; `*_participant` for others (configurable) - **Member** gets the `*_participant` preset - **Guest** gets the `*_guest` preset (most restrictive with media access) or `*_viewer` (view-only) ### 5.3 Preset Resolution Logic (Worker) ```typescript function resolvePreset( userRole: 'owner' | 'admin' | 'host' | 'member' | 'guest', meetingType: 'video' | 'audio' | 'webinar' | 'livestream', isCreator: boolean ): string { // Owner and Admin always get host preset if (userRole === 'owner' || userRole === 'admin') { return `${meetingType}_host`; } // Host gets host preset for own meetings, participant for others if (userRole === 'host') { return isCreator ? `${meetingType}_host` : `${meetingType}_participant`; } // Guest gets guest or viewer preset if (userRole === 'guest') { if (meetingType === 'webinar' || meetingType === 'livestream') { return `${meetingType}_viewer`; } return `${meetingType}_guest`; } // Member gets participant preset return `${meetingType}_participant`; } ``` ### 5.4 Preset Configurations by Meeting Type Each preset is created via the RTK REST API (`POST /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/presets`) when the org is provisioned. Below are the exact configurations using the **verified RTK OpenAPI schema**. > **Schema note**: The preset API uses snake_case throughout. Top-level required fields: `name`, `config`, `ui`. The `permissions` object is optional but all its sub-fields are required when provided. Media permissions use `"ALLOWED"` / `"NOT_ALLOWED"` / `"CAN_REQUEST"` enums (not booleans). The `config.view_type` has three valid values: `GROUP_CALL`, `WEBINAR`, `AUDIO_ROOM`. Livestream presets use `WEBINAR` view_type with `can_livestream: true`. #### 5.4.1 Video (Group Call) Presets **`video_host`** — Full meeting control ```json { "name": "video_host", "config": { "view_type": "GROUP_CALL", "max_video_streams": { "desktop": 25, "mobile": 6 }, "max_screenshare_count": 1, "media": { "video": { "quality": "hd", "frame_rate": 30 }, "screenshare": { "quality": "hd", "frame_rate": 15 } } }, "permissions": { "media": { "audio": { "can_produce": "ALLOWED" }, "video": { "can_produce": "ALLOWED" }, "screenshare": { "can_produce": "ALLOWED" } }, "kick_participant": true, "pin_participant": true, "can_spotlight": true, "disable_participant_audio": true, "disable_participant_video": true, "disable_participant_screensharing": true, "accept_waiting_requests": true, "can_accept_production_requests": true, "can_change_participant_permissions": true, "can_record": true, "can_livestream": false, "can_edit_display_name": true, "show_participant_list": true, "hidden_participant": false, "waiting_room_type": "ON_PRIVILEGED_USER_ENTRY", "recorder_type": "NONE", "is_recorder": false, "chat": { "public": { "can_send": true, "text": true, "files": true }, "private": { "can_send": true, "can_receive": true, "text": true, "files": true } }, "polls": { "can_create": true, "can_vote": true, "can_view": true }, "plugins": { "can_start": true, "can_close": true, "can_edit_config": true, "config": { "access_control": "FULL_ACCESS", "handles_view_only": false } }, "connected_meetings": { "can_alter_connected_meetings": true, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true } }, "ui": { "design_tokens": { "theme": "dark", "border_radius": "rounded", "border_width": "thin", "spacing_base": 4, "logo": "", "colors": { "brand": { "300": "#844d1c", "400": "#9d5b22", "500": "#b56927", "600": "#d37c30", "700": "#d9904f" }, "background": { "600": "#222222", "700": "#1f1f1f", "800": "#1b1b1b", "900": "#181818", "1000": "#141414" }, "text": "#EEEEEE", "text_on_brand": "#EEEEEE", "danger": "#FF2D2D", "success": "#62A504", "warning": "#FFCD07", "video_bg": "#191919" } } } } ``` **`video_participant`** — Standard participant (abbreviated: only differences from host shown) ```json { "name": "video_participant", "config": { "view_type": "GROUP_CALL", "...": "same as video_host" }, "permissions": { "media": { "audio": { "can_produce": "ALLOWED" }, "video": { "can_produce": "ALLOWED" }, "screenshare": { "can_produce": "ALLOWED" } }, "kick_participant": false, "pin_participant": true, "can_spotlight": false, "disable_participant_audio": false, "disable_participant_video": false, "disable_participant_screensharing": false, "accept_waiting_requests": false, "can_accept_production_requests": false, "can_change_participant_permissions": false, "can_record": false, "can_livestream": false, "can_edit_display_name": false, "show_participant_list": true, "hidden_participant": false, "waiting_room_type": "ON_PRIVILEGED_USER_ENTRY", "recorder_type": "NONE", "is_recorder": false, "chat": { "public": { "can_send": true, "text": true, "files": true }, "private": { "can_send": true, "can_receive": true, "text": true, "files": true } }, "polls": { "can_create": false, "can_vote": true, "can_view": true }, "plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } }, "connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true } }, "ui": { "design_tokens": "...same as video_host..." } } ``` **`video_guest`** — Restricted participant (no screenshare, no private chat) ```json { "name": "video_guest", "config": { "view_type": "GROUP_CALL", "...": "same as video_host" }, "permissions": { "media": { "audio": { "can_produce": "ALLOWED" }, "video": { "can_produce": "ALLOWED" }, "screenshare": { "can_produce": "NOT_ALLOWED" } }, "kick_participant": false, "pin_participant": false, "can_spotlight": false, "disable_participant_audio": false, "disable_participant_video": false, "disable_participant_screensharing": false, "accept_waiting_requests": false, "can_accept_production_requests": false, "can_change_participant_permissions": false, "can_record": false, "can_livestream": false, "can_edit_display_name": false, "show_participant_list": true, "hidden_participant": false, "waiting_room_type": "ON_PRIVILEGED_USER_ENTRY", "recorder_type": "NONE", "is_recorder": false, "chat": { "public": { "can_send": true, "text": true, "files": false }, "private": { "can_send": false, "can_receive": true, "text": false, "files": false } }, "polls": { "can_create": false, "can_vote": true, "can_view": true }, "plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } }, "connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": false, "can_switch_to_parent_meeting": false } }, "ui": { "design_tokens": "...same as video_host..." } } ``` #### 5.4.2 Audio-Only Presets Same structure as Video presets but with `config.view_type: "AUDIO_ROOM"` and video `can_produce` set to `"NOT_ALLOWED"`: | Preset | `view_type` | Audio | Video | Screenshare | Host Controls | |--------|-------------|:---:|:---:|:---:|:---:| | `audio_host` | `AUDIO_ROOM` | `ALLOWED` | `NOT_ALLOWED` | `ALLOWED` | All (`kick_participant`, `disable_*`, etc.) | | `audio_participant` | `AUDIO_ROOM` | `ALLOWED` | `NOT_ALLOWED` | `NOT_ALLOWED` | None | | `audio_guest` | `AUDIO_ROOM` | `ALLOWED` | `NOT_ALLOWED` | `NOT_ALLOWED` | None | Chat, polls, plugins, and other permissions mirror the video equivalents at the same role level. #### 5.4.3 Webinar Presets Webinars use `view_type: "WEBINAR"` with **stage management**. Only hosts and invited speakers are on stage; everyone else is a viewer. Stage management is handled by `can_accept_production_requests` (host accepts stage requests) and media `CAN_REQUEST` (participant requests to go on stage). **`webinar_host`** — On stage, full control ```json { "name": "webinar_host", "config": { "view_type": "WEBINAR", "max_video_streams": { "desktop": 25, "mobile": 6 }, "max_screenshare_count": 1, "media": { "video": { "quality": "hd", "frame_rate": 30 }, "screenshare": { "quality": "hd", "frame_rate": 15 } } }, "permissions": { "media": { "audio": { "can_produce": "ALLOWED" }, "video": { "can_produce": "ALLOWED" }, "screenshare": { "can_produce": "ALLOWED" } }, "kick_participant": true, "pin_participant": true, "can_spotlight": true, "disable_participant_audio": true, "disable_participant_video": true, "disable_participant_screensharing": true, "accept_waiting_requests": true, "can_accept_production_requests": true, "can_change_participant_permissions": true, "can_record": true, "can_livestream": true, "can_edit_display_name": true, "show_participant_list": true, "hidden_participant": false, "waiting_room_type": "ON_PRIVILEGED_USER_ENTRY", "recorder_type": "NONE", "is_recorder": false, "chat": { "public": { "can_send": true, "text": true, "files": true }, "private": { "can_send": true, "can_receive": true, "text": true, "files": true } }, "polls": { "can_create": true, "can_vote": true, "can_view": true }, "plugins": { "can_start": true, "can_close": true, "can_edit_config": true, "config": { "access_control": "FULL_ACCESS", "handles_view_only": false } }, "connected_meetings": { "can_alter_connected_meetings": true, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true } }, "ui": { "design_tokens": "...same as video_host..." } } ``` **`webinar_participant`** — Can request stage access ```json { "name": "webinar_participant", "config": { "view_type": "WEBINAR", "...": "same as webinar_host" }, "permissions": { "media": { "audio": { "can_produce": "CAN_REQUEST" }, "video": { "can_produce": "CAN_REQUEST" }, "screenshare": { "can_produce": "NOT_ALLOWED" } }, "kick_participant": false, "accept_waiting_requests": false, "can_accept_production_requests": false, "can_record": false, "can_livestream": false, "show_participant_list": true, "waiting_room_type": "ON_PRIVILEGED_USER_ENTRY", "chat": { "public": { "can_send": true, "text": true, "files": false }, "private": { "can_send": false, "can_receive": true, "text": false, "files": false } }, "polls": { "can_create": false, "can_vote": true, "can_view": true }, "...": "remaining fields same pattern as video_participant" }, "ui": { "design_tokens": "...same as video_host..." } } ``` **`webinar_viewer`** — View-only (guests and livestream viewers) ```json { "name": "webinar_viewer", "config": { "view_type": "WEBINAR", "...": "same as webinar_host" }, "permissions": { "media": { "audio": { "can_produce": "NOT_ALLOWED" }, "video": { "can_produce": "NOT_ALLOWED" }, "screenshare": { "can_produce": "NOT_ALLOWED" } }, "kick_participant": false, "accept_waiting_requests": false, "can_accept_production_requests": false, "can_record": false, "can_livestream": false, "show_participant_list": false, "hidden_participant": false, "waiting_room_type": "SKIP", "chat": { "public": { "can_send": false, "text": false, "files": false }, "private": { "can_send": false, "can_receive": false, "text": false, "files": false } }, "polls": { "can_create": false, "can_vote": true, "can_view": true }, "...": "remaining fields all false/NONE" }, "ui": { "design_tokens": "...same as video_host..." } } ``` #### 5.4.4 Livestream Presets Livestreaming uses `view_type: "WEBINAR"` (there is no `LIVESTREAM` view_type in the API) with `can_livestream: true` for hosts. RTMP/HLS export is controlled via the separate livestreaming API, not the preset itself. | Preset | `view_type` | `can_livestream` | Audio | Video | Screenshare | Host Controls | |--------|-------------|:---:|:---:|:---:|:---:|:---:| | `livestream_host` | `WEBINAR` | `true` | `ALLOWED` | `ALLOWED` | `ALLOWED` | All | | `livestream_participant` | `WEBINAR` | `false` | `CAN_REQUEST` | `CAN_REQUEST` | `NOT_ALLOWED` | None | | `livestream_viewer` | `WEBINAR` | `false` | `NOT_ALLOWED` | `NOT_ALLOWED` | `NOT_ALLOWED` | None | ### 5.5 Dynamic Preset Override Hosts can temporarily promote/demote participants during a meeting. This is handled via the RTK REST API: ``` PATCH /realtime/kit/{APP_ID}/meetings/{MEETING_ID}/participants/{PARTICIPANT_ID} Body: { "preset_name": "video_host" } ``` Our Worker wraps this with RBAC checks — only users with host-level permissions (Owner, Admin, Host of meeting) can call this endpoint. The promotion is tracked in an audit log: ```sql CREATE TABLE participant_events ( id TEXT PRIMARY KEY, meeting_id TEXT NOT NULL REFERENCES meetings(id), user_id TEXT, -- null for guests guest_token_id TEXT, -- null for authenticated users event_type TEXT NOT NULL, -- 'joined' | 'left' | 'promoted' | 'demoted' | 'kicked' | 'admitted' preset_from TEXT, preset_to TEXT, performed_by TEXT, -- user_id of the person who did it created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_participant_events_meeting ON participant_events(meeting_id); ``` --- ## Section 6: Meeting Lifecycle ### 6.1 Meeting Types Four meeting types, each mapping to an RTK `meetingType` in the preset: | Type | RTK meetingType | Key Characteristics | |------|----------------|---------------------| | **Video** | `video` | Full audio/video, all participants equal, screenshare allowed | | **Audio-Only** | `voice` | Microphone only, no camera, lower bandwidth | | **Webinar** | `webinar` | Stage-based, hosts present, audience watches, Q&A via chat | | **Livestream** | `webinar` + RTMP | Stage-based + RTMP export to external platforms + HLS playback | ### 6.2 D1 Schema: Meetings & Scheduling ```sql -- Meetings table CREATE TABLE meetings ( id TEXT PRIMARY KEY, -- nanoid org_id TEXT NOT NULL REFERENCES organizations(id), rtk_meeting_id TEXT, -- RTK meeting ID (set after RTK API call) title TEXT NOT NULL, slug TEXT NOT NULL, -- URL-friendly meeting identifier description TEXT, meeting_type TEXT NOT NULL DEFAULT 'video', -- 'video' | 'audio' | 'webinar' | 'livestream' scheduling_type TEXT NOT NULL DEFAULT 'instant', -- 'instant' | 'scheduled' | 'recurring' | 'permanent' status TEXT NOT NULL DEFAULT 'draft', -- 'draft' | 'scheduled' | 'active' | 'ended' | 'cancelled' created_by TEXT NOT NULL REFERENCES users(id), -- Scheduling fields (null for instant meetings) scheduled_start TEXT, -- ISO 8601 datetime scheduled_end TEXT, -- ISO 8601 datetime actual_start TEXT, -- set when first participant joins actual_end TEXT, -- set when last participant leaves timezone TEXT DEFAULT 'UTC', -- Permanent room fields is_permanent INTEGER NOT NULL DEFAULT 0, vanity_slug TEXT UNIQUE, -- e.g., "standup" → /acme-corp/m/standup -- Guest access (overrides org default) guest_access TEXT, -- null = inherit org default | 'disabled' | 'link_only' | 'open' -- Meeting configuration waiting_room_enabled INTEGER NOT NULL DEFAULT 1, recording_auto_start INTEGER NOT NULL DEFAULT 0, transcription_enabled INTEGER NOT NULL DEFAULT 1, max_participants INTEGER DEFAULT 100, -- Cloud storage override (null = use org default R2) cloud_storage_config TEXT, -- JSON: { provider, bucket, region, credentials_ref } created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(org_id, slug) ); -- Recurring meeting patterns CREATE TABLE recurring_patterns ( id TEXT PRIMARY KEY, meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, rrule TEXT NOT NULL, -- iCal RRULE string (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR") dtstart TEXT NOT NULL, -- pattern start date (ISO 8601) until_date TEXT, -- pattern end date (null = no end) occurrence_count INTEGER, -- alternative to until_date exceptions TEXT DEFAULT '[]', -- JSON array of excluded dates created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Individual occurrences of recurring meetings (materialized) CREATE TABLE meeting_occurrences ( id TEXT PRIMARY KEY, meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, pattern_id TEXT NOT NULL REFERENCES recurring_patterns(id) ON DELETE CASCADE, occurrence_date TEXT NOT NULL, -- the specific date/time of this occurrence status TEXT NOT NULL DEFAULT 'scheduled', -- 'scheduled' | 'active' | 'ended' | 'cancelled' rtk_meeting_id TEXT, -- each occurrence gets its own RTK meeting actual_start TEXT, actual_end TEXT, override_json TEXT, -- per-occurrence overrides (title, description, etc.) created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(meeting_id, occurrence_date) ); -- Meeting invites CREATE TABLE meeting_invites ( id TEXT PRIMARY KEY, meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, user_id TEXT REFERENCES users(id), email TEXT, -- for inviting non-members role_override TEXT, -- override the user's default preset mapping rsvp_status TEXT DEFAULT 'pending', -- 'pending' | 'accepted' | 'declined' | 'tentative' created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(meeting_id, user_id) ); CREATE INDEX idx_meetings_org ON meetings(org_id); CREATE INDEX idx_meetings_status ON meetings(org_id, status); CREATE INDEX idx_meetings_vanity ON meetings(vanity_slug); CREATE INDEX idx_meetings_scheduled ON meetings(org_id, scheduling_type, scheduled_start); CREATE INDEX idx_occurrences_meeting ON meeting_occurrences(meeting_id, occurrence_date); CREATE INDEX idx_occurrences_date ON meeting_occurrences(occurrence_date, status); CREATE INDEX idx_invites_user ON meeting_invites(user_id); CREATE INDEX idx_invites_meeting ON meeting_invites(meeting_id); ``` ### 6.3 Meeting Lifecycle State Machine ``` create │ ▼ ┌──────────────[DRAFT]──────────────┐ │ │ │ │ schedule │ start now │ cancel │ │ │ ▼ │ ▼ [SCHEDULED] │ [CANCELLED] │ │ │ activate │ │ (auto or manual)│ ▼ ▼ [ACTIVE] ◄──────────── │ │ last participant leaves │ OR host ends meeting ▼ [ENDED] ``` **State transitions and RTK API calls:** | Transition | Trigger | RTK API Call | |-----------|---------|-------------| | `draft → scheduled` | Host sets date/time | None (RTK meeting not created yet) | | `draft → active` | Host clicks "Start Now" | `POST /meetings` (create RTK meeting) then `PATCH /meetings/{id}` (set status=ACTIVE) | | `scheduled → active` | Cron trigger at scheduled time OR host starts early | `POST /meetings` (create RTK meeting) then `PATCH /meetings/{id}` (set ACTIVE) | | `active → ended` | Last participant leaves OR host clicks "End" | `PATCH /meetings/{id}` (set INACTIVE), kick all participants | | `any → cancelled` | Host/Admin cancels | If RTK meeting exists: `PATCH /meetings/{id}` (set INACTIVE) | ### 6.4 Meeting Creation Flow #### Instant Meeting ``` 1. POST /api/meetings Body: { title, meetingType: "video", schedulingType: "instant" } 2. Worker validates auth (must be Owner/Admin/Host) 3. Generate meeting slug (nanoid, 10 chars, URL-safe) 4. Create RTK meeting: POST /realtime/kit/{APP_ID}/meetings Body: { title, status: "ACTIVE" } → Returns rtk_meeting_id 5. Insert meeting row in D1 (status = 'active', rtk_meeting_id set) 6. Return { meetingId, slug, joinUrl: "/{orgSlug}/m/{slug}" } ``` #### Scheduled Meeting ``` 1. POST /api/meetings Body: { title, meetingType, schedulingType: "scheduled", scheduledStart, scheduledEnd, timezone } 2. Worker validates auth + time is in the future 3. Insert meeting row in D1 (status = 'scheduled', rtk_meeting_id = null) 4. RTK meeting is NOT created yet (saves resources, avoids stale meetings) 5. When scheduled time arrives: - Cron trigger (Workers Cron) queries meetings WHERE status='scheduled' AND scheduled_start <= now - Creates RTK meeting via API - Updates D1 row: status='active', rtk_meeting_id set - Optionally sends notification (webhook/email) to invitees ``` #### Recurring Meeting ``` 1. POST /api/meetings Body: { title, meetingType, schedulingType: "recurring", rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR", dtstart, timezone } 2. Create meeting row (template) in D1 3. Create recurring_patterns row with RRULE 4. Materialize next N occurrences (default: 4 weeks ahead) into meeting_occurrences table 5. Each occurrence is activated independently via the same cron mechanism 6. Cron also materializes additional future occurrences on a rolling basis 7. Each occurrence gets its own RTK meeting ID (created at activation time) ``` #### Permanent Room ``` 1. POST /api/meetings Body: { title, meetingType, schedulingType: "permanent", vanitySlug: "standup" } 2. Validate vanity slug uniqueness within org 3. Create meeting row with is_permanent=1, vanity_slug set 4. Create RTK meeting immediately (persistent room): POST /realtime/kit/{APP_ID}/meetings Body: { title, status: "INACTIVE" } 5. Room URL: /{orgSlug}/m/{vanitySlug} (e.g., /acme-corp/m/standup) 6. Room is always available but INACTIVE until someone joins: - On join request → PATCH RTK meeting to ACTIVE - On last leave → PATCH RTK meeting to INACTIVE - RTK meeting ID is reused across sessions (never deleted) 7. Permanent rooms never expire. Owner/Admin can deactivate (soft delete) by setting meeting status to 'cancelled'. ``` ### 6.5 Joining a Meeting ``` 1. Client navigates to /{orgSlug}/m/{meetingSlug} 2. POST /api/meetings/{meetingId}/join Headers: Authorization: Bearer (or guest token) 3. Worker resolves: a. User identity (authenticated user or guest) b. User's platform role c. Meeting type d. Whether user is meeting creator e. Preset name = resolvePreset(role, meetingType, isCreator) 4. Create RTK participant: POST /realtime/kit/{APP_ID}/meetings/{RTK_MEETING_ID}/participants Body: { name: user.name, preset_name: resolvedPreset, custom_participant_id: user.id || crypto.randomUUID() // REQUIRED — links RTK participant to our user (UUID for guests) } → Returns { id: rtk_participant_id, authToken: rtk_auth_token } 5. Log participant_events row (event_type = 'joined') 6. Return to client: { rtkAuthToken, // for SDK initialization rtkMeetingId, // for SDK meeting join preset_name, // for client-side UI hints meetingConfig: { // for client-side rendering title, type, waitingRoomEnabled, transcriptionEnabled, ... } } 7. Client initializes RTK SDK: const meeting = await RTKClient.init({ authToken: rtkAuthToken }); await meeting.join(); ``` ### 6.6 Ending a Meeting Two triggers: **Host ends meeting explicitly:** ``` POST /api/meetings/{meetingId}/end 1. Validate caller is Owner/Admin/Host-of-meeting 2. RTK: kick all participants POST /realtime/kit/{APP_ID}/meetings/{RTK_MEETING_ID}/active-session/kick-all 3. RTK: set meeting inactive PATCH /realtime/kit/{APP_ID}/meetings/{RTK_MEETING_ID} Body: { status: "INACTIVE" } 4. D1: update meeting status = 'ended', actual_end = now 5. Trigger post-meeting processing (recording finalization, transcript, summary) ``` **Last participant leaves (webhook-driven):** ``` 1. RTK fires `meeting.ended` webhook when last participant leaves (after `session_keep_alive_time_in_secs` elapses — default 60s, configurable 60-600s per meeting) 2. Worker webhook handler: - Look up meeting by rtk_meeting_id - If scheduling_type != 'permanent': Set meeting status = 'ended', actual_end = now - If scheduling_type == 'permanent': Set RTK meeting to INACTIVE (keep D1 status as 'active' for rejoining) - Trigger post-meeting processing ``` ### 6.7 Guest Access Configuration Hierarchy Guest access is controlled at two levels, with meeting-level overriding org-level: ``` Effective guest access = meeting.guest_access ?? org.guest_access_default ``` | Setting | Behavior | |---------|----------| | `disabled` | No guest access. Only authenticated org members can join. | | `link_only` | Guests can join via meeting link. Must provide display name. Waiting room recommended. | | `open` | Guests can join via link with minimal friction. Waiting room still applies if enabled. | **Per-meeting override:** When creating/editing a meeting, the Host can set `guest_access` to override the org default for that specific meeting. Null means "inherit org default." ### 6.8 Abuse Protection Mechanisms #### Rate Limiting (Cloudflare Workers) ```typescript // Rate limit configuration per endpoint category const RATE_LIMITS = { 'auth.login': { requests: 5, window: 60 }, // 5 login attempts per minute 'auth.register': { requests: 3, window: 3600 }, // 3 registrations per hour (per IP) 'meeting.create': { requests: 10, window: 60 }, // 10 meetings per minute (per org) 'meeting.join': { requests: 30, window: 60 }, // 30 joins per minute (per meeting) 'guest.join': { requests: 20, window: 60 }, // 20 guest joins per minute (per meeting) 'chat.send': { requests: 30, window: 60 }, // 30 messages per minute (per user) 'api.general': { requests: 100, window: 60 }, // 100 requests per minute (per user) }; ``` Rate limiting is enforced using a KV-based sliding window counter (org_id or IP as key). #### Capacity Caps | Resource | Default Limit | Configurable | |----------|:---:|:---:| | Max participants per meeting | 100 | Yes (per meeting) | | Max concurrent meetings per org | 25 | Yes (org setting) | | Max guests per meeting | 50% of max_participants | Yes (per meeting) | | Max meeting duration | 24 hours | Yes (org setting) | | Max recording storage (R2) | 50 GB per org | Yes (org setting) | When a cap is reached, the Worker returns HTTP 429 with a clear error message. RTK's own limits (if any) are respected as the floor. #### Guest Abuse Controls 1. **Waiting room enforcement**: Guests always enter waiting room when `guest_access = 'link_only'` (even if meeting-level waiting room is off for members) 2. **Display name validation**: Strip HTML/script tags, enforce 2-50 char length, block known abuse patterns 3. **Token expiry**: Guest tokens expire after 4 hours (configurable) 4. **IP-based join throttle**: Max 3 guest joins from the same IP within 5 minutes per meeting 5. **Host can lock meeting**: Once started, host can toggle `acceptNewParticipants = false` via RTK API to prevent new joins 6. **Auto-kick idle guests**: If a guest is in waiting room for >10 minutes without being admitted, their token is revoked #### Webhook Verification All incoming RTK webhooks are verified: 1. Check signature header against shared secret — **UNVERIFIED**: exact header name (`X-RTK-Signature`?) and verification mechanism not confirmed in RTK docs. Verify at build time; if no signature mechanism exists, validate by cross-referencing event data with REST API. 2. Check timestamp — reject if >5 minutes old (replay protection). **UNVERIFIED**: exact header name (`X-RTK-Timestamp`?) not confirmed. 3. Idempotency: store webhook event IDs in KV with 24-hour TTL to deduplicate ### 6.9 Post-Meeting Processing Pipeline After a meeting ends (either explicitly or via last-leave webhook): ``` Meeting Ends │ ├─→ Recording: Poll RTK recording status via REST API until 'completed' │ └─→ Store recording metadata in D1 (duration, size, R2 path) │ ├─→ Transcript: Wait for meeting.transcript webhook OR poll REST API │ └─→ Store transcript in D1 (full text + timestamped segments) │ ├─→ AI Summary: Wait for meeting.summary webhook │ └─→ Store summary in D1 │ ├─→ Chat Export: Wait for meeting.chatSynced webhook │ └─→ Store chat log in D1 │ └─→ Analytics: Aggregate session data └─→ Update analytics tables (session duration, participant count, peak concurrent, etc.) ``` All post-meeting data is linked to the meeting row and accessible via: - `GET /api/meetings/{meetingId}/recording` - `GET /api/meetings/{meetingId}/transcript` - `GET /api/meetings/{meetingId}/summary` - `GET /api/meetings/{meetingId}/chat-export` # Design Document: Sections 7-9 ## Section 7: RTK Feature Mapping (Exhaustive) Every RealtimeKit feature mapped to its app surface, API, and implementation status. **Legend:** - **Config Level**: `Preset` = controlled via preset permissions, `Meeting` = meeting-level config, `App` = app-wide config, `Client` = SDK-side only, `Server` = REST API / Worker - **Status**: `Build` = in scope now, `Research` = needs investigation, `Future` = planned later, `Low Priority` = groundwork only ### 7.1 Core Meeting Infrastructure | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Meeting creation | `POST /meetings` REST API | Create Meeting page, Schedule modal | Server | Build | | Meeting ACTIVE/INACTIVE toggle | `PATCH /meetings/{id}` REST API | Meeting list admin actions | Server | Build | | Meeting metadata | `meeting.meta` (client SDK) | Meeting header, lobby screen | Meeting | Build | | Meeting title display | `rtk-meeting-title` component | In-meeting header bar | Meeting | Build | | Meeting duration clock | `rtk-clock` component | In-meeting header bar | Client | Build | | Session lifecycle (auto-create on join, end on last leave) | Managed by RTK platform | Backend webhook handler | Server | Build | | Participant creation | `POST /meetings/{id}/participants` REST API | Join flow (Worker creates participant, returns authToken) | Server | Build | | Participant token refresh | `POST /participants/{id}/token` REST API | Auto-refresh middleware in Worker | Server | Build | | Preset creation & management | `POST /presets`, `PATCH /presets/{id}` REST API | Org Settings > Presets page | Server + App | Build | | Meeting type: Video (Group Call) | Preset `view_type: "GROUP_CALL"` | Meeting creation form, preset config | Preset | Build | | Meeting type: Voice (Audio Only) | Preset `view_type: "AUDIO_ROOM"` | Meeting creation form, preset config | Preset | Build | | Meeting type: Webinar | Preset `view_type: "WEBINAR"` | Meeting creation form, preset config | Preset | Build | | `record_on_start` config | Meeting creation parameter | Create Meeting form toggle | Meeting | Build | | `persist_chat` config | Meeting creation parameter | Create Meeting form toggle | Meeting | Build | | `ai_config` (transcription + summarization) | Meeting creation parameter | Create Meeting form — AI section | Meeting | Build | | `summarize_on_end` config | Meeting creation parameter | Create Meeting form toggle | Meeting | Build | ### 7.2 Participant & Room Management | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Participant list | `rtk-participants` component | Sidebar > Participants tab | Preset | Build | | Participants toggle | `rtk-participants-toggle` component | Control bar | Client | Build | | Single participant entry | `rtk-participant` component | Inside `rtk-participants` (name, media status, actions) | Client | Build | | Audio participant list | `rtk-participants-audio` component | Sidebar in audio-only meetings | Client | Build | | Participant count badge | `rtk-participant-count` component | Control bar, header | Client | Build | | Participant tile (video) | `rtk-participant-tile` component | Main grid area | Client | Build | | Participant tile (audio-only) | `rtk-audio-tile` component | Audio grid layout | Client | Build | | Participant name tag | `rtk-name-tag` component | Overlay on each tile | Client | Build | | Participant avatar | `rtk-avatar` component | Audio tiles, participant list | Client | Build | | Participant setup/preview | `rtk-participant-setup` component | Pre-join screen | Client | Build | | Virtualized participant list | `rtk-virtualized-participant-list` | Large meeting sidebar | Client | Build | | Viewer list (webinar) | `rtk-participants-viewer-list` component | Webinar sidebar tab | Client | Build | | Viewer count (livestream) | `rtk-viewer-count` component | Livestream header | Client | Build | | Kick participant | Host control via SDK | Participant context menu | Preset | Build | | Mute participant audio/video | Host control via SDK | Participant context menu | Preset | Build | | Mute all participants | `rtk-mute-all-button` + `rtk-mute-all-confirmation` | Control bar (host only) | Preset | Build | | Edit participant name | `meeting.self` permission | Participant settings | Preset | Build | | User ID (persistent across sessions) | `meeting.self.userId` / `participant.userId` | Analytics, user tracking | Client | Build | | Session ID (per-connection) | `meeting.self.id` / `participant.id` | Session-level tracking | Client | Build | ### 7.3 Waiting Room | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Waiting room enable/disable | Preset configuration | Preset editor > Waiting Room section | Preset | Build | | Waiting room screen | `rtk-waiting-screen` component | Shown to waitlisted participants | Client | Build | | Waiting room participant list | `rtk-participants-waiting-list` component | Host sidebar > Waiting tab | Preset | Build | | Admit participant | Host SDK method | Waiting list item action | Preset | Build | | Reject participant | Host SDK method | Waiting list item action | Preset | Build | | Admit all | Host SDK method | Waiting list bulk action | Preset | Build | | Auto-admit when host joins | Preset `waiting_room` config | Preset editor toggle | Preset | Build | | Bypass waiting room (by preset) | Preset configuration | Preset editor — bypass role list | Preset | Build | ### 7.4 Stage Management (Webinar) | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Stage view | `rtk-stage` component | Main content area (webinar mode) | Preset | Build | | Join/leave stage toggle | `rtk-stage-toggle` component | Control bar (webinar) | Preset | Build | | Join stage button | `rtk-join-stage` component | Viewer UI prompt | Preset | Build | | On-stage participants list | `rtk-participants-stage-list` component | Host sidebar | Preset | Build | | Stage request queue | `rtk-participants-stage-queue` component | Host sidebar > Requests tab | Preset | Build | | Host: approve/deny stage requests | SDK host methods | Stage queue item actions | Preset | Build | | Host: invite/remove from stage | SDK host methods | Participant context menu | Preset | Build | ### 7.5 Chat System | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Complete chat panel | `rtk-chat` component | Sidebar > Chat tab | Preset | Build | | Chat toggle | `rtk-chat-toggle` component | Control bar | Client | Build | | Chat header | `rtk-chat-header` component | Chat panel header (pinned msgs, DM selector) | Client | Build | | Message composer | `rtk-chat-composer-view` component | Chat panel bottom | Client | Build | | Chat messages list (paginated) | `rtk-chat-messages-ui-paginated` component | Chat panel body (infinite scroll) | Client | Build | | Single message view | `rtk-message-view` component | Within chat list | Client | Build | | Markdown rendering | `rtk-markdown-view` component | Chat message content | Client | Build | | Public chat | `chatPublic` preset permission | Default chat mode | Preset | Build | | Private chat (1:1 DMs) | `chatPrivate` preset permission + `privateChatRecipient` prop | Chat selector > DM tab | Preset | Build | | Chat selector (public/private) | `rtk-chat-selector` / `rtk-chat-selector-ui` | Chat panel header | Client | Build | | Pinned messages | `rtk-pinned-message-selector` component | Chat header area (public chat only) | Client | Build | | Chat search | `rtk-chat-messages-ui-paginated` component (search via props) | Chat panel search bar | Client | Build | | File messages | `rtk-file-message-view` component | In chat message list | Client | Build | | Image messages | `rtk-image-message-view` + `rtk-image-viewer` | In chat + full-screen viewer | Client | Build | | File upload (drag & drop) | `rtk-file-dropzone` + `rtk-file-picker-button` | Chat composer area | Client | Build | | Draft attachment preview | `rtk-draft-attachment-view` component | Chat composer area | Client | Build | | Emoji picker | `rtk-emoji-picker` + `rtk-emoji-picker-button` | Chat composer | Client | Build | | Chat export (post-meeting) | `meeting.chatSynced` webhook + Chat Replay REST API | Post-meeting page > Chat tab | Server | Build | | Chat CSV dump | REST API download URL | Post-meeting download button | Server | Build | | Fetch private messages | `meeting.chat.fetchPrivateMessages` SDK | DM chat history | Client | Build | ### 7.6 Polls | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Polls panel | `rtk-polls` component | Sidebar > Polls tab | Preset | Build | | Polls toggle | `rtk-polls-toggle` component | Control bar | Client | Build | | Single poll display | `rtk-poll` component | Within polls panel | Client | Build | | Poll creation form | `rtk-poll-form` component | Polls panel > Create | Preset | Build | | Poll permissions (create/view/interact) | Preset `polls` config | Preset editor > Polls section | Preset | Build | ### 7.7 Breakout Rooms (Connected Meetings) | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Breakout rooms manager | `rtk-breakout-rooms-manager` component | Sidebar > Breakout tab (host) | Preset | Build | | Single room manager | `rtk-breakout-room-manager` component | Within breakout panel | Preset | Build | | Room participant list | `rtk-breakout-room-participants` component | Within each room card | Client | Build | | Breakout rooms toggle | `rtk-breakout-rooms-toggle` component | Control bar (host) | Client | Build | | Broadcast message to rooms | `rtk-broadcast-message-modal` component | Breakout panel > Broadcast button | Preset | Build | | Create/switch/close rooms | Connected Meetings SDK APIs | Breakout room management UI | Preset | Build | | Cross-meeting broadcast | `meeting.participants.broadcastMessage` with `meetingIds` | Host broadcast action | Preset | Build | | **Platform limitation** | Web only (beta) | Show "web only" badge; hide on mobile | — | Build | ### 7.8 Message Broadcasting & Collaborative Stores | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Broadcast message to meeting | `meeting.participants.broadcastMessage(type, payload)` | Custom event system (reactions, notifications) | Client | Build | | Subscribe to broadcasts | `broadcastedMessage` event listener | Event handlers throughout app | Client | Build | | Rate limiting config | `rateLimitConfig` / `updateRateLimits()` | Internal config (not user-facing) | Client | Build | | Collaborative stores (KV) | `meeting.stores` API — create, subscribe, update | Shared state: hand raise, custom annotations, cursor position | Client | Build | | Store subscription | `RTKStore` instance — `.subscribe()` on key changes | Real-time UI updates from store changes | Client | Build | | Store session scoping | Data persists until session ends | Automatic cleanup — no action needed | — | Build | ### 7.9 Recording | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Start recording | Start Recording REST API + client SDK | Control bar > Record button (host) | Preset | Build | | Stop recording | Stop Recording REST API + client SDK | Control bar > Stop Record button | Preset | Build | | Recording toggle | `rtk-recording-toggle` component | Control bar | Preset | Build | | Recording indicator | `rtk-recording-indicator` component | Meeting header (visible to all) | Client | Build | | Fetch active recording | Fetch Active Recording REST API | Admin monitoring | Server | Build | | Fetch recording details/download | Fetch Recording Details REST API | Post-meeting page > Recordings | Server | Build | | `recording.statusUpdate` webhook | Webhook event (INVOKED/RECORDING/UPLOADING/UPLOADED/ERRORED) | Webhook handler > update D1 | Server | Build | | Record on start | `record_on_start` meeting config | Meeting creation form | Meeting | Build | | Composite recording | Default mode — multi-user single file (H.264/VP8) | Primary recording output | Meeting | Build | | Custom cloud storage upload | AWS, Azure, DigitalOcean, GCS, SFTP config via API (R2 via aws-compatible config) | Org Settings > Storage config | App | Build | | Watermarking | `video_config.watermark` in Start Recording API | Org Settings > Recording > Watermark | App | Build | | Recording codec config | Video/audio codec parameters in Start Recording API | Org Settings > Recording > Quality | App | Build | | Interactive recording (timed metadata) | Recording SDK + metadata API | Advanced: searchable playback markers | Meeting | Build | | Custom recording app | `@cloudflare/realtimekit-recording-sdk` | Future: custom recording layouts | App | Future | | Disable RTK bucket upload | Recording config option | Org Settings > Storage | App | Build | | Recording config precedence | Config hierarchy management | Internal Worker logic | Server | Build | | Per-track recording | `POST /recordings/track` — layers API for mapping audio/video tracks to output destinations ($0.0005/min) | Meeting settings toggle | Meeting | Build | ### 7.10 Livestreaming | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Livestream indicator | `rtk-livestream-indicator` component | Meeting header (during livestream) | Client | Build | | Livestream toggle | `rtk-livestream-toggle` component | Control bar (host, webinar mode) | Preset | Build | | Livestream player (HLS) | `rtk-livestream-player` component | Viewer page / external embed | Client | Build | | RTMP export config | Part of recording/export system | Meeting settings > Livestream section | Meeting | Build | | HLS playback (hls.js) | Bundled hls.js dependency | Viewer page, post-meeting playback | Client | Build | | Livestream as recording export mode | Pricing: same as "Export (recording, RTMP or HLS streaming)" | Worker config when starting stream | Server | Build | | Stage management for livestream | Stage + livestream integration | Webinar host controls during stream | Preset | Build | ### 7.11 Transcription & AI | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Real-time transcription | Whisper Large v3 Turbo (Workers AI) | In-meeting captions overlay | Meeting + Preset | Build | | Caption toggle | `rtk-caption-toggle` component | Control bar | Client | Build | | AI panel | `rtk-ai` component | Sidebar > AI tab | Client | Build | | AI toggle | `rtk-ai-toggle` component | Control bar | Client | Build | | Live transcription display | `rtk-ai-transcriptions` component | AI panel / caption overlay | Client | Build | | Single transcript entry | `rtk-transcript` component | Within transcription list | Client | Build | | Transcript history | `rtk-transcripts` component | AI panel scrollback | Client | Build | | Transcription language config | `ai_config.transcription.language` on meeting creation | Meeting creation form > Language | Meeting | Build | | `transcription_enabled` preset flag | Preset parameter | Preset editor toggle | Preset | Build | | Post-meeting transcript | `meeting.transcript` webhook + REST API fetch | Post-meeting page > Transcript tab | Server | Build | | Transcript download URL | Presigned R2 URL (7-day retention) | Post-meeting download button | Server | Build | | AI meeting summary | `meeting.summary` webhook + REST API fetch/trigger | Post-meeting page > Summary tab | Server | Build | | Summary download URL | Presigned R2 URL | Post-meeting download button | Server | Build | | Summary type config | `ai_config.summarization.summary_type` (e.g. `"team_meeting"`) | Meeting creation form > Summary type | Meeting | Build | | Summary text format | `text_format: "markdown"` | Internal config (always markdown) | Meeting | Build | | Manual summary trigger | `POST /sessions/{id}/summary` REST API | Post-meeting page > Generate Summary button | Server | Build | | Summary output (Key Points, Action Items, Decisions) | Structured markdown output | Post-meeting summary viewer | Server | Build | ### 7.12 Virtual Backgrounds & Video Effects | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Background blur | `@cloudflare/realtimekit-virtual-background` package | Settings > Video > Background | Client | Build | | Virtual background (custom image) | Same package + Video Background addon | Settings > Video > Background | Client | Build | | Video Background addon | `realtimekit-ui-addons` Video Background addon | Pre-join screen + in-meeting settings | Client | Build | ### 7.13 Plugins | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Plugins panel | `rtk-plugins` component | Sidebar > Plugins tab | Preset | Build | | Plugins toggle | `rtk-plugins-toggle` component | Control bar | Client | Build | | Plugin main view | `rtk-plugin-main` component | Main content area (when plugin active) | Client | Build | | Whiteboard (built-in) | Built-in plugin, enabled via preset | Plugins panel > Whiteboard | Preset | Build | | Document Sharing (built-in) | Built-in plugin, enabled via preset | Plugins panel > Document Sharing | Preset | Build | | Custom plugins | Plugin SDK (`meeting.plugins` API, iframe-based) | Org Settings > Custom Plugins (future) | Preset | Build | | Plugin permissions (view/open/close) | Preset `plugins` config | Preset editor > Plugins section | Preset | Build | | Plugin data exchange | `plugin.sendData()` / `plugin.handleIframeMessage()` | Plugin <-> app communication | Client | Build | ### 7.14 Media Controls & Device Selection | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Camera toggle | `rtk-camera-toggle` component | Control bar | Client | Build | | Camera selector | `rtk-camera-selector` component | Settings > Video > Camera picker | Client | Build | | Microphone toggle | `rtk-mic-toggle` component | Control bar | Client | Build | | Microphone selector | `rtk-microphone-selector` component | Settings > Audio > Mic picker | Client | Build | | Speaker selector | `rtk-speaker-selector` component | Settings > Audio > Speaker picker | Client | Build | | Audio visualizer | `rtk-audio-visualizer` component | Name tag, settings preview, pre-join | Client | Build | | Settings panel | `rtk-settings` component | Sidebar > Settings tab | Client | Build | | Audio settings | `rtk-settings-audio` component | Settings > Audio sub-panel | Client | Build | | Video settings | `rtk-settings-video` component | Settings > Video sub-panel | Client | Build | | Settings toggle | `rtk-settings-toggle` component | Control bar | Client | Build | ### 7.15 Screen Sharing & PiP | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Screen share toggle | `rtk-screen-share-toggle` component | Control bar | Client | Build | | Screen share view | `rtk-screenshare-view` component | Main content area (when sharing) | Client | Build | | Screen share frame rate config | SDK media config `screenShareConfig.frameRate` | Internal config (default 5 FPS) | Client | Build | | Picture-in-Picture toggle | `rtk-pip-toggle` component | Control bar | Client | Build | | PiP with reactions | SDK PiP support | Browser PiP window | Client | Build | | Fullscreen toggle | `rtk-fullscreen-toggle` component | Control bar | Client | Build | ### 7.16 Simulcast & Active Speakers | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Simulcast (multi-quality streams) | SDK config (added Core 1.2.0) | Internal — transparent to user | Client | Build | | Per-track simulcast config | SDK `simulcastConfig` (`disable`, `encodings`) via `initMeeting` overrides | Bandwidth-adaptive quality (automatic) | Client | Build | | Active speaker detection | `meeting.participants.lastActiveSpeaker` (single participantId) | Spotlight grid, speaker indicator | Client | Build | | Active participants map | `meeting.participants.active` (map of active participants) | Active speaker grid layout | Client | Build | | Spotlight grid | `rtk-spotlight-grid` component | Layout option: spotlight mode | Client | Build | | Spotlight indicator | `rtk-spotlight-indicator` component | On active speaker tile | Client | Build | | Network quality indicator | `rtk-network-indicator` component | On participant tiles | Client | Build | ### 7.17 Grid Layouts | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Default responsive grid | `rtk-grid` component | Main content area (default layout) | Client | Build | | Simple grid | `rtk-simple-grid` component | Compact layout option | Client | Build | | Mixed grid (video + screenshare) | `rtk-mixed-grid` component | When screen sharing active | Client | Build | | Audio-only grid | `rtk-audio-grid` component | Voice meeting layout | Client | Build | | Grid pagination | `rtk-grid-pagination` component | Below grid (large meetings) | Client | Build | ### 7.18 UI Chrome & Meeting Shell | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Complete meeting experience | `rtk-meeting` component | Drop-in full meeting page | Client | Build | | Pre-join setup screen | `rtk-setup-screen` component | Meeting lobby / pre-join | Client | Build | | Idle/disconnected screen | `rtk-idle-screen` component | Before joining / connection lost | Client | Build | | Meeting ended screen | `rtk-ended-screen` component | After meeting ends | Client | Build | | Leave meeting | `rtk-leave-meeting` / `rtk-leave-button` | Control bar > Leave | Client | Build | | Control bar | `rtk-controlbar` component | Bottom of meeting view | Client | Build | | Control bar buttons | `rtk-controlbar-button` component | Individual action buttons | Client | Build | | Sidebar container | `rtk-sidebar` / `rtk-sidebar-ui` component | Right side panel (chat, participants, etc.) | Client | Build | | Dialog/modal system | `rtk-dialog` / `rtk-dialog-manager` component | Confirmation dialogs, settings modals | Client | Build | | Menu system | `rtk-menu` / `rtk-menu-item` / `rtk-menu-list` | Context menus, dropdowns | Client | Build | | Notification system | `rtk-notification` / `rtk-notifications` | Toast notifications | Client | Build | | Tab navigation | `rtk-tab-bar` component | Sidebar tab switching | Client | Build | | Overlay modal | `rtk-overlay-modal` component | Full-screen overlays | Client | Build | | Confirmation modal | `rtk-confirmation-modal` component | Destructive action confirmations | Client | Build | | Permissions prompt | `rtk-permissions-message` component | Browser permission requests | Client | Build | | More options menu | `rtk-more-toggle` component | Control bar overflow menu | Client | Build | | Header bar | `rtk-header` component | Meeting top bar | Client | Build | | Logo display | `rtk-logo` component | Header / idle screen | Client | Build | | Loading spinner | `rtk-spinner` component | Loading states | Client | Build | ### 7.19 Addons | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Camera host control | `realtimekit-ui-addons` Camera Host Control | Participant tile menu (host) | Preset | Build | | Mic host control | `realtimekit-ui-addons` Mic Host Control | Participant tile menu (host) | Preset | Build | | Chat host control | `realtimekit-ui-addons` Chat Host Control | Participant tile menu (host) | Preset | Build | | Hand raise | `realtimekit-ui-addons` Hand Raise addon | Control bar + participant list | Client | Build | | Reactions | `realtimekit-ui-addons` Reactions Manager | Control bar + floating reactions overlay | Client | Build | | Participant tile menu | `realtimekit-ui-addons` Participant Tile Menu | Right-click / long-press on tile | Client | Build | | Custom control bar button | `realtimekit-ui-addons` Custom Control Bar Button | Extend control bar with app-specific actions | Client | Build | | Participant menu item | `@cloudflare/realtimekit-ui-addons` Participant Menu Item | Custom menu items in participant context menu | Client | Build | | Participant tab actions | `@cloudflare/realtimekit-ui-addons` Participants Tab Action/Toggle | Participant panel extensions | Client | Build | ### 7.20 Branding & Design System | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | UI Provider context | `rtk-ui-provider` component | App root wrapper | App | Build | | Design system tokens | `provideRtkDesignSystem()` utility | App initialization | App | Build | | Theme (light/dark/darkest) | Design token `theme` | Org Settings > Branding | App | Build | | Brand color | Design token `brand.500` | Org Settings > Branding | App | Build | | Background color | Design token `background.1000` | Org Settings > Branding | App | Build | | Typography | Design token `fontFamily` / `googleFont` | Org Settings > Branding | App | Build | | Spacing scale | Design token `spacingBase` | Internal (default 4px) | App | Build | | Border width/radius | Design tokens | Org Settings > Branding | App | Build | | Custom icon pack | JSON SVG icon pack (40+ icons) | Org Settings > Branding > Icons | App | Build | | Custom logo | `rtk-logo` component or `rtk-meeting` prop | Org Settings > Branding > Logo | App | Build | | Internationalization (i18n) | `RtkI18n` type + `t` prop / `useLanguage()` hook | Org Settings > Language | App | Build | | CSS variable prefix (`--rtk-*`) | All design system outputs | Automatic from token config | App | Build | ### 7.21 Debug & Diagnostics | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Debug panel | `rtk-debugger` component | Settings > Debug (admin/dev only) | Client | Build | | Debug toggle | `rtk-debugger-toggle` component | Control bar (admin only) | Client | Build | | Audio debug info | `rtk-debugger-audio` component | Debug panel > Audio tab | Client | Build | | Video debug info | `rtk-debugger-video` component | Debug panel > Video tab | Client | Build | | Screenshare debug info | `rtk-debugger-screenshare` component | Debug panel > Screen tab | Client | Build | | System debug info | `rtk-debugger-system` component | Debug panel > System tab | Client | Build | | Error codes | RTK error code reference | Error display UI + Sentry reporting | Client | Build | ### 7.22 Realtime Agents (Low Priority — Groundwork Only) | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | Agent as meeting participant | `@cloudflare/realtime-agents` SDK | Agent joins meeting with authToken | Server (DO) | Low Priority | | STT pipeline (Deepgram) | `DeepgramSTT` component | Automatic transcription of meeting audio | Server (DO) | Low Priority | | LLM processing | `TextComponent` + Workers AI binding | Agent text generation | Server (DO) | Low Priority | | TTS pipeline (ElevenLabs) | `ElevenLabsTTS` component | Agent spoken responses | Server (DO) | Low Priority | | RTK Transport | `RealtimeKitTransport` component | Agent media I/O to meeting | Server (DO) | Low Priority | | DO binding in wrangler | Durable Object config | `wrangler.jsonc` setup | Server | Low Priority | | Agent route stubs | `/agent/*` and `/agentsInternal` routes | Worker routing | Server | Low Priority | ### 7.23 TURN & NAT Traversal #### What TURN does TURN (Traversal Using Relays around NAT) is a relay service that ensures video/voice works for users behind corporate firewalls, strict NATs, or restrictive networks. Without it, ~10-15% of users (typically corporate/enterprise) cannot establish direct connections and would be unable to join meetings. TURN relays their media traffic through a public server as a fallback when direct or STUN-assisted connections fail. #### How RTK handles TURN — zero developer action required RealtimeKit manages TURN/ICE/NAT traversal entirely within its platform. When a participant joins a meeting using their `authToken`, the RTK SDK automatically: 1. Performs ICE candidate gathering (local, STUN reflexive, TURN relay) 2. Negotiates the best connection path with the RTK SFU 3. Falls back to TURN relay if direct connectivity fails 4. Handles credential rotation for long-running sessions **There is no TURN configuration in the RTK SDK.** The `RealtimeKitClient.init()` method accepts only `authToken`, `baseURI`, and `defaults` — no ICE server URLs, no TURN keys, no credentials. The RTK quickstart flow is: create meeting → add participant → get `authToken` → pass to SDK → done. **We write zero TURN code.** No key provisioning, no credential generation, no ICE server distribution. #### ⚠️ AGENT BUILD WARNING **Do NOT implement any TURN key provisioning, credential generation, or ICE server configuration code.** If you encounter Cloudflare documentation describing these steps, it is for a different product (the standalone TURN Service for raw WebRTC apps) — not RealtimeKit. RTK handles all TURN/ICE internally. We write zero TURN code. #### TURN diagnostics (post-meeting investigation) While we don't manage TURN, we can observe it via the Peer Report API. Each participant's report includes: | Field | Location in peer report | What it tells us | |---|---|---| | `turn_connectivity` | `metadata.pc_metadata[]` | Whether the participant's network could reach TURN servers | | `relay_connectivity` | `metadata.pc_metadata[]` | Whether relay (TURN) connections were available | | `reflexive_connectivity` | `metadata.pc_metadata[]` | Whether STUN reflexive connections were available | | `effective_network_type` | `metadata.pc_metadata[]` | Network type (4g, wifi, etc.) at connection time | | `local_candidate_type` | `metadata.candidate_pairs.producing_transport[]` | `host` (direct), `srflx` (STUN), or `relay` (TURN) — which path was actually used | | `remote_candidate_type` | `metadata.candidate_pairs.producing_transport[]` | The SFU's candidate type | This data is available via `GET /sessions/peer-report/{peer_id}?filters=precall_network_information` and is surfaced in the Meeting Investigation page (Section 3.6). | Feature | RTK API/Component | App Location | Config Level | Status | |---------|-------------------|-------------|-------------|--------| | TURN relay | Managed by RTK internally via `authToken` — zero developer code | Transparent — no user-facing config | Platform | Build | | NAT traversal (ICE) | RTK SDK handles candidate gathering, STUN/TURN fallback automatically | Transparent — no developer action | Platform | Build | | Protocol support | STUN/UDP, TURN/UDP, TURN/TCP, TURN/TLS — all handled by RTK | Automatic fallback based on network conditions | Platform | Build | | TURN diagnostics | Peer Report API: `turn_connectivity`, `candidate_pairs`, `effective_network_type` | Admin > Meeting Investigation page | Server | Build | --- ## Section 8: Post-Meeting Experience The post-meeting page is a first-class product surface — not an afterthought. Users land here after a meeting ends, from email links (triggered by `meeting.ended` webhook), and from the meeting history list. ### 8.1 Page Structure **Route**: `/meetings/{meetingId}/sessions/{sessionId}` **Layout**: Full-width page with tabbed content area and session metadata header. ``` +------------------------------------------------------------------+ | Session Header | | Meeting Title | Date & Time | Duration | Participant Count | | Host: Name | Type: Video/Voice/Webinar | Status: Ended | +------------------------------------------------------------------+ | [Summary] [Transcript] [Recording] [Chat] [Analytics] | +------------------------------------------------------------------+ | | | (Active tab content) | | | +------------------------------------------------------------------+ ``` ### 8.2 Session Metadata Header Sourced from D1 (populated by `meeting.ended` webhook + REST API enrichment): | Field | Source | |-------|--------| | Meeting title | `meeting.meta.meetingTitle` (stored in D1 on session start) | | Date & time | Webhook `meeting.ended` timestamp + session start time | | Duration | Calculated: end - start | | Participant count | From webhook payload or REST API session detail | | Host name | From our user system (mapped via participant userId) | | Meeting type | From preset config (Video/Voice/Webinar) | | Recording available | Boolean — from `recording.statusUpdate` UPLOADED event | | Transcript available | Boolean — from `meeting.transcript` webhook received | | Summary available | Boolean — from `meeting.summary` webhook received | ### 8.3 Summary Tab **Data source**: `meeting.summary` webhook `summaryDownloadUrl` or REST API `GET /sessions/{id}/summary` **Display**: - Rendered markdown (RTK returns structured markdown with sections: Key Discussion Points, Action Items, Decisions) - Copy-to-clipboard button for full summary - Download as `.md` button - If summary not yet generated and meeting has transcription data, show "Generate Summary" button (calls `POST /sessions/{id}/summary`) - If no transcription was enabled, show disabled state with explanation **Retention notice**: "Available for 7 days from meeting end" with countdown badge. ### 8.4 Transcript Tab **Data source**: `meeting.transcript` webhook `transcriptDownloadUrl` or REST API `GET /sessions/{id}/transcript` **Display**: - Timeline-based transcript viewer with speaker labels and timestamps - Each entry: `[HH:MM:SS] Speaker Name: "Transcript text"` - Search within transcript (client-side filter) - Jump to timestamp (if recording is available, clicking a timestamp seeks the recording player) - Download as `.txt` or `.json` - Partial transcript indicator: entries with `isPartialTranscript: true` are excluded from post-meeting view (only final transcripts shown) **Retention notice**: Same 7-day window. ### 8.5 Recording Tab **Data source**: `recording.statusUpdate` webhook (UPLOADED state) provides download URL via Fetch Recording Details REST API. **Display**: - Video player (HTML5 `