* Move Foundry HTTP APIs out of /api/rivet
* Move Foundry HTTP APIs onto /v1
* Fix Foundry Rivet base path and frontend endpoint fallback
* Configure Foundry Rivet runner pool for /v1
* Remove Foundry Rivet runner override
* Serve Foundry Rivet routes directly from Bun
* Log Foundry RivetKit deployment friction
* Add actor display metadata
* Tighten actor schema constraints
* Reset actor persistence baseline
* Remove temporary actor key version prefix
Railway has no persistent volumes so stale actors are wiped on
each deploy. The v2 key rotation is no longer needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Cache app workspace actor handle across requests
Every request was calling getOrCreate on the Rivet engine API
to resolve the workspace actor, even though it's always the same
actor. Cache the handle and invalidate on error so retries
re-resolve. This eliminates redundant cross-region round-trips
to api.rivet.dev on every request.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add temporary debug logging to GitHub OAuth exchange
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Make squashed baseline migrations idempotent
Use CREATE TABLE IF NOT EXISTS and CREATE UNIQUE INDEX IF NOT
EXISTS so the squashed baseline can run against actors that
already have tables from the pre-squash migration sequence.
This fixes the "table already exists" error when org workspace
actors wake up with stale migration journals.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Revert "Make squashed baseline migrations idempotent"
This reverts commit 356c146035.
* Fix GitHub OAuth callback by removing retry wrapper
OAuth authorization codes are single-use. The appWorkspaceAction wrapper
retries failed calls up to 20 times, but if the code exchange succeeds
and a later step fails, every retry sends the already-consumed code,
producing "bad_verification_code" from GitHub.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add runner versioning to RivetKit registry
Uses Date.now() so each process start gets a unique version.
This ensures Rivet Cloud migrates actors to the new runner on
deploy instead of routing requests to stale runners.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add backend request and workspace logging
* Log callback request headers
* Make GitHub OAuth callback idempotent against duplicate requests
Clear oauthState before exchangeCode so duplicate callback requests
fail the state check instead of hitting GitHub with a consumed code.
Marked as HACK — root cause of duplicate HTTP requests is unknown.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add temporary header dump on GitHub OAuth callback
Log all request headers on the callback endpoint to diagnose
the source of duplicate requests (Railway proxy, Cloudflare, browser).
Remove once root cause is identified.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Defer slow GitHub org sync to workflow queue for fast OAuth callback
Split syncGithubSessionFromToken into a fast path (initGithubSession:
exchange code, get viewer, store token+identity) and a slow path
(syncGithubOrganizations: list orgs/installations, sync workspaces).
completeAppGithubAuth now returns the 302 redirect in ~2s instead of
~18s by enqueuing the org sync to the workspace workflow queue
(fire-and-forget). This eliminates the proxy timeout window that was
causing duplicate callback requests.
bootstrapAppGithubSession (dev-only) still calls the full synchronous
sync since proxy timeouts are not a concern and it needs the session
fully populated before returning.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* foundry: async app repo import on org select
* foundry: parallelize app snapshot org reads
* repo: push all current workspace changes
* foundry: update runner version and snapshot logging
* Refactor Foundry GitHub state and sandbox runtime
Refactors Foundry around organization/repository ownership and adds an organization-scoped GitHub state actor plus a user-scoped GitHub auth actor, removing the old project PR/branch sync actors and repo PR cache.
Updates sandbox provisioning to rely on sandbox-agent for in-sandbox work, hardens Daytona startup and image-build behavior, and surfaces runtime and task-startup errors more clearly in the UI.
Extends workbench and GitHub state handling to track merged PR state, adds runtime-issue tracking, refreshes client/test/config wiring, and documents the main live Foundry test flow plus actor coordination rules.
Also updates the remaining Sandbox Agent install-version references in docs/examples to the current pinned minor channel.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
23 KiB
Auth & Identity Simplification: Adopt BetterAuth + Extract User Model
Read 00-end-to-end-async-realtime-plan.md first for the governing migration order, runtime constraints, and realtime client model this brief assumes.
Problem
Authentication and user identity are conflated into a single appSessions table that serves as the session store, user record, OAuth credential store, navigation state, and onboarding tracker simultaneously. There is no canonical user record — identity fields are denormalized into every session row. BetterAuth env vars exist (BETTER_AUTH_URL, BETTER_AUTH_SECRET) but the library is not used; all OAuth and session handling is hand-rolled.
Specific issues
- No user table. Same GitHub user in two browsers = two independent copies of identity fields with no shared record. Org membership, onboarding state, and role are per-session instead of per-user.
- Unsigned session tokens. Session IDs are plain UUIDs in
localStorage, sent viax-foundry-sessionheader. The backend trusts them at face value — no signature verification. - Unstable user IDs. User ID is
user-${slugify(viewer.login)}which breaks on GitHub username renames. GitHub numericidis available from the API but not used as the stable key. - Dead BetterAuth references.
BETTER_AUTH_URLis used as a URL alias inapp-shell-runtime.ts:65.BETTER_AUTH_SECRETis documented but never read. This creates confusion about what auth system is actually in use. - Overloaded session row.
appSessionshas 15+ columns mixing auth credentials, user identity, org navigation, onboarding state, and transient OAuth flow state.
Current Code Context
- Custom OAuth flow:
foundry/packages/backend/src/services/app-github.ts(buildAuthorizeUrl,exchangeCode,getViewer) - Session + identity management:
foundry/packages/backend/src/actors/workspace/app-shell.ts(ensureAppSession,updateAppSession,initGithubSession,syncGithubOrganizations) - Session schema:
foundry/packages/backend/src/actors/workspace/db/schema.ts(appSessionstable) - Shared types:
foundry/packages/shared/src/app-shell.ts(FoundryUser,FoundryAppSnapshot) - HTTP routes:
foundry/packages/backend/src/index.ts(resolveSessionId,/v1/auth/github/*, all/v1/app/*routes) - Frontend session persistence:
foundry/packages/client/src/backend-client.ts(persistAppSessionId,x-foundry-sessionheader,foundrySessionURL param extraction) - Runtime config:
foundry/packages/backend/src/services/app-shell-runtime.ts(BETTER_AUTH_URLfallback) - Compose config:
foundry/compose.dev.yaml(BETTER_AUTH_URL,BETTER_AUTH_SECRETenv vars) - Self-hosting docs:
docs/deploy/foundry-self-hosting.mdx(documents both env vars)
Target State
BetterAuth owns auth plumbing
- BetterAuth handles GitHub OAuth (authorize URL, code exchange, CSRF state, token storage).
- BetterAuth manages session lifecycle (signed tokens, expiration, revocation).
- BetterAuth creates and maintains
user,session, andaccounttables with proper FKs. BETTER_AUTH_SECRETis actually used for session signing.BETTER_AUTH_URLis actually used as the auth callback base URL.
Custom actor-routed adapter
- BetterAuth uses a custom adapter that routes all DB operations through RivetKit actors.
- Each user has their own actor. BetterAuth's
user,session, andaccounttables live in the per-user actor's SQLite viac.db. - The adapter resolves which actor to target based on the primary key BetterAuth passes for each operation (user ID, session ID, account ID).
- A lightweight session index on the app-shell workspace actor maps session tokens → user actor identity, so inbound requests can be routed to the correct user actor without knowing the user ID upfront.
Canonical user record
- Users are identified by GitHub numeric account ID (immutable across renames).
- BetterAuth's
usertable in the per-user actor is the single source of truth for identity. - App-specific user fields (
eligibleOrganizationIds,starterRepoStatus,roleLabel) live in auserProfilestable in the same per-user actor, keyed by user ID, not duplicated per session.
Thin sessions
- Sessions reference a user ID (FK) instead of duplicating identity fields.
- App-specific session state (
activeOrganizationId) lives in asessionStatetable in the per-user actor or as BetterAuth session additional fields. - Transient OAuth flow state (
oauthState,oauthStateExpiresAt) is handled by BetterAuth internally.
Snapshot projection unchanged
FoundryAppSnapshotandFoundryUsertypes remain the same — they're already the right shape.- The snapshot builder reads from the user actor's BetterAuth tables +
userProfilesinstead of reading everything fromappSessions.
Architecture: Custom Actor-Routed BetterAuth Adapter
Why a custom adapter
BetterAuth expects a single database. Foundry uses per-actor SQLite — each actor instance gets its own c.db. Users each have their own actor, so BetterAuth's user, session, and account records must live inside the correct user actor's database. The adapter must route each BetterAuth DB operation to the right actor based on the primary key.
Routing challenge: session → user actor
When an HTTP request arrives, the backend has a session token but doesn't know the user ID yet. BetterAuth calls adapter methods like findSession(sessionId) to resolve this. But which actor holds that session row?
Solution: session index on the app-shell workspace actor.
The app-shell workspace actor (which already handles auth routing) maintains a lightweight index table:
sessionIndex
├── sessionId (text, PK)
├── userActorKey (text) — actor key for the user actor that owns this session
├── createdAt (integer)
The adapter flow for session lookup:
- BetterAuth calls
findSession(sessionId). - Adapter queries
sessionIndexon the workspace actor to resolveuserActorKey. - Adapter gets the user actor handle and queries BetterAuth's
sessiontable in that actor'sc.db.
The adapter flow for user creation (OAuth callback):
- BetterAuth calls
createUser(userData). - Adapter resolves the GitHub numeric ID from the user data.
- Adapter creates/gets the user actor keyed by GitHub ID.
- Adapter inserts into BetterAuth's
usertable in that actor'sc.db. - When
createSessionfollows, adapter writes to the user actor'ssessiontable AND inserts into the workspace actor'ssessionIndex.
User actor shape
UserActor (key: ["ws", workspaceId, "user", githubNumericId])
├── BetterAuth tables: user, session, account (managed by BetterAuth schema)
├── userProfiles (app-specific: eligibleOrganizationIds, starterRepoStatus, roleLabel)
└── sessionState (app-specific: activeOrganizationId per session)
BetterAuth adapter interface (concrete)
BetterAuth uses createAdapterFactory from "better-auth/adapters". The adapter is model-based, not entity-based — it receives a model string ("user", "session", "account", "verification") and generic CRUD parameters. All methods are async and return Promises. The adapter can do arbitrary async work including actor handle resolution and cross-actor messages.
// Adapter methods (all async, all receive model name + generic params):
create: ({ model, data, select? }) => Promise<T>
findOne: ({ model, where, select?, join? }) => Promise<T | null>
findMany: ({ model, where, limit?, offset?, sortBy?, join? }) => Promise<T[]>
update: ({ model, where, update }) => Promise<T | null>
updateMany: ({ model, where, update }) => Promise<number>
delete: ({ model, where }) => Promise<void>
deleteMany: ({ model, where }) => Promise<number>
count: ({ model, where }) => Promise<number>
The where clauses use { field, value, operator?, connector? } objects (operators: eq, ne, in, contains, etc.).
Routing logic inside the adapter
The adapter must inspect model and where to determine the target actor:
| Model | Routing strategy |
|---|---|
user (by id) |
User actor key derived directly from user ID |
user (by email) |
emailIndex on workspace actor → user actor key |
session (by token) |
sessionIndex on workspace actor → user actor key |
session (by id) |
sessionIndex on workspace actor → user actor key |
session (by userId) |
User actor key derived directly from userId |
account |
Always has userId in where or data → user actor key |
verification |
Workspace actor (not user-scoped — used for email verification, password reset) |
On create for session model: write to user actor's session table AND insert into workspace actor's sessionIndex.
On delete for session model: delete from user actor's session table AND remove from workspace actor's sessionIndex.
Adapter construction
The adapter is instantiated at BetterAuth init time with a closure over the RivetKit registry. It does not depend on an ambient actor context — it resolves actor handles on demand via the registry.
import { createAdapterFactory } from "better-auth/adapters";
const actorRoutedAdapter = (registry: Registry) => {
return createAdapterFactory({
config: {
adapterId: "rivetkit-actor",
adapterName: "RivetKit Actor Adapter",
supportsJSON: false, // SQLite — auto-serialize JSON
supportsDates: false, // SQLite — ISO string conversion
supportsBooleans: false, // SQLite — 0/1 conversion
},
adapter: ({ getModelName, transformInput, transformOutput, transformWhereClause }) => ({
create: async ({ model, data }) => {
const actorKey = resolveActorKeyForCreate(model, data);
const actor = await registry.get("user", actorKey);
// delegate insert to actor's c.db
// if model === "session", also write sessionIndex
},
findOne: async ({ model, where }) => {
const actorKey = await resolveActorKeyForQuery(model, where);
// ...
},
// ... remaining methods
}),
});
};
BetterAuth session tokens
BetterAuth uses opaque session tokens stored in the session table's token column. By default, the token is set as a cookie (better-auth.session_token). On every request, BetterAuth looks up the session in the DB by token and checks expiresAt.
Cookie caching can be enabled to reduce DB lookups: the session data is signed (HMAC-SHA256) or encrypted (AES-256) and embedded in the cookie. When the cache is fresh (configurable maxAge, e.g., 5 minutes), BetterAuth validates the signature locally without hitting the adapter. This eliminates the hot-path actor lookup for most requests — the adapter is only called when the cache expires or on write operations.
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes — most requests skip the adapter entirely
strategy: "compact", // HMAC-signed, minimal size
},
}
BetterAuth core tables
Four tables, all in the per-user actor's SQLite (except verification which goes on workspace actor):
user: id, name, email, emailVerified, image, createdAt, updatedAt
session: id, token, userId, expiresAt, ipAddress?, userAgent?, createdAt, updatedAt
account: id, userId, accountId (GitHub numeric ID), providerId ("github"), accessToken?, refreshToken?, scope?, createdAt, updatedAt
verification: id, identifier, value, expiresAt, createdAt, updatedAt
For findUserByEmail, a secondary index (email → user actor key) is needed on the workspace actor alongside sessionIndex.
Implementation Plan
Phase 0: Spike — custom adapter feasibility
Research confirms:
- BetterAuth adapter methods are fully async (
Promise-based). Arbitrary async work (actor handle resolution, cross-actor messages) is allowed. - The adapter is instantiated at BetterAuth init time and receives no request context — it's a plain object of async functions. This means the adapter can close over a RivetKit registry reference and resolve actor handles on demand.
- Cookie caching (
cookieCache.enabled: true) eliminates the adapter hot-path for most read requests — the session is validated from the signed cookie, and the adapter is only called when the cache expires or on writes.
Remaining spike work:
- Prototype the adapter + user actor end-to-end — wire up
createAdapterFactorywith a minimal actor-routed implementation. Confirm that BetterAuth's GitHub OAuth flow completes successfully with user/session/account records landing in the correct per-user actor's SQLite. - Verify
findOnefor session model — confirm thewhereclause BetterAuth passes for session lookup includes thetokenfield (not justid), so the adapter can route viasessionIndexkeyed by token. - Measure cookie-cached vs uncached request latency — confirm that with cookie caching enabled, the adapter is not called on every request, and that the uncached fallback (workspace actor index → user actor → session table) is acceptable.
Phase 1: User actor + adapter infrastructure (no behavior change)
- Install
better-authpackage inpackages/backend. - Define
UserActorwith actor key["ws", workspaceId, "user", githubNumericId]. Include BetterAuth's required tables (user,session,account) plus app-specific tables in its schema. - Create
userProfilestable in user actor schema:userProfiles ├── userId (text, PK) — GitHub numeric account ID (string form) ├── githubLogin (text) ├── roleLabel (text) ├── eligibleOrganizationIdsJson (text) ├── starterRepoStatus (text) ├── starterRepoStarredAt (integer, nullable) ├── starterRepoSkippedAt (integer, nullable) ├── createdAt (integer) ├── updatedAt (integer) - Create
sessionStatetable in user actor schema:sessionState ├── sessionId (text, PK) — references BetterAuth session ID ├── activeOrganizationId (text, nullable) ├── createdAt (integer) ├── updatedAt (integer) - Create
sessionIndexandemailIndextables on the app-shell workspace actor:sessionIndex ├── sessionId (text, PK) ├── userActorKey (text) ├── createdAt (integer) emailIndex ├── email (text, PK) ├── userActorKey (text) ├── updatedAt (integer) - Implement the custom BetterAuth adapter that routes operations through the index tables and user actors.
- Configure BetterAuth with GitHub OAuth provider using existing
GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRETenv vars. WireBETTER_AUTH_SECRETfor session signing andBETTER_AUTH_URLas the auth base URL. - Keep
appSessionstable operational — no reads/writes change yet.
Phase 2: Migrate OAuth flow to BetterAuth
- Replace
startAppGithubAuth— delegate to BetterAuth's GitHub OAuth initiation instead of hand-rollingbuildAuthorizeUrl+oauthState+oauthStateExpiresAt. - Replace
completeAppGithubAuth— delegate to BetterAuth's callback handler. BetterAuth creates/updates the user record in the user actor and creates a signed session. The adapter writes tosessionIndexon the workspace actor. - After BetterAuth callback completes, populate
userProfilesin the user actor with app-specific fields and enqueue the slow org sync (same background workflow pattern as today). - Replace
signOutApp— delegate to BetterAuth session invalidation. Adapter removes entry fromsessionIndex. - Update
resolveSessionIdinindex.ts— validate the session via BetterAuth (which routes through the adapter →sessionIndex→ user actor). BetterAuth verifies the signature and checks expiration. - Keep
bootstrapAppGithubSession(dev-only) — adapt it to create a BetterAuth session from a raw token for local development.
Phase 3: Migrate reads to new tables
- Update
getAppSnapshot— read user identity from BetterAuth's user table in the user actor, app-specific fields fromuserProfiles, and active org fromsessionState. - Update
selectOrganization— write tosessionStatein the user actor instead ofappSessions. - Update
syncGithubOrganizations— writeeligibleOrganizationIdstouserProfilesin the user actor instead ofappSessions. This fixes the multi-session divergence bug. - Update onboarding actions (
skipAppStarterRepo,starAppStarterRepo) — write touserProfilesin the user actor instead ofappSessions. - Update
FoundryUser.id— use GitHub numeric ID (from BetterAuth'saccount.providerAccountId) instead ofuser-${slugify(login)}.
Phase 4: Frontend migration
- Replace
x-foundry-sessionheader with BetterAuth's session mechanism (likely a signed cookie or Authorization header, depending on BetterAuth config). - Remove
foundrySessionURL param extraction frombackend-client.ts— BetterAuth handles post-OAuth session establishment via cookies. - Remove
localStoragesession persistence — BetterAuth manages this via HTTP-only cookies. - Update
signInWithGithub— redirect to BetterAuth's auth endpoint instead of/v1/auth/github/start.
Phase 5: Cleanup
- Drop
appSessionstable (migration). - Remove hand-rolled OAuth functions from
app-shell.ts:ensureAppSession,updateAppSession,initGithubSession,encodeOauthState,decodeOauthState,requireAppSessionRow,requireSignedInSession. - Remove
buildAuthorizeUrlandexchangeCodefromGitHubAppClient(keepgetViewer, installation token methods, webhook verification). - Update
foundry-self-hosting.mdx— documentBETTER_AUTH_SECRETas required for session signing (already documented, now actually true). - Remove
BETTER_AUTH_URLfallback fromapp-shell-runtime.ts— BetterAuth reads it directly.
Constraints
- Actor-routed adapter. BetterAuth does not natively support per-user actor databases. The custom adapter must route every DB operation to the correct actor. This adds a layer of indirection and latency (actor handle resolution + message) on adapter calls.
- Session index cost is mitigated by cookie caching. With
cookieCacheenabled, BetterAuth validates sessions from a signed cookie on most requests — the adapter (and thus thesessionIndexlookup + user actor round-trip) is only called when the cache expires or on writes. Without caching, every authenticated request would hit the workspace actor'ssessionIndextable then the user actor. - Two-actor write on session create/destroy. Creating or destroying a session requires writing to both the user actor (BetterAuth's
sessiontable) and the workspace actor (sessionIndex). These must be consistent — if the user actor write succeeds but the index write fails, the session exists but is unreachable. - Background org sync pattern must be preserved. The fast-path/slow-path split (
initGithubSessionreturns immediately,syncGithubOrganizationsruns in workflow queue) is critical for avoiding proxy timeout retries. BetterAuth handles the OAuth exchange, but the org sync stays as a background workflow. GitHubAppClientis still needed. BetterAuth replaces the OAuth user-auth flow, but installation tokens, webhook verification, repo listing, and org listing are GitHub App operations that BetterAuth does not cover.- User ID migration. Changing user IDs from
user-${slugify(login)}to GitHub numeric IDs affectsorganizationMembers,seatAssignments, and any cross-actor references to user IDs. Existing data needs a migration path. findUserByEmailrequires a secondary index. BetterAuth sometimes looks up users by email (e.g., account linking). AnemailIndextable on the workspace actor is needed. This must be kept in sync with the user actor's email field.
Risk Assessment
- Adapter call context — RESOLVED. Research confirms BetterAuth adapter methods are plain async functions with no request context dependency. The adapter closes over the RivetKit registry at init time and resolves actor handles on demand. No ambient
ccontext needed. - Hot-path latency — MITIGATED. Cookie caching (
cookieCachewithstrategy: "compact") means most authenticated requests validate the session from a signed cookie without calling the adapter at all. The adapter (and thus the actor round-trip) is only hit when the cache expires (configurable, e.g., every 5 minutes) or on writes. This makes the session index + user actor lookup acceptable. - Two-actor consistency. Session create/destroy touches two actors (user actor + workspace index). If either write fails, the system is in an inconsistent state. Recommended: write index first, then user actor. A dangling index entry pointing to a nonexistent session is benign — BetterAuth treats it as "session not found" and the user just re-authenticates.
- Cookie vs header auth. BetterAuth defaults to HTTP-only cookies (
better-auth.session_token). The current system uses a customx-foundry-sessionheader withlocalStorage. BetterAuth supportsbearertoken mode for programmatic clients via itsbearerplugin. Enable both for browser + API access. - Dev bootstrap flow.
bootstrapAppGithubSessionbypasses the normal OAuth flow for local development. BetterAuth supports programmatic session creation via its internal adapter — the dev path can call the adapter'screatemethod directly for thesessionandaccountmodels. - Actor lifecycle for users. User actors are long-lived but low-traffic. RivetKit will idle/unload them. With cookie caching, cold-start only happens when the cache expires — not on every request. Acceptable.
Suggested Implementation Order
- Phase 0 spike — confirm adapter feasibility (go/no-go gate)
- Phase 1 (user actor + adapter infrastructure, no behavior change)
- Phase 2 (OAuth migration)
- Phase 3 (read path migration)
- Phase 4 (frontend migration)
- Phase 5 (cleanup)
Phases 2-4 can be deployed incrementally. Each phase should leave the system fully functional — no big-bang cutover.
Alternative: Fix Without BetterAuth
If the BetterAuth + actor SQLite spike fails, the same goals can be achieved without BetterAuth:
- Extract
userProfilesandsessionStatetables (same as Phase 1). - Sign session tokens with HMAC using
BETTER_AUTH_SECRET(rename toSESSION_SECRET). - Use GitHub numeric ID as user PK.
- Keep the custom OAuth flow but thin it out.
- Drop
appSessionsonce migration is complete.
This is more code to maintain but avoids the BetterAuth integration risk.