auth
Epicenter auth packages: @epicenter/auth and the Svelte wrapper at @epicenter/svelte/auth, OAuth sessions, identity state, auth-owned fetch/WebSocket, and workspace lifecycle binding. Use when editing Epicenter auth clients, session state, hosted sign-in, or auth/workspace integration.
Works with
Agent Skills format with YAML frontmatter. Claude Code reads it as-is.
---
name: "auth"
description: "Epicenter auth packages: @epicenter/auth and the Svelte wrapper at @epicenter/svelte/auth, OAuth sessions, identity state, auth-owned fetch/WebSocket, and workspace lifecycle binding. Use when editing Epicenter auth clients, session state, hosted sign-in, or auth/workspace integration."
license: "MIT"
---
# Epicenter Auth
## Upstream Grounding
When changes depend on Better Auth OAuth provider behavior, bearer token
verification, cookie handling, token rotation, plugin shape, JWKS, or generated
API shape, ask DeepWiki a narrow question against `better-auth/better-auth`
before relying on memory. Use it to orient, then verify decisive details against
local installed types, source, tests, or official docs before changing code.
Known Better Auth source landmarks:
```txt
packages/oauth-provider/src/oauth.ts
packages/oauth-provider/src/authorize.ts
packages/oauth-provider/src/token.ts
packages/oauth-provider/src/revoke.ts
packages/oauth-provider/src/client-resource.ts
packages/better-auth/src/plugins/jwt/index.ts (ES256 signing + JWKS)
```
Better Auth remains the auth server and session engine. Epicenter extends it
through plugins and options; it does not replace Better Auth's server-side
session model.
Use this composition sentence when explaining the architecture:
```txt
Epicenter uses Better Auth for auth-server machinery, OAuth for the app/resource boundary, and AuthState{ownerId} for workspace boot.
```
That means Better Auth owns users, account cookies, login, consent, token
issuing, revocation, JWKS, and metadata. Epicenter clients store
`PersistedAuth`, not Better Auth sessions. `/api/session` is the adapter that
verifies an OAuth access token, resolves the request to an `ownerId`, and
returns `ApiSessionResponse`.
When the user asks whether this is idiomatic Better Auth, be precise:
```txt
It is not the shortest Better Auth browser-cookie path.
It is an idiomatic composition of Better Auth as the auth server beneath a cross-client OAuth runtime.
```
Do not suggest removing Better Auth unless the user has a concrete blocker that
cannot be handled with configuration, a small adapter, or an upstream fix.
Building OAuth by hand means owning PKCE validation, redirect URI validation,
state and mix-up protections, trusted clients, token signing, refresh token
rotation, revocation, JWKS, metadata, consent, account sessions, and security
fixes forever.
## Current Model
Epicenter app clients use one OAuth app auth factory:
```ts
const auth = createOAuthAppAuth({
baseURL: EPICENTER_API_URL,
clientId,
launcher,
persistedAuthStorage,
});
```
There is exactly one factory. The old split between `createCookieAuth` and
`createBearerAuth` (and `BearerSession` / `auth.bearerToken`) is fully removed,
not legacy-but-present. Do not reintroduce those names.
The public surface lives in one package plus a Svelte subpath:
- `@epicenter/auth`: framework-agnostic core. Owns the persisted auth cell,
refresh, refresh-token revocation, `/api/session` verification, the network
gate, authenticated fetch, and WebSocket opening. Also exports the Node
machine-auth surface for CLI and daemons.
- `@epicenter/svelte/auth`: Svelte 5 wrapper (in the `@epicenter/svelte`
package, which also owns `createSession` / `SignedIn`). Mirrors `auth.state`
through `createSubscriber` so templates and `$derived` reads are reactive.
- `createSession` / `SignedIn` from `@epicenter/svelte`: workspace lifecycle
binding over an `AuthClient`.
The API server composes Better Auth like this:
```txt
Hono app
-> CORS
-> per-request DB
-> createAuth({ db, env, baseURL })
-> singleCredential
-> /auth/* Better Auth handler
-> /api/session (mountSessionApp: cookie-or-bearer + ownership)
-> protected resources (bearer + ownership)
```
`createAuth()` configures Better Auth with Drizzle (Postgres via Hyperdrive),
Google sign-in (plus GitHub when its credentials are present), and exactly two
plugins:
```ts
jwt({ jwks: { keyPairConfig: { alg: JWT_SIGNING_ALG } } }), // ES256
oauthProvider({
loginPage: '/sign-in',
consentPage: '/consent',
requirePKCE: true,
accessTokenExpiresIn: 600,
cachedTrustedClients: trustedOAuthClientIds,
validAudiences: [apiBaseURL],
allowDynamicClientRegistration: false,
scopes: [...EPICENTER_OAUTH_SCOPES],
})
```
There are no bearer, device-authorization, or custom-session plugins. Local
email/password is disabled (`emailAndPassword: { enabled: false }`): enabling
unverified local credentials reopens an account-linking takeover on
better-auth 1.5.6 (no `requireLocalEmailVerified` gate). Only Google is a
trusted linking provider; see the `better-auth-security` skill's Account
Linking note.
## Public Surface
Auth has one public client interface (copied verbatim from
`packages/auth/src/auth-contract.ts`):
```ts
export type AuthState =
| { status: 'signed-out' }
| {
status: 'signed-in';
ownerId: OwnerId;
}
| {
status: 'reauth-required';
ownerId: OwnerId;
};
export type AuthClient = {
state: AuthState;
baseURL: string;
onStateChange(fn: (state: AuthState) => void): () => void;
startSignIn(): Promise<Result<undefined, AuthError>>;
signOut(): Promise<Result<undefined, AuthError>>;
fetch(input: Request | string | URL, init?: RequestInit): Promise<Response>;
openWebSocket(url: string | URL, protocols?: string[]): Promise<WebSocket>;
[Symbol.dispose](): void;
};
```
`AuthState` arms carry `ownerId` directly. There is no nested
identity object and no `user` field in state: profile (user/email) is fetched
by surfaces that display it, not held in state. `ownerId` is
present in `signed-in` and `reauth-required` because it belongs to local
workspace operations: even when the OAuth grant needs reauth, the cached owner
id picks the right local storage partition.
Read `auth.state` synchronously. Use `auth.onStateChange(fn)` for future
changes only; it does not replay. Consumers that need bootstrap behavior must
read `auth.state` once and then register the listener.
Do not expose raw tokens above auth storage and transport boundaries. UI,
workspace binding, AI fetches, and sync consume capabilities: `auth.fetch` and
`auth.openWebSocket`.
## The Persisted Cell
`PersistedAuth` is the single durable auth record (copied verbatim from
`packages/auth/src/auth-types.ts`):
```ts
export const OAuthTokenGrant = type({
'+': 'delete',
accessToken: 'string',
refreshToken: 'string',
accessTokenExpiresAt: 'number',
});
export type OAuthTokenGrant = typeof OAuthTokenGrant.infer;
export const PersistedAuth = type({
'+': 'delete',
grant: OAuthTokenGrant,
userId: UserId,
ownerId: OwnerId,
});
export type PersistedAuth = typeof PersistedAuth.infer;
export const ApiSessionResponse = type({
'+': 'delete',
user: AuthUser,
ownerId: OwnerId,
});
export type ApiSessionResponse = typeof ApiSessionResponse.infer;
```
The grant is a nested object; identity is split out:
```txt
PersistedAuth
grant: { accessToken, refreshToken, accessTokenExpiresAt } -> online-only server access
userId -> stored explicitly so the shared daemon can read it
ownerId -> local storage partition selection
```
The grant lets the app call the server and is useless offline on its own.
`userId` / `ownerId` remain useful offline: they select
this user's local workspace data. `userId` is stored explicitly rather than
synthesised from `ownerId` so the shared daemon can read it when
`ownerId === SHARED_OWNER_ID` (in shared mode `ownerId` is the literal shared id
and is structurally not a `UserId`). Profile data is intentionally absent;
application surfaces fetch it when they display it.
The app can boot from a cached `PersistedAuth` without calling the network.
Refresh failure must preserve the cached `ownerId` so local workspace data can
remain available. The cached owner id selects the local storage partition; it
does not decrypt anything.
## Network Gate (local-first invariant)
The runtime tracks a `networkAccess` state per signed-in cell (internal to
`createOAuthAppAuth`):
```txt
networkAccess: 'unverified' | 'verified' | 'paused'
```
`bearerForNetwork` is the gate. It NEVER attaches a bearer until `/api/session`
verifies the current persisted auth in this runtime:
```txt
signed-out / paused -> no bearer
refresh stale grant -> if refresh fails, no bearer (offline = fail closed)
unverified -> call /api/session
ok -> mark verified, attach bearer
AuthRejected (401/403) -> pauseNetworkAuth() -> reauth-required
Unavailable (offline) -> no bearer; local workspace boot can continue by ownerId
```
Fail closed offline: server access is refused until the current persisted auth
has been verified by the API, but local workspace boot continues because the
cached `ownerId` selects the right local partition. A different-`ownerId`
`/api/session` response wipes the local cell (same-owner guard).
`auth.fetch` layers retry on top of the gate: verify-before-attach,
`credentials: 'omit'`, one forced-refresh retry on a 401, and
`pauseNetworkAuth()` on a second 401.
## Sign-In Flow
Apps ask auth to start hosted sign-in. `startSignIn` takes NO arguments:
```ts
await auth.startSignIn();
```
The launcher decides how the runtime completes OAuth and returns one of two
shapes:
- `'launched'`: control moved to a redirect / deep-link callback. The browser
redirect launcher navigates to the hosted `/sign-in` and usually does not
resolve before the page unloads.
- `'completed'` with `{ grant }`: the launcher exchanged a token grant in
process (extension, OOB CLI). The runtime then calls `/api/session`,
resolves identity, and persists `PersistedAuth`.
The return value of `startSignIn` is not the "user is signed in" signal.
Observe `auth.state.status === 'signed-in'` for completion.
## PersistedAuthStorage Port
Storage is a small port (copied verbatim from
`packages/auth/src/persisted-auth-storage.ts`):
```ts
export type PersistedAuthStorage = {
initial: PersistedAuth | null;
set(value: PersistedAuth | null): void | Promise<void>;
};
```
`initial` is read exactly once, synchronously, at construction to seed the
state machine; it is never re-read. `set` is the only write path (no watch
hook: cross-context sign-out propagates via the server, where the next
bearer-bearing call hits a revoked token and reauth-requires organically).
Adapters:
- `createWebStoragePersistedAuthStorage({ key, storage })`: sync Web Storage
(`localStorage` / `sessionStorage`). A corrupt record reads as signed-out
instead of throwing; write failures propagate so an unpersistable credential
fails its sign-in or refresh.
- `loadPersistedAuthStorage({ read, write })`: pre-load an async-backed store
(extension `chrome.storage.local`, a file) into a synchronous port. Await it
before constructing the client so `initial` stays synchronous.
- `parsePersistedAuth` / `serializePersistedAuth`: the shared decode/encode
helpers (re-validate against the arktype on both sides).
## CLI and Daemon (machine auth)
`packages/auth/src/node/machine-auth.ts` is the Node surface. One auth file per
API target lives at `<dataDir>/auth/<host>.json` with mode `0o600` (`:` in the
host replaced by `_`); `machineAuthFilePath({ baseURL })` resolves it. Loading
refuses a file whose permissions are wider than `0o600`.
- `loginWithOob(...)`: runs the OOB OAuth dance once, calls `/api/session` for
the identity, persists `PersistedAuth`, and returns the email for CLI output.
It deliberately BYPASSES `createOAuthAppAuth`: login is a one-shot human
action, and routing it through the factory would double the round-trip count.
- `createMachineAuthClient(...)`: the daemon boot entry point. Loads the cell
and constructs a normal `createOAuthAppAuth` client over a file-backed
storage port. Its launcher errors on `startSignIn` (a human must run
`epicenter auth login` to refresh the cell); daemons never sign in
interactively.
- `status` / `logout`: read the cell and reach the server through a regular
client. `status` returns `'unverified'` on network failure so the CLI can
still print the cached identity.
## Transport
Use `auth.fetch` for HTTP resources:
```ts
const response = await auth.fetch(`${EPICENTER_API_URL}/api/ai/chat`, {
method: 'POST',
body,
});
```
`auth.fetch` runs the network gate (verify-before-attach), sends
`credentials: 'omit'` so OAuth tokens stay the resource credential, retries one
401 after a forced refresh, and pauses network auth on a second 401. Storage
writes are awaited before a refreshed token is used.
Use `auth.openWebSocket` for sync:
```ts
const collaboration = openCollaboration(workspace.ydoc, {
url: roomWsUrl({ baseURL, ownerId, guid: workspace.ydoc.guid, nodeId }),
waitFor: idb.whenLoaded,
openWebSocket: signedIn.openWebSocket,
onReconnectSignal: signedIn.onReconnectSignal,
});
```
Browsers cannot attach `Authorization` headers to `new WebSocket()`, so auth
carries the bearer token as a WebSocket subprotocol
(`BEARER_SUBPROTOCOL_PREFIX`). The API's `singleCredential` middleware
normalizes that subprotocol into `Authorization` and rejects requests that
carry multiple credentials.
## Stateless access tokens and revocation windows
The OAuth provider issues JWT access tokens that the resource server verifies
statelessly against JWKS (no per-request introspection). That is fast, but it
means a token cannot be revoked before it expires: signing out revokes the
refresh token, not the already-issued access token. Three mitigations follow
from that one invariant and only make sense together. Treat them as a unit.
```txt
stateless JWT access token -> cannot revoke before exp
1. short access-token TTL (accessTokenExpiresIn: 600 / 10 min)
2. bound WebSocket connection lifetime + force re-auth on reconnect
3. classify verify failures: 401 (bad token) vs 503 (JWKS unreachable)
```
1. Keep `accessTokenExpiresIn` short (10 minutes). The client refreshes
transparently (refresh tokens rotate; the runtime refreshes on a skew window
and on any 401), so the UX cost is ~nil and the post-revocation window stays
small.
2. A route that authenticates only at the WebSocket upgrade MUST bound the
connection lifetime, or a socket opened with a valid token outlives the
token. The rooms Durable Object closes an over-age socket and the client
reconnects through a fresh authenticated upgrade. Crucially, a per-frame
check misses idle sockets (their only traffic is the auto-responded `ping`),
so the bound also needs an alarm-driven sweep over `getWebSockets()`.
3. Close codes and statuses carry meaning the client acts on:
```txt
WS close 4401 -> permanent auth failure; client gives up
WS close 4408/4503 -> transient; client reconnects with backoff
HTTP 401 (InvalidToken) -> discard and refresh the token
HTTP 503 (ServerError) -> retry; the token is fine, JWKS was unreachable
```
Never flatten a JWKS-fetch failure into a 401, or a transient server fault
makes clients discard and refresh a good token and pause network auth.
## Workspace Binding
Workspace construction reads identity from `createSession` and gives lower
layers callbacks for data they need at their own boundary. The build callback
receives a `SignedIn` value (copied verbatim from
`packages/svelte-utils/src/session.svelte.ts`):
```ts
export type SignedIn = {
server: string;
baseURL: string;
ownerId: OwnerId;
openWebSocket: AuthClient['openWebSocket'];
onReconnectSignal: AuthClient['onStateChange'];
};
```
Use it against the real `createSession`:
```ts
import { createSession, type SignedIn } from '@epicenter/svelte/auth';
export const session = createSession({
auth,
build: (signedIn: SignedIn) => {
const workspace = createWorkspace({
id: workspaceId,
tables,
kv,
});
const idb = attachLocalStorage(workspace.ydoc, {
server: signedIn.server,
ownerId: signedIn.ownerId,
});
const collaboration = openCollaboration(workspace.ydoc, {
url: roomWsUrl({
baseURL: signedIn.baseURL,
ownerId: signedIn.ownerId,
guid: workspace.ydoc.guid,
nodeId,
}),
waitFor: idb.whenLoaded,
openWebSocket: signedIn.openWebSocket,
onReconnectSignal: signedIn.onReconnectSignal,
});
return {
workspace,
[Symbol.dispose]() {
collaboration[Symbol.dispose]();
idb[Symbol.dispose]();
},
};
},
});
```
`server` is the API host alone (local-storage partition names); `baseURL` is
the full origin (`roomWsUrl` wants the scheme for the `wss://` upgrade).
`createSession` owns workspace lifecycle. A sign-out disposes the payload. A
`reauth-required` transition keeps the existing payload mounted (OAuth sessions
publish a signed-out gap before a different owner mounts, so two consecutive
identity-bearing states are always the same owner). `session.current` is the
nullable payload; `session.require()` throws when signed-out.
Local workspace data must not be wiped just because network auth failed. Wiping
Yjs or local storage is a separate destructive user action.
## Server Routes and Deployment Seam
`/api/session` is mounted via `mountSessionApp(app, { ownership })`, which wires
`requireCookieOrBearerUser` (the endpoint serves both browser apps and API
clients) plus `createRequireOwnership`, then mounts the handler. The handler
returns `{ user: { id, email }, ownerId }`.
External-only protected routes (AI chat, rooms) use `requireBearerUser`, which
skips the cookie path and always answers 401 with a standard OAuth
`WWW-Authenticate` header. Both auth middlewares verify the bearer through
`verifyAccessToken` from `oauthProviderResourceClient`:
```txt
audience = c.var.authBaseURL (the API origin)
issuer = <API origin> + /auth
jwksUrl = <API origin> + /auth JWKS
```
A token-verification failure (expired, bad audience/issuer/signature) is a real
401 (`OAuthError.InvalidToken`); an unreachable JWKS is a retryable 503
(`OAuthError.ServerError`). Never flatten the latter into a 401.
The deployment seam lives in `packages/server/src/ownership.ts`:
```ts
export type OwnershipRule =
| { kind: 'personal' }
| { kind: 'shared'; admit: Admit };
export const personal = (): OwnershipRule => ({ kind: 'personal' });
export const shared = (opts: { admit: Admit }): OwnershipRule => ({
kind: 'shared',
admit: opts.admit,
});
```
`resolveOwnerPartition(rule, c)` is the single switch on `rule.kind`. Personal
mode returns the user's id branded as `OwnerId` (`ownerId === userId`). Shared
mode runs the admission predicate and returns the literal `SHARED_OWNER_ID`, or
`RequestGuardError.NotAdmitted` (403) for rejected users. `createRequireOwnership`
sets `c.var.ownerId` and, on routes with a `:ownerId` segment, rejects a URL
mismatch with `OwnerMismatch` (403).
Note: the same-origin dashboard SPA (`apps/api/ui`) uses
`createSameOriginCookieAuth`, not PKCE. Served same-origin by the API, it already
holds a first-party Better Auth session cookie after Google sign-in, so minting a
bearer (and an unused `offline_access` refresh token) via PKCE against its own
origin would be redundant. The cookie client uses that cookie directly
(`credentials: 'include'`, no `Authorization`), reads `/api/session` once for
`ownerId`, and is a plain `AuthClient` (no `openWebSocket`: a billing surface
has no sync). It is the cookie-credential sibling of `createOAuthAppAuth`, not a
mode flag on it.
## Common Pitfalls
- Do not add `auth.bearerToken` or any token reader. Token reading leaks
transport details back into app code.
- Do not reintroduce cookie-vs-bearer app factories. Better Auth still uses
cookies for hosted sign-in pages, but app resources use OAuth access tokens
through the one `createOAuthAppAuth` factory.
- Do not treat `startSignIn()` resolving as signed-in. State is the source of
truth; `startSignIn` takes no args.
- Do not clear local workspace data on refresh failure. Move to
`reauth-required` (the runtime pauses network auth) and keep `ownerId`
available for local partition selection.
- Do not let `accessTokenExpiresAt` decide local identity state. It is a
transport refresh hint only; the resource server is the source of truth for
token validity.
- Do not send both cookies and bearer tokens to resource routes.
`singleCredential` rejects ambiguity before Better Auth sees it.
- Do not hide persistence failures in storage adapters. If `set` cannot save
the refreshed cell, the failure must propagate, not silently look saved.
- Do not import `requireSignedIn`, `InferSignedIn`, `openFuji`,
`encryptionKeys`, `EncryptionKeys`, `keyring`, or `Keyring`. They do not
exist in Epicenter workspace auth. Workspace binding goes through
`createSession` / `SignedIn`.More Frontend Frameworks skills
frontend-design
anthropics/skills
Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
design-taste-frontend
leonxlnx/taste-skill
Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.
hyperframes-creative
heygen-com/hyperframes
Non-animation creative direction for HyperFrames videos. Use for design spec (frame.md / design.md) handling, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns, and brand / style decisions. For atomic motion patterns and scene blueprints, use hyperframes-animation.

