cursedauth
v0.9.3
Published
The app side of fleet SSO — sign in through the accounts service, keep a local snapshot of who signed in, and gate the app on it. Bun + Hono + React, one SQLite file per app.
Maintainers
Readme
cursedauth
The app side of "sign in through the accounts service". Mount it in a Bun + Hono app and the app has sessions, multi-account switching, an account menu, and a gate — without a password surface of its own.
import { createSsoConsumer, createUserSessionStore, mountGatedApp } from "cursedauth";
const sessions = createUserSessionStore({
cookieName: "flix_session",
dbPath: `${dataDir}/sessions.sqlite`,
sessionSecret,
sessionTtlSeconds: 60 * 60 * 24 * 30,
});
const sso = createSsoConsumer({
app: "flix",
authUrl: "https://auth.example.com",
publicUrl: "https://flix.example.com",
registeredAudiences: ["flix", "flix-local"],
sessions,
db,
});
mountGatedApp(app, { gate: sso, mountPublic: (a) => sso.mountSsoRoutes(a), mount: routes });What it is not
It is the consumer, never the provider. It holds no password, mints no grant, and
has no user table beyond a local snapshot of whoever has signed in here — so
request-time auth never calls out, and the accounts service being down breaks new
logins and nothing else. The provider is a separate service reached over HTTPS at
authUrl; this package's only knowledge of it is that URL and its public key.
Subpaths
| import | side | what |
|---|---|---|
| cursedauth | server | the barrel: consumer, sessions, gate, census |
| cursedauth/sso-consumer | server | the six routes and requireUser |
| cursedauth/user-sessions | server | signed cookies + a sessions table, per app — the bun:sqlite half |
| cursedauth/user-sessions-core | server | the same store over a SessionRows seam, plus SESSIONS_DDL. Worker-safe |
| cursedauth/sso-user-store | server | the sso_users snapshot behind SsoUserStore, plus SSO_USERS_DDL. Worker-safe |
| cursedauth/d1-stores | server | both seams on Cloudflare D1: one snapshot per invocation, writes settled before the response. Worker-only |
| cursedauth/mount-gated | server | mount public routes before the gate, gated ones after |
| cursedauth/content-census | server | answer "what has this account made here?" by COUNT |
| cursedauth/account-slots | both | several accounts signed in on one browser |
| cursedauth/sso | both | the grant: its wire shape, its claims, its verification |
| cursedauth/session-claims | server | the bearer-token half: SESSION_CLAIMS_KEY, SessionClaims, createRequireSession. Worker-safe — reaches no bun:sqlite |
| cursedauth/constant-time | both | constantTimeEqual — compare two secrets without leaking where they differ |
| cursedauth/secure-request | both | isSecureRequest — whether a request arrived over HTTPS (reading x-forwarded-proto behind the tunnel), the one question the cookie Secure attribute turns on. Published 0.9.3 because patterns and auth each carried a copy (task 2106) |
| cursedauth/offline-grant | both | the bounded offline window: how long a device with no network may keep using cached content |
| cursedauth/auth | browser | startSsoSignIn, useSsoAutoSignIn, session hooks |
| cursedauth/account-ui | browser | the account menu |
| cursedauth/account-slot-client | browser | slot switching in the page |
| cursedauth/testing | server | the grant→callback→cookie harness, for a consumer's own suite |
🔴 The barrel reaches bun:sqlite. A browser bundle imports the three browser
subpaths and never the root — and so does a Cloudflare Worker, which is why
cursedauth/session-claims exists. apps/patterns could not wrangler deploy at
all on 2026-09-18 (Could not resolve "bun:sqlite", naming dist/userSessions.js)
because one string constant was reachable only through the barrel. Which subpaths
may reach bun:sqlite is now a CHECK — src/subpathsReachNoBunBuiltin.test.ts —
and that allowlist may only shrink.
🔴 Two different things are called a "grant" here, and only one is a
credential. cursedauth/sso has the grant: signed by the accounts service,
single-use, and it authorizes a sign-in. cursedauth/offline-grant has an
OfflineGrant: a local stamp saying "this device completed a verified session
for account X at time T", which authorizes nothing, is never sent anywhere, and
only bounds how long already-cached bytes may be replayed while there is no
network to re-check. Separate subpaths, no import between them.
🔴 This table is a CHECK, not prose — src/publishShape.test.ts fails if an
export subpath is missing a row. A subpath nobody can find is a subpath every app
re-implements: constant-time shipped on 2026-09-17 after three apps had each
copied constantTime.ts into their own src/, because it was on no subpath at
all. Add the exports entry and the row in the same commit.
Where it came from, and what was cut
Extracted 2026-09-14 from the previous generation's satellite-kit, which was the
whole application platform — build, vite, dev-runner, smokes, backups, launchd
agents, route manifests, metrics, mail — with identity inside it. Only identity
came here.
Measured before the cut. Seeding the closure from the subpaths apps import and following relative imports:
| seeded from | files | what pulled the tail in | |---|---|---| | the 8 subpaths including the root barrel | 65 | the root IS the whole satellite server: metrics, engagement, mail, binary store | | the 7 identity subpaths only | 25 | — | | what this package ships | 21 | see the three cuts below |
@aws-sdk/client-ses was the dependency the extraction was told to prune. It never
had to be pruned: it enters through mailer.ts, which is reachable only from the
root satellite-server barrel, not from sso-consumer or user-sessions. This
package's external surface is hono, react, react-dom, bun:sqlite and node
builtins — nothing else, and no runtime dependencies block at all.
Three things were deliberately left behind:
fleet/registry.ts+fleet/directory.ts(936 lines) — a fleet-wide table of every app compiled into the shared library, and the/api/fleet/directoryroute that served it. Every app carried a roster of every other app, so adding one meant editing the platform. The one thing the consumer genuinely needed from it is nowSsoConsumerOptions.registeredAudiences: an app declares its own doors.ipGeo.ts— a machine-wide 375 MB IP→city snapshot, defaulted-on, living outside any repo.placeLookupis still injectable; unset, a session row records the city the edge headers already carry.ownerAuth.server.ts— single-principal password auth (argon2, one owner). Nothing here signs in that way, and keeping it would have madecursedbelta runtime dependency of an auth library for one re-exported type. The SSO path used exactly one function from it; that issrc/secureRequest.ts, with its own suite.
