Adds DEMO_BOT_PERSONA env var (inline JSON or file:<path>) 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) <noreply@anthropic.com>
6.2 KiB
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=trueandDEMO_BOT_PERSONAis 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_PERSONAis set to a valid inline JSON object withusername,displayName,bio,locales, andcontent.names/content.descriptionsfor each listed locale - THEN the bot uses that persona —
ensureDemoUserinserts 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_PERSONAis set tofile:<absolute-path>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_PERSONAis 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:<path>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
usersrow, 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=trueand nousersrow matches the persona's username - THEN a new
usersrow is inserted with that username, the persona's display name, the persona's bio, a sentinel email<username>@<domain>, no passkey credentials, andterms_accepted_at+terms_versionpopulated 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_ENABLEDenv 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
trekkingprofile, persists the returned GPX as a new route withvisibility='public'andsynthetic=true, and inserts a linked activity with the same GPX,visibility='public',synthetic=true, a plausiblestarted_atandduration, 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
localeslist 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