Apply configurable-demo-persona: per-instance demo identity + voice

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>
This commit is contained in:
Ullrich Schäfer 2026-04-19 10:30:32 +02:00
parent f447852d70
commit fc4485f6ef
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
15 changed files with 877 additions and 113 deletions

View file

@ -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 (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="flex items-start gap-4">
@ -98,7 +103,7 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
<h1 className="text-2xl font-bold text-gray-900">
{user.displayName ?? user.username}
</h1>
{isDemo && (
{isDemoUser && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">
{t("demo.badge")}
</span>