Skip to content

feat(embed,api): embeddable chat widget and public /api/v1 - #26

Open
loidosts wants to merge 1 commit into
mainfrom
feat/embeddable-widget-and-public-api
Open

feat(embed,api): embeddable chat widget and public /api/v1#26
loidosts wants to merge 1 commit into
mainfrom
feat/embeddable-widget-and-public-api

Conversation

@loidosts

@loidosts loidosts commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Adds two new ways into the assistant besides the SPA — an embeddable chat bubble and a public REST API — on top of one shared auth foundation.

Shared foundation

SessionGuard becomes a credential chain rather than a cookie reader: sid cookie → embed bearer JWT → sk_… API key, each producing a req.principal (auth/principal.ts). EffectiveProfileService.resolve() and ViewerAccessService.resolve() now take a Principal instead of a Session. Cookie and embed requests still get req.session, so existing call sites are untouched.

API keys are accepted only on /api/v1/* (enforced in the guard) — a long-lived credential that leaks into CI logs must not be able to reach /api/admin/*.

Embeddable widget (docs/embedding.md)

One <script src="/embed.js"> renders a shadow-DOM launcher plus an iframe pointing at /embed.html, a second Vite entry reusing MessageList, ChatComposer and the extracted useAssistantChat hook — no forked chat UI. The loader is built in a separate pass (vite.loader.config.ts, build.lib + iife) so host pages don't need type="module".

Auth is the subtle part: a cross-site iframe never receives the SameSite=Lax cookie, and Microsoft's login page sets X-Frame-Options: DENY, so OAuth can't run in the frame. A popup does top-level OAuth and postMessages back a short-lived HS256 bearer token (auth/embed-token.service.ts). The token is a pointer — every request re-reads the session row — so deleting a session revokes it within its TTL.

Public API (docs/public-api.md)

/api/v1/* is controller-only and drives the same ChatService, retrieval filter and chat_histories table as the browser, so the two can't drift. Two deliberate differences:

  • It answers in JSON unless you send Accept: text/event-stream.
  • Retrieval scope comes from the key, not a person. ApiKey.jobTitle/department pin a profile; no pin means public documents only, with no fallback to DEFAULT_JOB_TITLE — a machine inheriting the human default profile is privilege escalation, not convenience.

ChatHistory.ownerApiKeyId is a separate column from ownerEmail rather than a synthetic apikey:<id> string, which would collide with a real address.

RateLimitGuard.callerKey now buckets on the bearer too (hashing the embed token's sub, so rotation doesn't hand out a fresh budget); otherwise every widget user and integration would share one ip: bucket.

Admin & infra

  • Admin pages for API keys (create/revoke, per-key rate-limit overrides) and the embed snippet.
  • Migration 0017_api_keys.
  • EMBED_ALLOWED_ORIGINS is env-only and has a twin: the backend uses it for CORS and the popup's postMessage target, and nginx needs the same list as EMBED_FRAME_ANCESTORS for its frame-ancestors CSP (nginx.conf.template). It can't be a runtime setting because nginx would never see a portal change.
  • Docs: docs/embedding.md, docs/public-api.md, and a new CLAUDE.md section.

Testing

Manual/E2E only — this repo has no backend test runner. Verified cross-domain embed flow (popup OAuth → token → chat), iframe isolation, CSP frame-ancestors, token revocation via session delete, and API-key scoping (unpinned key sees public docs only).

🤖 Generated with Claude Code

Two new ways into the assistant, sharing one auth foundation.

- auth: SessionGuard becomes a credential chain (sid cookie -> embed
  bearer JWT -> sk_ API key) producing req.principal; profile and access
  resolution take a Principal instead of a Session. API keys are
  accepted only on /api/v1/*.
- embed: /embed.js loader (separate iife build) renders a shadow-DOM
  launcher plus an iframe at /embed.html, reusing MessageList,
  ChatComposer and the extracted useAssistantChat hook. Cross-site auth
  runs through a top-level popup that postMessages a short-lived HS256
  bearer token pointing at the session row.
- api-v1: JSON-by-default chat endpoints over the same ChatService,
  retrieval filter and chat_histories table. Key-pinned job profile;
  unpinned keys see public documents only.
- admin: API keys page and embed snippet page; per-key rate-limit
  overrides bucketed on the bearer in RateLimitGuard.
- infra: migration 0017_api_keys, EMBED_ALLOWED_ORIGINS for CORS and the
  postMessage target, nginx.conf.template rendered with
  EMBED_FRAME_ANCESTORS for the frame-ancestors CSP.
- docs: docs/embedding.md, docs/public-api.md, CLAUDE.md section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant