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
+19 -7
View File
@@ -1,25 +1,35 @@
import type { AppConfig, AuthService, Feature, JobHandler, ServerContext } from "@stc/contracts";
import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerContext } from "@stc/contracts";
import { createApp, createJobQueue, loadConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import { learnerFeature } from "@stc/feature-learner";
import indexHtml from "../../web/index.html";
// Temporary: the learner package replaces this with real sessions.
// Used until a feature supplies an `auth` service (the learner package does).
const anonymousAuth: AuthService = { userFromRequest: async () => null };
/** A feature, or a factory that needs the server context. A feature may carry the `AuthService`. */
export type FeatureEntry = (Feature & { auth?: AuthService }) | ((ctx: ServerContext) => Feature & { auth?: AuthService });
export async function startServer(
config: AppConfig,
features: Feature[],
features: FeatureEntry[],
_webFeatures?: unknown,
): Promise<{ server: Bun.Server<undefined>; stop(): Promise<void> }> {
const db = await connect(config.databaseUrl);
await migrate(db);
const courses = await createCourseRepository(config.courseDirs);
const handlers: Record<string, JobHandler> = {};
for (const f of features) Object.assign(handlers, f.jobs);
// Factories get the context before the job queue exists; ctx.jobs is set right after they run.
const ctx: ServerContext = { config, db, courses, jobs: null as unknown as JobQueue, auth: anonymousAuth };
const built = features.map((f) => (typeof f === "function" ? f(ctx) : f));
for (const f of built) {
Object.assign(handlers, f.jobs);
if (f.auth) ctx.auth = f.auth;
}
const jobs = createJobQueue(db, handlers);
const ctx: ServerContext = { config, db, courses, jobs, auth: anonymousAuth };
const app = createApp(ctx, features);
ctx.jobs = jobs;
const app = createApp(ctx, built);
jobs.start();
const server = Bun.serve({
port: config.port,
@@ -39,6 +49,8 @@ export async function startServer(
if (import.meta.main) {
const config = loadConfig();
const { server } = await startServer(config, []);
const { server } = await startServer(config, [
learnerFeature,
]);
console.log(`STCourses listening on ${server.url}`);
}
+22
View File
@@ -0,0 +1,22 @@
# Contract requests from WP-A (learner)
## 1. Feature factories and the auth service (`apps/server/src/main.ts`, `@stc/contracts`)
**Problem.** Phase 0 passes `Feature[]` to `startServer`, but `ServerContext` is built inside `startServer`, so a feature that needs `ctx` (every Phase 1 package exports `xFeature(ctx)`) cannot be listed. There is also no way for a feature to replace the anonymous `AuthService`.
**Workaround in `feat/learner` (applied, small):** `startServer` accepts `FeatureEntry = Feature | ((ctx) => Feature & { auth?: AuthService })`. Factories run after `ctx` exists (with `ctx.jobs` set right after they return, so factories must not use `jobs` synchronously), and a feature carrying `auth` replaces `ctx.auth`. The `main` entry lists `learnerFeature`.
**Proposal.** Move both into the contract:
```ts
export type FeatureFactory = (ctx: ServerContext) => Feature;
export interface Feature { /* … */ auth?: AuthService; migrationsDirs?: string[] }
```
and let `startServer` take `Array<Feature | FeatureFactory>`.
## 2. Migrations per package
`migrate()` only reads `packages/db/migrations`. The learner package exports `migrationsDirs` and, until Phase 2 wires it in, runs its own migrations lazily inside `learnerFeature(ctx)` through `runMigrations(db, dirs)`, which uses the same `schema_migrations` table and file-name keys. When `migrate()` learns about `migrationsDirs`, the lazy call becomes a no-op; it can then be removed.
## 3. Package entry for the browser
`@stc/feature-learner`'s index exports both `learnerFeature` (server) and `learnerWeb`/`LessonView` (browser). Bun's browser bundler tree-shakes the server half (checked), so one entry works. The web shell imports `learnerWeb` from `@stc/feature-learner`; the studio can import `LessonView` the same way.
+1
View File
@@ -1 +1,2 @@
export { migrationsDirs, runMigrations } from "./server/migrate";
export { learnerFeature } from "./server/feature";
@@ -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; }
};
+48
View File
@@ -0,0 +1,48 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type { AppConfig, JobQueue, ServerContext } from "@stc/contracts";
import { createApp } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import type { Db } from "@stc/db";
import { learnerFeature } from "../src/server/feature";
export const FIXTURES = resolve(import.meta.dir, "../../../fixtures/courses");
export const ORIGIN = "http://localhost:3000";
const fakeJobs: JobQueue = { enqueue: async () => "j", get: async () => null, list: async () => [] };
export async function makeApp(db: Db, courseDirs: string[] = [FIXTURES]) {
const config: AppConfig = {
port: 0, databaseUrl: "", dataDir: mkdtempSync(join(tmpdir(), "stc-learner-")), courseDirs,
secret: "test-secret-0123456789abcdef0123456789", publicUrl: ORIGIN,
};
const ctx: ServerContext = {
config, db, courses: await createCourseRepository(courseDirs), jobs: fakeJobs,
auth: { userFromRequest: async () => null },
};
const feature = learnerFeature(ctx);
ctx.auth = feature.auth;
const app = createApp(ctx, [feature]);
async function call(method: string, path: string, opts: { body?: unknown; cookie?: string; headers?: Record<string, string> } = {}) {
const headers: Record<string, string> = { origin: ORIGIN, ...opts.headers };
if (opts.cookie) headers.cookie = opts.cookie;
let body: BodyInit | undefined;
if (opts.body !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(opts.body); }
return app.fetch(new Request(ORIGIN + path, { method, headers, body }));
}
/** Signs up and returns a cookie header for the new session. */
async function signUp(email: string, name = email.split("@")[0]!, password = "correct horse battery") {
const r = await call("POST", "/api/auth/sign-up/email", { body: { email, name, password } });
if (r.status !== 200) throw new Error(`sign-up failed ${r.status}: ${await r.text()}`);
return cookieFrom(r);
}
return { app, ctx, feature, call, signUp };
}
export function cookieFrom(r: Response): string {
return r.headers.getSetCookie().map((c) => c.split(";")[0]).filter((c) => !c.endsWith("=")).join("; ");
}
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { assignInitialRole } from "../src/server/auth";
import { syncClaimGroups, groupsFromClaims } from "../src/server/auth/groups";
import { forEachDb } from "./helpers";
import { cookieFrom, makeApp } from "./app";
forEachDb("first sign-up becomes owner, later ones learners", async (db) => {
const t = await makeApp(db);
const ownerCookie = await t.signUp("owner@example.com");
const me = await (await t.call("GET", "/api/me", { cookie: ownerCookie })).json();
expect(me.user.email).toBe("owner@example.com");
expect(me.user.roles).toEqual(["owner"]);
const second = await t.signUp("learner@example.com");
expect((await (await t.call("GET", "/api/me", { cookie: second })).json()).user.roles).toEqual(["learner"]);
expect((await (await t.call("GET", "/api/me")).json()).user).toBeNull();
}, 20_000);
forEachDb("sign in with password and sign out", async (db) => {
const t = await makeApp(db);
await t.signUp("a@example.com", "A", "pw-123456789");
const bad = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "a@example.com", password: "nope-nope-nope" } });
expect(bad.status).toBe(401);
const ok = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "a@example.com", password: "pw-123456789" } });
expect(ok.status).toBe(200);
const cookie = cookieFrom(ok);
expect((await (await t.call("GET", "/api/me", { cookie })).json()).user.name).toBe("A");
const out = await t.call("POST", "/api/auth/sign-out", { cookie, body: {} });
expect(out.status).toBe(200);
// the session row is gone: the old token no longer resolves once the cookie cache is dropped
const sessionOnly = cookie.split("; ").filter((c) => !c.includes("session_data")).join("; ");
expect((await (await t.call("GET", "/api/me", { cookie: sessionOnly })).json()).user).toBeNull();
}, 20_000);
forEachDb("owner claim is exclusive under concurrency", async (db) => {
const roles = await Promise.all(["u1", "u2", "u3", "u4"].map((u) => assignInitialRole(db, u)));
expect(roles.filter((r) => r === "owner").length).toBe(1);
});
forEachDb("SSO provider administration is owner-only", async (db) => {
const t = await makeApp(db);
await t.signUp("owner@example.com");
const learner = await t.signUp("l@example.com");
expect((await t.call("POST", "/api/auth/sso/register", { cookie: learner, body: {} })).status).toBe(403);
expect((await t.call("POST", "/api/auth/sso/register", { body: {} })).status).toBe(403);
}, 20_000);
forEachDb("claim groups sync keeps manual memberships", async (db) => {
await db.sql`INSERT INTO groups (id, name) VALUES ('staff', 'Staff')`;
await db.sql`INSERT INTO group_members (group_id, user_id) VALUES ('staff', 'u1')`;
await syncClaimGroups(db, "u1", ["Staff", "DevOps Team"]);
const ids = async () => (await db.sql`SELECT group_id FROM group_members WHERE user_id = 'u1' ORDER BY group_id`).map((r: { group_id: string }) => r.group_id);
expect(await ids()).toEqual(["devops-team", "staff"]);
await syncClaimGroups(db, "u1", []);
expect(await ids()).toEqual(["staff"]); // manual one stays, claim one goes
});
test("groupsFromClaims reads arrays and strings", () => {
expect(groupsFromClaims({ groups: ["a", "b", "a"] })).toEqual(["a", "b"]);
expect(groupsFromClaims({ roles: "x, y" }, "roles")).toEqual(["x", "y"]);
expect(groupsFromClaims({ extraFields: { groups: ["z"] } })).toEqual(["z"]);
expect(groupsFromClaims(null)).toEqual([]);
});