From 63c7d83a4306d1bb60aa5aaf51b289acbc8353b9 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:05:03 +0100 Subject: [PATCH] Add DEMO_PERSONAS flag and server-owned demo actor roster Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .env.example | 4 +++ apps/server/src/auth/config.test.ts | 49 +++++++++++++++++++++++++++++ apps/server/src/auth/config.ts | 7 +++++ apps/server/src/demo/roster.test.ts | 29 +++++++++++++++++ apps/server/src/demo/roster.ts | 24 ++++++++++++++ docs/self-hosting.md | 1 + 6 files changed, 114 insertions(+) create mode 100644 apps/server/src/auth/config.test.ts create mode 100644 apps/server/src/demo/roster.test.ts create mode 100644 apps/server/src/demo/roster.ts diff --git a/.env.example b/.env.example index d087f39fe..95f20b9c9 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,10 @@ SESSION_ENCRYPTION_KEY= # can restore the same login; see docs/authentication.md#local-device-flow-sign-in. # GITHUB_APP_CLIENT_SECRET above is not read in local mode. #AUTH_MODE=local +# Enables the server-owned local demo collaborators (Lily and Felix). Only +# "on" is accepted besides "off"; startup fails unless AUTH_MODE=local on a +# loopback bind. Unset or "off" leaves the facility inert. +#DEMO_PERSONAS=on # Overrides the plaintext-fallback credential directory used only when the OS # credential store is unavailable and the user consents; must resolve outside # the repository. Defaults to a platform-specific config directory. diff --git a/apps/server/src/auth/config.test.ts b/apps/server/src/auth/config.test.ts new file mode 100644 index 000000000..cb0f76236 --- /dev/null +++ b/apps/server/src/auth/config.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { loadAuth } from "./config"; + +let saved = { ...process.env }; + +function base(extra: Record = {}) { + for (let key of ["AUTH_MODE", "DEMO_PERSONAS"]) delete process.env[key]; + Object.assign(process.env, { + GITHUB_APP_SLUG: "chopin-test", + GITHUB_APP_CLIENT_ID: "client", + GITHUB_APP_CLIENT_SECRET: "secret", + APP_ORIGIN: "http://127.0.0.1:8787", + SESSION_ENCRYPTION_KEY: "0".repeat(64), + ...extra, + }); +} + +beforeEach(() => base()); +afterEach(() => { + process.env = { ...saved }; +}); + +describe("DEMO_PERSONAS", () => { + test("is off by default and when off", () => { + expect(loadAuth(8787, "127.0.0.1").demoPersonas).toBeUndefined(); + base({ DEMO_PERSONAS: "off" }); + expect(loadAuth(8787, "127.0.0.1").demoPersonas).toBeUndefined(); + }); + + test("on with local auth and loopback bind enables personas", () => { + base({ AUTH_MODE: "local", DEMO_PERSONAS: "on" }); + expect(loadAuth(8787, "127.0.0.1").demoPersonas).toBe(true); + }); + + test("on without local auth refuses startup", () => { + base({ DEMO_PERSONAS: "on" }); + expect(() => loadAuth(8787, "127.0.0.1")).toThrow("DEMO_PERSONAS=on requires AUTH_MODE=local"); + }); + + test("on with a non-loopback bind refuses startup", () => { + base({ AUTH_MODE: "local", DEMO_PERSONAS: "on" }); + expect(() => loadAuth(8787, "0.0.0.0")).toThrow("loopback"); + }); + + test("other values refuse startup", () => { + base({ AUTH_MODE: "local", DEMO_PERSONAS: "true" }); + expect(() => loadAuth(8787, "127.0.0.1")).toThrow("DEMO_PERSONAS must be on or off"); + }); +}); diff --git a/apps/server/src/auth/config.ts b/apps/server/src/auth/config.ts index 4490ef4aa..1d55443dd 100644 --- a/apps/server/src/auth/config.ts +++ b/apps/server/src/auth/config.ts @@ -8,6 +8,7 @@ export type AuthConfig = { allowedUsers?: ReadonlySet; allowedOrganizations?: ReadonlySet; local?: { installation: string; credentialsDir: string; port: number }; + demoPersonas?: true; }; const GITHUB_USER_LOGIN = /^[a-z0-9](?:[a-z0-9_-]{0,37}[a-z0-9_])?$/i; @@ -82,6 +83,11 @@ export function loadAuth(port = 8787, host = process.env.SERVER_HOST || "127.0.0 !== port ) ) throw new Error("AUTH_MODE=local requires a loopback SERVER_HOST and APP_ORIGIN on PORT"); + let demo = process.env.DEMO_PERSONAS || "off"; + if (demo !== "on" && demo !== "off") throw new Error("DEMO_PERSONAS must be on or off"); + if (demo === "on" && mode !== "local") { + throw new Error("DEMO_PERSONAS=on requires AUTH_MODE=local on a loopback bind"); + } return { origin: appOrigin, appSlug, @@ -105,6 +111,7 @@ export function loadAuth(port = 8787, host = process.env.SERVER_HOST || "127.0.0 }, } : {}), + ...(demo === "on" ? { demoPersonas: true as const } : {}), allowedUsers: allowed( "GITHUB_ALLOWED_USERS", process.env.GITHUB_ALLOWED_USERS, diff --git a/apps/server/src/demo/roster.test.ts b/apps/server/src/demo/roster.test.ts new file mode 100644 index 000000000..5e42296bd --- /dev/null +++ b/apps/server/src/demo/roster.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { demoRoster } from "./roster"; + +describe("demo roster", () => { + test("enabled exposes Lily and Felix with stable ids and local avatars", () => { + let roster = demoRoster({ demoPersonas: true }); + expect(roster.list().map(actor => actor.id)).toEqual(["demo:lily", "demo:felix"]); + expect(roster.get("demo:lily")).toEqual({ + id: "demo:lily", + name: "Lily", + avatar: "/demo/lily.svg", + }); + expect(roster.get("demo:felix")?.name).toBe("Felix"); + for (let actor of roster.list()) expect(actor.avatar.startsWith("/")).toBe(true); + }); + + test("unknown or arbitrary ids are refused", () => { + let roster = demoRoster({ demoPersonas: true }); + for (let id of ["demo:mallory", "lily", "Demo:Lily", "demo:", "", "octocat", "__proto__"]) { + expect(roster.get(id)).toBeUndefined(); + } + }); + + test("disabled resolves nothing", () => { + let roster = demoRoster({}); + expect(roster.list()).toEqual([]); + expect(roster.get("demo:lily")).toBeUndefined(); + }); +}); diff --git a/apps/server/src/demo/roster.ts b/apps/server/src/demo/roster.ts new file mode 100644 index 000000000..869ce61fa --- /dev/null +++ b/apps/server/src/demo/roster.ts @@ -0,0 +1,24 @@ +export type DemoActor = { + readonly id: string; + readonly name: string; + readonly avatar: string; +}; + +const ACTORS: readonly DemoActor[] = Object.freeze([ + Object.freeze({ id: "demo:lily", name: "Lily", avatar: "/demo/lily.svg" }), + Object.freeze({ id: "demo:felix", name: "Felix", avatar: "/demo/felix.svg" }), +]); + +export type DemoRoster = { + list(): readonly DemoActor[]; + get(id: string): DemoActor | undefined; +}; + +// Inert unless the validated local flag (AuthConfig.demoPersonas) is on. +export function demoRoster(config: { demoPersonas?: true }): DemoRoster { + let enabled = config.demoPersonas === true; + return { + list: () => enabled ? ACTORS : [], + get: id => enabled ? ACTORS.find(actor => actor.id === id) : undefined, + }; +} diff --git a/docs/self-hosting.md b/docs/self-hosting.md index f0bc99b55..80bd67007 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -352,6 +352,7 @@ the image. | `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | | `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the hosted OAuth attempt cookie and derives a separate key for session credentials in PostgreSQL. Keep stable across releases and outside the database; replacing it invalidates hosted logins. Local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | | `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | +| `DEMO_PERSONAS` | `off` | Set `on` to enable the server-owned local demo collaborators (Lily and Felix). Requires `AUTH_MODE=local` on a loopback bind; otherwise, or for any value besides `on` and `off`, startup fails. | | `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | | `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | | `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. |