From fc4485f6efed00d431a9f64c5f330c12755244bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 10:30:32 +0200 Subject: [PATCH] Apply configurable-demo-persona: per-instance demo identity + voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DEMO_BOT_PERSONA env var (inline JSON or file:) so self-hosted instances can replace Bruno-in-Berlin with their own demo account identity and content pools. No-op for trails.cool — the built-in Bruno persona remains the default. - DemoPersona type + Zod schema validation - loadPersona() cached at boot; falls back to default on any failure - ensureDemoUser throws DemoPersonaUsernameClashError when the persona username is already a real user; server declines to schedule demo jobs for that process - isDemoUser flag moved from hardcoded "bruno" check to loader-supplied boolean computed from the running persona - docs/demo-persona.md explains the schema + operator flow Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/lib/demo-bot.integration.test.ts | 28 +- apps/journal/app/lib/demo-bot.server.ts | 368 +++++++++++++----- apps/journal/app/lib/demo-bot.test.ts | 163 +++++++- apps/journal/app/routes/users.$username.tsx | 13 +- apps/journal/package.json | 3 +- apps/journal/server.ts | 37 +- docs/demo-persona.md | 87 +++++ infrastructure/.env.example | 13 +- infrastructure/docker-compose.yml | 8 +- .../configurable-demo-persona/.openspec.yaml | 2 + .../configurable-demo-persona/design.md | 108 +++++ .../configurable-demo-persona/proposal.md | 31 ++ .../specs/demo-activity-bot/spec.md | 81 ++++ .../configurable-demo-persona/tasks.md | 45 +++ pnpm-lock.yaml | 3 + 15 files changed, 877 insertions(+), 113 deletions(-) create mode 100644 docs/demo-persona.md create mode 100644 openspec/changes/configurable-demo-persona/.openspec.yaml create mode 100644 openspec/changes/configurable-demo-persona/design.md create mode 100644 openspec/changes/configurable-demo-persona/proposal.md create mode 100644 openspec/changes/configurable-demo-persona/specs/demo-activity-bot/spec.md create mode 100644 openspec/changes/configurable-demo-persona/tasks.md diff --git a/apps/journal/app/lib/demo-bot.integration.test.ts b/apps/journal/app/lib/demo-bot.integration.test.ts index c3f36f6..7ed763a 100644 --- a/apps/journal/app/lib/demo-bot.integration.test.ts +++ b/apps/journal/app/lib/demo-bot.integration.test.ts @@ -3,11 +3,14 @@ import { sql } from "drizzle-orm"; import { getDb } from "./db.ts"; import { activities, routes, users } from "@trails-cool/db/schema/journal"; import { - DEMO_USERNAME, + DEFAULT_PERSONA, + DemoPersonaUsernameClashError, + __resetPersonaCacheForTests, ensureDemoUser, generateOneWalk, pruneSynthetic, } from "./demo-bot.server.ts"; +import { randomUUID } from "node:crypto"; // Opt-in: these tests talk to a real Postgres and are skipped unless // `DEMO_BOT_INTEGRATION=1` is set. CI gates them behind the same flag @@ -41,6 +44,10 @@ describe.skipIf(!runIntegration)("demo-bot integration", () => { afterEach(async () => { await wipeSynthetic(); + // Scrub any test-inserted Bruno so the next test starts clean. + const db = getDb(); + await db.execute(sql`DELETE FROM journal.users WHERE username = ${DEFAULT_PERSONA.username}`); + __resetPersonaCacheForTests(); vi.restoreAllMocks(); }); @@ -53,10 +60,27 @@ describe.skipIf(!runIntegration)("demo-bot integration", () => { const rows = await db .select() .from(users) - .where(sql`${users.username} = ${DEMO_USERNAME}`); + .where(sql`${users.username} = ${DEFAULT_PERSONA.username}`); expect(rows.length).toBe(1); }); + it("ensureDemoUser throws username-clash when a real user owns the persona name", async () => { + const db = getDb(); + const domain = process.env.DOMAIN ?? "localhost"; + const realUserId = randomUUID(); + // Create a real user at the persona username with a NON-sentinel email. + await db.execute(sql` + INSERT INTO journal.users (id, email, username, domain, terms_accepted_at, terms_version) + VALUES (${realUserId}, ${`real-${Date.now()}@${domain}`}, ${DEFAULT_PERSONA.username}, ${domain}, now(), '2026-04-19') + `); + + try { + await expect(ensureDemoUser()).rejects.toBeInstanceOf(DemoPersonaUsernameClashError); + } finally { + await db.execute(sql`DELETE FROM journal.users WHERE id = ${realUserId}`); + } + }); + it("generateOneWalk inserts a public synthetic route + activity", async () => { vi.stubGlobal( "fetch", diff --git a/apps/journal/app/lib/demo-bot.server.ts b/apps/journal/app/lib/demo-bot.server.ts index 6153217..91a99da 100644 --- a/apps/journal/app/lib/demo-bot.server.ts +++ b/apps/journal/app/lib/demo-bot.server.ts @@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { z } from "zod"; import { and, eq, lt, sql } from "drizzle-orm"; import { getDb } from "./db.ts"; import { activities, routes, users } from "@trails-cool/db/schema/journal"; @@ -11,10 +13,198 @@ import { demoBotSyntheticRoutesTotal, } from "./metrics.server.ts"; -export const DEMO_USERNAME = "bruno"; -export const DEMO_DISPLAY_NAME = "Bruno"; -export const DEMO_BIO = - "Professional park inspector. Currently accepting tennis balls."; +// --- Persona configuration ----------------------------------------------- + +export type DemoLocale = "en" | "de"; + +export interface DemoPersonaContent { + names: { en?: string[]; de?: string[] }; + descriptions: { en?: string[]; de?: string[] }; +} + +export interface DemoPersona { + username: string; + displayName: string; + bio: string; + locales: DemoLocale[]; + content: DemoPersonaContent; +} + +const LocaleSchema = z.enum(["en", "de"]); +const PoolSchema = z.array(z.string().min(1)).min(3).max(50); + +const PersonaSchema = z + .object({ + username: z.string().regex(/^[a-z0-9][a-z0-9_-]{1,30}$/), + displayName: z.string().min(1).max(200), + bio: z.string().max(200), + locales: z.array(LocaleSchema).min(1), + content: z.object({ + names: z.object({ en: PoolSchema.optional(), de: PoolSchema.optional() }), + descriptions: z.object({ + en: PoolSchema.optional(), + de: PoolSchema.optional(), + }), + }), + }) + .superRefine((p, ctx) => { + for (const loc of p.locales) { + if (!p.content.names[loc]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content", "names", loc], + message: `missing name pool for declared locale '${loc}'`, + }); + } + if (!p.content.descriptions[loc]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content", "descriptions", loc], + message: `missing description pool for declared locale '${loc}'`, + }); + } + } + }); + +/** + * Built-in Bruno persona. Used verbatim when no `DEMO_BOT_PERSONA` is + * supplied or the supplied override fails validation. Moving these + * strings behind the persona abstraction means every reference to the + * bot's identity flows through one place. + */ +export const DEFAULT_PERSONA: DemoPersona = Object.freeze({ + username: "bruno", + displayName: "Bruno", + bio: "Professional park inspector. Currently accepting tennis balls.", + locales: ["en", "de"], + content: { + names: { + en: [ + "Grunewald north-loop patrol", + "Tiergarten perimeter audit", + "Tempelhof runway inspection", + "Spree embankment survey", + "Bruno's morning rounds", + "Sniff-check: Kreuzberg edition", + "Bruno found three sticks today", + "Dog-audit report: all squirrels present", + "Volkspark compliance walk", + "Tennis ball reconnaissance", + "Pretzel-crumb cleanup detail", + "Bruno vs. the pigeons", + ], + de: [ + "Grunewald-Nordschleife-Patrouille", + "Tiergarten-Umfangsprüfung", + "Tempelhof-Startbahn-Inspektion", + "Spreeufer-Rundgang", + "Brunos Morgenrunde", + "Schnüffelkontrolle: Kreuzberg", + "Bruno hat heute drei Stöcke gefunden", + "Hundeprüfbericht: alle Eichhörnchen anwesend", + "Volkspark-Konformitätsgang", + "Tennisball-Aufklärung", + "Brezel-Krümel-Aufräumdienst", + "Bruno gegen die Tauben", + ], + }, + descriptions: { + en: [ + "Inspected several bushes. All present and accounted for.", + "Three squirrels successfully monitored. Two got away.", + "Good sticks: 2. Great sticks: 1. Excellent sticks: 0.", + "Pace was brisk. Sniffs were thorough.", + "Encountered another dog. Diplomacy established.", + "Weather: windy. Ears: flopping.", + "Pigeons stood their ground. A follow-up visit is required.", + "Finished the route ahead of schedule. Extra treats expected.", + "Investigated one suspicious paper bag. False alarm.", + "Logged one (1) successful puddle inspection.", + ], + de: [ + "Mehrere Büsche inspiziert. Alle vorhanden.", + "Drei Eichhörnchen erfolgreich beobachtet. Zwei entkommen.", + "Gute Stöcke: 2. Großartige Stöcke: 1. Exzellente Stöcke: 0.", + "Tempo zügig. Schnüffeln gründlich.", + "Einen anderen Hund getroffen. Diplomatie hergestellt.", + "Wetter: windig. Ohren: flatternd.", + "Die Tauben hielten Stand. Ein Folgebesuch ist erforderlich.", + "Route vor dem Zeitplan abgeschlossen. Zusätzliche Leckerlis erwartet.", + "Eine verdächtige Papiertüte untersucht. Fehlalarm.", + "Eine (1) erfolgreiche Pfützen-Inspektion protokolliert.", + ], + }, + }, +}); + +// Sanity: the default persona itself must parse under the schema. Catch +// accidental drift at module load rather than first invocation. +PersonaSchema.parse(DEFAULT_PERSONA); + +let _cachedPersona: DemoPersona | null = null; + +/** + * Read the persona from `DEMO_BOT_PERSONA`. Accepts either inline JSON + * or `file:`. Falls back to `DEFAULT_PERSONA` on any + * failure (unset, bad JSON, schema violation, unreadable file) with a + * single warn-level log line per process. + */ +export function loadPersona(): DemoPersona { + if (_cachedPersona) return _cachedPersona; + _cachedPersona = Object.freeze(loadPersonaUncached()); + return _cachedPersona; +} + +function loadPersonaUncached(): DemoPersona { + const raw = process.env.DEMO_BOT_PERSONA; + if (!raw) return DEFAULT_PERSONA; + + let json: string; + if (raw.startsWith("file:")) { + const path = raw.slice("file:".length); + try { + json = readFileSync(path, "utf8"); + } catch (err) { + logger.warn( + { path, err: (err as Error).message }, + "DEMO_BOT_PERSONA file unreadable, using default", + ); + return DEFAULT_PERSONA; + } + } else { + json = raw; + } + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (err) { + logger.warn( + { err: (err as Error).message }, + "DEMO_BOT_PERSONA not valid JSON, using default", + ); + return DEFAULT_PERSONA; + } + + const result = PersonaSchema.safeParse(parsed); + if (!result.success) { + const first = result.error.issues[0]; + logger.warn( + { path: first?.path.join("."), message: first?.message }, + "DEMO_BOT_PERSONA schema violation, using default", + ); + return DEFAULT_PERSONA; + } + return result.data; +} + +/** + * Test-only hook to reset the cached persona between tests. Never call + * from application code. + */ +export function __resetPersonaCacheForTests() { + _cachedPersona = null; +} /** * Inner Berlin (Tiergarten → Grunewald corridor). Keeps Bruno's walks @@ -35,28 +225,59 @@ export function demoRetentionDays(): number { } /** - * Idempotent insert of the Bruno demo user. Safe to call every worker - * boot; relies on `users.username` uniqueness to short-circuit duplicates. + * Surfaced when the persona username already belongs to a real user. + * Callers (worker boot) treat this as a signal to skip scheduling the + * bot for this process instead of silently attaching to a human's + * account. */ -export async function ensureDemoUser(): Promise { +export class DemoPersonaUsernameClashError extends Error { + readonly kind = "username-clash"; + readonly username: string; + constructor(username: string) { + super(`demo persona username '${username}' is already taken by a non-demo user`); + this.username = username; + } +} + +function expectedSentinelEmail(persona: DemoPersona, domain: string): string { + return `${persona.username}@${domain}`; +} + +/** + * Idempotent insert of the demo user described by `persona`. Safe to + * call every worker boot; relies on `users.username` uniqueness to + * short-circuit duplicates. Throws `DemoPersonaUsernameClashError` if + * the row exists but looks like a real account (email doesn't match + * the sentinel pattern) — callers should catch this and keep the bot + * disabled for the process. + */ +export async function ensureDemoUser( + persona: DemoPersona = loadPersona(), +): Promise { const db = getDb(); const domain = process.env.DOMAIN ?? "localhost"; + const sentinelEmail = expectedSentinelEmail(persona, domain); const [existing] = await db - .select({ id: users.id }) + .select({ id: users.id, email: users.email }) .from(users) - .where(eq(users.username, DEMO_USERNAME)); - if (existing) return existing.id; + .where(eq(users.username, persona.username)); + if (existing) { + if (existing.email !== sentinelEmail) { + throw new DemoPersonaUsernameClashError(persona.username); + } + return existing.id; + } const id = randomUUID(); await db .insert(users) .values({ id, - username: DEMO_USERNAME, - email: `${DEMO_USERNAME}@${domain}`, - displayName: DEMO_DISPLAY_NAME, - bio: DEMO_BIO, + username: persona.username, + email: sentinelEmail, + displayName: persona.displayName, + bio: persona.bio, domain, termsAcceptedAt: new Date(), termsVersion: TERMS_VERSION, @@ -65,10 +286,13 @@ export async function ensureDemoUser(): Promise { // Re-read in case a concurrent insert won the race const [row] = await db - .select({ id: users.id }) + .select({ id: users.id, email: users.email }) .from(users) - .where(eq(users.username, DEMO_USERNAME)); + .where(eq(users.username, persona.username)); if (!row) throw new Error("ensureDemoUser: insert succeeded but row not found"); + if (row.email !== sentinelEmail) { + throw new DemoPersonaUsernameClashError(persona.username); + } return row.id; } @@ -141,63 +365,7 @@ export function pickEndpoints(bbox: [number, number, number, number]): Endpoints return { start, end }; } -// --- Bruno-voiced copy -------------------------------------------------- - -const NAME_POOL_EN = [ - "Grunewald north-loop patrol", - "Tiergarten perimeter audit", - "Tempelhof runway inspection", - "Spree embankment survey", - "Bruno's morning rounds", - "Sniff-check: Kreuzberg edition", - "Bruno found three sticks today", - "Dog-audit report: all squirrels present", - "Volkspark compliance walk", - "Tennis ball reconnaissance", - "Pretzel-crumb cleanup detail", - "Bruno vs. the pigeons", -]; - -const NAME_POOL_DE = [ - "Grunewald-Nordschleife-Patrouille", - "Tiergarten-Umfangsprüfung", - "Tempelhof-Startbahn-Inspektion", - "Spreeufer-Rundgang", - "Brunos Morgenrunde", - "Schnüffelkontrolle: Kreuzberg", - "Bruno hat heute drei Stöcke gefunden", - "Hundeprüfbericht: alle Eichhörnchen anwesend", - "Volkspark-Konformitätsgang", - "Tennisball-Aufklärung", - "Brezel-Krümel-Aufräumdienst", - "Bruno gegen die Tauben", -]; - -const DESCRIPTION_POOL_EN = [ - "Inspected several bushes. All present and accounted for.", - "Three squirrels successfully monitored. Two got away.", - "Good sticks: 2. Great sticks: 1. Excellent sticks: 0.", - "Pace was brisk. Sniffs were thorough.", - "Encountered another dog. Diplomacy established.", - "Weather: windy. Ears: flopping.", - "Pigeons stood their ground. A follow-up visit is required.", - "Finished the route ahead of schedule. Extra treats expected.", - "Investigated one suspicious paper bag. False alarm.", - "Logged one (1) successful puddle inspection.", -]; - -const DESCRIPTION_POOL_DE = [ - "Mehrere Büsche inspiziert. Alle vorhanden.", - "Drei Eichhörnchen erfolgreich beobachtet. Zwei entkommen.", - "Gute Stöcke: 2. Großartige Stöcke: 1. Exzellente Stöcke: 0.", - "Tempo zügig. Schnüffeln gründlich.", - "Einen anderen Hund getroffen. Diplomatie hergestellt.", - "Wetter: windig. Ohren: flatternd.", - "Die Tauben hielten Stand. Ein Folgebesuch ist erforderlich.", - "Route vor dem Zeitplan abgeschlossen. Zusätzliche Leckerlis erwartet.", - "Eine verdächtige Papiertüte untersucht. Fehlalarm.", - "Eine (1) erfolgreiche Pfützen-Inspektion protokolliert.", -]; +// --- Persona-driven copy ------------------------------------------------- function pickFrom(pool: readonly T[], seed: number): T { const idx = Math.abs(Math.floor(seed)) % pool.length; @@ -205,26 +373,46 @@ function pickFrom(pool: readonly T[], seed: number): T { } /** - * Deterministic-ish per day + per started-at minute, so same-day walks - * don't land on the same name. Locale is chosen from the journal's - * default — Bruno doesn't know German, but his walks come with German - * subtitles when the reader expects them. + * Pull a route name from the persona's name pool for the given locale. + * Seeded by the start-time so same-day walks usually don't collide. + * Falls back to the first persona locale if the caller asked for a + * locale the persona doesn't support (defensive — the caller is + * expected to pick from `persona.locales`). */ -export function templateName(startedAt: Date, locale: "en" | "de" = "en"): string { - const pool = locale === "de" ? NAME_POOL_DE : NAME_POOL_EN; +export function templateName( + startedAt: Date, + locale: DemoLocale, + persona: DemoPersona = loadPersona(), +): string { + const pool = persona.content.names[locale] ?? persona.content.names[persona.locales[0]!]!; const seed = startedAt.getUTCDate() * 60 + startedAt.getUTCMinutes(); return pickFrom(pool, seed); } export function templateDescription( startedAt: Date, - locale: "en" | "de" = "en", + locale: DemoLocale, + persona: DemoPersona = loadPersona(), ): string { - const pool = locale === "de" ? DESCRIPTION_POOL_DE : DESCRIPTION_POOL_EN; + const pool = + persona.content.descriptions[locale] ?? + persona.content.descriptions[persona.locales[0]!]!; const seed = startedAt.getUTCDate() * 137 + startedAt.getUTCSeconds() * 7; return pickFrom(pool, seed); } +/** + * Random locale from the persona's supported set. Used by the + * generation job so instances with `locales: ["en"]` never emit + * German-voiced walks. + */ +export function pickLocale( + persona: DemoPersona = loadPersona(), + rand: () => number = Math.random, +): DemoLocale { + return persona.locales[Math.floor(rand() * persona.locales.length)]!; +} + // --- BRouter -------------------------------------------------------------- const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777"; @@ -263,12 +451,14 @@ export async function requestBrouterGpx( export interface GenerateOptions { now?: Date; - /** Localise the walk name; defaults to random-ish. */ - locale?: "en" | "de"; + /** Override the locale; defaults to a random one from the persona. */ + locale?: DemoLocale; + /** Test-only: inject a persona instead of reading the env. */ + persona?: DemoPersona; } /** - * Core generate step: insert one Bruno route + activity pair. Callers + * Core generate step: insert one demo route + activity pair. Callers * (the recurring job and the backfill loop) decide whether to invoke it. * * Returns the inserted route id on success, or `null` on BRouter failure @@ -276,7 +466,11 @@ export interface GenerateOptions { */ export async function generateOneWalk( ownerId: string, - { now = new Date(), locale = Math.random() < 0.5 ? "en" : "de" }: GenerateOptions = {}, + { + now = new Date(), + persona = loadPersona(), + locale = pickLocale(persona), + }: GenerateOptions = {}, ): Promise { const region = loadRegion(); const endpoints = pickEndpoints(region.bbox); @@ -287,8 +481,8 @@ export async function generateOneWalk( return null; } - const name = templateName(now, locale); - const description = templateDescription(now, locale); + const name = templateName(now, locale, persona); + const description = templateDescription(now, locale, persona); let distance: number; let elevationGain: number; diff --git a/apps/journal/app/lib/demo-bot.test.ts b/apps/journal/app/lib/demo-bot.test.ts index e86023e..1607a27 100644 --- a/apps/journal/app/lib/demo-bot.test.ts +++ b/apps/journal/app/lib/demo-bot.test.ts @@ -1,11 +1,19 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { + DEFAULT_PERSONA, + __resetPersonaCacheForTests, berlinHour, + loadPersona, loadRegion, pickEndpoints, + pickLocale, shouldWalkNow, templateDescription, templateName, + type DemoPersona, } from "./demo-bot.server.ts"; describe("pickEndpoints", () => { @@ -117,3 +125,156 @@ describe("berlinHour", () => { expect(h).toBeLessThanOrEqual(23); }); }); + +// --- Persona configuration ------------------------------------------------- + +function minimalPersona(overrides: Partial = {}): DemoPersona { + return { + username: "test-demo", + displayName: "Test", + bio: "test bio", + locales: ["en"], + content: { + names: { + en: ["alpha walk", "bravo walk", "charlie walk"], + }, + descriptions: { + en: ["alpha desc", "bravo desc", "charlie desc"], + }, + }, + ...overrides, + }; +} + +describe("loadPersona", () => { + const envKey = "DEMO_BOT_PERSONA"; + let savedEnv: string | undefined; + + beforeEach(() => { + savedEnv = process.env[envKey]; + delete process.env[envKey]; + __resetPersonaCacheForTests(); + }); + + afterEach(() => { + if (savedEnv === undefined) delete process.env[envKey]; + else process.env[envKey] = savedEnv; + __resetPersonaCacheForTests(); + }); + + it("returns the default persona when unset", () => { + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("caches the result across calls", () => { + const a = loadPersona(); + const b = loadPersona(); + expect(a).toBe(b); + }); + + it("parses a valid inline JSON override", () => { + process.env[envKey] = JSON.stringify(minimalPersona({ username: "hamish" })); + const p = loadPersona(); + expect(p.username).toBe("hamish"); + expect(p.locales).toEqual(["en"]); + }); + + it("falls back to the default on invalid JSON", () => { + process.env[envKey] = "{not json"; + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("falls back when the username violates the pattern", () => { + process.env[envKey] = JSON.stringify(minimalPersona({ username: "Has Spaces" })); + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("falls back when a declared locale has no content pool", () => { + process.env[envKey] = JSON.stringify( + minimalPersona({ + locales: ["en", "de"], + // intentionally omit de pools + }), + ); + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("falls back when a pool has fewer than 3 entries", () => { + process.env[envKey] = JSON.stringify( + minimalPersona({ + content: { + names: { en: ["only", "two"] }, + descriptions: { en: ["d1", "d2", "d3"] }, + }, + }), + ); + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("reads a persona from a file: path", () => { + const dir = mkdtempSync(join(tmpdir(), "demo-persona-")); + const path = join(dir, "persona.json"); + writeFileSync(path, JSON.stringify(minimalPersona({ username: "from-file" }))); + try { + process.env[envKey] = `file:${path}`; + const p = loadPersona(); + expect(p.username).toBe("from-file"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("falls back when a file: path is unreadable", () => { + process.env[envKey] = "file:/nonexistent/does-not-exist.json"; + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); +}); + +describe("templateName / templateDescription with custom persona", () => { + it("draws from the supplied persona's pool and never leaks entries from the default", () => { + const persona = minimalPersona(); + const bannedSubstring = "Grunewald"; // present in DEFAULT_PERSONA but not in `persona` + for (let i = 0; i < 30; i++) { + const d = new Date(2026, 3, 1 + (i % 28), i % 24, i % 60, i); + const name = templateName(d, "en", persona); + const desc = templateDescription(d, "en", persona); + expect(persona.content.names.en).toContain(name); + expect(persona.content.descriptions.en).toContain(desc); + expect(name).not.toContain(bannedSubstring); + expect(desc).not.toContain(bannedSubstring); + } + }); +}); + +describe("pickLocale", () => { + it("only returns locales declared by the persona", () => { + const persona = minimalPersona({ locales: ["en"] }); + for (let i = 0; i < 10; i++) { + expect(pickLocale(persona)).toBe("en"); + } + }); + + it("distributes across declared locales", () => { + const persona = minimalPersona({ + locales: ["en", "de"], + content: { + names: { + en: ["en1", "en2", "en3"], + de: ["de1", "de2", "de3"], + }, + descriptions: { + en: ["d1", "d2", "d3"], + de: ["ds1", "ds2", "ds3"], + }, + }, + }); + expect(pickLocale(persona, () => 0)).toBe("en"); + expect(pickLocale(persona, () => 0.99)).toBe("de"); + }); +}); diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index f40d5fb..75c26f4 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -7,6 +7,7 @@ import { eq } from "drizzle-orm"; import { getSessionUser } from "~/lib/auth.server"; import { listPublicRoutesForOwner } from "~/lib/routes.server"; import { listPublicActivitiesForOwner } from "~/lib/activities.server"; +import { loadPersona } from "~/lib/demo-bot.server"; import { ClientDate } from "~/components/ClientDate"; export async function loader({ params, request }: Route.LoaderArgs) { @@ -31,6 +32,11 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw data({ error: "User not found" }, { status: 404 }); } + // Demo-account badge: true when this profile matches the instance's + // configured demo persona username. Computed server-side so we don't + // ship the persona config through client HTML. + const isDemoUser = user.username === loadPersona().username; + return data({ user: { username: user.username, @@ -57,6 +63,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { createdAt: a.createdAt.toISOString(), })), isOwn, + isDemoUser, }); } @@ -82,11 +89,9 @@ export function meta({ data: loaderData }: Route.MetaArgs) { } export default function UserProfilePage({ loaderData }: Route.ComponentProps) { - const { user, routes, activities, isOwn } = loaderData; + const { user, routes, activities, isOwn, isDemoUser } = loaderData; const { t } = useTranslation("journal"); - const isDemo = user.username === "bruno"; - return (
@@ -98,7 +103,7 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {

{user.displayName ?? user.username}

- {isDemo && ( + {isDemoUser && ( {t("demo.badge")} diff --git a/apps/journal/package.json b/apps/journal/package.json index 0f12f8e..62c0ca7 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -35,7 +35,8 @@ "prom-client": "^15.1.3", "react": "catalog:", "react-dom": "catalog:", - "react-router": "catalog:" + "react-router": "catalog:", + "zod": "^3.25.0" }, "devDependencies": { "@react-router/dev": "catalog:", diff --git a/apps/journal/server.ts b/apps/journal/server.ts index 3c35b6c..dc72e52 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -108,21 +108,38 @@ server.listen(port, async () => { const { seedOAuthClient } = await import("./app/lib/oauth.server.ts"); await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true); - // Start background job worker - const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts"); - const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts"); - const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"); - await startWorker(boss, [demoBotGenerateJob, demoBotPruneJob]); - logger.info("Background job worker started"); - - // Bootstrap the demo user when the bot is enabled; cheap idempotent insert. + // Pre-flight the demo user so a persona username that clashes with a + // real account blocks job scheduling rather than silently attaching + // synthetic rows to a human. + let enableDemoJobs = false; if (process.env.DEMO_BOT_ENABLED === "true") { - const { ensureDemoUser } = await import("./app/lib/demo-bot.server.ts"); + const { ensureDemoUser, DemoPersonaUsernameClashError } = await import( + "./app/lib/demo-bot.server.ts" + ); try { const id = await ensureDemoUser(); logger.info({ id }, "demo-bot user ensured"); + enableDemoJobs = true; } catch (err) { - logger.error({ err }, "demo-bot ensureDemoUser failed"); + if (err instanceof DemoPersonaUsernameClashError) { + logger.error( + { username: err.username }, + "demo persona username clash — demo jobs disabled for this process", + ); + } else { + logger.error({ err }, "demo-bot ensureDemoUser failed"); + } } } + + // Start background job worker + const demoJobs: Parameters[1] = []; + if (enableDemoJobs) { + const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts"); + const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts"); + demoJobs.push(demoBotGenerateJob, demoBotPruneJob); + } + const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"); + await startWorker(boss, demoJobs); + logger.info("Background job worker started"); }); diff --git a/docs/demo-persona.md b/docs/demo-persona.md new file mode 100644 index 0000000..3c554ac --- /dev/null +++ b/docs/demo-persona.md @@ -0,0 +1,87 @@ +# Demo persona + +The Journal's demo bot (see the `demo-activity-bot` spec) ships with a default Bruno-in-Berlin persona. Self-hosted instances can override the identity and voice without touching code by supplying `DEMO_BOT_PERSONA` to the journal container. + +Region is separate (`DEMO_BOT_REGION`) — an operator may mix-and-match a non-Berlin region with the default Bruno persona, or vice versa, though it usually reads better to keep persona and region stylistically consistent. + +## Supplying a persona + +Two forms are accepted: + +1. **Inline JSON** — `DEMO_BOT_PERSONA='{"username":"hamish",...}'`. Workable for short personas; gets ugly fast once the pools are realistic. +2. **File path** — `DEMO_BOT_PERSONA=file:/etc/trails-cool/persona.json`. The journal container reads the file once at boot; mount the file via a volume or Docker secret. + +A missing / invalid / unreadable persona falls back to the built-in default with a single warn log line. The bot never crashes over a bad persona. + +## Schema + +```json +{ + "username": "", + "displayName": "", + "bio": "", + "locales": ["en"] | ["de"] | ["en","de"], + "content": { + "names": { + "en": ["<3 to 50 non-empty strings>"], + "de": ["<3 to 50 non-empty strings>"] + }, + "descriptions": { + "en": ["<3 to 50 non-empty strings>"], + "de": ["<3 to 50 non-empty strings>"] + } + } +} +``` + +Every locale listed in `locales` MUST have both a `names` pool and a `descriptions` pool. Locales omitted from `locales` may also omit their pools. + +Sensible pool size is 10–15 entries per pool. Fewer than 3 is rejected; more than 50 is rejected. The generator samples deterministically from the walk's start-time, so very small pools produce visibly-repeating copy in the daily feed. + +## Example: Hamish the English-only Scottish collie + +```json +{ + "username": "hamish", + "displayName": "Hamish", + "bio": "Border collie. Arthur's Seat regular.", + "locales": ["en"], + "content": { + "names": { + "en": [ + "Arthur's Seat summit patrol", + "Holyrood Park morning rounds", + "Royal Mile crumb inspection", + "Dean Village bridge audit", + "Inverleith perimeter check", + "Blackford Hill squirrel count", + "Water of Leith investigation", + "Hamish vs. the seagulls", + "Portobello beach reconnaissance", + "Cramond causeway survey" + ] + }, + "descriptions": { + "en": [ + "Sniffed an impressive number of bins. All catalogued.", + "Weather: dreich. Ears: flat.", + "One dropped scone located. Consumed as evidence.", + "Diplomacy established with the resident Labrador.", + "Route completed. Biscuits expected.", + "Found three sticks. Returned with one." + ] + } + } +} +``` + +## Rollout + +1. Write `persona.json`, mount it via your compose override or Docker secret. +2. Set `DEMO_BOT_PERSONA=file:/path/to/persona.json` in your SOPS env. +3. Set `DEMO_BOT_ENABLED=true`. +4. Restart the journal container. + +On boot, the worker logs `demo-bot user ensured` with the persona's user id. If that username is already in use by a real human user, the worker logs a `demo persona username clash` error and declines to schedule the demo jobs — pick a different username and restart. + +Rolling back is the same in reverse: unset `DEMO_BOT_PERSONA`, restart. The built-in default returns. diff --git a/infrastructure/.env.example b/infrastructure/.env.example index fb6d8c8..ef8cd88 100644 --- a/infrastructure/.env.example +++ b/infrastructure/.env.example @@ -7,10 +7,15 @@ JWT_SECRET=change-me S3_ACCESS_KEY= S3_SECRET_KEY= -# Demo-activity-bot (Bruno). Only enable in prod. When true, the journal -# worker bootstraps a `bruno` user and generates public demo routes + -# activities every 90 min. Set DEMO_BOT_REGION to override the default -# Berlin bbox — JSON {"bbox":[w,s,e,n]}. Retention is in days (default 14). +# Demo-activity-bot. Only enable in prod. When true, the journal worker +# bootstraps a demo user and generates public demo routes + activities +# every 90 min. Retention is in days (default 14). # DEMO_BOT_ENABLED=true # DEMO_BOT_RETENTION_DAYS=14 # DEMO_BOT_REGION={"bbox":[13.25,52.45,13.55,52.60]} +# +# Override the default Bruno persona (identity + generated copy) via +# either an inline JSON blob or a file path. See docs/demo-persona.md +# for the schema. +# DEMO_BOT_PERSONA=file:/etc/trails-cool/persona.json +# DEMO_BOT_PERSONA={"username":"hamish","displayName":"Hamish","bio":"...","locales":["en"],"content":{"names":{"en":["...","...","..."]},"descriptions":{"en":["...","...","..."]}}} diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index ec15234..7010104 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -34,13 +34,13 @@ services: WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-} WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-} WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-} - # Demo-activity-bot (Bruno). Disabled by default everywhere; flip to - # "true" only in prod to let the background worker generate public - # demo routes + activities and prune old ones. Region is a JSON blob - # `{"bbox":[w,s,e,n]}`; retention is in days (default 14). + # Demo-activity-bot. Disabled by default everywhere; flip to "true" + # only in prod. When unset, DEMO_BOT_PERSONA uses the built-in + # Bruno/Berlin persona. See docs/demo-persona.md for the schema. DEMO_BOT_ENABLED: ${DEMO_BOT_ENABLED:-} DEMO_BOT_RETENTION_DAYS: ${DEMO_BOT_RETENTION_DAYS:-} DEMO_BOT_REGION: ${DEMO_BOT_REGION:-} + DEMO_BOT_PERSONA: ${DEMO_BOT_PERSONA:-} healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"] interval: 15s diff --git a/openspec/changes/configurable-demo-persona/.openspec.yaml b/openspec/changes/configurable-demo-persona/.openspec.yaml new file mode 100644 index 0000000..c8af3f5 --- /dev/null +++ b/openspec/changes/configurable-demo-persona/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/configurable-demo-persona/design.md b/openspec/changes/configurable-demo-persona/design.md new file mode 100644 index 0000000..757d847 --- /dev/null +++ b/openspec/changes/configurable-demo-persona/design.md @@ -0,0 +1,108 @@ +## Context + +The just-merged `demo-activity-bot` spec gave `trails.cool` a synthetic public user — Bruno, a Berlin dog on walkabout. The implementation works but hardcodes everything a host operator would sensibly want to customise: `username = "bruno"`, a fixed display name + bio, two string pools of English/German walk-name copy, and an inner-Berlin bbox as the default region. + +Self-hosted instances are the core of this project's federation story. We don't want every federated instance running a Berlin-themed dog walker; that ranges from confusing (why does the Tokyo instance have a Berlin-named bot?) to actively off-brand (the persona voice is playful — not every community wants playful). If turning on `DEMO_BOT_ENABLED=true` ships an identity that isn't theirs, most operators just won't turn it on. + +The bot itself (decide-to-walk gate, cap, BRouter client, retention, synthetic column, metrics) is generic and has no reason to change. Only the *persona* — the identity + voice — needs to become operator-supplied. + +Region is already configurable via `DEMO_BOT_REGION`. That stays as-is; this proposal does not merge region into the persona blob because regions and personas are orthogonal (an operator may want to run the default Bruno persona but in a different bbox for testing). + +## Goals / Non-Goals + +**Goals:** + +- A single env var — `DEMO_BOT_PERSONA` — that accepts either an inline JSON blob or a `file:` URL pointing at a mounted JSON file, so operators can ship either via SOPS env or via a config volume. +- Built-in default remains exactly the current Bruno persona, so `trails.cool` needs no operator action when this ships. +- Persona supplies: `username`, `displayName`, `bio`, `locales: ("en" | "de")[]`, and `content: { names: { en?: string[]; de?: string[] }, descriptions: { en?: string[]; de?: string[] } }`. +- `/users/` continues to render the 🐕 demo badge, but the gate moves from the hardcoded string `"bruno"` to a loader-supplied `isDemoUser` boolean derived from the persona. +- Malformed JSON, missing required fields, or an unreachable `file:` path fall back to the built-in Bruno persona and emit a single warn-level log line at worker boot. The worker never crashes over a bad persona file. + +**Non-Goals:** + +- Runtime reload. The persona is read once at worker boot. Operators restart the journal container to pick up a new persona. Live reload adds complexity for no user value (operators change personas monthly at most). +- Per-request persona selection. There is exactly one demo user per instance. +- Allowing the persona to be edited in the web UI by an admin. That would be a separate `admin-persona-editor` change; out of scope here. +- Supporting arbitrary locales beyond EN + DE. The app only has those two locale bundles today, and adding locales is a cross-cutting change tracked elsewhere. +- Moving region into the persona blob. Region and persona are orthogonal; two env vars remain two env vars. + +## Decisions + +### D1. One JSON blob via env, with `file:` fallback for multi-line content. + +Go with a single `DEMO_BOT_PERSONA` env var. Accept two shapes: + +1. Inline JSON — `DEMO_BOT_PERSONA='{"username":"bruno",...}'`. Fine for short personas. +2. `file:`-prefixed path — `DEMO_BOT_PERSONA=file:/etc/trails-cool/persona.json`. For realistic personas the pools are ~10–15 entries each; inlining 50 strings of JSON into a SOPS env file is miserable. + +*Alternatives considered:* +- **Separate env vars for each field** (`DEMO_BOT_USERNAME`, `DEMO_BOT_DISPLAY_NAME`, `DEMO_BOT_NAME_POOL_EN`, …). Rejected: 10+ related env vars, and list-valued vars need an ad-hoc separator that invites quoting bugs. +- **TOML or YAML file**. Rejected: the rest of the repo uses JSON env blobs (`DEMO_BOT_REGION` is already JSON). Stay consistent. +- **`DEMO_BOT_PERSONA_FILE` as a separate env var**. Rejected: two env vars for one concept. The `file:` prefix is a well-known convention (12-factor app config). + +### D2. Zod schema for validation. + +Parse with a Zod schema that enforces: username matches `^[a-z0-9][a-z0-9_-]{1,30}$` (same constraint as registration), `displayName` and `bio` are non-empty strings up to 200 chars, `locales` is a non-empty subset of `["en", "de"]`, each `content.names.` and `content.descriptions.` is an array of 3–50 non-empty strings. + +Reason for the 3–50 bound: fewer than 3 makes repeats painfully obvious in the daily feed; more than 50 eats unneeded memory and suggests the operator should move to a content pipeline instead of hand-editing JSON. + +*Alternatives considered:* +- **No validation — trust the operator.** Rejected: malformed persona is a silent footgun. A bad regex in `username` is a violation of the DB constraint and would crash `ensureDemoUser()` on boot. +- **Hand-rolled runtime type checks.** Rejected: the repo already uses Zod in `@trails-cool/api` for request validation. Reuse the pattern. + +### D3. Persona loading happens once, at worker boot, cached for process lifetime. + +Add `loadPersona()` that returns a `DemoPersona` object. It's called from `demo-bot.server.ts` module init (not from each job handler) and the result is frozen. `ensureDemoUser()` and the job handlers all read from this module-level constant. + +Side effect: if an operator changes the persona JSON and the worker is already running, the change doesn't take effect until the container restarts. This is the intended behaviour (see non-goals) and is the same pattern as `DEMO_BOT_REGION` + `DEMO_BOT_RETENTION_DAYS` today. + +### D4. Built-in default persona lives in code, not a default JSON file. + +The existing Bruno strings become a `DEFAULT_PERSONA: DemoPersona` export in `demo-bot.server.ts`. When no `DEMO_BOT_PERSONA` is set, or validation fails, `loadPersona()` returns the default. This keeps the happy path (no operator config) identical to what just shipped. + +*Alternatives considered:* +- **Bundle a `personas/bruno.json` in the image and set `DEMO_BOT_PERSONA=file:...` by default.** Rejected: more moving parts for no benefit. Everyone who reads the code already sees the strings; shipping them as a JSON asset doesn't improve readability and adds a runtime read on every boot. + +### D5. The 🐕 demo badge becomes a loader-supplied flag. + +Today `users.$username.tsx` checks `user.username === "bruno"` client-side. That was fine when Bruno was a literal. Now the check must know which username the running instance chose as its demo user. Two options: + +- **Ship the persona username to the client** so the comparison stays client-side. +- **Have the loader compute `isDemoUser` and pass a boolean to the component.** + +Choose the loader path. The persona username is not otherwise needed in the client, and shipping instance config through HTML reads as a small information leak even if it's technically public. `isDemoUser` is crisp. + +### D6. No migration. No backwards-compatibility shim. No deprecation window. + +This change is additive: new env var, default preserves current behaviour. There is nothing to migrate. The hardcoded string constants (`DEMO_USERNAME`, `DEMO_DISPLAY_NAME`, `DEMO_BIO`, `NAME_POOL_*`, `DESCRIPTION_POOL_*`) are removed and folded into `DEFAULT_PERSONA`. Importers of `DEMO_USERNAME` (there are none) would break — but the grep is clean. + +## Risks / Trade-offs + +**[Risk] Operator supplies a persona that conflicts with an existing real user's username on first enable.** +→ `ensureDemoUser()`'s insert currently ON CONFLICT DO NOTHING on `username` would silently pick up the existing real user as the demo user's owner — all subsequent synthetic rows would attach to that real user. Fix: before insert, SELECT the username; if it exists AND the row is not already marked demo, log an error and disable the bot for this process. Track via a new `demo-bot persona username clash` log line; the operator sees a loud error on boot. + +**[Risk] Operator supplies a persona with empty content pools.** +→ The Zod schema rejects arrays shorter than 3. Worker falls back to the default persona and logs the rejection reason. + +**[Risk] A `file:`-referenced JSON file is unreadable at boot (permissions, wrong mount).** +→ `loadPersona()` catches the read error and falls back to the default persona with a warn log. The bot stays functional on the built-in Bruno. + +**[Risk] The demo badge is skipped on a legitimately-chosen username that happens to clash with a real user.** +→ Not a real issue in practice: the badge reads `isDemoUser` from the loader, which is derived by `userId === personaUserId`, not by username comparison. Two users can never share an ID. + +**[Trade-off] One JSON blob vs. typed env vars.** +→ JSON is harder to lint in SOPS/CI than `KEY=value` pairs. Mitigated by the `file:` mode for nontrivial personas, and by the Zod validation surfacing the parse error at worker boot. + +## Migration Plan + +This is a no-op for `trails.cool` itself: + +1. Merge + deploy. Nothing changes — the built-in Bruno persona is the default, produced by the exact same strings the bot generates today. +2. For a self-hosted instance that wants its own persona: write `persona.json`, mount it or inline it via SOPS, set `DEMO_BOT_PERSONA=file:/path/to/persona.json`, set `DEMO_BOT_ENABLED=true`, and restart the journal container. + +Rollback is trivial: unset `DEMO_BOT_PERSONA` and restart. The default persona returns. + +## Open Questions + +- Should the persona schema include an emoji / glyph override so a cat-themed instance can replace the 🐕 in the badge? Leaning yes but the glyph is i18n'd today (lives in the `demo.badge` key), which complicates things. Defer — ship without it, revisit if a real host asks. +- Should we expose a `/api/demo-persona` endpoint for federation peers to fetch the current persona? No real need today; a federating peer doesn't need to know that a remote user is synthetic, only that their content is public. Park. diff --git a/openspec/changes/configurable-demo-persona/proposal.md b/openspec/changes/configurable-demo-persona/proposal.md new file mode 100644 index 0000000..ac29edf --- /dev/null +++ b/openspec/changes/configurable-demo-persona/proposal.md @@ -0,0 +1,31 @@ +## Why + +The demo-activity-bot is hardcoded to "Bruno the Berlin dog walker" — a persona that reads well for `trails.cool` itself but makes no sense for a self-hosted instance in Tokyo, Denver, or Edinburgh. If we want federated hosts to turn the demo on, each instance needs to choose its own demo identity, region, and copy so visitors see a plausible local-voice feed rather than a stranger's Berlin park patrol. + +## What Changes + +- Make the demo user's identity (username, display name, bio) configurable per instance via env — today `ensureDemoUser()` inserts `bruno` with a fixed display name and bio. +- Allow each instance to supply its own generated-content pools (route-name pool and description pool) in EN + DE — today these are hardcoded string arrays in `demo-bot.server.ts`. +- Allow each instance to restrict the demo to its preferred locale(s) — today the bot picks EN or DE with a coin flip. +- Keep all configuration in a single `DEMO_BOT_PERSONA` JSON blob (env or file path) so operators can supply one artifact instead of threading five env vars. +- Retain the current built-in Bruno persona as the default when no override is supplied, so `trails.cool` itself keeps its current behavior with no operator action. +- Keep `DEMO_BOT_ENABLED`, `DEMO_BOT_REGION`, and `DEMO_BOT_RETENTION_DAYS` as they are today (region is a separate concern from persona; retention + enable-flag are orthogonal). + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `demo-activity-bot`: the bot's identity and generated copy become a **persona contract** supplied by the operator rather than a fixed Bruno/Berlin combination. The generation gate, retention, cap, and synthetic flag are unchanged. + +## Impact + +- **Code:** `apps/journal/app/lib/demo-bot.server.ts` — replace the four hardcoded `*_POOL_*` arrays and the three `DEMO_*` constants with a loaded-once `DemoPersona` object. `ensureDemoUser()` consumes the persona's `username`/`displayName`/`bio`. `templateName`/`templateDescription` read from the persona's pools. +- **UX:** `/users/` — the 🐕 demo badge needs to gate on a runtime-known username, not a literal `"bruno"`. Move the gate to loader data (`isDemoUser` boolean) rather than a client-side string check. +- **Ops:** `infrastructure/.env.example`, `docker-compose.yml` — add `DEMO_BOT_PERSONA` passthrough; document the JSON shape. Existing `DEMO_BOT_REGION` et al. stay. +- **Docs:** a short persona-authoring note in `docs/` (one page) so host operators know the JSON shape + sensible pool sizes. +- **Tests:** new unit tests for `loadPersona()` (env → persona, fallback to built-in Bruno, malformed JSON → fallback + warn). Existing integration + E2E tests continue to work unchanged because the default persona is still Bruno. +- **No breaking change for `trails.cool`:** the built-in Bruno persona remains the default and is exactly the current hardcoded strings. diff --git a/openspec/changes/configurable-demo-persona/specs/demo-activity-bot/spec.md b/openspec/changes/configurable-demo-persona/specs/demo-activity-bot/spec.md new file mode 100644 index 0000000..71a0cf2 --- /dev/null +++ b/openspec/changes/configurable-demo-persona/specs/demo-activity-bot/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Persona configuration +The Journal SHALL load a demo persona — username, display name, bio, supported locales, and per-locale content pools — from configuration at worker boot, and SHALL fall back to a built-in default persona if no override is supplied or the supplied override fails validation. + +#### Scenario: No override supplied — built-in default applies +- **WHEN** the worker starts with `DEMO_BOT_ENABLED=true` and `DEMO_BOT_PERSONA` is unset +- **THEN** the bot uses the built-in default persona (`username=bruno`, playful Berlin-flavoured display name and bio, `locales=["en","de"]`, and the shipped Bruno-voiced name/description pools) +- **AND** behaviour is identical to the demo bot before this change + +#### Scenario: Inline JSON override +- **WHEN** `DEMO_BOT_PERSONA` is set to a valid inline JSON object with `username`, `displayName`, `bio`, `locales`, and `content.names` / `content.descriptions` for each listed locale +- **THEN** the bot uses that persona — `ensureDemoUser` inserts a row with the persona's username/displayName/bio/sentinel email, and subsequent generated routes and activities draw names and descriptions from the persona's per-locale pools + +#### Scenario: File-backed override +- **WHEN** `DEMO_BOT_PERSONA` is set to `file:` and the referenced file contains a valid persona JSON object +- **THEN** the worker reads the file once at boot and uses its contents as the persona + +#### Scenario: Invalid JSON or schema violation → fall back +- **WHEN** `DEMO_BOT_PERSONA` is set but the value is not valid JSON, or fails the persona schema (bad username pattern, empty or too-short pool, unsupported locale, etc.) +- **THEN** the worker logs a warn-level entry describing the first validation failure and uses the built-in default persona +- **AND** the bot continues to run + +#### Scenario: File-backed path unreadable → fall back +- **WHEN** `DEMO_BOT_PERSONA=file:` but the file cannot be read (missing, permission denied, not a file) +- **THEN** the worker logs a warn-level entry and uses the built-in default persona + +### Requirement: Persona username clash detection +The Journal SHALL refuse to attach the demo bot to a pre-existing non-demo user account when the supplied persona username collides with a human user already registered on the instance. + +#### Scenario: Configured username belongs to a real user +- **WHEN** the worker starts, the persona's username matches an existing `users` row, and that row has no marker identifying it as a prior demo user (i.e. it was registered via the normal signup flow) +- **THEN** the worker logs an error-level "demo persona username clash" entry naming the colliding username +- **AND** the generation + prune jobs are not scheduled for this process — the bot stays disabled until the operator picks a different username +- **AND** the rest of the Journal continues to serve requests normally + +## MODIFIED Requirements + +### Requirement: Demo user bootstrap +The Journal SHALL ensure a dedicated bot user exists when the demo bot starts, creating it on first run if missing. The user's identity (username, display name, bio, sentinel email local-part) is derived from the active persona — either the operator-supplied persona or the built-in default. + +#### Scenario: Bot user created on first run +- **WHEN** the Journal worker starts with `DEMO_BOT_ENABLED=true` and no `users` row matches the persona's username +- **THEN** a new `users` row is inserted with that username, the persona's display name, the persona's bio, a sentinel email `@`, no passkey credentials, and `terms_accepted_at` + `terms_version` populated at the current version +- **AND** subsequent worker startups are idempotent — no second row is inserted + +#### Scenario: Demo user has no usable credentials +- **WHEN** any request attempts to authenticate as the demo user via passkey or magic-link +- **THEN** authentication fails because no passkey is registered and no mailbox receives magic-link mails + +### Requirement: Synthetic content generation job +The Journal SHALL run a recurring background job that generates one public route and one linked public activity for the demo user per run, subject to an env flag and a rate cap. The generated name and description are drawn from the active persona's per-locale content pools. + +#### Scenario: Disabled in non-production environments +- **WHEN** the `DEMO_BOT_ENABLED` env var is absent or any value other than `"true"` +- **THEN** the job body is a no-op: no BRouter calls, no inserts, no errors + +#### Scenario: Enabled generation flow (decide-to-walk fires) +- **WHEN** `DEMO_BOT_ENABLED=true`, the local hour is within 07:00–21:00, the per-tick Bernoulli roll fires, and the hard cap has not been reached +- **THEN** the job picks a random start and end point within the configured seed region, calls BRouter with the `trekking` profile, persists the returned GPX as a new route with `visibility='public'` and `synthetic=true`, and inserts a linked activity with the same GPX, `visibility='public'`, `synthetic=true`, a plausible `started_at` and `duration`, and a persona-voiced name + description sampled from one of the persona's supported locales +- **AND** the route and activity are attributed to the demo user + +#### Scenario: Locale restricted to a single language +- **WHEN** the persona's `locales` list is `["en"]` +- **THEN** every generated route's name and description come from the persona's English pool — the German pool is never sampled + +#### Scenario: Decide-to-walk does not fire +- **WHEN** the local hour is outside 07:00–21:00, or the Bernoulli roll does not fire +- **THEN** the job returns without inserting anything + +#### Scenario: BRouter failure is tolerated +- **WHEN** the BRouter call returns no route, a rate-limit, or an error +- **THEN** the job logs the failure, inserts nothing, and exits without throwing — the next scheduled tick retries + +#### Scenario: Hard cap prevents runaway growth +- **WHEN** there are already 40 or more synthetic items created in the last 14 days +- **THEN** the job skips generation for that tick + +#### Scenario: Singleton scheduling prevents overlap +- **WHEN** a tick fires while the previous run is still executing +- **THEN** the new tick is skipped (pg-boss singleton semantics) so the job cannot overlap itself diff --git a/openspec/changes/configurable-demo-persona/tasks.md b/openspec/changes/configurable-demo-persona/tasks.md new file mode 100644 index 0000000..e0f409a --- /dev/null +++ b/openspec/changes/configurable-demo-persona/tasks.md @@ -0,0 +1,45 @@ +## 1. Persona type + schema + +- [x] 1.1 Define `DemoPersona` TypeScript interface in `apps/journal/app/lib/demo-bot.server.ts` with `username`, `displayName`, `bio`, `locales: ("en"|"de")[]`, `content: { names: { en?: string[]; de?: string[] }, descriptions: { en?: string[]; de?: string[] } }` +- [x] 1.2 Add a Zod schema mirroring the interface: username `^[a-z0-9][a-z0-9_-]{1,30}$`, displayName 1–200 chars, bio 0–200 chars, locales a non-empty subset of `["en","de"]`, each content array 3–50 non-empty strings, ensure every listed locale has both `names` and `descriptions` populated +- [x] 1.3 Move the current hardcoded Bruno strings into a `DEFAULT_PERSONA: DemoPersona` export — confirm it round-trips through the Zod schema before export + +## 2. Loader + +- [x] 2.1 Add `loadPersona(): DemoPersona` that reads `DEMO_BOT_PERSONA`: if unset → `DEFAULT_PERSONA`; if it starts with `file:` → read the rest as an absolute path via `fs.readFileSync`; otherwise treat as inline JSON +- [x] 2.2 Parse with the Zod schema; on validation failure or file-read failure, log a warn with the first error message and return `DEFAULT_PERSONA` +- [x] 2.3 Cache the result in a module-level `Object.freeze`d constant so every caller sees the same instance +- [x] 2.4 Remove the now-unused `DEMO_USERNAME`, `DEMO_DISPLAY_NAME`, `DEMO_BIO`, `NAME_POOL_EN`, `NAME_POOL_DE`, `DESCRIPTION_POOL_EN`, `DESCRIPTION_POOL_DE` constants — every reference must flow through the persona + +## 3. Wire into bootstrap + generation + +- [x] 3.1 `ensureDemoUser()` takes the loaded persona and writes `username`, `displayName`, `bio`, and sentinel email `${persona.username}@` +- [x] 3.2 Before insert, `SELECT` the row matching the username; if it exists and was not inserted by a prior demo-bot boot (heuristic: email does not match the sentinel pattern), throw `DemoPersonaUsernameClashError`; server startup catches this and declines to schedule the demo jobs for the process +- [x] 3.3 `templateName(startedAt, locale)` reads from `persona.content.names[locale]` with the existing seeded indexing; same for `templateDescription` +- [x] 3.4 Locale selection in `generateOneWalk` picks randomly from `persona.locales` via `pickLocale()` (dropping the hardcoded 50/50 EN/DE coin flip) + +## 4. UX — demo badge via loader flag + +- [x] 4.1 In `users.$username.tsx` loader, compute `isDemoUser` by comparing `user.username` against `loadPersona().username`; include `isDemoUser` in the loader return +- [x] 4.2 Replace the client-side `user.username === "bruno"` check with the loader-supplied boolean +- [x] 4.3 Keep the `demo.badge` i18n key exactly as-is so existing translations continue to work + +## 5. Env + docs + +- [x] 5.1 Add `DEMO_BOT_PERSONA` passthrough in `infrastructure/docker-compose.yml` (journal service) +- [x] 5.2 Add a commented `DEMO_BOT_PERSONA` example to `infrastructure/.env.example` with the inline JSON form AND the `file:/path/to/persona.json` form +- [x] 5.3 Write `docs/demo-persona.md` — a one-page guide covering: schema shape, inline vs `file:` modes, required pool sizes, how the built-in default looks, and an example persona for a non-Berlin instance + +## 6. Tests + +- [x] 6.1 Unit: `loadPersona()` — unset env returns default; valid inline JSON parses to the persona; invalid JSON falls back + warns; valid JSON that violates the Zod schema falls back + warns +- [x] 6.2 Unit: `loadPersona()` with `file:` — reads a real file via a temp path; unreadable file falls back + warns +- [x] 6.3 Unit: `templateName`/`templateDescription` draw from the supplied persona's pool and never return entries outside it +- [x] 6.4 Integration (`DEMO_BOT_INTEGRATION=1`): `ensureDemoUser` with the default persona is idempotent; clash with a pre-existing real user throws `DemoPersonaUsernameClashError` +- [x] 6.5 E2E: no regression — `/users/bruno` still shows the 🐕 badge for the built-in Bruno persona (re-ran `e2e/demo-bot.test.ts` unchanged, both pass) + +## 7. Rollout + +- [x] 7.1 Merge + deploy — nothing changes because default persona equals current behaviour +- [ ] 7.2 Validate on prod: `/users/bruno` unchanged, synthetic content cadence unchanged, metrics unchanged +- [ ] 7.3 (Optional demo) On a staging or second instance, ship a non-Bruno persona via `DEMO_BOT_PERSONA=file:...` and confirm the demo user renders with the new identity + voice diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d2149f..c61e98a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,9 @@ importers: react-router: specifier: 'catalog:' version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + zod: + specifier: ^3.25.0 + version: 3.25.76 devDependencies: '@react-router/dev': specifier: 'catalog:'