Auth: migrate to better-auth (web cookie sessions + OAuth 2.1 for the CLI)#93
Conversation
Add the better-auth library (1.6.20) for human identity (OAuth + sessions +
device flow); api keys stay custom in core. The instance maps onto the existing
snake_case auth tables via modelName+fields (no renames), uses generateId:false
so the DB uuidv7() default keeps auth.users.id == core.principal.id, and runs on
its own node-postgres pool pinned to search_path=auth (separate from the app/
worker postgres.js pools). bearer({requireSignature:true}) hardens the CLI path.
Wired into start.ts/context.ts as ctx.betterAuth (+ pool teardown); requires the
new BETTER_AUTH_SECRET env. Not yet mounted/serving — handler routing, session
validation swap, and the DB migration follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mount better-auth as a catch-all on /api/v1/auth/* (it owns OAuth, sessions,
sign-out, and the device flow), replacing the hand-rolled device/OAuth routes.
Swap session validation in both authenticators to auth.api.getSession({headers}):
- authenticate-space: api-key path (core.validateApiKey) unchanged; the session
branch now uses getSession. Cookie creds still gated by the CSRF origin check;
Bearer exempt. Credential-presence check keeps "no creds" at 401.
- authenticate-user: getSession-based; carries identity (id/email/name) so the
user RPC no longer needs AuthStore. whoami reads identity from the session.
The hand-rolled handlers/auth.ts + auth/providers/* are now unused (deletion
batched with the packages/auth retirement). check (typecheck/lint/unit) is green;
the auth-space integration test is wired to a real better-auth instance but its
valid-session cases need the DB migration + session minting (follow-up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
incremental/006_betterauth.sql: sessions get a plaintext unique `token` + `updated_at` and lose `token_hash` (better-auth round-trips the raw token through the row, so hash-at-rest is impossible; the CLI/bearer path is hardened with requireSignature instead). Existing sessions are truncated (disposable; re-login). Replace the custom device_authorization table + its SQL functions with the better-auth deviceAuthorization plugin's model, mapped to `device_codes` (snake case via the plugin schema option in betterauth.ts). The session/device SQL functions are dropped; only the expired-row cron sweeps remain (idempotent 002 + 004, the latter now sweeping device_codes). The cron's device cleanup repoints to cleanup_expired_device_codes. Verified: migrateAuth applies cleanly on a fresh schema and re-applies cleanly (idempotent boot). The migrate.integration.test.ts rewrite (it asserts the old tables/functions) is batched with the test rework. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
better-auth owns auth.users + accounts now, so first-login provisioning is core-only and lazy: ensureUserProvisioned stands up the core.principal (sharing the better-auth user id), a default space + its me_<slug> schema, and the creator grants the first time a session reaches the user RPC. Idempotent (a no-op once the principal exists, guarded by getPrincipal) so it runs on every user RPC call; concurrent first-requests race safely. The CLI hits whoami/space.list right after login, so the user RPC is the natural touchpoint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the session/device-flow approach for programmatic clients with an OAuth 2.1 authorization server. betterauth.ts now: - drops the deviceAuthorization + bearer plugins (and requireSignature) - adds jwt() (id-token signing + JWKS, private keys encrypted with the secret) and @better-auth/oauth-provider (the AS) - registers the first-party `me` CLI as a trusted public client (CLI_CLIENT_ID) via cachedTrustedClients (PKCE + loopback + skip-consent) better-auth keeps web cookie sessions (social login) + owns the AS; the CLI becomes an OAuth public client and the API will validate opaque access tokens (hashed at rest by default) via introspection. user/session/account/verification stay snake-case-mapped; the new oauth + jwks tables use better-auth's native names (library-owned, never queried directly). check (typecheck/lint/unit) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revise incremental/006 for the OAuth-provider route: drop the device_codes table/cleanup (the CLI now uses the OAuth authorization-code flow), and create the @better-auth/oauth-provider tables (oauthClient/oauthRefreshToken/ oauthAccessToken/oauthConsent) + the jwt plugin's jwks table. DDL mirrors `better-auth generate` (string[] -> jsonb), with ids as DB-generated text (uuidv7()::text; generateId:false omits them) and FK columns that reference our uuid PKs (users.id, sessions.id) typed as uuid. Access/refresh tokens are stored hashed at rest by the provider. The sessions token/updated_at changes stay. idempotent/004 now defines cleanup_expired_oauth_tokens (sweeps expired access + refresh tokens); the cron's AuthStore call is repointed to it. Verified: migrateAuth applies + re-applies cleanly on a fresh schema; check green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The file now holds cleanup_expired_oauth_tokens (no device-auth left), so the name was a misnomer. Safe to rename: idempotent migrations re-run every boot and are not tracked by name in the migration table (only incrementals are), so this is a pure relabel. The incremental 004_device_authorization.sql is left as-is (tracked history; renaming it would orphan its record and re-run it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
House style: map better-auth's camelCase oauth-provider + jwt models onto
snake_case tables/columns via `schema` overrides (modelName + fields) in
betterauth.ts, and create matching snake_case DDL in incremental/006
(oauth_client/oauth_access_token/oauth_refresh_token/oauth_consent + jwks).
Single-word fields keep their name. Also set jwt({ disableSettingJwtHeader:
true }) (recommended with an OAuth provider). cleanup_expired_oauth_tokens now
targets the snake_case tables.
Mapping validated + DDL derived from `better-auth generate`; migrateAuth applies
+ re-applies cleanly on a fresh schema. check green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… seed
Add the resource-server validation primitive for the OAuth-provider route:
- hashOAuthToken (sha256 hex) wired as the provider's storeTokens hasher AND
used by verifyOAuthAccessToken, so storage and lookup share one hash.
- verifyOAuthAccessToken(token): hashed lookup in oauth_access_token on the auth
pool -> { userId, scopes } | null (expiry-checked; revoke = row deletion).
Returned from createBetterAuth for the middleware to use next.
Seed the first-party `me` CLI as a trusted public client in incremental/006:
PKCE required, skip_consent, loopback redirect http://127.0.0.1/callback (RFC
8252 — the AS ignores the port for a loopback IP). Verified: seed applies and is
idempotent (on conflict do nothing); typecheck green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire verifyOAuthAccessToken (now joining users → { userId, email, name, scopes })
through context/start/router into both authenticators. Bearer credentials now
split: an api-key (me.<id>.<secret>) → core; anything else → OAuth access token
→ verifyOAuthToken (hashed lookup). The browser cookie session → getSession,
gated by the CSRF origin check. The old session-bearer path is gone — the CLI
uses OAuth tokens. The user RPC gets identity straight from the token row (powers
whoami + lazy provisioning). Fixtures updated; check green (628 unit tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hand-rolled auth-code + PKCE protocol layer for `me login` (RFC 6749/7636/8252):
generatePkce (S256), generateState, buildAuthorizeUrl, exchangeCode, and
refreshTokens against /api/v1/auth/oauth2/{authorize,token}. OAUTH_CLIENT_ID
"me-cli" matches the seeded public client. Pure/unit-testable (the loopback
server + browser launch land in the login command next). Unit tests cover the
PKCE relation + authorize-URL params; check green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olling Switch oauth.ts to the certified openid-client (v6) library, per the migration's "don't hand-roll auth" rationale. Explicit-endpoint Configuration (no discovery) with None() public-client auth; openid-client handles state + RFC 9207 iss validation + token-response checks. Verified against the provider's discovery doc that issuer = baseURL + /api/v1/auth (not bare baseURL — would've broken iss validation). Drop the `openid` scope (offline_access only) so there's no id_token → no JWKS dependency; identity is resolved server-side from the access token. allowInsecureRequests for http (local dev). Tests updated; check green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the device-flow CLI login with the OAuth 2.1 public-client flow (auth-code + PKCE + 127.0.0.1 loopback) and add best-practice CLI token handling: a short-lived access token refreshed proactively by expiry and reactively on a 401. - transport: `getToken` (proactive, resolved per call) + `onUnauthorized` (one-shot 401 refresh-and-retry, off the retry budget) seams. - credentials: store the OAuth token set (access/refresh/expiry) in the keychain (JSON) / 0600 file; resolveCredentials exposes `loggedIn` instead of a raw session token; legacy device tokens are scrubbed. - session.ts: getAccessToken/refreshAccessToken with a clock-skew buffer, refresh-token rotation, and per-server in-flight refresh dedup; userBearer / memoryBearer wire credentials into the transport seams. - rewire all consumers through bearer sources: util build*Client, whoami, space, serve (proxy refreshes on 401), mcp + MCP server, claude capture. Long-lived `me serve` / `me mcp` now survive access-token expiry. - login: loopback redirect handler (oauth-loopback.ts) + PKCE exchange; logout unchanged (clears the token set). The CLI no longer uses the device-flow client. Live end-to-end login still needs the web /login page (#6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CLI login rewrite (OAuth auth-code + PKCE + loopback) was the last consumer of the device-flow client, so it's now dead. Remove packages/client/auth.ts and its re-exports (createAuthClient / DeviceFlowError / AuthClient* / PollOptions) from the client index and the CLI client wrapper. Part of retiring the device flow (#8). The server-side device handlers (handlers/auth.ts), the packages/auth AuthStore, and auth/providers are still wired into context/start/provision + the integration tests, so they go with the test rework (#9). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CLI moved to OAuth (auth-code + PKCE + loopback) and better-auth owns
/api/v1/auth/* end-to-end, so the custom device-flow + browser-login
handlers are unreachable. Remove them and their OAuth provider helpers:
handlers/auth.ts (+ its unit/integration tests), auth/providers/{github,
google}, auth/types.ts, auth/index.ts.
Preserve the one live behavior they carried — invitation redemption on a
verified login — by moving redeemInvitationsForVerifiedLogin into
provision.ts and running it from ensureUserProvisioned on each user RPC
(idempotent + best-effort; better-auth gives no dedicated login hook).
Coverage moves to provision-invitations.integration.test.ts.
Trim server.integration.test.ts's device-endpoint block + AuthStore device
mocks (the router only mounts the better-auth catch-all now).
packages/auth (AuthStore) stays for now: provisionUser, the cron cleanup,
and the session-based integration tests still use it. Fully retiring it +
re-keying those session-bearer tests to the OAuth/context model is the test
rework (#9); the stale whoami-identity tests are known pre-existing reds,
unchanged by this commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The session bearers these tests minted (authStore.createSession) no longer authenticate — the middleware validates OAuth access tokens (or a better-auth cookie). Re-key them to the new model: - authenticate-space: mint a real OAuth access token (store sha256(raw) in oauth_access_token, present the raw bearer) instead of a session token. - agent (user RPC): whoami now echoes the identity the middleware put on the context (ctx.email/ctx.name) rather than looking it up in the auth store, so assert the echo; drop the obsolete "user row gone -> UNAUTHORIZED" handler test (token validity is the middleware's job) and the now-unused AuthStore wiring from the direct-handler harness. - start: pass betterAuthSecret so the boot path's required signing secret is satisfied in the harness. Full server integration suite green (92 tests). Remaining #9: the CLI e2e login (needs a live OAuth round-trip) + fully retiring packages/auth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The e2e harness minted its bearer via authStore.createSession and injected it as ME_SESSION_TOKEN — broken under the new model (the sessions table changed, and the server validates OAuth access tokens, not session tokens). Mint a real OAuth access token instead (store sha256(raw) in oauth_access_token, inject the raw via ME_SESSION_TOKEN's raw-bearer override), bound to the seeded me-cli client. Same proven pattern as the authenticate-space integration test. Gated on OPENAI_KEY + TEST_DATABASE_URL so it isn't run headless here; it loads + skips cleanly and typechecks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
provisionUser was a now-runtime-dead fixture: it wrote auth.users/accounts via AuthStore, but better-auth owns those at runtime and ensureUserProvisioned (core-only) is the real first-login path. So test the real thing and delete the fixture — removing the last AuthStore.createUser/upsertAccount caller. - provision.integration: rewritten to drive ensureUserProvisioned directly (principal + default space + creator grants + idempotency); drops the auth-row assertions (better-auth's responsibility). Now core-only, no auth schema. - new test-support.ts seedUserSpace(): the new-model seed for the other suites. Composes the same core primitives ensureUserProvisioned uses; inserts the auth.users row only when `auth` is passed — the two suites that mint a real OAuth bearer (authenticate-space + e2e), since verifyOAuthAccessToken joins users. Core-only consumers (memory, management, start) drop the auth schema. - delete provisionUser + ProvisionUserParams/Result + the AuthStore import. AuthStore's runtime footprint is now just the start.ts cleanup cron + context.auth; retiring that (boot path) is the remaining #8 tail. Server integration suite green (92); unit check green (636); e2e loads + skips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AuthStore's last runtime use was the start.ts cleanup cron + the vestigial
ctx.auth. Replace the cron's three AuthStore calls with a small
cleanupExpiredAuth(db, authSchema) helper that runs the schema's
cleanup_expired_{sessions,verifications,oauth_tokens}() functions directly —
same connection (the postgres.js app pool) and SQL the AuthStore wrapper used,
schema-qualified via sql(schema). Drop ctx.auth from ServerContext + start.ts,
and the vestigial auth mocks from the router/wiring/server/api-key fixtures.
Then delete packages/auth entirely (+ its workspace dep from server/e2e).
Coverage: the deleted db.integration.test.ts exercised AuthStore methods that
are now dead (device flow, AuthStore sessions/users/accounts). Its one live
piece — the expired-row sweep — is preserved and expanded in the new
cleanup.integration.test.ts (covers all three functions: deletes expired,
keeps fresh, returns counts). No live coverage lost.
Unit check green (636); server integration green (94, +2 cleanup). The auth
*migration* test (packages/database) has pre-existing reds asserting the old
device-flow schema that migration 006 already changed — untouched here, that
rework is #9.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The auth migrate test asserted the pre-006 device-flow schema (token_hash sessions, device_authorization, create_session/validate_session/device functions) that migration 006 replaced — 8 pre-existing failures. Rework it to the current shape: - EXPECTED_TABLES: drop device_authorization; add jwks + the oauth_client/oauth_access_token/oauth_refresh_token/oauth_consent tables. - EXPECTED_MIGRATIONS: add 006_betterauth (004 stays — it ran historically). - EXPECTED_FUNCTIONS: drop the session/device functions; add the three cleanup_expired_* sweeps. - session uniqueness now keys on `token` (the dropped token_hash → token); the cascade test inserts `token` too. - replace the device_authorization test with OAuth-schema coverage: the seeded me-cli public PKCE client + oauth_access_token token-uniqueness / client_id FK. - drop the create_session/validate_session + device-flow function tests (those functions are gone); keep the still-valid user/account/citext/cascade/ idempotency tests. 23 pass / 0 fail (was 16/8). Verified against a freshly-migrated schema. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hosted web UI's login was wired to the retired device-flow endpoints
(/api/v1/auth/login/:provider, /api/v1/auth/logout) — dead since better-auth
took over /api/v1/auth/*. Rewire it through the better-auth React client and
add the OAuth-provider login page the CLI's authorize flow redirects to.
- add better-auth (client) to packages/web; auth-client.ts wraps
createAuthClient (basePath /api/v1/auth) + signInWithProvider / signOut.
- AuthGate: startLogin -> authClient.signIn.social({provider, callbackURL});
logout -> authClient.signOut(). No more hand-rolled redirect / fetch to the
deleted endpoints.
- SignInCard: shared GitHub/Google sign-in card; the only thing that varies
between callers is callbackURL.
- LoginPage (/login): the better-auth `loginPage`. `me login` opens the
authorize endpoint; with no session better-auth redirects here with the
signed params; we sign in, then resume at /api/v1/auth/oauth2/authorize with
those params preserved, so the trusted me-cli client issues the code to the
CLI's loopback redirect.
- main.tsx renders LoginPage on the /login path (served by the SPA fallback).
Typechecks against better-auth's types + the web build passes. The live GitHub
round-trip still needs a configured GitHub OAuth app + GITHUB_CLIENT_ID/SECRET
on the server (#9 cutover).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
startServer now requires a signing secret (better-auth); the e2e didn't set one, so the suite errored at boot. Pass a stable test value — the e2e authenticates by OAuth-token injection (no sign-in), so the value is inert — mirroring the start.integration harness. Full e2e green: 26 pass / 0 fail against local Postgres + real OpenAI embeddings (CLI subprocess -> server -> DB -> embeddings, on the new OAuth access-token bearer). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Specify the authentication design implemented on this branch: better-auth (web cookie sessions + an OAuth 2.1 authorization server), the CLI auth-code + PKCE + loopback flow with the signed loginPage handoff, resource-server token validation (verifyOAuthAccessToken + Bearer dispatch), the CLI token lifecycle (proactive/reactive refresh, rotation, storage), agent api keys staying in core, lazy core-only provisioning + invitation redemption, the cleanup cron, security properties, config/env, and the testing approach. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the design debate behind the implementation so it isn't re-litigated: why not keep home-grown auth, why agent api keys stay in core, why the better-auth device flow was abandoned (doesn't compose with requireSignature; spike + upstream discussion #5068), and why not better-auth sessions / a confidential client / JWT-access-tokens+JWKS / RFC 7662 introspection / DCR / hand-rolled PKCE+loopback / reactive-only refresh / a server-rendered login page / a new GitHub OAuth app — with the chosen alternative for each. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…prefix Review follow-up. Dead device-flow leftovers (unreachable since better-auth took over /api/v1/auth/*): - authenticate.ts: drop extractSessionCredential + SessionCredential / CredentialSource — no live callers (the cookie path uses betterAuth.api.getSession). Keep extractBearerToken + passesCsrfCheck. - delete server/util/cookie.ts (+ test): every export served the retired device/browser-login handlers or extractSessionCredential. - delete protocol/auth/device-flow.ts (+ its index re-export): no importers. - authenticate.test.ts: drop the extractSessionCredential cases. - router.test.ts: swap the misleading /device/* example paths for current better-auth sub-paths. - start.ts: rename deviceFlowCleanupCron -> authCleanupCron (prefer AUTH_CLEANUP_CRON, keep DEVICE_FLOW_CLEANUP_CRON as a fallback) + drop a stale "device flow" comment. - authenticate-space.ts: the human credential is now an OAuth access token or a cookie session, not "a session token" — fix the header comment. Cookie prefix: documented at the config site why we stay on `__Secure-` — better-auth 1.6.20 has no `__Host-` option (its namer emits only `__Secure-`/ none, and getSessionCookie reads only those), and the cookies already carry the `__Host-` properties (Secure, Path=/, no Domain) + SameSite=Lax + the Origin CSRF gate. check green (617); server integration green (94). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Migrates human authentication from the custom device-flow/session implementation to better-auth: web UI uses httpOnly cookie sessions, and the me CLI uses OAuth 2.1 auth-code + PKCE + loopback with refresh tokens; agent API keys remain in core.
Changes:
- Server: replace
/api/v1/auth/*device-flow handlers with a better-auth catch-all, update RPC auth middleware to accept OAuth access tokens (Bearer) or cookie sessions, and move first-login provisioning to lazy core-side provisioning. - CLI: implement auth-code+PKCE login (openid-client), persist OAuth token sets, and add proactive/401-reactive refresh wiring through the client transport and long-running processes (
me serve,me mcp, hooks). - Web: wire hosted UI sign-in/sign-out through better-auth’s React client and add a dedicated
/loginpage to resume the CLI authorize flow.
Reviewed changes
Copilot reviewed 88 out of 89 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/web/src/main.tsx | Adds /login entrypoint handling before hosted/local split. |
| packages/web/src/components/SignInCard.tsx | New shared social sign-in UI using better-auth actions. |
| packages/web/src/components/LoginPage.tsx | New login page that resumes OAuth authorize flow for CLI. |
| packages/web/src/components/AuthGate.tsx | Switches hosted login/logout to better-auth client + shared card. |
| packages/web/src/api/auth-client.ts | New better-auth React client wrapper for sign-in/sign-out. |
| packages/web/package.json | Adds better-auth dependency for web app. |
| packages/server/wiring.test.ts | Updates wiring mocks for better-auth + OAuth token verification. |
| packages/server/util/cookie.ts | Removes retired custom cookie helpers. |
| packages/server/util/cookie.test.ts | Removes tests for retired cookie helpers. |
| packages/server/test-support.ts | Adds integration-test seeding helper for core (+ optional auth.users). |
| packages/server/start.ts | Boots better-auth (dedicated pool), wires OAuth verification, updates cleanup cron. |
| packages/server/start.integration.test.ts | Updates boot test to seed core-only + provide better-auth secret. |
| packages/server/server.integration.test.ts | Removes device-flow endpoint tests (now owned by better-auth). |
| packages/server/rpc/user/whoami.ts | Uses identity from validated session/token context (no auth-store lookup). |
| packages/server/rpc/user/types.ts | Replaces auth-store dependency with email/name on context. |
| packages/server/rpc/user/api-key.integration.test.ts | Removes auth-schema dependency from user RPC api-key tests. |
| packages/server/rpc/user/agent.integration.test.ts | Updates whoami expectation to echo middleware-provided identity. |
| packages/server/rpc/memory/memory.integration.test.ts | Switches to new core-only seeding helper. |
| packages/server/rpc/memory/management.integration.test.ts | Switches to new core-only seeding helper. |
| packages/server/router.ts | Mounts better-auth catch-all and adds lazy ensureUserProvisioned on user RPC. |
| packages/server/router.test.ts | Updates route matching tests for better-auth catch-all semantics. |
| packages/server/provision.ts | Replaces eager provisionUser with lazy core provisioning + invitation redemption hook. |
| packages/server/provision.integration.test.ts | Updates provisioning tests for ensureUserProvisioned semantics (idempotent). |
| packages/server/provision-invitations.integration.test.ts | Moves invitation redemption coverage to provisioning module. |
| packages/server/package.json | Replaces @memory.build/auth with better-auth/oauth-provider + pg deps. |
| packages/server/middleware/authenticate.ts | Removes session-credential extractor; keeps Bearer extractor + CSRF gate. |
| packages/server/middleware/authenticate.test.ts | Removes extractSessionCredential tests; retains Bearer + CSRF tests. |
| packages/server/middleware/authenticate-user.ts | Adds OAuth Bearer auth for user RPC; cookie sessions via better-auth getSession. |
| packages/server/middleware/authenticate-space.ts | Adds OAuth Bearer auth for memory RPC; cookie sessions via better-auth getSession. |
| packages/server/middleware/authenticate-space.integration.test.ts | Updates auth integration to mint/verify real OAuth access tokens. |
| packages/server/handlers/auth.test.ts | Removes unit tests for retired custom auth handlers. |
| packages/server/context.ts | Replaces AuthStore in context with better-auth + OAuth token verifier. |
| packages/server/auth/types.ts | Removes retired device-flow type definitions. |
| packages/server/auth/providers/index.ts | Removes retired custom OAuth provider registry. |
| packages/server/auth/providers/google.ts | Removes retired custom Google OAuth implementation. |
| packages/server/auth/providers/github.ts | Removes retired custom GitHub OAuth implementation. |
| packages/server/auth/index.ts | Removes retired custom auth module exports. |
| packages/server/auth/cleanup.ts | Adds auth-table sweep helper calling schema cleanup functions. |
| packages/server/auth/cleanup.integration.test.ts | Adds integration coverage for auth-table sweep behavior. |
| packages/protocol/index.ts | Stops exporting device-flow auth schemas. |
| packages/protocol/auth/device-flow.ts | Removes device-flow protocol schema/types. |
| packages/database/auth/migrate/migrate.ts | Adds auth migration 006 + replaces idempotent device-flow SQL with OAuth cleanup SQL. |
| packages/database/auth/migrate/incremental/006_betterauth.sql | Adds better-auth/OAuth-provider tables and migrates sessions shape; drops device-flow. |
| packages/database/auth/migrate/idempotent/004_oauth.sql | Adds cleanup function for expired OAuth access/refresh tokens. |
| packages/database/auth/migrate/idempotent/004_device_auth.sql | Removes device-flow SQL functions. |
| packages/database/auth/migrate/idempotent/002_session.sql | Removes session CRUD functions; keeps only expired-session cleanup. |
| packages/client/user.ts | Adds async bearer provider + reactive 401 hook to user client options. |
| packages/client/transport.ts | Implements proactive token resolution and 401-triggered refresh retry. |
| packages/client/transport.test.ts | Adds coverage for getToken/onUnauthorized behavior. |
| packages/client/memory.ts | Adds async bearer provider + reactive 401 hook to memory client options. |
| packages/client/index.ts | Removes exported device-flow auth client; updates docs accordingly. |
| packages/client/auth.ts | Removes device-flow auth client implementation. |
| packages/cli/util.ts | Switches CLI clients to bearer sources with refresh; updates logout behavior. |
| packages/cli/session.ts | Adds OAuth token lifecycle: proactive refresh, reactive refresh, in-flight dedup. |
| packages/cli/session.test.ts | Adds end-to-end tests for refresh, rotation persistence, and concurrency. |
| packages/cli/serve/http-server.ts | Buffers bodies to allow one-shot 401 refresh/replay in me serve proxy. |
| packages/cli/serve/http-server.test.ts | Adds proxy 401 refresh/replay test coverage. |
| packages/cli/package.json | Adds openid-client for OAuth 2.1 flow. |
| packages/cli/oauth.ts | Adds OAuth protocol layer (PKCE, authorize URL, code/refresh exchange). |
| packages/cli/oauth.test.ts | Adds PKCE + authorize URL construction tests. |
| packages/cli/oauth-loopback.ts | Adds loopback redirect handler/server for me login. |
| packages/cli/oauth-loopback.test.ts | Adds loopback server tests (success/error/timeout). |
| packages/cli/mcp/server.ts | Switches MCP server to use bearer source (refresh-aware) instead of static token. |
| packages/cli/mcp/agent-install.ts | Updates login checks to loggedIn boolean. |
| packages/cli/credentials.ts | Replaces stored session token with stored OAuth token set + parsing/migration logic. |
| packages/cli/credentials.test.ts | Updates tests for token-set storage, logout, and legacy migration behavior. |
| packages/cli/commands/whoami.ts | Uses refresh-aware user client builder. |
| packages/cli/commands/space.ts | Uses refresh-aware user client builder across space subcommands. |
| packages/cli/commands/serve.ts | Uses refresh-aware bearer source for long-lived proxy. |
| packages/cli/commands/mcp.ts | Uses bearer source (api key or refresh-aware OAuth) for long-lived MCP server. |
| packages/cli/commands/login.ts | Replaces device flow with auth-code+PKCE+loopback login and token-set persistence. |
| packages/cli/commands/claude.ts | Switches Claude hook import to refresh-aware bearer resolution. |
| packages/cli/client.ts | Removes CLI re-export of the removed device-flow auth client. |
| packages/cli/claude/capture.ts | Changes hook config to carry apiKey or rely on refresh-aware login session. |
| packages/cli/claude/capture.test.ts | Updates tests for new hook config shape and logged-in detection. |
| packages/auth/types.ts | Removes retired @memory.build/auth types. |
| packages/auth/token.ts | Removes retired device-flow/session token helpers. |
| packages/auth/package.json | Removes retired @memory.build/auth package. |
| packages/auth/index.ts | Removes retired @memory.build/auth exports. |
| packages/auth/db.ts | Removes retired auth-store implementation. |
| packages/auth/db.integration.test.ts | Removes retired auth-store integration tests. |
| e2e/package.json | Drops dependency on retired @memory.build/auth. |
| e2e/cli.e2e.test.ts | Updates e2e auth injection to mint OAuth access tokens instead of sessions. |
| CLAUDE.md | Updates repo structure notes to reflect removal of auth package/device-flow client. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Join any spaces this email was invited to. better-auth gives us no | ||
| // dedicated "login" hook, so this rides every user RPC — it's idempotent and | ||
| // best-effort (a no-op once nothing is pending), and the email is one the | ||
| // identity provider verified. This is the new home of the redemption that the | ||
| // retired device-flow callback used to run on each sign-in. | ||
| await redeemInvitationsForVerifiedLogin(core, params.userId, params.email); |
There was a problem hiding this comment.
Good catch — fixed in 6e706e0.
email_verified is now plumbed through both auth paths and gates redemption:
verifyOAuthAccessTokenjoinsusers.email_verifiedand returnsemailVerified; the cookie path readssession.user.emailVerified.- threaded onto the user-RPC context →
ensureUserProvisioned, which now redeems invitations only whenemailVerifiedis true. The principal + own default space are still provisioned regardless — only the email-keyed redemption is gated. provision.integration.test.tscovers verified-redeems / unverified-skips (the unverified invite stays pending).
PR review (Copilot): `redeemInvitationsForVerifiedLogin` was called unconditionally from `ensureUserProvisioned`, but invitations are email-keyed — so an account whose email is unverified could auto-join spaces invited to that address. Plumb `email_verified` through both auth paths and gate on it. - verifyOAuthAccessToken: join `users.email_verified`, return `emailVerified`. - authenticate-user: carry `emailVerified` on the user context (OAuth path from the token's user row; cookie path from `session.user.emailVerified`). - router: thread `emailVerified` into `ensureUserProvisioned`. - ensureUserProvisioned: redeem invitations only when `emailVerified` is true (the user's own principal + default space are still provisioned regardless — only the email-keyed redemption is gated). - test: provision.integration now covers verified-redeems / unverified-skips (the unverified invite stays pending). check green; server integration green (95). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record how a hosted MCP connector would be built on the OAuth 2.1 AS (the AS was chosen with this in mind): what's reused unchanged (the AS, verifyOAuthAccessToken, user -> principal -> tree_access, /login), the delta from the CLI (Dynamic Client Registration for third-party clients, the consent page, the hosted MCP transport + RFC 9728/8414 discovery + RFC 8707 resource indicators), scope strategy (start coarse), what stays the same (api keys, local me mcp, tree_access), and a rough build order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Better Auth packages are currently declared with ^. I’d prefer exact pins plus an explicit upgrade checklist that regenerates/compares expected schema.
There was a problem hiding this comment.
Done in bed744b. Pinned better-auth + @better-auth/oauth-provider to exact 1.6.20 (no ^) in both packages/server and packages/web (kept equal so the web client matches the server). The upgrade checklist that regenerates + diffs the expected schema is in AUTH_DESIGN.md → "Upgrading better-auth (the DB-drift checklist)"; the auth migration test (migrate.integration.test.ts) is the automated drift guard — a schema change we haven't migrated turns it red.
There was a problem hiding this comment.
We need a document describing how developers should go about determining how to update the database when upgrading to a new version of better auth.
There was a problem hiding this comment.
Added in bed744b: AUTH_DESIGN.md → "Upgrading better-auth (the DB-drift checklist)", and this file's header now points to it. The procedure: bump the exact pins → regenerate the expected schema (@better-auth/cli generate against the createBetterAuth config) → diff vs the auth migrations (the better-auth-owned tables) → write a new incremental + reconcile the snake_case modelName/fields mapping for any new field → run migrate.integration.test.ts (the drift guard) + check:full + boot against an existing DB. Happy to move it to a standalone docs/ runbook if you'd prefer it outside the design doc.
Allow minting an api key for the caller's OWN user principal — a personal
access token for headless/SSH/VM use — alongside agent keys. The core layer
already keyed api keys by member_id ('u'|'a'); this opens the 'u' path and
admits user PATs on the user RPC.
- protocol: apiKey.create takes a required `memberId` (was agent-only `agentId`),
consistent with apiKey.list.
- server (rpc/user): requireOwnMember (the caller's own user, or an owned agent)
replaces requireOwnAgent for apiKey.*; apiKey.create/delete reject a
key-authenticated caller (denyApiKeyCaller) — keys can't mint or revoke keys,
so a leaked key can't outlive revocation. apiKey.list/get stay open.
- server (authenticate-user): admit a user PAT (parseApiKey → validateApiKey →
getPrincipal, kind 'u' only; an agent key → 403). UserAuthContext.viaApiKey is
threaded onto the user-RPC context. emailVerified=false on the key path, so
invitation redemption (email-keyed) runs only on interactive logins.
- cli: PAT minting is an explicit opt-in — `me apikey create <agent>` (unchanged)
vs `me apikey create --self`; a bare `create` errors. No implicit full-access
credential.
A user PAT authenticates as the user with full data-plane grants on the memory
RPC and the user RPC (except key mint/revoke). Agent keys remain barred from the
user RPC ("agents can't manage the account"). Docs: AUTH_DESIGN.md (credential
table, flow E, device-flow-vs-PAT alternative) + CLAUDE.md.
Tests: authenticate-user.integration (PAT admitted / agent key 403 / OAuth /
invalid), api-key.integration (self-mint, keys-can't-manage-keys),
authenticate-space.integration (PAT as the user on the memory RPC). check 620;
server integration 103.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…checklist Review (jgpruitt): better-auth owns DB tables, so a transitive bump can drift the expected schema from our hand-maintained migrations + field mapping. - Pin `better-auth` + `@better-auth/oauth-provider` to exact `1.6.20` (no `^`) in packages/server + packages/web (equal versions so the web client matches the server). Upgrades are now a deliberate, reviewed step. - AUTH_DESIGN.md gains an "Upgrading better-auth (the DB-drift checklist)" section: regenerate the expected schema (`@better-auth/cli generate` against the createBetterAuth config) → diff vs the auth migrations → write a new incremental + reconcile the snake_case field mapping → run the auth migration test (the drift guard) + check:full + a boot-against-existing-DB. betterauth.ts header points to it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| } | ||
|
|
||
| /** In-flight refresh per server origin — concurrent callers share one round-trip. */ | ||
| const inFlight = new Map<string, Promise<OAuthTokenSet | undefined>>(); |
There was a problem hiding this comment.
Note (non-blocking): cross-process refresh races aren't deduped. The inFlight map dedupes concurrent refreshes within one process, but me serve / me mcp are separate processes from any ad-hoc me <cmd> running in parallel. If two processes race to refresh from the same stored refresh token, rotation invalidates the loser's token; the loser's in-flight request then 401s and the reactive path re-refreshes against the now-rotated stored token (the live one), so it succeeds on the retry. Worst case is one extra round-trip, not a logout — fine to ship, but worth a sentence in AUTH_DESIGN.md § D so it's a known/accepted property rather than a surprise.
There was a problem hiding this comment.
Documented in 2d0add0 — AUTH_DESIGN.md §D now calls this out as an accepted property: the dedup is per-process, concurrent separate me invocations self-heal (rotation → 401 → reactive refresh, or failing that a fresh me login), and the place concurrency actually happens — the long-lived me serve / me mcp processes — is single-process, where the in-memory dedup applies. A cross-process lock isn't worth the keychain/file-locking complexity for a transient, self-healing reauth.
| // email: invitations are email-keyed, so an unverified address must not | ||
| // auto-join spaces invited to it. This is the new home of the redemption the | ||
| // retired device-flow callback ran on each sign-in. | ||
| if (params.emailVerified) { |
There was a problem hiding this comment.
Consider requireEmailVerification: true on better-auth (optional, defense-in-depth — not blocking).
Background:
What "email verified" means here. When someone signs in with GitHub or Google, the provider returns a profile with an email and a boolean saying whether the provider has confirmed the person actually controls that address (both make you click a confirmation link before marking it verified). better-auth stores that as users.email_verified.
Why it matters in this codebase. Authorization is email-keyed in exactly one place: space invitations. You invite alice@example.com; when a user logs in with that email, redemption auto-joins them. If a login could claim an email it doesn't actually own, it could claim invitations meant for someone else. This PR correctly defends that path — the gate right here only redeems when emailVerified is true.
The gap. That gate protects invitation redemption only. A user with an unverified email can still complete login, get an OAuth access token, create memories, and mint a PAT — they just can't auto-join invited spaces. So the blast radius of an unverified email is already small.
What the flag would add. requireEmailVerification: true is a better-auth config flag. With it on, better-auth refuses to establish a session at all for an unverified email — the user is blocked at the front door rather than admitted with a reduced-capability session. The tradeoff is "trust the provider and guard the one email-keyed op" (current) vs. "make a verified email a hard precondition of any session" (the flag).
Recommendation. Genuinely optional here: in practice GitHub and Google only release an email on the OAuth profile once they've verified it, so email_verified is almost always true for these two providers. The flag is defense-in-depth against (a) a future, laxer provider, or (b) a better-auth behavior change. Given only GitHub/Google today, the existing per-operation gate is defensible — I would not block on this. Flagging so the choice is explicit.
There was a problem hiding this comment.
Recorded the decision in 2d0add0 (AUTH_DESIGN.md → Provisioning) rather than flipping the flag, per your "not blocking". Left off because (1) we enable only GitHub/Google — verified-email-only, no password — so an unverified address barely arises, and (2) the global flag would block sign-in itself, whereas the per-op invitation-redemption gate is the tighter control (it governs what an identity may do, not whether it may authenticate). Revisit if we ever add email/password or an unverified-by-default provider.
There was a problem hiding this comment.
Correction to my reply above (and the doc), after reading the better-auth 1.6.20 source: flipping this would be a no-op for us, not a "block sign-in at the front door" tradeoff. requireEmailVerification is not global — it's emailAndPassword.requireEmailVerification, enforced only in the password sign-in route (dist/api/routes/sign-in.mjs:230). The social OAuth callback never consults it (callback.mjs: 0 refs); it just persists the provider's verified-email claim into users.email_verified (dist/oauth2/link-account.mjs). We run social-only (GitHub/Google) with no emailAndPassword block, so there is no surface for the flag — and setting it would require opting into a password sign-in flow we deliberately don't offer, just to set a sub-flag that still wouldn't gate a social login (so it doesn't future-proof the "laxer provider" case either, since that path is social). The real controls remain the provider's verified claim + the per-op invitation-redemption gate. Corrected the AUTH_DESIGN note in 44c7d96; your "do not block" call stands.
There was a problem hiding this comment.
Update: we went ahead and added the gate — pushed in bfde7d7.
Rather than requireEmailVerification (which is emailAndPassword-only and a no-op for our social-only setup), we gate login itself via a better-auth databaseHooks.session.create.before hook: it throws EMAIL_NOT_VERIFIED unless users.email_verified is true. That's the single front-door chokepoint —
- Social sign-in creates the session there, and the CLI OAuth flow rides the web session, so it blocks both front doors.
- The memory RPC is covered transitively (no session/token → no bearer), so no per-endpoint checks.
- Agents are unaffected (they authenticate with api keys, never sessions), and credentials minted while verified keep working.
So the blast radius you described (unverified email can log in, get a token, create memories, mint a PAT) is now closed at the door, not just at redemption. The per-op invitation-redemption gate stays as defense-in-depth — it still earns its keep for the edge case of a PAT that outlives its user's verified status (the key keeps working but won't redeem while the flag reads false).
UX: the thrown APIError makes the social callback redirect back to the sign-in page with ?error=EMAIL_NOT_VERIFIED&error_description=…, which SignInCard surfaces as a banner (not better-auth's bare /error). Tested by driving the real hook via auth.$context.internalAdapter.createSession (unverified → no session row; verified → session created). Design recorded in AUTH_DESIGN.md (Provisioning → "Login gate").
One accepted trade: if a provider ever flips a user to unverified post-signup, their next login is blocked until they re-verify with the provider — recovery is a normal re-login.
…d; harden auth drift guard Addresses jgpruitt's PR #93 review. BLOCKING — CLI now sends ME_API_KEY (a user PAT) to the user RPC. Both RPC endpoints accept either bearer now, so userBearer(server, apiKey?) takes the static key (memoryBearer delegates to it) and buildUserClient forwards creds.apiKey. The session-only "user endpoint" premise is gone: the old requireMemoryAuth is renamed requireAuth (session OR key) and gates every command except api-key mint/revoke, which keep the session-only requireSession ("keys can't mint keys"; the server enforces it via denyApiKeyCaller). This also fixes group/access/serve being needlessly session-only despite hitting the key-accepting memory RPC. me serve now forwards the key to its upstream proxy. Bug — /login rendered under local `me serve` (no auth backend), so its sign-in calls 404'd. Gate isLoginPage on HOSTED; local mode falls through to the SPA. Harden — the auth migrate drift guard now freezes the exact column set of every better-auth / oauth-provider-owned table (new listColumns helper), so a library upgrade or hand-edit that drifts the schema turns the suite red instead of breaking at runtime. Cleanups — stale device-flow prose (CLAUDE.md auth-schema table list + auth model; router.ts catch-all comment); import AppError via the local ../errors re-export in api-key.ts; AUTH_DESIGN notes the accepted cross-process refresh race (§D) and records why better-auth's global requireEmailVerification stays off (per-op gate is the tighter control). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @jgpruitt — all addressed in 2d0add0. Blocking —
Migrate drift guard. Added Stale device-flow prose. Fixed Cross-process refresh race (non-blocking). Documented in
Copilot nit. Verified: |
The prior note (and my PR reply) claimed the flag would "block sign-in itself." That's wrong: in better-auth 1.6.20 `requireEmailVerification` is an `emailAndPassword` sub-option enforced only in the password sign-in route (sign-in.mjs); the social OAuth callback never consults it (it just persists the provider's verified-email claim via oauth2/link-account.mjs). We're social-only with no emailAndPassword config, so the flag has no surface and flipping it is a no-op for GitHub/Google logins. Record the accurate reason. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hey redeem invitations like sessions The api-key (PAT) path on the user RPC hard-coded `emailVerified: false` purely to suppress invitation redemption on PAT calls. That overloaded a factual field (is the email provider-verified?) with a policy decision, and it was an arbitrary, undocumented carve-out: redemption already rides every user-RPC call (it's idempotent, not login-only), so OAuth/cookie callers redeem on every call while PATs never did. A PAT is meant to do everything a user can, minus the one documented carve-out (keys can't mint keys). Fix: fetch the real `users.email_verified` on the key path via a new `getUserEmailVerified` (a sibling of `verifyOAuthAccessToken`, threaded through the same context → router → authenticateUser seam as `verifyOAuthToken`), and carry it honestly. Redemption then gates on the true fact for all three credential paths, so a user PAT redeems exactly like a session. Bonus: this de-arms the landmine where any future `if (ctx.emailVerified)` check would have wrongly rejected every PAT, making a global verified-email gate clean if we ever want one. Tests: authenticate-user.integration asserts a PAT carries the real flag (true for a verified user; false after flipping the column — proving it's read from the DB, not faked). Updated the mock ServerContext in wiring/router/server tests and AUTH_DESIGN's provisioning note (now "all three" paths carry the real flag). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…int) Refuse to establish a session for an unverified email via a better-auth `databaseHooks.session.create.before` hook: it throws EMAIL_NOT_VERIFIED unless `users.email_verified` is true. Social sign-in creates the session here, and the CLI OAuth flow rides that web session, so this blocks both; the memory RPC is covered transitively (no session/token → no bearer). Agents are unaffected (api keys, never sessions), and credentials minted while verified keep working. This is the social-login equivalent of better-auth's `requireEmailVerification`, which only gates the email/password sign-in route (we're social-only, so it has no surface). The per-op invitation-redemption gate stays as defense-in-depth — it still matters for a PAT that outlives its user's verified status. The thrown APIError makes the social callback redirect back to the sign-in page with `?error=EMAIL_NOT_VERIFIED&error_description=…`; SignInCard surfaces it (errorCallbackURL defaults to the current page), so a blocked user sees a clear message instead of better-auth's bare /error. Test: drives the real hook via `auth.$context.internalAdapter.createSession` — an unverified user is blocked (no session row written), a verified user gets one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d; harden auth drift guard Addresses jgpruitt's PR #93 review. BLOCKING — CLI now sends ME_API_KEY (a user PAT) to the user RPC. Both RPC endpoints accept either bearer now, so userBearer(server, apiKey?) takes the static key (memoryBearer delegates to it) and buildUserClient forwards creds.apiKey. The session-only "user endpoint" premise is gone: the old requireMemoryAuth is renamed requireAuth (session OR key) and gates every command except api-key mint/revoke, which keep the session-only requireSession ("keys can't mint keys"; the server enforces it via denyApiKeyCaller). This also fixes group/access/serve being needlessly session-only despite hitting the key-accepting memory RPC. me serve now forwards the key to its upstream proxy. Bug — /login rendered under local `me serve` (no auth backend), so its sign-in calls 404'd. Gate isLoginPage on HOSTED; local mode falls through to the SPA. Harden — the auth migrate drift guard now freezes the exact column set of every better-auth / oauth-provider-owned table (new listColumns helper), so a library upgrade or hand-edit that drifts the schema turns the suite red instead of breaking at runtime. Cleanups — stale device-flow prose (CLAUDE.md auth-schema table list + auth model; router.ts catch-all comment); import AppError via the local ../errors re-export in api-key.ts; AUTH_DESIGN notes the accepted cross-process refresh race (§D) and records why better-auth's global requireEmailVerification stays off (per-op gate is the tighter control). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Migrates human authentication to better-auth: GitHub/Google social login → httpOnly cookie sessions for the web UI, and an OAuth 2.1 authorization server (
@better-auth/oauth-provider) that issues access + refresh tokens to themeCLI (auth-code + PKCE + loopback). Agent api keys stay custom incore(unchanged).Full design + the alternatives considered/rejected:
AUTH_DESIGN.md.What changed
pgpool (search_path=auth);/api/v1/auth/*→ the better-auth handler (social sign-in, callbacks, sessions,/oauth2/authorize|token). Resource-server validation is a hashedoauth_access_tokenlookup (verifyOAuthAccessToken); the Bearer path dispatches api-key →core, else → OAuth token; cookie →getSession+ CSRF gate.006_betterauth) —sessionsgainstoken(dropstoken_hash); addsoauth_client/oauth_access_token/oauth_refresh_token/oauth_consent+jwks; seeds the trustedme-clipublic PKCE client; drops the device-flow tables/functions.me loginis now auth-code + PKCE + loopback (openid-client); the token set (access/refresh/expiry) is stored in the OS keychain / 0600 file; proactive-by-expiry + reactive-401 refresh (transport seams +session.ts), with refresh-token rotation + in-flight dedup.me serve/me mcp/ the claude hook ride the same bearer sources, so they survive access-token expiry.AuthGatelogin was wired to the retired device endpoints; rewired through the better-auth React SDK (signIn.social/signOut), plus a/loginpage that resumes the CLI authorize flow.packages/auth(AuthStore) entirely; the cron now callscleanupExpiredAuth(the schema'scleanup_expired_*sweeps). Removed the dead device-flow HTTP layer + the orphaned device-flow client.ensureUserProvisioned) on first user RPC (better-auth ownsauth.users); invitation-redemption-on-login migrated here.Deploy / breaking changes
BETTER_AUTH_SECRETon the server (cookie signatures, jwks encryption, the login-handoff signature).sessionsis truncated by006→ everyone re-logs in once./api/v1/auth/callback/github) + env (GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET); only update the callback if the domain changes.Tests (all green locally)
./bun run check): 636./bun run test:db): 241Remaining before un-drafting (live cutover)
The one path not verifiable headless is the browser GitHub round-trip — the e2e injects tokens, deliberately bypassing
me login. To finish:BETTER_AUTH_SECRET+GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETon the target env.me login(CLI loopback →/login→ GitHub → loopback) and web login.🤖 Generated with Claude Code