mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
69f1261060
commit
493db0c3b2
14 changed files with 849 additions and 8 deletions
112
scripts/personas/seed.ts
Normal file
112
scripts/personas/seed.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue