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

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();