feat(learner): owner admin API for roles, groups, course access and library curation

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:20:49 +01:00
co-authored by Claude Opus 5.5
parent 668ae379bf
commit a8a8e32bcb
3 changed files with 195 additions and 0 deletions
@@ -0,0 +1,150 @@
import type { Feature, Role, ServerContext } from "@stc/contracts";
import { libraryRepo, type Collection } from "@stc/db";
import { ensureGroup } from "./auth/groups";
import { badRequest, body, json, notFound, ownerOnly, param } from "./http";
const ROLES: Role[] = ["owner", "author", "learner"];
/** Owner-only administration: users and roles, groups, course access lists and library curation. */
export function adminRoutes(ctx: ServerContext, invalidate: (userId?: string) => void): Feature["routes"] {
const { db } = ctx;
const lib = libraryRepo(db);
return {
"/api/admin/users": {
GET: ownerOnly(async () => {
const users = await db.sql`SELECT id, email, name, created_at FROM auth_user ORDER BY created_at, email`;
const roles = await db.sql`SELECT user_id, role FROM user_roles`;
const members = await db.sql`SELECT user_id, group_id FROM group_members`;
return json(users.map((u: { id: string; email: string; name: string; created_at: string }) => ({
id: u.id, email: u.email, name: u.name, createdAt: u.created_at,
roles: roles.filter((r: { user_id: string }) => r.user_id === u.id).map((r: { role: string }) => r.role).sort(),
groups: members.filter((m: { user_id: string }) => m.user_id === u.id).map((m: { group_id: string }) => m.group_id).sort(),
})));
}),
},
"/api/admin/users/:id/roles": {
PUT: ownerOnly(async (req, rc) => {
const id = param(rc, "id");
const b = await body<{ roles: Role[] }>(req);
if (!b || !Array.isArray(b.roles) || b.roles.some((r) => !ROLES.includes(r))) return badRequest("roles must be a list of owner, author, learner");
const exists = await db.sql`SELECT 1 AS x FROM auth_user WHERE id = ${id}`;
if (!exists.length) return notFound();
const roles = [...new Set(b.roles)];
if (!roles.includes("owner")) {
const owners = await db.sql`SELECT user_id FROM user_roles WHERE role = 'owner'`;
const others = owners.filter((o: { user_id: string }) => o.user_id !== id);
if (others.length === 0) return json({ error: "the last owner cannot lose the owner role" }, 409);
}
await db.sql.begin(async (tx) => {
await tx`DELETE FROM user_roles WHERE user_id = ${id}`;
for (const r of roles) await tx`INSERT INTO user_roles (user_id, role) VALUES (${id}, ${r})`;
});
invalidate(id);
return json({ id, roles: roles.sort() });
}),
},
"/api/admin/groups": {
GET: ownerOnly(async () => {
const groups = await db.sql`SELECT id, name FROM groups ORDER BY name`;
const members = await db.sql`SELECT group_id, user_id FROM group_members ORDER BY user_id`;
return json(groups.map((g: { id: string; name: string }) => ({
id: g.id, name: g.name,
members: members.filter((m: { group_id: string }) => m.group_id === g.id).map((m: { user_id: string }) => m.user_id),
})));
}),
POST: ownerOnly(async (req) => {
const b = await body<{ name: string }>(req);
const name = typeof b?.name === "string" ? b.name.trim() : "";
if (!name) return badRequest("name is required");
return json({ id: await ensureGroup(db, name), name }, 201);
}),
},
"/api/admin/groups/:id": {
DELETE: ownerOnly(async (_req, rc) => {
const id = param(rc, "id");
await db.sql.begin(async (tx) => {
await tx`DELETE FROM group_members WHERE group_id = ${id}`;
await tx`DELETE FROM learner_group_claims WHERE group_id = ${id}`;
await tx`DELETE FROM course_acl WHERE principal_type = 'group' AND principal_id = ${id}`;
await tx`DELETE FROM groups WHERE id = ${id}`;
});
invalidate();
return json({ ok: true });
}),
},
"/api/admin/groups/:id/members": {
PUT: ownerOnly(async (req, rc) => {
const id = param(rc, "id");
const b = await body<{ userIds: string[] }>(req);
if (!b || !Array.isArray(b.userIds)) return badRequest("userIds must be a list");
const g = await db.sql`SELECT 1 AS x FROM groups WHERE id = ${id}`;
if (!g.length) return notFound();
await db.sql.begin(async (tx) => {
await tx`DELETE FROM group_members WHERE group_id = ${id}`;
await tx`DELETE FROM learner_group_claims WHERE group_id = ${id}`;
for (const u of new Set(b.userIds.map(String))) await tx`INSERT INTO group_members (group_id, user_id) VALUES (${id}, ${u})`;
});
invalidate();
return json({ id, members: [...new Set(b.userIds)].sort() });
}),
},
"/api/admin/courses/:id/access": {
GET: ownerOnly(async (_req, rc) => {
const id = param(rc, "id");
const course = ctx.courses.get(id);
if (!course) return notFound();
const acl = await db.sql`SELECT principal_type, principal_id FROM course_acl WHERE course_id = ${id} ORDER BY principal_type, principal_id`;
const authors = await db.sql`SELECT user_id FROM course_authors WHERE course_id = ${id} ORDER BY user_id`;
return json({
visibility: course.manifest?.visibility ?? "restricted",
acl: acl.map((a: { principal_type: string; principal_id: string }) => ({ principal_type: a.principal_type, principal_id: a.principal_id })),
authors: authors.map((a: { user_id: string }) => a.user_id),
});
}),
PUT: ownerOnly(async (req, rc) => {
const id = param(rc, "id");
if (!ctx.courses.get(id)) return notFound();
const b = await body<{ acl?: { principal_type: string; principal_id: string }[]; authors?: string[] }>(req);
if (!b) return badRequest("body required");
if (b.acl && b.acl.some((a) => (a.principal_type !== "user" && a.principal_type !== "group") || !a.principal_id)) return badRequest("acl entries need principal_type user|group and principal_id");
await db.sql.begin(async (tx) => {
if (b.acl) {
await tx`DELETE FROM course_acl WHERE course_id = ${id}`;
const seen = new Set<string>();
for (const a of b.acl) {
const k = `${a.principal_type}\0${a.principal_id}`;
if (seen.has(k)) continue;
seen.add(k);
await tx`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES (${id}, ${a.principal_type}, ${a.principal_id})`;
}
}
if (b.authors) {
await tx`DELETE FROM course_authors WHERE course_id = ${id}`;
for (const u of new Set(b.authors)) await tx`INSERT INTO course_authors (course_id, user_id) VALUES (${id}, ${u})`;
}
});
return json({ ok: true });
}),
},
"/api/admin/library": {
GET: ownerOnly(async () => json({ featured: await lib.getFeatured(), collections: await lib.listCollections() })),
PUT: ownerOnly(async (req) => {
const b = await body<{ featured?: string | null; collections?: Collection[] }>(req);
if (!b) return badRequest("body required");
if (b.featured !== undefined) await lib.setFeatured(b.featured || null);
if (b.collections) {
const keep = new Set<string>();
for (const [i, c] of b.collections.entries()) {
if (!c.title?.trim()) return badRequest("every collection needs a title");
const cid = c.id || c.title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "");
keep.add(cid);
await lib.saveCollection({ id: cid, title: c.title.trim(), sort: c.sort ?? i, courseIds: c.courseIds ?? [] });
}
for (const c of await lib.listCollections()) if (!keep.has(c.id)) await lib.deleteCollection(c.id);
}
return json({ featured: await lib.getFeatured(), collections: await lib.listCollections() });
}),
},
};
}
@@ -2,6 +2,7 @@ import type { AuthService, Feature, ServerContext } from "@stc/contracts";
import { createAuth } from "./auth";
import { runMigrations } from "./migrate";
import { json } from "./http";
import { adminRoutes } from "./admin";
/**
* The learner feature: auth, library, reader, notes, progress and exercises.
@@ -20,6 +21,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
"/api/learner/providers": {
GET: async () => json({ emailPassword: true, providers: auth.providers(), sso: await auth.hasSso() }),
},
...adminRoutes(ctx, auth.service.invalidate),
};
return { name: "learner", routes, auth: auth.service };
}
@@ -0,0 +1,43 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { makeApp } from "./app";
forEachDb("admin API is owner-only and manages roles, groups, access and library", async (db) => {
const t = await makeApp(db);
const owner = await t.signUp("[email protected]");
const learner = await t.signUp("[email protected]");
expect((await t.call("GET", "/api/admin/users")).status).toBe(401);
expect((await t.call("GET", "/api/admin/users", { cookie: learner })).status).toBe(403);
const users = await (await t.call("GET", "/api/admin/users", { cookie: owner })).json();
expect(users.map((u: { email: string }) => u.email)).toEqual(["[email protected]", "[email protected]"]);
const [o, l] = users;
// the last owner keeps the role
expect((await t.call("PUT", `/api/admin/users/${o.id}/roles`, { cookie: owner, body: { roles: ["learner"] } })).status).toBe(409);
expect((await t.call("PUT", `/api/admin/users/${l.id}/roles`, { cookie: owner, body: { roles: ["bogus"] } })).status).toBe(400);
const r = await t.call("PUT", `/api/admin/users/${l.id}/roles`, { cookie: owner, body: { roles: ["author", "learner"] } });
expect((await r.json()).roles).toEqual(["author", "learner"]);
expect((await (await t.call("GET", "/api/me", { cookie: learner })).json()).user.roles).toEqual(["author", "learner"]);
const g = await (await t.call("POST", "/api/admin/groups", { cookie: owner, body: { name: "DevOps Team" } })).json();
expect(g.id).toBe("devops-team");
await t.call("PUT", `/api/admin/groups/${g.id}/members`, { cookie: owner, body: { userIds: [l.id] } });
const groups = await (await t.call("GET", "/api/admin/groups", { cookie: owner })).json();
expect(groups).toEqual([{ id: "devops-team", name: "DevOps Team", members: [l.id] }]);
expect((await (await t.call("GET", "/api/me", { cookie: learner })).json()).user.groups).toEqual(["devops-team"]);
await t.call("PUT", "/api/admin/courses/sample/access", { cookie: owner, body: { acl: [{ principal_type: "group", principal_id: g.id }], authors: [l.id] } });
const access = await (await t.call("GET", "/api/admin/courses/sample/access", { cookie: owner })).json();
expect(access).toEqual({ visibility: "public", acl: [{ principal_type: "group", principal_id: "devops-team" }], authors: [l.id] });
expect((await t.call("GET", "/api/admin/courses/nope/access", { cookie: owner })).status).toBe(404);
const libr = await (await t.call("PUT", "/api/admin/library", { cookie: owner, body: { featured: "sample", collections: [{ title: "Getting started", courseIds: ["sample"] }] } })).json();
expect(libr.featured).toBe("sample");
expect(libr.collections).toEqual([{ id: "getting-started", title: "Getting started", sort: 0, courseIds: ["sample"] }]);
const cleared = await (await t.call("PUT", "/api/admin/library", { cookie: owner, body: { collections: [] } })).json();
expect(cleared.collections).toEqual([]);
await t.call("DELETE", `/api/admin/groups/${g.id}`, { cookie: owner });
expect(await (await t.call("GET", "/api/admin/groups", { cookie: owner })).json()).toEqual([]);
}, 30_000);