feat(personas): M2.a-c — persona schemas + admin endpoints + seed pipeline

Continuation of docs/plans/mana-mcp-and-personas.md. Personas are the
auto-test users the M3 runner will drive — they're real Mana users
(kind='persona', tier='founder'), registered through the same Better
Auth pipeline as humans, just stamped differently and metadata-tracked
so the persona-runner knows how to role-play them.

Schemas (auth namespace — personas are 1:1 with users, no reason for a
separate platform.* schema that the plan originally sketched)

- userKindEnum ('human' | 'persona' | 'system') + users.kind column,
  wired into better-auth additionalFields so the JWT/user object carry
  the flag. Default 'human' keeps every existing user untouched.
- auth.personas — 1:1 descriptor (archetype, systemPrompt, moduleMix
  jsonb, tickCadence, lastActiveAt). CASCADE from users.id.
- auth.persona_actions — tick-grouped audit of every tool call the
  runner makes (toolName, inputHash for dedup, result, latency).
- auth.persona_feedback — structured 1-5 ratings per module per tick,
  plus free-text notes. This is where the runner writes the
  self-reflection step at end of each tick.

Admin endpoints (/api/v1/admin/personas, admin-tier-gated)

- POST /            create-or-update by email. Uses auth.api.signUpEmail
                    if the user's new, then stamps kind+tier+verified
                    and upserts the personas row. Idempotent — safe to
                    re-run after catalog edits.
- GET  /            list with 7-day action count per persona.
- GET  /:id         detail + recent 20 actions + per-module feedback
                    aggregate.
- DELETE /:id       hard delete. Refuses non-persona users as
                    defense-in-depth: an admin typo here would cascade
                    through the full user-delete chain.

Catalog + seed pipeline (scripts/personas/)

- catalog.json      10 handwritten personas spanning 7 archetypes
                    (adhd-student, ceo-busy, creative-parent, solo-dev,
                    researcher, freelancer, overwhelmed-newbie).
                    Five pairs of personas that will later share
                    family/team spaces (cross-space setup is deferred
                    to M2.d per the plan).
- catalog.ts        zod-validated loader. Refines email to require
                    @mana.test TLD — non-existent, no bounce risk.
- password.ts       deterministic HMAC-SHA256(PERSONA_SEED_SECRET,
                    email). No stored per-persona credentials; the
                    runner re-derives on every login. Refuses the
                    dev-fallback secret in production.
- seed.ts           POST /admin/personas per catalog entry. Flags:
                    --auth=, --jwt=, --dry-run.
- cleanup.ts        Hard-delete every live persona. Warns when the
                    live set drifts from the catalog.

Root package.json:
  pnpm seed:personas
  pnpm seed:personas:cleanup

Extends the ESLint root-ignore list with `scripts/**` so Bun-typed
utility scripts don't fail the typed-parser check they weren't opted
into. Consistent with the rest of scripts/ being .mjs+.sh.

To go live (user action):
  pnpm docker:up
  cd services/mana-auth && bun run db:push
  export MANA_ADMIN_JWT=...
  pnpm seed:personas

M2.d deferred: cross-space (family/team/practice) memberships between
persona pairs. Better Auth's org-invite flow is multi-step and would
roughly double the M2 scope; the persona-runner (M3) can operate in
personal spaces first, shared-space tests land as their own milestone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 13:55:14 +02:00
parent 69f1261060
commit 493db0c3b2
14 changed files with 849 additions and 8 deletions

View file

@ -0,0 +1,86 @@
{
"$schema": "./catalog.schema.json",
"description": "Persona catalog for the M2 phase of the MCP/Personas plan. Each persona becomes a real Mana user with kind='persona' and access_tier='founder'. Edit freely; pnpm seed:personas re-applies as upserts.",
"personas": [
{
"name": "Anna",
"email": "persona.anna@mana.test",
"archetype": "adhd-student",
"systemPrompt": "Du bist Anna, 28, Studentin der Sozialen Arbeit mit ADHS. Du nutzt Mana, um deine vielen Gedanken irgendwo abzulegen, Aufgaben nicht zu vergessen und deine Stimmung zu tracken. Du schreibst eher kurz, oft umgangssprachlich, manchmal hektisch. Vergisst auch mal Dinge. Tippst schnell, machst Tippfehler.",
"moduleMix": { "todo": 0.3, "journal": 0.3, "notes": 0.2, "mood": 0.2 },
"tickCadence": "daily"
},
{
"name": "Ben",
"email": "persona.ben@mana.test",
"archetype": "adhd-student",
"systemPrompt": "Du bist Ben, 24, Mitbewohner von Anna, ebenfalls ADHS. Du teilst dir mit Anna einen Family-Space für WG-Sachen (Einkaufslisten, geteilte Termine). Persönlich nutzt du Mana für Studium und Sport.",
"moduleMix": { "todo": 0.4, "calendar": 0.3, "journal": 0.2, "habits": 0.1 },
"tickCadence": "daily"
},
{
"name": "Marcus",
"email": "persona.marcus@mana.test",
"archetype": "ceo-busy",
"systemPrompt": "Du bist Marcus, 42, Geschäftsführer eines Software-Unternehmens. Wenig Zeit, viele Kontakte, viele Termine. Du nutzt Mana hauptsächlich für strukturierte Notizen aus Meetings, Kontakt-Pflege, Tagesplanung. Schreibst präzise und ergebnisorientiert.",
"moduleMix": { "contacts": 0.3, "calendar": 0.3, "todo": 0.2, "articles": 0.2 },
"tickCadence": "weekdays"
},
{
"name": "Lena",
"email": "persona.lena@mana.test",
"archetype": "ceo-busy",
"systemPrompt": "Du bist Lena, 38, Co-Founder im selben Unternehmen wie Marcus. Du teilst dir mit Marcus einen Team-Space für Projekt-Notizen und gemeinsame Termine. Privater Stil: ruhig, reflektierend, mehr Journal als Marcus.",
"moduleMix": { "journal": 0.3, "contacts": 0.2, "calendar": 0.2, "notes": 0.3 },
"tickCadence": "weekdays"
},
{
"name": "Sofia",
"email": "persona.sofia@mana.test",
"archetype": "creative-parent",
"systemPrompt": "Du bist Sofia, 35, Mutter zweier Kinder, freiberufliche Designerin. Du nutzt Mana sehr visuell — viele Notizen mit Skizzen-Gedanken, Tagebuch-Einträge zur Familie, Träume. Schreibst poetisch und assoziativ.",
"moduleMix": { "journal": 0.3, "notes": 0.3, "dreams": 0.2, "mood": 0.2 },
"tickCadence": "daily"
},
{
"name": "Tom",
"email": "persona.tom@mana.test",
"archetype": "creative-parent",
"systemPrompt": "Du bist Tom, 37, Sofias Partner. Lehrer, organisierter als Sofia. Ihr teilt einen Family-Space für Familien-Termine, Einkäufe und gemeinsame Projekte. Privat: pragmatisch, listenorientiert.",
"moduleMix": { "todo": 0.4, "calendar": 0.3, "notes": 0.2, "habits": 0.1 },
"tickCadence": "daily"
},
{
"name": "Kai",
"email": "persona.kai@mana.test",
"archetype": "solo-dev",
"systemPrompt": "Du bist Kai, 30, Solo-Entwickler. Du dokumentierst alles in Mana — gelesene Artikel, Code-Notizen, Bücher-Library, Lern-Ziele. Sehr strukturiert, viele Tags, lange Markdown-Notes. Schreibst auf Deutsch oder Englisch je nach Kontext.",
"moduleMix": { "articles": 0.3, "notes": 0.3, "library": 0.2, "goals": 0.2 },
"tickCadence": "daily"
},
{
"name": "Julia",
"email": "persona.julia@mana.test",
"archetype": "researcher",
"systemPrompt": "Du bist Julia, 33, wissenschaftliche Mitarbeiterin Linguistik. Du nutzt Mana fast ausschließlich als Wissens-Datenbank: Artikel, RSS-Feeds, lange Notizen mit Zitaten. Sehr akribisch in Tagging.",
"moduleMix": { "articles": 0.4, "notes": 0.4, "newsResearch": 0.2 },
"tickCadence": "weekdays"
},
{
"name": "Paul",
"email": "persona.paul@mana.test",
"archetype": "freelancer",
"systemPrompt": "Du bist Paul, 45, freiberuflicher Fotograf. Du nutzt Mana für Rechnungen, Kunden-Kontakte und Termine. Pragmatisch, knapp. Meist ein Eintrag pro Tag.",
"moduleMix": { "invoices": 0.4, "contacts": 0.3, "calendar": 0.3 },
"tickCadence": "weekdays"
},
{
"name": "Maya",
"email": "persona.maya@mana.test",
"archetype": "overwhelmed-newbie",
"systemPrompt": "Du bist Maya, 22, neu bei Mana, hast die App vor zwei Wochen entdeckt. Du verstehst noch nicht alle Features. Manchmal probierst du Dinge aus, die nicht zusammenpassen, oder gibst widersprüchliche Anweisungen. Schreibst einzelne Wörter, kurze Sätze, machst Tippfehler. Du sollst auch ehrlich kommentieren wenn etwas verwirrend ist.",
"moduleMix": { "todo": 0.6, "journal": 0.4 },
"tickCadence": "daily"
}
]
}

View file

@ -0,0 +1,43 @@
/**
* Catalog loader + zod-validated schema. Centralised so seed.ts and
* cleanup.ts (and any future runner) all see the same shape.
*
* The JSON itself lives at `catalog.json` and is the human-edited source.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { z } from 'zod';
const cadenceSchema = z.enum(['daily', 'weekdays', 'hourly']);
const personaSchema = z.object({
name: z.string().min(1),
email: z
.string()
.email()
.refine((e) => e.endsWith('@mana.test'), {
message: 'persona emails must end in @mana.test (non-existent TLD, no real bounces)',
}),
archetype: z.string().min(1),
systemPrompt: z.string().min(20),
moduleMix: z.record(z.string(), z.number().nonnegative()),
tickCadence: cadenceSchema.default('daily'),
});
export type PersonaSpec = z.infer<typeof personaSchema>;
const catalogSchema = z.object({
$schema: z.string().optional(),
description: z.string().optional(),
personas: z.array(personaSchema).min(1),
});
export function loadCatalog(): { personas: PersonaSpec[] } {
const here = dirname(fileURLToPath(import.meta.url));
const raw = readFileSync(join(here, 'catalog.json'), 'utf-8');
const parsed = JSON.parse(raw) as unknown;
const validated = catalogSchema.parse(parsed);
return { personas: validated.personas };
}

111
scripts/personas/cleanup.ts Normal file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env bun
/**
* Hard-delete every persona known to the catalog.
*
* Wipes the user row (cascades through personas, persona_actions,
* persona_feedback, sessions, vault, organizations the user owns alone,
* and downstream sync data). Useful after iterating on the catalog or
* before a fresh seed run.
*
* Refuses to touch any user where kind != 'persona' (the admin endpoint
* enforces this server-side too defense in depth).
*
* Usage:
* pnpm seed:personas:cleanup
* pnpm seed:personas:cleanup --auth=https://auth.mana.how --jwt=eyJ…
*/
import { loadCatalog } from './catalog';
interface CliOptions {
authUrl: string;
adminJwt: string;
}
function parseArgs(): CliOptions {
const args = process.argv.slice(2);
const get = (key: string): string | undefined => {
const found = args.find((a) => a.startsWith(`--${key}=`));
return found?.slice(`--${key}=`.length);
};
const authUrl = get('auth') ?? process.env.MANA_AUTH_URL ?? 'http://localhost:3001';
const adminJwt = get('jwt') ?? process.env.MANA_ADMIN_JWT ?? '';
if (!adminJwt) {
console.error('❌ Missing admin JWT. Set MANA_ADMIN_JWT or pass --jwt=…');
process.exit(1);
}
return { authUrl, adminJwt };
}
interface PersonaListEntry {
userId: string;
email: string;
archetype: string;
}
async function listPersonas(opts: CliOptions): Promise<PersonaListEntry[]> {
const res = await fetch(`${opts.authUrl}/api/v1/admin/personas`, {
headers: { authorization: `Bearer ${opts.adminJwt}` },
});
if (!res.ok) {
throw new Error(`GET /admin/personas → ${res.status}: ${await res.text()}`);
}
const body = (await res.json()) as { personas: PersonaListEntry[] };
return body.personas;
}
async function deletePersona(opts: CliOptions, userId: string, email: string): Promise<void> {
const res = await fetch(`${opts.authUrl}/api/v1/admin/personas/${userId}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${opts.adminJwt}` },
});
if (!res.ok) {
const body = await res.text().catch(() => '<unreadable>');
throw new Error(`DELETE /admin/personas/${userId}${res.status}: ${body.slice(0, 300)}`);
}
console.log(` ✓ deleted ${email} (${userId.slice(0, 8)}…)`);
}
async function main(): Promise<void> {
const opts = parseArgs();
// Cross-reference catalog ↔ live to catch drift (a persona that lives
// in the DB but not the catalog still gets deleted — cleanup means
// "back to zero personas", not "back to catalog").
const catalogEmails = new Set(loadCatalog().personas.map((p) => p.email));
const live = await listPersonas(opts);
if (live.length === 0) {
console.log('▸ Nothing to delete — no personas in mana-auth.');
return;
}
console.log(`▸ Found ${live.length} persona(s) to delete:`);
for (const p of live) {
const inCatalog = catalogEmails.has(p.email);
console.log(` · ${p.email} (${p.archetype})${inCatalog ? '' : ' [⚠ not in catalog]'}`);
}
console.log('');
const failures: string[] = [];
for (const p of live) {
try {
await deletePersona(opts, p.userId, p.email);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`${p.email}${msg}`);
failures.push(p.email);
}
}
console.log('');
if (failures.length > 0) {
console.error(`✗ Done with ${failures.length} failure(s).`);
process.exit(1);
}
console.log(`✓ Done. ${live.length} personas deleted.`);
}
void main();

View file

@ -0,0 +1,31 @@
/**
* Deterministic per-persona password.
*
* HMAC-SHA256(PERSONA_SEED_SECRET, persona.email) base64 trim to 32
* chars. Long enough for Better Auth's 8128 range, identical every run
* so seed/login are repeatable, but only knowable to whoever holds the
* env secret.
*
* Why not random + storage: storing per-persona passwords means another
* place to encrypt and audit. The runner needs to log in as the persona
* many times looking up a stored cred per call is wasteful when the
* same HMAC gives us the same answer in O(1).
*/
import { createHmac } from 'node:crypto';
const DEV_FALLBACK_SECRET = 'dev-persona-seed-secret-rotate-in-prod';
export function personaPassword(email: string): string {
const secret = process.env.PERSONA_SEED_SECRET ?? DEV_FALLBACK_SECRET;
if (secret === DEV_FALLBACK_SECRET && process.env.NODE_ENV === 'production') {
throw new Error(
'PERSONA_SEED_SECRET must be set in production — refusing to derive persona passwords from the dev fallback.'
);
}
const hmac = createHmac('sha256', secret).update(email).digest('base64');
// Strip non-alphanumerics so the result is safe in CLI quotes,
// keep at least one digit + one letter for password-policy compliance.
const cleaned = hmac.replace(/[^a-zA-Z0-9]/g, '');
return cleaned.slice(0, 32);
}

112
scripts/personas/seed.ts Normal file
View file

@ -0,0 +1,112 @@
#!/usr/bin/env bun
/**
* Seed the persona catalog into mana-auth.
*
* Idempotent: re-running upserts metadata, never duplicates users. New
* personas in catalog.json get registered; existing ones get their
* descriptor refreshed.
*
* Requires:
* - mana-auth running (default http://localhost:3001)
* - An admin-tier user JWT (export MANA_ADMIN_JWT or pass --jwt=)
* - PERSONA_SEED_SECRET in env (or accept the dev fallback locally)
*
* Usage:
* pnpm seed:personas
* pnpm seed:personas --auth=https://auth.mana.how --jwt=eyJ…
*/
import { loadCatalog, type PersonaSpec } from './catalog';
import { personaPassword } from './password';
interface CliOptions {
authUrl: string;
adminJwt: string;
dryRun: boolean;
}
function parseArgs(): CliOptions {
const args = process.argv.slice(2);
const get = (key: string): string | undefined => {
const found = args.find((a) => a.startsWith(`--${key}=`));
return found?.slice(`--${key}=`.length);
};
const authUrl = get('auth') ?? process.env.MANA_AUTH_URL ?? 'http://localhost:3001';
const adminJwt = get('jwt') ?? process.env.MANA_ADMIN_JWT ?? '';
const dryRun = args.includes('--dry-run');
if (!adminJwt) {
console.error(
'❌ Missing admin JWT. Set MANA_ADMIN_JWT or pass --jwt=… (must be a token for a user with role=admin).'
);
process.exit(1);
}
return { authUrl, adminJwt, dryRun };
}
async function upsertPersona(opts: CliOptions, p: PersonaSpec): Promise<void> {
const password = personaPassword(p.email);
const body = {
email: p.email,
name: p.name,
password,
archetype: p.archetype,
systemPrompt: p.systemPrompt,
moduleMix: p.moduleMix,
tickCadence: p.tickCadence,
};
if (opts.dryRun) {
console.log(` · would upsert ${p.email} (${p.archetype})`);
return;
}
const res = await fetch(`${opts.authUrl}/api/v1/admin/personas`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${opts.adminJwt}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '<unreadable body>');
throw new Error(`POST /admin/personas → ${res.status}: ${text.slice(0, 300)}`);
}
const result = (await res.json()) as { ok: true; userId: string; email: string };
console.log(`${result.email} (${p.archetype}) user=${result.userId.slice(0, 8)}`);
}
async function main(): Promise<void> {
const opts = parseArgs();
const { personas } = loadCatalog();
console.log(`▸ Persona catalog: ${personas.length} entries`);
console.log(`▸ Auth URL: ${opts.authUrl}`);
if (opts.dryRun) console.log('▸ DRY-RUN — no requests will be sent');
console.log('');
const failures: Array<{ email: string; error: string }> = [];
for (const persona of personas) {
try {
await upsertPersona(opts, persona);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`${persona.email}${msg}`);
failures.push({ email: persona.email, error: msg });
}
}
console.log('');
if (failures.length > 0) {
console.error(`✗ Done with ${failures.length} failure(s).`);
process.exit(1);
}
console.log(`✓ Done. ${personas.length} personas upserted.`);
}
void main();