feat(learner): Better Auth sessions, owner bootstrap, claim groups; server takes feature factories

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 03:20:06 +01:00
co-authored by Claude Opus 5.5
parent 9aa475329a
commit 668ae379bf
9 changed files with 431 additions and 7 deletions
@@ -0,0 +1,45 @@
import type { Db } from "@stc/db";
export const groupIdFromName = (name: string): string =>
name.trim().toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "group";
/** Reads a groups claim from an IdP profile: an array, or a comma/space separated string. */
export function groupsFromClaims(profile: Record<string, unknown> | null | undefined, claim = "groups"): string[] {
if (!profile) return [];
const raw = profile[claim] ?? (profile.extraFields as Record<string, unknown> | undefined)?.[claim];
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[,\s]+/) : [];
return [...new Set(list.map((g) => String(g).trim()).filter(Boolean))];
}
/** Finds or creates the group called `name`; returns its id. */
export async function ensureGroup(db: Db, name: string): Promise<string> {
const byName = await db.sql`SELECT id FROM groups WHERE name = ${name}`;
if (byName.length) return byName[0].id;
let id = groupIdFromName(name);
const taken = await db.sql`SELECT 1 AS x FROM groups WHERE id = ${id}`;
if (taken.length) id = `${id}-${Bun.randomUUIDv7().slice(-6)}`;
await db.sql`INSERT INTO groups (id, name) VALUES (${id}, ${name})`;
return id;
}
/**
* Makes the user's claim-sourced group memberships equal `names`. Memberships the owner added by hand
* are never removed here, and a group the user already belongs to by hand is not recorded as a claim.
*/
export async function syncClaimGroups(db: Db, userId: string, names: string[]): Promise<void> {
const wanted = new Set<string>();
for (const n of names) wanted.add(await ensureGroup(db, n));
const prior = new Set<string>((await db.sql`SELECT group_id FROM learner_group_claims WHERE user_id = ${userId}`).map((r: { group_id: string }) => r.group_id));
for (const g of prior) {
if (wanted.has(g)) continue;
await db.sql`DELETE FROM learner_group_claims WHERE user_id = ${userId} AND group_id = ${g}`;
await db.sql`DELETE FROM group_members WHERE user_id = ${userId} AND group_id = ${g}`;
}
for (const g of wanted) {
if (prior.has(g)) continue;
const member = await db.sql`SELECT 1 AS x FROM group_members WHERE group_id = ${g} AND user_id = ${userId}`;
if (member.length) continue; // manual membership stays manual
await db.sql`INSERT INTO group_members (group_id, user_id) VALUES (${g}, ${userId})`;
await db.sql`INSERT INTO learner_group_claims (user_id, group_id) VALUES (${userId}, ${g})`;
}
}
@@ -0,0 +1,181 @@
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { sso } from "@better-auth/sso";
import type { AuthService, AuthUser, Role, ServerContext } from "@stc/contracts";
import type { Db } from "@stc/db";
import { bunSqlAdapter } from "./adapter";
import { groupsFromClaims, syncClaimGroups } from "./groups";
export interface ProviderInfo { id: string; label: string; kind: "social" | "oidc" }
type Env = Record<string, string | undefined>;
const SOCIAL: { id: string; label: string; env: string }[] = [
{ id: "github", label: "GitHub", env: "GITHUB" },
{ id: "google", label: "Google", env: "GOOGLE" },
{ id: "microsoft", label: "Microsoft", env: "MICROSOFT" },
{ id: "gitlab", label: "GitLab", env: "GITLAB" },
{ id: "discord", label: "Discord", env: "DISCORD" },
];
/** Owner-only Better Auth paths (SSO provider administration). Sign-in and callbacks stay open. */
const OWNER_ONLY = /^\/api\/auth\/sso\/(register|update-provider|delete-provider|providers|get-provider|request-domain-verification|verify-domain)\b/;
export async function rolesOf(db: Db, userId: string): Promise<Role[]> {
const rows = await db.sql`SELECT role FROM user_roles WHERE user_id = ${userId} ORDER BY role`;
return rows.map((r: { role: Role }) => r.role);
}
export async function groupsOf(db: Db, userId: string): Promise<string[]> {
const rows = await db.sql`SELECT group_id FROM group_members WHERE user_id = ${userId} ORDER BY group_id`;
return rows.map((r: { group_id: string }) => r.group_id);
}
/**
* First user ever to be created becomes the owner. The claim is an INSERT into `learner_meta`
* keyed `owner`, so with concurrent sign-ups exactly one wins (primary-key conflict for the rest).
*/
export async function assignInitialRole(db: Db, userId: string): Promise<Role> {
let role: Role = "learner";
try {
await db.sql`INSERT INTO learner_meta (key, value) VALUES ('owner', ${userId})`;
role = "owner";
} catch {
role = "learner";
}
await db.sql`INSERT INTO user_roles (user_id, role) VALUES (${userId}, ${role})`;
return role;
}
export interface LearnerAuth {
handler(req: Request, user: AuthUser | null): Promise<Response>;
service: AuthService & { invalidate(userId?: string): void };
providers(): ProviderInfo[];
hasSso(): Promise<boolean>;
// biome-ignore lint/suspicious/noExplicitAny: Better Auth's instance type is deeply generic
api: any;
}
export function createAuth(ctx: ServerContext, ready: Promise<unknown>, env: Env = process.env): LearnerAuth {
const { db, config } = ctx;
const groupsClaim = env.STC_AUTH_GROUPS_CLAIM || "groups";
const pendingClaims = new Map<string, { groups: string[]; at: number }>();
const providers: ProviderInfo[] = [];
const socialProviders: Record<string, { clientId: string; clientSecret: string }> = {};
for (const s of SOCIAL) {
const id = env[`STC_AUTH_${s.env}_ID`], secret = env[`STC_AUTH_${s.env}_SECRET`];
if (id && secret) {
socialProviders[s.id] = { clientId: id, clientSecret: secret };
providers.push({ id: s.id, label: s.label, kind: "social" });
}
}
// biome-ignore lint/suspicious/noExplicitAny: plugin list is heterogeneous
const plugins: any[] = [];
if (env.STC_AUTH_OIDC_ISSUER && env.STC_AUTH_OIDC_ID) {
const issuer = env.STC_AUTH_OIDC_ISSUER.replace(/\/$/, "");
plugins.push(genericOAuth({
config: [{
providerId: "oidc",
clientId: env.STC_AUTH_OIDC_ID,
clientSecret: env.STC_AUTH_OIDC_SECRET ?? "",
discoveryUrl: env.STC_AUTH_OIDC_DISCOVERY_URL || `${issuer}/.well-known/openid-configuration`,
scopes: (env.STC_AUTH_OIDC_SCOPES || "openid email profile").split(/[\s,]+/),
pkce: true,
mapProfileToUser: (profile) => {
const email = typeof profile.email === "string" ? profile.email.toLowerCase() : null;
if (email) pendingClaims.set(email, { groups: groupsFromClaims(profile as Record<string, unknown>, groupsClaim), at: Date.now() });
return {};
},
}],
}));
providers.push({ id: "oidc", label: env.STC_AUTH_OIDC_NAME || "Single sign-on", kind: "oidc" });
}
plugins.push(sso({
provisionUserOnEveryLogin: true,
provisionUser: async ({ user, userInfo }) => {
await syncClaimGroups(db, user.id, groupsFromClaims(userInfo, groupsClaim));
cache.clear();
},
modelName: "ssoProvider",
}));
const options: BetterAuthOptions = {
appName: "STCourses",
baseURL: config.publicUrl,
basePath: "/api/auth",
secret: config.secret,
database: bunSqlAdapter(db),
emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
socialProviders,
plugins,
trustedOrigins: [config.publicUrl, ...(env.STC_TRUSTED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean)],
telemetry: { enabled: false },
session: { cookieCache: { enabled: true, maxAge: 60 } },
databaseHooks: {
user: { create: { after: async (user) => { await assignInitialRole(db, user.id); } } },
session: {
create: {
after: async (session) => {
const rows = await db.sql`SELECT email FROM auth_user WHERE id = ${session.userId}`;
const email = rows[0]?.email?.toLowerCase();
const claim = email ? pendingClaims.get(email) : undefined;
if (claim && email) {
pendingClaims.delete(email);
if (Date.now() - claim.at < 10 * 60_000) await syncClaimGroups(db, session.userId, claim.groups);
}
cache.delete(session.userId);
},
},
},
},
};
const auth = betterAuth(options);
// userId -> roles/groups, briefly cached: every request (media ranges included) resolves the user.
const cache = new Map<string, { roles: Role[]; groups: string[]; at: number }>();
const TTL = 15_000;
const service: LearnerAuth["service"] = {
async userFromRequest(req) {
if (!req.headers.get("cookie")) return null;
await ready;
let s: { user: { id: string; email: string; name: string } } | null = null;
try {
s = await auth.api.getSession({ headers: req.headers });
} catch {
return null;
}
if (!s) return null;
const id = s.user.id;
let c = cache.get(id);
if (!c || Date.now() - c.at > TTL) {
c = { roles: await rolesOf(db, id), groups: await groupsOf(db, id), at: Date.now() };
cache.set(id, c);
}
return { id, email: s.user.email, name: s.user.name, roles: c.roles, groups: c.groups };
},
invalidate(userId) {
if (userId) cache.delete(userId);
else cache.clear();
},
};
return {
api: auth.api,
service,
providers: () => providers,
async hasSso() {
await ready;
const rows = await db.sql`SELECT COUNT(*) AS n FROM auth_sso_provider`;
return Number(rows[0].n) > 0;
},
async handler(req, user) {
await ready;
const path = new URL(req.url).pathname;
if (OWNER_ONLY.test(path) && !user?.roles.includes("owner")) return Response.json({ error: "owner only" }, { status: 403 });
return auth.handler(req);
},
};
}
@@ -0,0 +1,25 @@
import type { AuthService, Feature, ServerContext } from "@stc/contracts";
import { createAuth } from "./auth";
import { runMigrations } from "./migrate";
import { json } from "./http";
/**
* The learner feature: auth, library, reader, notes, progress and exercises.
* Returns the routes plus the `AuthService` that replaces the server's anonymous one.
*/
export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthService } {
const ready = runMigrations(ctx.db);
ready.catch((e) => console.error("[learner] migrations failed", e));
const auth = createAuth(ctx, ready);
const routes: Feature["routes"] = {
"/api/auth/*": {
GET: (req, rc) => auth.handler(req, rc.user),
POST: (req, rc) => auth.handler(req, rc.user),
},
"/api/me": { GET: async (_req, rc) => json({ user: rc.user }) },
"/api/learner/providers": {
GET: async () => json({ emailPassword: true, providers: auth.providers(), sso: await auth.hasSso() }),
},
};
return { name: "learner", routes, auth: auth.service };
}
@@ -0,0 +1,28 @@
import type { AuthUser, RequestContext, RouteHandler } from "@stc/contracts";
export const json = (body: unknown, status = 200): Response => Response.json(body, { status });
export const notFound = (): Response => json({ error: "not found" }, 404);
export const badRequest = (message: string): Response => json({ error: message }, 400);
/** Wraps a handler that needs a signed-in user; anonymous callers get 401. */
export const signedIn = (h: (req: Request, rc: RequestContext, user: AuthUser) => Response | Promise<Response>): RouteHandler =>
(req, rc) => (rc.user ? h(req, rc, rc.user) : json({ error: "sign in required" }, 401));
/** Owner-only handler: 401 when anonymous, 403 for other roles. */
export const ownerOnly = (h: (req: Request, rc: RequestContext, user: AuthUser) => Response | Promise<Response>): RouteHandler =>
signedIn((req, rc, user) => (user.roles.includes("owner") ? h(req, rc, user) : json({ error: "owner only" }, 403)));
export async function body<T = Record<string, unknown>>(req: Request): Promise<T | null> {
try {
const v = await req.json();
return v && typeof v === "object" ? (v as T) : null;
} catch {
return null;
}
}
/** Route params, decoded (core's matcher already decodes; Bun's router may not). */
export const param = (rc: RequestContext, name: string): string => {
const v = rc.params[name] ?? "";
try { return decodeURIComponent(v); } catch { return v; }
};