feat(chat-demo): bring-your-own-key sign-in - #18
Open
s23h wants to merge 1 commit into
Open
Conversation
s23h
force-pushed
the
feat/chat-demo-byok-cloudflare
branch
from
July 28, 2026 14:37
757970a to
5cffb0c
Compare
The demo read a single TEXTQL_API_KEY from the environment and built one process-global SDK client from it, which is right for running it locally and wrong for hosting it anywhere: the first visitor would be using your key against your workspace. This makes it multi-tenant. Sign-in, without a user table: Each visitor pastes their own API key. It is verified against the API before anything is stored, then sealed with AES-GCM under SESSION_SECRET and returned as an httpOnly cookie (~75 bytes). `hooks.server.ts` decrypts it per request and builds SDK clients into `event.locals`. So the key exists in exactly two places — the visitor's browser, opaque to it, and the memory of the request using it. Nothing is persisted, and rotating SESSION_SECRET signs everyone out. That also means a deployment holds no TextQL credentials of its own: there is no TEXTQL_API_KEY binding to leak or rotate. An API key is the only credential this surface accepts. TextQL does run an OAuth 2.1 authorization server, but its only scope is `mcp:tools` and tokens from it are refused by the public API — so OAuth is not an option here yet. Details in the PR. On-prem users can supply a serverURL alongside their key. Threading the clients through: `textqlClients()` becomes `textqlClients(locals)`, so the 20 API routes now take `locals` and get clients scoped to the caller rather than to the process. Mechanical, but it is the change that makes per-visitor keys possible at all. Nothing here presumes a host: no adapter change, no deploy config, and package.json is untouched. Cloudflare Workers hosting is a follow-up. Also: Node >=22.12 is now stated (sanitize-html already required it while the README still said 18+), and a sign-out control was added, since a signed-in visitor previously had no way back out.
s23h
force-pushed
the
feat/chat-demo-byok-cloudflare
branch
from
July 28, 2026 15:10
5cffb0c to
9f7db94
Compare
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.
Makes
examples/chat-demomulti-tenant and hostable, so it can sit on the open web as a demonstration of what the public API supports. Live at https://clone.textql.com.Independent of #17 (the SDK streaming fix) — either can land first. #21 stacks the optional Cloudflare deployment on top of this.
Why
The demo read one
TEXTQL_API_KEYfrom the environment and built a single process-global SDK client from it. Correct for running it locally, unusable for hosting: the first visitor would be using your key against your workspace.Sign-in, without a user table
A visitor pastes their own API key. It's verified against the API before anything is stored, then sealed with AES-GCM under
SESSION_SECRETand returned as an httpOnly cookie (~75 bytes).hooks.server.tsdecrypts it per request and builds SDK clients intoevent.locals.The key therefore exists in exactly two places: the visitor's browser, opaque to it, and the memory of the request using it. Nothing is persisted server-side, and rotating
SESSION_SECRETsigns everyone out (unsealing fails, which is treated as "signed out" rather than an error).The deployment holds no TextQL credentials of its own — there's deliberately no
TEXTQL_API_KEYbinding to leak, rotate, or accidentally bill.On credentials — I tested this rather than assumed it. TextQL does run a full
OAuth 2.1 authorization server (
/.well-known/oauth-authorization-server, opendynamic client registration, PKCE
S256, refresh tokens), and it's what the MCPconnector authenticates with. I ran the whole flow: registration
201, tokenexchange
200, and the resultingmcp:toolsJWT works againstPOST /mcp(returns a proper
initializeresult).That same token is refused by the public API:
401 {"error":{"code":401, "status":"Unauthorized","message":"The request could not be authorized"}}on/rpc/public/**. Notably that's a different error shape from the Connect-level{"code":"unauthenticated"}, andAuthorization: Bearer <api key>doesreturn
200on the same path — so bearer transport is already wired and it'sthe authorization decision, not the credential type, that says no. Makes sense:
the only advertised scope is
mcp:tools.So an API key is the only credential that reaches this surface today, and it's
the only one the sign-in asks for. Making "Sign in with TextQL" possible is a
server-side change (a scope/audience the public API accepts) — the session layer
here would barely move, sealing an access + refresh token instead of a key.
On-prem users can supply a
serverURLalongside it.Threading clients through the routes
textqlClients()→textqlClients(locals), so the 20 API routes takelocalsand receive clients scoped to the caller rather than the process. Mechanical (50 insertions, 50 deletions, applied by script and limited to handlers that actually reach for the SDK) but it's the change that makes per-visitor keys possible.Deliberately no hosting in here
Per review feedback, this PR presumes no platform:
vite.config.tsandpackage.jsonare byte-identical to main, there's no adapter change, nowrangler.jsonc, and no deploy scripts. Cloning the example gets you exactlythe build it had before.
Cloudflare Workers hosting is #21, stacked on this branch.
Verification
End to end on Workers with a real API key, via Playwright against the deployed Worker:
GET /signed out/login?next=%2F/chat/abcnextpreservedGET /api/*signed outsvelte-checkis clean on everything touched. Seven pre-existing errors remain inLogo.svelte,SpinnerArrows.svelte, and the dev-proxy typing invite.config.ts— untouched here.Also fixed in passing
sanitize-html@2.17.6requires>=22.12.0and.npmrcsetsengine-strict=true, sonpm installhard-fails on Node 20 — but the README said "Node 18+". Corrected.Correction to an earlier draft of this PR
An earlier version of this description claimed that
curl-ing a public API pathreturned the SPA's 404 HTML shell. That was wrong — I was requesting the path
without its
/rpc/publicprefix. On the correct pathcurlbehaves properly:200with a real key, and401 {"code":"unauthenticated", ...}without one.No bug here; disregard.
🤖 Generated with Claude Code