Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/auth/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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");
});
});
7 changes: 7 additions & 0 deletions apps/server/src/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type AuthConfig = {
allowedUsers?: ReadonlySet<string>;
allowedOrganizations?: ReadonlySet<string>;
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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/demo/roster.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
24 changes: 24 additions & 0 deletions apps/server/src/demo/roster.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
1 change: 1 addition & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading