Skip to content

feat(chat-demo): bring-your-own-key sign-in - #18

Open
s23h wants to merge 1 commit into
mainfrom
feat/chat-demo-byok-cloudflare
Open

feat(chat-demo): bring-your-own-key sign-in#18
s23h wants to merge 1 commit into
mainfrom
feat/chat-demo-byok-cloudflare

Conversation

@s23h

@s23h s23h commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Makes examples/chat-demo multi-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_KEY from 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_SECRET and returned as an httpOnly cookie (~75 bytes). hooks.server.ts decrypts it per request and builds SDK clients into event.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_SECRET signs 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_KEY binding 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, open
dynamic client registration, PKCE S256, refresh tokens), and it's what the MCP
connector authenticates with. I ran the whole flow: registration 201, token
exchange 200, and the resulting mcp:tools JWT works against POST /mcp
(returns a proper initialize result).

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"}, and Authorization: Bearer <api key> does
return 200 on the same path — so bearer transport is already wired and it's
the 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 serverURL alongside it.

Threading clients through the routes

textqlClients()textqlClients(locals), so the 20 API routes take locals and 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.ts and
package.json are byte-identical to main, there's no adapter change, no
wrangler.jsonc, and no deploy scripts. Cloning the example gets you exactly
the 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:

Step Result
GET / signed out 303 → /login?next=%2F
Deep link /chat/abc 303, next preserved
GET /api/* signed out 401 JSON, not an HTML redirect
Sign in, real key session sealed, chat history loads
Send a message run streams: tool calls → thinking → settled answer
Tampered cookie GCM tag fails → treated as signed out
Cross-origin POST 403
Bad / empty key rejected at sign-in with a readable message
Console errors / failed requests none / none

svelte-check is clean on everything touched. Seven pre-existing errors remain in Logo.svelte, SpinnerArrows.svelte, and the dev-proxy typing in vite.config.ts — untouched here.

Also fixed in passing

  • Node version. sanitize-html@2.17.6 requires >=22.12.0 and .npmrc sets engine-strict=true, so npm install hard-fails on Node 20 — but the README said "Node 18+". Corrected.
  • No way to sign out. Added a control to the sidebar; a signed-in visitor previously had no exit.

Correction to an earlier draft of this PR

An earlier version of this description claimed that curl-ing a public API path
returned the SPA's 404 HTML shell. That was wrong — I was requesting the path
without its /rpc/public prefix. On the correct path curl behaves properly:
200 with a real key, and 401 {"code":"unauthenticated", ...} without one.
No bug here; disregard.

🤖 Generated with Claude Code

@s23h
s23h force-pushed the feat/chat-demo-byok-cloudflare branch from 757970a to 5cffb0c Compare July 28, 2026 14:37
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 s23h changed the title feat(chat-demo): bring-your-own-key sign-in, deployable to Cloudflare Workers feat(chat-demo): bring-your-own-key sign-in Jul 28, 2026
@s23h
s23h force-pushed the feat/chat-demo-byok-cloudflare branch from 5cffb0c to 9f7db94 Compare July 28, 2026 15:10
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