feat(embed,api): embeddable chat widget and public /api/v1 - #26
Open
loidosts wants to merge 1 commit into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
SessionGuardbecomes a credential chain rather than a cookie reader:sidcookie → embed bearer JWT →sk_…API key, each producing areq.principal(auth/principal.ts).EffectiveProfileService.resolve()andViewerAccessService.resolve()now take aPrincipalinstead of aSession. Cookie and embed requests still getreq.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 reusingMessageList,ChatComposerand the extracteduseAssistantChathook — no forked chat UI. The loader is built in a separate pass (vite.loader.config.ts,build.lib+iife) so host pages don't needtype="module".Auth is the subtle part: a cross-site iframe never receives the
SameSite=Laxcookie, and Microsoft's login page setsX-Frame-Options: DENY, so OAuth can't run in the frame. A popup does top-level OAuth andpostMessages 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 sameChatService, retrieval filter andchat_historiestable as the browser, so the two can't drift. Two deliberate differences:Accept: text/event-stream.ApiKey.jobTitle/departmentpin a profile; no pin means public documents only, with no fallback toDEFAULT_JOB_TITLE— a machine inheriting the human default profile is privilege escalation, not convenience.ChatHistory.ownerApiKeyIdis a separate column fromownerEmailrather than a syntheticapikey:<id>string, which would collide with a real address.RateLimitGuard.callerKeynow buckets on the bearer too (hashing the embed token'ssub, so rotation doesn't hand out a fresh budget); otherwise every widget user and integration would share oneip:bucket.Admin & infra
0017_api_keys.EMBED_ALLOWED_ORIGINSis env-only and has a twin: the backend uses it for CORS and the popup'spostMessagetarget, and nginx needs the same list asEMBED_FRAME_ANCESTORSfor itsframe-ancestorsCSP (nginx.conf.template). It can't be a runtime setting because nginx would never see a portal change.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