diff --git a/.env.example b/.env.example
index 04c77ef..9dc1277 100644
--- a/.env.example
+++ b/.env.example
@@ -47,3 +47,7 @@ STUDENT_VERIFICATION_TTL_DAYS=365
# PN_ENTRA_OIDC_ADMIN_GROUP_ID=
# Comma-separated local user IDs that are always Master Admin (break-glass).
IDP_ADMIN_USER_IDS=
+
+# Local development only: see `.env.local.example`. DEV_LOGIN=1 adds the dev sign-in to
+# `vp dev`; startup refuses it unless BETTER_AUTH_URL is localhost.
+# DEV_LOGIN=1
diff --git a/.env.local.example b/.env.local.example
new file mode 100644
index 0000000..5d6ffb3
--- /dev/null
+++ b/.env.local.example
@@ -0,0 +1,21 @@
+# Local development against the Docker Compose database. Copy to `.env.local`, then run
+# `vp run dev:setup` once and `vp run dev`. Everything here is for your machine only.
+DB_HOST=localhost
+DB_PORT=55432
+DB_USER=postgres
+DB_PASS=postgres
+DB_NAME=polinetwork_auth
+
+BETTER_AUTH_URL=http://localhost:3000
+# Public on purpose: startup refuses this value anywhere but localhost.
+BETTER_AUTH_SECRET=local-development-only-secret-never-deploy-this
+
+# The dev admin persona holds Master Admin through this allowlist.
+IDP_ADMIN_USER_IDS=dev-admin
+
+# One-click test personas on the login page and at /api/dev/login. Development builds
+# only; startup refuses it unless BETTER_AUTH_URL is localhost.
+DEV_LOGIN=1
+
+# Provider credentials are optional locally: copy them from `.env.example` if you need a
+# real sign-in, otherwise the dev personas cover it.
diff --git a/README.md b/README.md
index 54fa76b..0442d18 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,18 @@ A standalone TanStack Start and Better Auth identity provider. The backend remai
Use Node and pnpm through Vite+.
+The quickest way in needs only Docker and no provider credentials:
+
+1. Run `vp install` and copy `.env.local.example` to `.env.local`.
+2. Run `vp run dev:setup`. It starts PostgreSQL with `compose.yaml` (on `localhost:55432`), applies the migrations, and seeds four test personas plus 60 fake people. Run `vp run dev:seed` again whenever you want them back; it updates them in place.
+3. Run `vp run dev`, open `http://localhost:3000`, and pick a persona under **Dev sign-in** on the login page.
+
+The personas are Ada Admin (Master Admin, through `IDP_ADMIN_USER_IDS=dev-admin`), Sam Staff (a role with only `idp:users:read` and `idp:roles:read`), Stella Student (a verified Polimi student with Telegram linked) and Nico Newcomer (Google only, nothing else). Socio and Direttivo cannot be personas: they are always checked live against Entra, never trusted from the database. Scripts and agents can skip the page: opening `/api/dev/login?as=staff&redirect=/users` signs in and redirects, `/api/dev/login` alone lists the personas, and with curl `curl -c jar 'localhost:3000/api/dev/login?as=admin'` stores the session cookie. An OpenID Connect sign-in started from an application resumes after picking a persona.
+
+The dev sign-in exists only in `vp dev`. Production builds do not contain it, and `vp run build` fails if they ever do. It also stays off unless `.env.local` sets `DEV_LOGIN=1`, which startup refuses unless `BETTER_AUTH_URL` is localhost. Startup likewise refuses the public example secret from `.env.local.example` anywhere but localhost.
+
+To use your own database or real providers instead:
+
1. Run `vp install`.
2. Copy `.env.example` to `.env.local`, set a random secret, point `DB_*` at a **new, separate PostgreSQL database**, and configure an explicit admin group or `IDP_ADMIN_USER_IDS` bootstrap allowlist.
3. Set `BETTER_AUTH_URL=http://localhost:3000` for local development.
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..f91ebf4
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,23 @@
+# Local development database. `vp run dev:setup` starts it, then migrates and seeds it.
+# It listens on localhost only; never point anything but local development at it.
+name: polinetwork-auth
+
+services:
+ db:
+ image: postgres:17-alpine
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: polinetwork_auth
+ ports:
+ - "127.0.0.1:55432:5432"
+ volumes:
+ - db:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d polinetwork_auth"]
+ interval: 2s
+ timeout: 3s
+ retries: 15
+
+volumes:
+ db:
diff --git a/package.json b/package.json
index fac29b7..3956486 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,9 @@
},
"scripts": {
"dev": "dotenv -e .env.local -- env NODE_OPTIONS='--import ./instrument.server.mjs' vp dev",
+ "dev:db": "docker compose up -d --wait",
+ "dev:setup": "docker compose up -d --wait && drizzle-kit migrate && vp run dev:seed",
+ "dev:seed": "dotenv -e .env.local -- tsx src/dev/seed.ts",
"generate-routes": "tsr generate",
"build": "vp build && node scripts/check-server-bundle.mjs && cp instrument.server.mjs .output/server",
"preview": "vp preview",
diff --git a/scripts/check-server-bundle.mjs b/scripts/check-server-bundle.mjs
index 91bc43f..0e9300b 100644
--- a/scripts/check-server-bundle.mjs
+++ b/scripts/check-server-bundle.mjs
@@ -57,6 +57,24 @@ export function findBundleProblems(sources) {
return problems;
}
+/**
+ * The dev sign-in (`src/dev/`) is imported only behind `import.meta.env.DEV`, so a
+ * production build must not contain it. Its shared marker, `DEV_LOGIN_KIND` in
+ * `src/dev/shared.ts`, is how a leak would show: anything that pulls the dev code in
+ * pulls the marker in with it.
+ */
+export const DEV_LOGIN_MARKER = "pn-dev-login";
+
+/** @param {{ path: string, code: string }[]} sources */
+export function findDevLoginLeaks(sources) {
+ return sources
+ .filter(({ code }) => code.includes(DEV_LOGIN_MARKER))
+ .map(
+ ({ path }) =>
+ `the development-only sign-in is bundled into ${path}. Import \`src/dev/\` only behind \`import.meta.env.DEV\`.`,
+ );
+}
+
async function serverChunks(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
@@ -69,15 +87,27 @@ async function serverChunks(directory) {
return files.flat();
}
-if (process.argv[1] === fileURLToPath(import.meta.url)) {
- const serverDir = fileURLToPath(new URL("../.output/server", import.meta.url));
- const chunks = await serverChunks(serverDir);
- const sources = await Promise.all(
+async function readChunks(directory) {
+ const chunks = await serverChunks(directory);
+ return Promise.all(
chunks.map(async (path) => ({
- path: path.slice(serverDir.length + 1),
+ path: path.slice(directory.length + 1),
code: await readFile(path, "utf8"),
})),
);
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ const sources = await readChunks(fileURLToPath(new URL("../.output/server", import.meta.url)));
+ const browserSources = await readChunks(
+ fileURLToPath(new URL("../.output/public", import.meta.url)),
+ );
+
+ const leaks = findDevLoginLeaks([...sources, ...browserSources]);
+ if (leaks.length) {
+ console.error(`Production build check failed:\n- ${leaks.join("\n- ")}`);
+ process.exit(1);
+ }
const problems = findBundleProblems(sources);
if (problems.length) {
@@ -89,5 +119,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exit(1);
}
- console.info("Server bundle check passed: per-request state is not duplicated.");
+ console.info(
+ "Server bundle check passed: per-request state is not duplicated and the dev sign-in is absent.",
+ );
}
diff --git a/scripts/check-server-bundle.test.mjs b/scripts/check-server-bundle.test.mjs
index 5ab04e8..0d8c435 100644
--- a/scripts/check-server-bundle.test.mjs
+++ b/scripts/check-server-bundle.test.mjs
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
-import { findBundleProblems } from "./check-server-bundle.mjs";
+import { DEV_LOGIN_KIND } from "../src/dev/shared.ts";
+import { DEV_LOGIN_MARKER, findBundleProblems, findDevLoginLeaks } from "./check-server-bundle.mjs";
const core = `function defineRequestState(initFn) {}
throw new Error("No request state found. Please make sure...");`;
@@ -39,4 +40,22 @@ describe("server bundle check", () => {
it("fails when it can no longer find what it guards", () => {
expect(findBundleProblems([{ path: "_ssr/router.mjs", code: "" }])).toHaveLength(2);
});
+
+ it("looks for the same marker the dev sign-in carries", () => {
+ expect(DEV_LOGIN_MARKER).toBe(DEV_LOGIN_KIND);
+ });
+
+ it("fails when the dev sign-in reaches a production bundle", () => {
+ const sources = [
+ { path: "_ssr/router.mjs", code: core },
+ {
+ path: "assets/index.js",
+ code: `fetch("/api/dev/login").then(r => r.kind === "${DEV_LOGIN_KIND}")`,
+ },
+ ];
+
+ expect(findDevLoginLeaks(sources)).toEqual([
+ "the development-only sign-in is bundled into assets/index.js. Import `src/dev/` only behind `import.meta.env.DEV`.",
+ ]);
+ });
});
diff --git a/scripts/security-config.mjs b/scripts/security-config.mjs
index 7db582f..e3c3da8 100644
--- a/scripts/security-config.mjs
+++ b/scripts/security-config.mjs
@@ -2,6 +2,16 @@ import { z } from "zod";
const optional = (schema) =>
z.preprocess((value) => (value === "" ? undefined : value), schema.optional());
+const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
+// Published in `.env.local.example` so a fresh clone runs as is; worthless as a secret.
+const LOCAL_EXAMPLE_SECRET = "local-development-only-secret-never-deploy-this";
+const isLoopback = (value) => {
+ try {
+ return LOOPBACK_HOSTS.includes(new URL(value).hostname);
+ } catch {
+ return false;
+ }
+};
const schema = z
.object({
BETTER_AUTH_URL: z
@@ -12,8 +22,7 @@ const schema = z
if (url.username || url.password) return false;
// Cleartext HTTP would expose sessions and identity claims, so it is only ever
// tolerated for local development.
- if (url.protocol === "http:")
- return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
+ if (url.protocol === "http:") return LOOPBACK_HOSTS.includes(url.hostname);
return url.protocol === "https:";
}, "BETTER_AUTH_URL must be an HTTPS URL without credentials, or HTTP on localhost."),
BETTER_AUTH_SECRET: z.string().trim().min(32),
@@ -38,6 +47,10 @@ const schema = z
.default("")
.transform((value) => (value.trim() === "" ? [] : value.split(",").map((id) => id.trim())))
.pipe(z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/))),
+ // Turns on the dev sign-in in `vp dev`. Production builds do not contain it at all;
+ // this only keeps a stray setting from ever pairing with a public origin.
+ DEV_LOGIN: optional(z.literal("1", { error: "DEV_LOGIN must be 1 or unset." })),
+ NODE_ENV: z.string().optional(),
})
.superRefine((config, context) => {
for (const keys of [
@@ -63,6 +76,17 @@ const schema = z
code: "custom",
message: "PN Entra requires tenant, client ID and client secret together.",
});
+ const local = config.NODE_ENV !== "production" && isLoopback(config.BETTER_AUTH_URL);
+ if (config.BETTER_AUTH_SECRET === LOCAL_EXAMPLE_SECRET && !local)
+ context.addIssue({
+ code: "custom",
+ message: "BETTER_AUTH_SECRET is the public example from .env.local.example.",
+ });
+ if (config.DEV_LOGIN && !local)
+ context.addIssue({
+ code: "custom",
+ message: "DEV_LOGIN is only allowed outside production, with BETTER_AUTH_URL on localhost.",
+ });
if (!config.PN_ENTRA_OIDC_ADMIN_GROUP_ID && config.IDP_ADMIN_USER_IDS.length === 0)
context.addIssue({
code: "custom",
diff --git a/src/auth/security-config.test.ts b/src/auth/security-config.test.ts
index c62be0b..9e8ee5f 100644
--- a/src/auth/security-config.test.ts
+++ b/src/auth/security-config.test.ts
@@ -62,4 +62,40 @@ describe("security configuration startup validation", () => {
).not.toThrow();
},
);
+ it("accepts the dev sign-in on a local development origin", () => {
+ expect(() =>
+ validateSecurityConfiguration({
+ IDP_ADMIN_USER_IDS: "dev-admin",
+ BETTER_AUTH_URL: "http://localhost:3000",
+ DEV_LOGIN: "1",
+ NODE_ENV: "development",
+ }),
+ ).not.toThrow();
+ });
+ it("refuses the public example secret anywhere but local development", () => {
+ const secret = { BETTER_AUTH_SECRET: "local-development-only-secret-never-deploy-this" };
+ expect(() =>
+ validate({ IDP_ADMIN_USER_IDS: "root", BETTER_AUTH_URL: "http://localhost:3000", ...secret }),
+ ).not.toThrow();
+ expect(() => validate({ IDP_ADMIN_USER_IDS: "root", ...secret })).toThrow();
+ expect(() =>
+ validate({
+ IDP_ADMIN_USER_IDS: "root",
+ BETTER_AUTH_URL: "http://localhost:3000",
+ NODE_ENV: "production",
+ ...secret,
+ }),
+ ).toThrow();
+ });
+ it.each([
+ { DEV_LOGIN: "1" },
+ { DEV_LOGIN: "1", BETTER_AUTH_URL: "https://auth.polinetwork.org" },
+ { DEV_LOGIN: "1", BETTER_AUTH_URL: "https://localhost.attacker.example" },
+ { DEV_LOGIN: "1", BETTER_AUTH_URL: "http://localhost:3000", NODE_ENV: "production" },
+ { DEV_LOGIN: "true", BETTER_AUTH_URL: "http://localhost:3000" },
+ ])("refuses the dev sign-in anywhere but local development: %j", (invalid) => {
+ expect(() =>
+ validateSecurityConfiguration({ IDP_ADMIN_USER_IDS: "root", ...invalid }),
+ ).toThrow();
+ });
});
diff --git a/src/components/dev-login.tsx b/src/components/dev-login.tsx
new file mode 100644
index 0000000..03f7353
--- /dev/null
+++ b/src/components/dev-login.tsx
@@ -0,0 +1,74 @@
+import { useEffect, useState } from "react";
+import { FlaskConical, TriangleAlert } from "lucide-react";
+import { cn } from "cn";
+import { buttonVariants } from "@/components/ui/button";
+import {
+ DEV_LOGIN_KIND,
+ DEV_LOGIN_PATH,
+ devLoginHref,
+ type DevLoginListing,
+ type DevPersonaSummary,
+} from "@/dev/shared";
+
+/**
+ * One-click sign-in as a test persona. Rendered only behind `import.meta.env.DEV`, and only
+ * once the server confirms `DEV_LOGIN=1`, so it never appears in production.
+ */
+export function DevLoginPanel({ redirect }: { redirect: string }) {
+ const [personas, setPersonas] = useState([]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ fetch(DEV_LOGIN_PATH, { signal: controller.signal })
+ .then((response) => (response.ok ? (response.json() as Promise) : null))
+ .then((listing) => {
+ if (listing?.kind === DEV_LOGIN_KIND) setPersonas(listing.personas);
+ })
+ .catch(() => {
+ /* Dev sign-in is off: show nothing. */
+ });
+ return () => controller.abort();
+ }, []);
+
+ if (!personas.length) return null;
+ return (
+
+
+
+
+ Dev sign-in
+
+
+ Local development only. Signs you in as a test person, no account needed.
+
+
+
+
+ );
+}
diff --git a/src/components/login-page.tsx b/src/components/login-page.tsx
index c7917bb..bdeb62f 100644
--- a/src/components/login-page.tsx
+++ b/src/components/login-page.tsx
@@ -3,6 +3,7 @@ import { useLocation } from "@tanstack/react-router";
import { cn } from "cn";
import { Building2, Fingerprint, Link2, LoaderCircle } from "lucide-react";
import { authClient } from "@/auth/client";
+import { DevLoginPanel } from "@/components/dev-login";
import { GoogleIcon } from "@/components/google-icon";
import { AppLogo } from "@/components/oidc/app-logo";
import { ThemeSwitch } from "@/components/theme-switch";
@@ -256,6 +257,13 @@ export function LoginPage({ callbackURL = "/" }: { callbackURL?: string }) {
+ {import.meta.env.DEV && (
+
+ )}
);
}
diff --git a/src/dev/login.ts b/src/dev/login.ts
new file mode 100644
index 0000000..60a8175
--- /dev/null
+++ b/src/dev/login.ts
@@ -0,0 +1,69 @@
+/**
+ * Signs the browser in as a fixed test persona, for local development. Reached only through
+ * `src/routes/api/dev/login.ts`, which imports this module behind `import.meta.env.DEV`
+ * so production builds never contain it. On top of that it only answers when `.env.local`
+ * sets `DEV_LOGIN=1`, which `scripts/security-config.mjs` refuses outside localhost.
+ */
+import { makeSignature } from "better-auth/crypto";
+import { auth } from "../auth";
+import { findPersona, listPersonas, saveDevUser } from "./personas";
+import { DEV_LOGIN_KIND, localRedirect, type DevLoginListing } from "./shared";
+
+const noStore = { "Cache-Control": "no-store" };
+
+function serializeCookie(
+ name: string,
+ value: string,
+ attributes: { path?: string; secure?: boolean; sameSite?: string; maxAge?: number },
+) {
+ const sameSite = attributes.sameSite ?? "lax";
+ return [
+ `${name}=${encodeURIComponent(value)}`,
+ `Path=${attributes.path ?? "/"}`,
+ "HttpOnly",
+ `SameSite=${sameSite[0]!.toUpperCase()}${sameSite.slice(1).toLowerCase()}`,
+ ...(attributes.secure ? ["Secure"] : []),
+ ...(attributes.maxAge ? [`Max-Age=${attributes.maxAge}`] : []),
+ ].join("; ");
+}
+
+/**
+ * `GET ?as=&redirect=` signs in and redirects; without `as` it lists the
+ * personas. A real session is created through Better Auth, so everything downstream (the
+ * session hook, RBAC, API guards) behaves exactly as after a normal sign-in.
+ */
+export async function handleDevLogin(request: Request): Promise {
+ if (process.env.DEV_LOGIN !== "1")
+ return new Response("Not found", { status: 404, headers: noStore });
+
+ const url = new URL(request.url);
+ const key = url.searchParams.get("as");
+ if (!key) {
+ const listing: DevLoginListing = { kind: DEV_LOGIN_KIND, personas: listPersonas() };
+ return Response.json(listing, { headers: noStore });
+ }
+
+ const persona = findPersona(key);
+ if (!persona)
+ return Response.json(
+ { error: `Unknown persona "${key}".`, personas: listPersonas().map((entry) => entry.key) },
+ { status: 400, headers: noStore },
+ );
+
+ await saveDevUser(persona);
+ const context = await auth.$context;
+ const session = await context.internalAdapter.createSession(persona.id);
+ const cookie = context.authCookies.sessionToken;
+ const signed = `${session.token}.${await makeSignature(session.token, context.secret)}`;
+ return new Response(null, {
+ status: 303,
+ headers: {
+ ...noStore,
+ Location: localRedirect(url.searchParams.get("redirect")),
+ "Set-Cookie": serializeCookie(cookie.name, signed, {
+ ...cookie.attributes,
+ maxAge: context.sessionConfig.expiresIn,
+ }),
+ },
+ });
+}
diff --git a/src/dev/personas.ts b/src/dev/personas.ts
new file mode 100644
index 0000000..5ea7f7a
--- /dev/null
+++ b/src/dev/personas.ts
@@ -0,0 +1,205 @@
+/**
+ * Test people for local development, written straight into the database. Only the dev
+ * sign-in endpoint and `src/dev/seed.ts` import this module, and production builds include
+ * neither, so nothing here can run against production.
+ */
+import { inArray, eq } from "drizzle-orm";
+import { db } from "../db";
+import {
+ account,
+ identityEvidence,
+ permission,
+ role,
+ rolePermission,
+ user,
+ userRole,
+} from "../db/schema";
+import { env } from "../env";
+import type { DevPersonaSummary } from "./shared";
+
+type Provider = "google" | "pn-entra" | "telegram" | "polimi-email";
+
+export type DevAccount = {
+ providerId: Provider;
+ /** The provider's subject: an opaque ID, a Telegram user ID, or the Polimi address. */
+ accountId: string;
+ /** Identity evidence states, such as `student`. Only Polimi evidence is trusted from the
+ * database; Socio and Direttivo are always rechecked against Entra, so here they are
+ * display data only. */
+ states?: string[];
+ /** The address shown for a PoliNetwork account. */
+ email?: string;
+};
+
+export type DevRole = { key: string; name: string; description: string; permissions: string[] };
+
+export type DevUser = {
+ id: string;
+ name: string;
+ email: string;
+ createdAt?: Date;
+ accounts: DevAccount[];
+ roles?: DevRole[];
+};
+
+type DevPersona = DevUser & { key: string; description: string };
+
+const ISSUERS: Record = {
+ google: "https://accounts.google.com",
+ // Without a configured tenant no Entra evidence is trusted anyway, so any well-formed
+ // issuer will do for display.
+ "pn-entra": `https://login.microsoftonline.com/${env.PN_ENTRA_TENANT_ID ?? "00000000-0000-0000-0000-000000000000"}/v2.0`,
+ telegram: "https://oauth.telegram.org",
+ "polimi-email": "https://mail.polimi.it",
+};
+
+/** The persona that holds Master Admin, when `.env.local` lists it in `IDP_ADMIN_USER_IDS`. */
+export const DEV_ADMIN_ID = "dev-admin";
+
+export const DEV_PERSONAS: DevPersona[] = [
+ {
+ key: "admin",
+ id: DEV_ADMIN_ID,
+ name: "Ada Admin",
+ email: "admin@polinetwork.test",
+ description: "Master Admin: every permission",
+ accounts: [{ providerId: "google", accountId: "dev-admin" }],
+ },
+ {
+ key: "staff",
+ id: "dev-staff",
+ name: "Sam Staff",
+ email: "staff@polinetwork.test",
+ description: "Can read the user directory and roles, nothing else",
+ accounts: [{ providerId: "google", accountId: "dev-staff" }],
+ roles: [
+ {
+ key: "dev-staff",
+ name: "Dev staff",
+ description: "Created by the dev sign-in for the staff persona.",
+ permissions: ["idp:users:read", "idp:roles:read"],
+ },
+ ],
+ },
+ {
+ key: "student",
+ id: "dev-student",
+ name: "Stella Student",
+ email: "student@polinetwork.test",
+ description: "Verified Polimi student with Telegram linked, no admin access",
+ accounts: [
+ { providerId: "google", accountId: "dev-student" },
+ {
+ providerId: "polimi-email",
+ accountId: "stella.student@mail.polimi.it",
+ states: ["student"],
+ },
+ { providerId: "telegram", accountId: "100000001" },
+ ],
+ },
+ {
+ key: "member",
+ id: "dev-member",
+ name: "Nico Newcomer",
+ email: "member@polinetwork.test",
+ description: "Just signed up with Google: no statuses, no roles",
+ accounts: [{ providerId: "google", accountId: "dev-member" }],
+ },
+];
+
+export function findPersona(key: string) {
+ return DEV_PERSONAS.find((persona) => persona.key === key);
+}
+
+export function listPersonas(): DevPersonaSummary[] {
+ return DEV_PERSONAS.map(({ key, name, description, id }) => ({
+ key,
+ name,
+ description,
+ ...(id === DEV_ADMIN_ID && !env.IDP_ADMIN_USER_IDS.includes(DEV_ADMIN_ID)
+ ? {
+ warning: `Add ${DEV_ADMIN_ID} to IDP_ADMIN_USER_IDS in .env.local to make this Master Admin.`,
+ }
+ : {}),
+ }));
+}
+
+type Writer = Parameters[0]>[0];
+
+async function saveRole(transaction: Writer, userId: string, entry: DevRole) {
+ await transaction
+ .insert(role)
+ .values({ id: entry.key, key: entry.key, name: entry.name, description: entry.description })
+ .onConflictDoNothing({ target: role.key });
+ const [saved] = await transaction
+ .select({ id: role.id })
+ .from(role)
+ .where(eq(role.key, entry.key));
+ const permissions = await transaction
+ .select({ id: permission.id })
+ .from(permission)
+ .where(inArray(permission.key, entry.permissions));
+ // Only adds what is missing, so edits made through the UI while testing survive.
+ if (permissions.length)
+ await transaction
+ .insert(rolePermission)
+ .values(permissions.map(({ id }) => ({ roleId: saved!.id, permissionId: id })))
+ .onConflictDoNothing();
+ await transaction
+ .insert(userRole)
+ .values({ userId, roleId: saved!.id, assignedBy: "dev-login" })
+ .onConflictDoNothing();
+}
+
+/** Creates or refreshes a test person, their linked accounts, evidence, and roles. */
+export async function saveDevUser(entry: DevUser) {
+ const now = new Date();
+ const validUntil = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1_000);
+ await db.transaction(async (transaction) => {
+ await transaction
+ .insert(user)
+ .values({
+ id: entry.id,
+ name: entry.name,
+ email: entry.email,
+ emailVerified: false,
+ ...(entry.createdAt ? { createdAt: entry.createdAt, updatedAt: entry.createdAt } : {}),
+ })
+ .onConflictDoUpdate({ target: user.id, set: { name: entry.name, email: entry.email } });
+
+ for (const linked of entry.accounts) {
+ const issuer = ISSUERS[linked.providerId];
+ await transaction
+ .insert(account)
+ .values({
+ id: `${entry.id}:${linked.providerId}`,
+ accountId: linked.accountId,
+ providerId: linked.providerId,
+ issuer,
+ userId: entry.id,
+ updatedAt: now,
+ })
+ .onConflictDoNothing();
+ if (linked.providerId === "google") continue;
+ // Evidence is refreshed on every save so it never expires mid-session.
+ const proof = {
+ issuer,
+ subject: linked.accountId,
+ providerId: linked.providerId,
+ states: linked.states ?? [],
+ validUntil,
+ telegramId: linked.providerId === "telegram" ? linked.accountId : null,
+ email: linked.email ?? null,
+ };
+ await transaction
+ .insert(identityEvidence)
+ .values(proof)
+ .onConflictDoUpdate({
+ target: [identityEvidence.issuer, identityEvidence.subject],
+ set: proof,
+ });
+ }
+
+ for (const entryRole of entry.roles ?? []) await saveRole(transaction, entry.id, entryRole);
+ });
+}
diff --git a/src/dev/seed.ts b/src/dev/seed.ts
new file mode 100644
index 0000000..b98dc04
--- /dev/null
+++ b/src/dev/seed.ts
@@ -0,0 +1,61 @@
+/**
+ * Fills a local development database with the dev personas and a few dozen fake people,
+ * so the user directory has something to show. `vp run dev:seed`; safe to run again, since
+ * every fake person has a fixed ID and is updated in place.
+ */
+import { faker } from "@faker-js/faker";
+import { env } from "../env";
+import { DEV_PERSONAS, saveDevUser, type DevAccount } from "./personas";
+
+const FAKE_PEOPLE = 60;
+
+const local = ["localhost", "127.0.0.1", "[::1]"];
+if (!local.includes(new URL(env.BETTER_AUTH_URL).hostname)) {
+ console.error("Refusing to seed: BETTER_AUTH_URL is not a local development origin.");
+ process.exit(1);
+}
+
+// A fixed seed keeps the same people across runs, so links to them keep working.
+faker.seed(20_260_930);
+
+for (const persona of DEV_PERSONAS) await saveDevUser(persona);
+
+for (let index = 1; index <= FAKE_PEOPLE; index++) {
+ const id = `dev-seed-${String(index).padStart(3, "0")}`;
+ const firstName = faker.person.firstName();
+ const lastName = faker.person.lastName();
+ const dotted = `${firstName}.${lastName}`.toLowerCase().replace(/[^a-z.]/g, "");
+ const accounts: DevAccount[] = [];
+ const polinetwork = faker.datatype.boolean(0.4);
+ if (polinetwork)
+ accounts.push({
+ providerId: "pn-entra",
+ accountId: faker.string.uuid(),
+ email: `${dotted}@polinetwork.org`,
+ states: faker.datatype.boolean(0.6) ? ["socio"] : [],
+ });
+ if (!polinetwork || faker.datatype.boolean(0.3))
+ accounts.push({ providerId: "google", accountId: faker.string.numeric(21) });
+ if (faker.datatype.boolean(0.5))
+ accounts.push({
+ providerId: "polimi-email",
+ accountId: `${dotted}@mail.polimi.it`,
+ states: ["student"],
+ });
+ if (faker.datatype.boolean(0.6))
+ accounts.push({
+ providerId: "telegram",
+ accountId: faker.string.numeric({ length: 9, allowLeadingZeros: false }),
+ });
+
+ await saveDevUser({
+ id,
+ name: `${firstName} ${lastName}`,
+ email: `${dotted}.${index}@example.com`,
+ createdAt: faker.date.past({ years: 2 }),
+ accounts,
+ });
+}
+
+console.info(`Seeded ${DEV_PERSONAS.length} dev personas and ${FAKE_PEOPLE} fake people.`);
+process.exit(0);
diff --git a/src/dev/shared.test.ts b/src/dev/shared.test.ts
new file mode 100644
index 0000000..29a8d3c
--- /dev/null
+++ b/src/dev/shared.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vite-plus/test";
+import { devLoginHref, localRedirect } from "./shared";
+
+describe("dev sign-in redirect", () => {
+ it.each(["/", "/users", "/users?socio=yes#top", "/api/auth/oauth2/authorize?client_id=x"])(
+ "keeps the local path %s",
+ (path) => {
+ expect(localRedirect(path)).toBe(path);
+ },
+ );
+
+ it.each([
+ null,
+ "",
+ "users",
+ "https://evil.example/",
+ "//evil.example/",
+ "/\\evil.example/",
+ "javascript:alert(1)",
+ ])("falls back to the home page for %j", (value) => {
+ expect(localRedirect(value)).toBe("/");
+ });
+
+ it("builds a link that survives the round trip", () => {
+ const url = new URL(devLoginHref("staff", "/users?socio=yes&page=2"), "http://localhost");
+ expect(url.pathname).toBe("/api/dev/login");
+ expect(url.searchParams.get("as")).toBe("staff");
+ expect(localRedirect(url.searchParams.get("redirect"))).toBe("/users?socio=yes&page=2");
+ });
+});
diff --git a/src/dev/shared.ts b/src/dev/shared.ts
new file mode 100644
index 0000000..e678e50
--- /dev/null
+++ b/src/dev/shared.ts
@@ -0,0 +1,34 @@
+/**
+ * Shared by the dev sign-in endpoint and the login page panel. Development builds only:
+ * `scripts/check-server-bundle.mjs` fails a production build that still contains
+ * `DEV_LOGIN_KIND`, which is how a leak of this code into production would show up.
+ */
+export const DEV_LOGIN_KIND = "pn-dev-login";
+
+export const DEV_LOGIN_PATH = "/api/dev/login";
+
+export type DevPersonaSummary = {
+ key: string;
+ name: string;
+ description: string;
+ /** Set when the persona is missing something it needs from `.env.local`. */
+ warning?: string;
+};
+
+export type DevLoginListing = { kind: typeof DEV_LOGIN_KIND; personas: DevPersonaSummary[] };
+
+/**
+ * Keeps the post-sign-in redirect on this origin. Anything that is not a plain local path,
+ * including protocol-relative and backslash tricks, falls back to the home page.
+ */
+export function localRedirect(value: string | null | undefined) {
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\"))
+ return "/";
+ const base = "http://dev.invalid";
+ const url = new URL(value, base);
+ return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : "/";
+}
+
+export function devLoginHref(key: string, redirect: string) {
+ return `${DEV_LOGIN_PATH}?${new URLSearchParams({ as: key, redirect })}`;
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 4f9d434..3127e33 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -31,6 +31,7 @@ import { Route as AccessRolesRoleIdRouteImport } from './routes/access/roles/$ro
import { Route as AccessRolesNewRouteImport } from './routes/access/roles/new'
import { Route as ApiAccountsUnlinkRouteImport } from './routes/api/accounts/unlink'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
+import { Route as ApiDevLoginRouteImport } from './routes/api/dev/login'
import { Route as ApiIdpAccessRouteImport } from './routes/api/idp/access'
import { Route as ApiOidcClientUpdateRouteImport } from './routes/api/oidc/client-update'
import { Route as ApiOidcClientsRouteImport } from './routes/api/oidc/clients'
@@ -153,6 +154,11 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiDevLoginRoute = ApiDevLoginRouteImport.update({
+ id: '/api/dev/login',
+ path: '/api/dev/login',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiIdpAccessRoute = ApiIdpAccessRouteImport.update({
id: '/api/idp/access',
path: '/api/idp/access',
@@ -225,6 +231,7 @@ export interface FileRoutesByFullPath {
'/access/roles/new': typeof AccessRolesNewRoute
'/api/accounts/unlink': typeof ApiAccountsUnlinkRoute
'/api/auth/$': typeof ApiAuthSplatRoute
+ '/api/dev/login': typeof ApiDevLoginRoute
'/api/idp/access': typeof ApiIdpAccessRoute
'/api/oidc/client-update': typeof ApiOidcClientUpdateRoute
'/api/oidc/clients': typeof ApiOidcClientsRoute
@@ -256,6 +263,7 @@ export interface FileRoutesByTo {
'/access/roles/new': typeof AccessRolesNewRoute
'/api/accounts/unlink': typeof ApiAccountsUnlinkRoute
'/api/auth/$': typeof ApiAuthSplatRoute
+ '/api/dev/login': typeof ApiDevLoginRoute
'/api/idp/access': typeof ApiIdpAccessRoute
'/api/oidc/client-update': typeof ApiOidcClientUpdateRoute
'/api/oidc/clients': typeof ApiOidcClientsRoute
@@ -291,6 +299,7 @@ export interface FileRoutesById {
'/access/roles/new': typeof AccessRolesNewRoute
'/api/accounts/unlink': typeof ApiAccountsUnlinkRoute
'/api/auth/$': typeof ApiAuthSplatRoute
+ '/api/dev/login': typeof ApiDevLoginRoute
'/api/idp/access': typeof ApiIdpAccessRoute
'/api/oidc/client-update': typeof ApiOidcClientUpdateRoute
'/api/oidc/clients': typeof ApiOidcClientsRoute
@@ -327,6 +336,7 @@ export interface FileRouteTypes {
| '/access/roles/new'
| '/api/accounts/unlink'
| '/api/auth/$'
+ | '/api/dev/login'
| '/api/idp/access'
| '/api/oidc/client-update'
| '/api/oidc/clients'
@@ -358,6 +368,7 @@ export interface FileRouteTypes {
| '/access/roles/new'
| '/api/accounts/unlink'
| '/api/auth/$'
+ | '/api/dev/login'
| '/api/idp/access'
| '/api/oidc/client-update'
| '/api/oidc/clients'
@@ -392,6 +403,7 @@ export interface FileRouteTypes {
| '/access/roles/new'
| '/api/accounts/unlink'
| '/api/auth/$'
+ | '/api/dev/login'
| '/api/idp/access'
| '/api/oidc/client-update'
| '/api/oidc/clients'
@@ -417,6 +429,7 @@ export interface RootRouteChildren {
ApiStudentVerificationRoute: typeof ApiStudentVerificationRoute
ApiAccountsUnlinkRoute: typeof ApiAccountsUnlinkRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
+ ApiDevLoginRoute: typeof ApiDevLoginRoute
ApiIdpAccessRoute: typeof ApiIdpAccessRoute
ApiOidcClientUpdateRoute: typeof ApiOidcClientUpdateRoute
ApiOidcClientsRoute: typeof ApiOidcClientsRoute
@@ -585,6 +598,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/dev/login': {
+ id: '/api/dev/login'
+ path: '/api/dev/login'
+ fullPath: '/api/dev/login'
+ preLoaderRoute: typeof ApiDevLoginRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/idp/access': {
id: '/api/idp/access'
path: '/api/idp/access'
@@ -722,6 +742,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiStudentVerificationRoute: ApiStudentVerificationRoute,
ApiAccountsUnlinkRoute: ApiAccountsUnlinkRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
+ ApiDevLoginRoute: ApiDevLoginRoute,
ApiIdpAccessRoute: ApiIdpAccessRoute,
ApiOidcClientUpdateRoute: ApiOidcClientUpdateRoute,
ApiOidcClientsRoute: ApiOidcClientsRoute,
diff --git a/src/routes/api/dev/login.ts b/src/routes/api/dev/login.ts
new file mode 100644
index 0000000..798472c
--- /dev/null
+++ b/src/routes/api/dev/login.ts
@@ -0,0 +1,15 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/api/dev/login")({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ // `import.meta.env.DEV` is false in `vp build`, so the import below is dead code
+ // there and the dev sign-in never reaches a production bundle.
+ if (!import.meta.env.DEV) return new Response("Not found", { status: 404 });
+ const { handleDevLogin } = await import("@/dev/login");
+ return handleDevLogin(request);
+ },
+ },
+ },
+});