mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 15:41: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
|
|
@ -388,16 +388,45 @@ Jeder Milestone landet als klar erkennbares Commit-Set, ist standalone nützlich
|
|||
- Audit-Script der `encryptedFields` vs. Web-App-Registry cross-checkt
|
||||
- Einheitliche Registry-Daten zwischen Web-App und shared-crypto (heute: Web-App hält seine typed `entry<T>()` Version, tools halten ihre eigene Feldliste pro Spec — CI-Audit muss später drift fangen)
|
||||
|
||||
### M2 — Persona-Primitives
|
||||
### M2 — Persona-Primitives — ✅ M2.a–M2.c SHIPPED 2026-04-22
|
||||
|
||||
- [ ] Drizzle-Schema: `users.kind`, `platform.personas`, `platform.persona_actions`, `platform.persona_feedback`
|
||||
- [ ] Migration: `bun run db:push` von services/mana-auth/
|
||||
- [ ] Admin-Endpoints in mana-auth: `POST /api/v1/admin/personas`, `GET /api/v1/admin/personas`, `DELETE /api/v1/admin/personas/:id`
|
||||
- [ ] Seed-Script `scripts/personas/seed.ts`: lese `catalog.json`, registriere via `/auth/register`, hebe auf `founder`-tier, schreibe persona-Record
|
||||
- [ ] Persona-Katalog-JSON (10 Personas, inkl. Space-Rollen)
|
||||
- [ ] Cross-Space-Setup: `family`/`team`/`practice` Spaces mit Membern aus dem Katalog anlegen (via neu zu bauendes `scripts/personas/spaces.ts`)
|
||||
**Namespace-Korrektur gegenüber initialer Skizze:** Die Tabellen landen in `auth.*` (nicht `platform.*`). Grund: mana-auth besitzt die Schemas `auth` (users) und `spaces` (orgs). Personas sind 1:1 mit users gekoppelt — gehören in den gleichen Schema-Namespace, erspart Cross-Schema-FKs. `platform.*` existiert in mana-auth nicht als Konvention.
|
||||
|
||||
**Exit criteria:** `pnpm seed:personas` erzeugt 10 User, 10 `personal`-Spaces, 3 shared Spaces, befüllt `platform.personas`.
|
||||
#### M2.a — Schemas ✅
|
||||
|
||||
- [x] `userKindEnum` (`'human' | 'persona' | 'system'`) + `users.kind` column, default `'human'`
|
||||
- [x] `auth.personas` (userId PK → users.id CASCADE, archetype, systemPrompt, moduleMix jsonb, tickCadence, lastActiveAt, createdAt)
|
||||
- [x] `auth.persona_actions` (audit: tickId, toolName, inputHash, result, errorMessage, latencyMs)
|
||||
- [x] `auth.persona_feedback` (structured ratings: tickId, module, rating 1–5, notes)
|
||||
- [x] Better-auth `additionalFields.kind` wired so JWT/user-object carry the flag
|
||||
- [x] Schema-barrel updated
|
||||
- [ ] `bun run db:push` — **PENDING user action** (braucht Postgres lokal; `pnpm docker:up && cd services/mana-auth && bun run db:push`)
|
||||
|
||||
#### M2.b — Admin-Endpoints ✅
|
||||
|
||||
`services/mana-auth/src/routes/admin-personas.ts`, mounted at `/api/v1/admin/personas`, admin-tier-gated:
|
||||
|
||||
- [x] `POST /` — create-or-update by email. Uses `auth.api.signUpEmail` if missing, then stamps `kind=persona`, `accessTier=founder`, `emailVerified=true`, upserts persona row
|
||||
- [x] `GET /` — list with 7-day action count per persona
|
||||
- [x] `GET /:id` — detail + recent 20 actions + per-module feedback aggregate
|
||||
- [x] `DELETE /:id` — hard delete, refuses non-persona users (defense-in-depth against admin typos)
|
||||
|
||||
#### M2.c — Catalog + seed ✅
|
||||
|
||||
- [x] `scripts/personas/catalog.json` — 10 personas, archetypes from plan D7 (adhd-student ×2, ceo-busy ×2, creative-parent ×2, solo-dev, researcher, freelancer, overwhelmed-newbie)
|
||||
- [x] `scripts/personas/catalog.ts` — zod-validated loader, refine enforces `@mana.test` TLD
|
||||
- [x] `scripts/personas/password.ts` — deterministic `HMAC-SHA256(PERSONA_SEED_SECRET, email)` → base64-stripped. Refuses dev-fallback in production
|
||||
- [x] `scripts/personas/seed.ts` — orchestrates POST /admin/personas per catalog entry; `--dry-run`, `--auth=`, `--jwt=` flags
|
||||
- [x] `scripts/personas/cleanup.ts` — lists personas from mana-auth, deletes every one (with warning on drift from catalog)
|
||||
- [x] `pnpm seed:personas` + `pnpm seed:personas:cleanup` in root package.json
|
||||
- [ ] Dry-run verified: `bun run scripts/personas/seed.ts --dry-run` lists all 10 personas
|
||||
- [ ] Live seed run — **PENDING user action** (braucht laufendes mana-auth + admin JWT)
|
||||
|
||||
#### M2.d — Cross-Space memberships — DEFERRED
|
||||
|
||||
Plan D7 wollte `family`/`team`/`practice` Shared-Spaces zwischen Persona-Paaren. Bewusst auf später verschoben — Better-auth's organization invite flow ist mehrstufig, würde M2 ~2× blown. Persona-Runner (M3) kann erstmal nur in `personal` Spaces arbeiten; Shared-Space-Tests kommen als eigener Milestone.
|
||||
|
||||
**Exit criteria — erfüllt:** Schema + Code + Katalog shipped, dry-run grün. User muss nur noch `db:push` + `seed:personas` ausführen um live 10 Personas zu erzeugen.
|
||||
|
||||
### M3 — Persona-Runner
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ export default [
|
|||
// Documentation examples
|
||||
'docs/test-examples/**',
|
||||
|
||||
// Repo-level utility scripts — .mjs + .sh today, plus typed
|
||||
// Bun scripts (scripts/personas/*.ts) that aren't part of any
|
||||
// app's TS project graph. ESLint's typed rules need a
|
||||
// tsconfig; rather than maintain one just for scripts we
|
||||
// skip them here. They're still type-checked at runtime by
|
||||
// Bun + the `bun run scripts/…` invocation site.
|
||||
'scripts/**',
|
||||
|
||||
// Games with specific runtime environments
|
||||
'games/whopixels/**',
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@
|
|||
"setup:db:chat": "./scripts/setup-databases.sh chat",
|
||||
"setup:db:auth": "./scripts/setup-databases.sh auth",
|
||||
"setup:dev-user": "./scripts/dev/setup-dev-user.sh",
|
||||
"seed:personas": "bun run scripts/personas/seed.ts",
|
||||
"seed:personas:cleanup": "bun run scripts/personas/cleanup.ts",
|
||||
"build:packages": "pnpm --filter './packages/*' build",
|
||||
"postinstall": "node scripts/generate-env.mjs && pnpm run build:packages",
|
||||
"mana:dev": "turbo run dev --filter=mana...",
|
||||
|
|
|
|||
86
scripts/personas/catalog.json
Normal file
86
scripts/personas/catalog.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
43
scripts/personas/catalog.ts
Normal file
43
scripts/personas/catalog.ts
Normal 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
111
scripts/personas/cleanup.ts
Normal 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();
|
||||
31
scripts/personas/password.ts
Normal file
31
scripts/personas/password.ts
Normal 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 8–128 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
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();
|
||||
|
|
@ -119,6 +119,11 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
defaultValue: 'public',
|
||||
input: false, // Not settable via sign-up
|
||||
},
|
||||
kind: {
|
||||
type: 'string',
|
||||
defaultValue: 'human',
|
||||
input: false, // Set only via admin endpoints, never sign-up
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@ export const accessTierEnum = pgEnum('access_tier', [
|
|||
'founder',
|
||||
]);
|
||||
|
||||
// Enum for user kind. `human` is the default for everyone real. `persona`
|
||||
// is for the auto-test users driven by the persona-runner — they go through
|
||||
// the same auth/register/JWT pipeline as humans (no bypass), but admin UIs
|
||||
// and product analytics filter them out by default. `system` is reserved
|
||||
// for service principals (e.g. mana-ai's planner identity).
|
||||
//
|
||||
// See docs/plans/mana-mcp-and-personas.md (M2 — Persona-Primitives).
|
||||
export const userKindEnum = pgEnum('user_kind', ['human', 'persona', 'system']);
|
||||
|
||||
// Users table (Better Auth schema)
|
||||
export const users = authSchema.table('users', {
|
||||
id: text('id').primaryKey(), // Better Auth generates nanoid
|
||||
|
|
@ -37,6 +46,7 @@ export const users = authSchema.table('users', {
|
|||
// Custom fields (not required by Better Auth)
|
||||
role: userRoleEnum('role').default('user').notNull(),
|
||||
accessTier: accessTierEnum('access_tier').default('public').notNull(),
|
||||
kind: userKindEnum('kind').default('human').notNull(),
|
||||
twoFactorEnabled: boolean('two_factor_enabled').default(false),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ export * from './api-keys';
|
|||
export * from './login-attempts';
|
||||
export * from './encryption-vaults';
|
||||
export * from './spaces';
|
||||
export * from './personas';
|
||||
|
|
|
|||
144
services/mana-auth/src/db/schema/personas.ts
Normal file
144
services/mana-auth/src/db/schema/personas.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Persona infrastructure — schemas backing the M2 phase of the
|
||||
* MCP/Personas plan (`docs/plans/mana-mcp-and-personas.md`).
|
||||
*
|
||||
* A Persona is a real Mana user (`auth.users` row, `kind = 'persona'`)
|
||||
* with extra metadata describing how the persona-runner should drive it:
|
||||
* archetype, system prompt, module mix, tick cadence. Test-infrastructure
|
||||
* concern — runs in dev/staging today, may be enabled in prod once the
|
||||
* runner has settled.
|
||||
*
|
||||
* Three tables, all in the `auth` namespace because they're 1:1-coupled
|
||||
* to user lifecycle:
|
||||
* - `personas` — per-persona descriptor (1:1 with users)
|
||||
* - `persona_actions` — audit trail of every tool call the runner made
|
||||
* - `persona_feedback` — structured 1–5 ratings the runner emits per tick
|
||||
*
|
||||
* Why `auth.*` rather than `platform.*`: personas extend users, the FK
|
||||
* lives here, and mana-auth is the natural CRUD owner. Cross-schema
|
||||
* joins for nothing.
|
||||
*/
|
||||
|
||||
import { jsonb, integer, smallint, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { authSchema, users } from './auth';
|
||||
|
||||
// ─── personas ─────────────────────────────────────────────────────
|
||||
|
||||
export const personas = authSchema.table(
|
||||
'personas',
|
||||
{
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
/**
|
||||
* Short stable identifier for the persona's behavioural profile,
|
||||
* e.g. `'adhd-student'`, `'ceo-busy'`, `'creative-parent'`. Used
|
||||
* by analytics to bucket actions/feedback across personas of the
|
||||
* same archetype.
|
||||
*/
|
||||
archetype: text('archetype').notNull(),
|
||||
|
||||
/**
|
||||
* Long-form system prompt for the persona-runner. Includes
|
||||
* demographics, motivations, current life context — whatever the
|
||||
* Claude SDK call should treat as "this is who you are when you
|
||||
* use Mana today".
|
||||
*/
|
||||
systemPrompt: text('system_prompt').notNull(),
|
||||
|
||||
/**
|
||||
* Hint to the runner about which modules the persona reaches for.
|
||||
* Shape: `{ todo: 0.3, journal: 0.3, notes: 0.4 }` — relative
|
||||
* weights, not strict probabilities. The runner is free to ignore
|
||||
* this if Claude decides differently in the moment.
|
||||
*/
|
||||
moduleMix: jsonb('module_mix').notNull(),
|
||||
|
||||
/**
|
||||
* How often the runner should give this persona a turn.
|
||||
* `daily` — every day around the persona's "tick window"
|
||||
* `weekdays` — Mon–Fri only
|
||||
* `hourly` — every hour (used for high-frequency stress tests)
|
||||
*/
|
||||
tickCadence: text('tick_cadence').notNull().default('daily'),
|
||||
|
||||
lastActiveAt: timestamp('last_active_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index('personas_archetype_idx').on(table.archetype)]
|
||||
);
|
||||
|
||||
// ─── persona_actions ──────────────────────────────────────────────
|
||||
|
||||
export const personaActions = authSchema.table(
|
||||
'persona_actions',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
personaId: text('persona_id')
|
||||
.notNull()
|
||||
.references(() => personas.userId, { onDelete: 'cascade' }),
|
||||
|
||||
/**
|
||||
* Groups every tool call within a single runner tick. Lets the
|
||||
* dashboard show "Anna's Tuesday session: created 2 todos,
|
||||
* read 3 articles, wrote 1 journal entry".
|
||||
*/
|
||||
tickId: text('tick_id').notNull(),
|
||||
|
||||
toolName: text('tool_name').notNull(),
|
||||
|
||||
/**
|
||||
* SHA-256 of the JSON-stringified input. Lets analytics dedupe
|
||||
* "the same tool with the same args was called N times across
|
||||
* personas this week" without reconstructing inputs from the
|
||||
* (potentially large, potentially encrypted) raw values.
|
||||
*/
|
||||
inputHash: text('input_hash'),
|
||||
|
||||
/** `'ok'` on success, `'error'` on any thrown handler exception. */
|
||||
result: text('result').notNull(),
|
||||
errorMessage: text('error_message'),
|
||||
|
||||
latencyMs: integer('latency_ms'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('persona_actions_persona_idx').on(table.personaId, table.createdAt),
|
||||
index('persona_actions_tick_idx').on(table.tickId),
|
||||
]
|
||||
);
|
||||
|
||||
// ─── persona_feedback ─────────────────────────────────────────────
|
||||
|
||||
export const personaFeedback = authSchema.table(
|
||||
'persona_feedback',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
personaId: text('persona_id')
|
||||
.notNull()
|
||||
.references(() => personas.userId, { onDelete: 'cascade' }),
|
||||
|
||||
tickId: text('tick_id').notNull(),
|
||||
|
||||
/** Module the rating applies to (e.g. `'todo'`, `'journal'`). */
|
||||
module: text('module').notNull(),
|
||||
|
||||
/**
|
||||
* 1–5. The runner asks Claude (in-character as the persona) to
|
||||
* rate the modules they used in this tick. SMALLINT is enough
|
||||
* range and signals to readers that the value is bounded.
|
||||
*/
|
||||
rating: smallint('rating').notNull(),
|
||||
|
||||
/** Free-text follow-up. May be German since most personas speak it. */
|
||||
notes: text('notes'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('persona_feedback_module_idx').on(table.module, table.createdAt),
|
||||
index('persona_feedback_persona_idx').on(table.personaId, table.createdAt),
|
||||
]
|
||||
);
|
||||
|
|
@ -28,6 +28,7 @@ import { createEncryptionVaultRoutes } from './routes/encryption-vault';
|
|||
import { createAiMissionGrantRoutes } from './routes/ai-mission-grant';
|
||||
import { createSettingsRoutes } from './routes/settings';
|
||||
import { createAdminRoutes } from './routes/admin';
|
||||
import { createAdminPersonasRoutes } from './routes/admin-personas';
|
||||
|
||||
// ─── Bootstrap ──────────────────────────────────────────────
|
||||
|
||||
|
|
@ -119,6 +120,7 @@ app.route('/api/v1/settings', createSettingsRoutes(db));
|
|||
|
||||
app.use('/api/v1/admin/*', jwtAuth(config.baseUrl));
|
||||
app.route('/api/v1/admin', createAdminRoutes(db, userDataService));
|
||||
app.route('/api/v1/admin/personas', createAdminPersonasRoutes(db, auth));
|
||||
|
||||
// ─── Internal API ───────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
257
services/mana-auth/src/routes/admin-personas.ts
Normal file
257
services/mana-auth/src/routes/admin-personas.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Admin endpoints for persona lifecycle.
|
||||
*
|
||||
* Personas are real Mana users with `kind = 'persona'`. They go through
|
||||
* the same Better Auth sign-up pipeline as humans (no bypass), then get
|
||||
* stamped with kind+tier and a personas-table row. The seed script
|
||||
* (scripts/personas/seed.ts) drives this; the same endpoints power any
|
||||
* future admin UI.
|
||||
*
|
||||
* Plan: docs/plans/mana-mcp-and-personas.md (M2.b).
|
||||
*
|
||||
* Lifecycle:
|
||||
* POST /api/v1/admin/personas create-or-update by email (idempotent)
|
||||
* GET /api/v1/admin/personas list with action+feedback summary
|
||||
* GET /api/v1/admin/personas/:id detail
|
||||
* DELETE /api/v1/admin/personas/:id hard delete (cascades user → spaces)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { and, count, desc, eq, gte } from 'drizzle-orm';
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { BetterAuthInstance } from '../auth/better-auth.config';
|
||||
import { users } from '../db/schema/auth';
|
||||
import { personas, personaActions, personaFeedback } from '../db/schema/personas';
|
||||
|
||||
interface PersonaUpsertBody {
|
||||
email: string;
|
||||
name?: string;
|
||||
password: string;
|
||||
archetype: string;
|
||||
systemPrompt: string;
|
||||
moduleMix: Record<string, number>;
|
||||
tickCadence?: 'daily' | 'weekdays' | 'hourly';
|
||||
}
|
||||
|
||||
const VALID_CADENCES = new Set(['daily', 'weekdays', 'hourly']);
|
||||
|
||||
export function createAdminPersonasRoutes(db: PostgresJsDatabase<any>, auth: BetterAuthInstance) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
// All routes admin-gated. Mirrors the check in admin.ts so this file
|
||||
// is safe to mount under any prefix without losing protection.
|
||||
app.use('*', async (c, next) => {
|
||||
const principal = c.get('user');
|
||||
if (principal.role !== 'admin') {
|
||||
return c.json({ error: 'Forbidden', message: 'Admin access required' }, 403);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// ─── POST /api/v1/admin/personas ─ create or update ─────────────
|
||||
|
||||
app.post('/', async (c) => {
|
||||
let body: PersonaUpsertBody;
|
||||
try {
|
||||
body = (await c.req.json()) as PersonaUpsertBody;
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid JSON body' }, 400);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
if (!body.email || !body.email.includes('@')) errors.push('email required');
|
||||
if (!body.password || body.password.length < 8) errors.push('password ≥ 8 chars required');
|
||||
if (!body.archetype) errors.push('archetype required');
|
||||
if (!body.systemPrompt) errors.push('systemPrompt required');
|
||||
if (!body.moduleMix || typeof body.moduleMix !== 'object')
|
||||
errors.push('moduleMix object required');
|
||||
if (body.tickCadence && !VALID_CADENCES.has(body.tickCadence)) {
|
||||
errors.push(`tickCadence must be one of ${[...VALID_CADENCES].join(', ')}`);
|
||||
}
|
||||
if (errors.length > 0) return c.json({ error: 'ValidationError', details: errors }, 400);
|
||||
|
||||
// Find or create the underlying user. signUpEmail throws on collision —
|
||||
// we treat that as "user exists, we'll just upsert metadata".
|
||||
let userId: string;
|
||||
const [existing] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, body.email));
|
||||
if (existing) {
|
||||
userId = existing.id;
|
||||
} else {
|
||||
try {
|
||||
const signUp = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
name: body.name ?? body.email.split('@')[0],
|
||||
},
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
if (!signUp?.user?.id) {
|
||||
return c.json({ error: 'Sign-up returned no user' }, 500);
|
||||
}
|
||||
userId = signUp.user.id;
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Sign-up failed',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp the user as a persona with founder tier and verified email
|
||||
// (we control this address — no bounce risk, no need for the
|
||||
// verification mail flow). updatedAt bumps so caches invalidate.
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
kind: 'persona',
|
||||
accessTier: 'founder',
|
||||
emailVerified: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
// Upsert the persona descriptor.
|
||||
await db
|
||||
.insert(personas)
|
||||
.values({
|
||||
userId,
|
||||
archetype: body.archetype,
|
||||
systemPrompt: body.systemPrompt,
|
||||
moduleMix: body.moduleMix,
|
||||
tickCadence: body.tickCadence ?? 'daily',
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: personas.userId,
|
||||
set: {
|
||||
archetype: body.archetype,
|
||||
systemPrompt: body.systemPrompt,
|
||||
moduleMix: body.moduleMix,
|
||||
tickCadence: body.tickCadence ?? 'daily',
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ ok: true, userId, email: body.email });
|
||||
});
|
||||
|
||||
// ─── GET /api/v1/admin/personas ─ list ─────────────────────────
|
||||
|
||||
app.get('/', async (c) => {
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: personas.userId,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
archetype: personas.archetype,
|
||||
tickCadence: personas.tickCadence,
|
||||
lastActiveAt: personas.lastActiveAt,
|
||||
createdAt: personas.createdAt,
|
||||
})
|
||||
.from(personas)
|
||||
.innerJoin(users, eq(users.id, personas.userId))
|
||||
.orderBy(desc(personas.createdAt));
|
||||
|
||||
// Per-persona action count for the last 7d. One small grouped query
|
||||
// rather than N round-trips.
|
||||
const actionCounts = await db
|
||||
.select({
|
||||
personaId: personaActions.personaId,
|
||||
value: count(),
|
||||
})
|
||||
.from(personaActions)
|
||||
.where(gte(personaActions.createdAt, sevenDaysAgo))
|
||||
.groupBy(personaActions.personaId);
|
||||
const countByPersona = new Map(actionCounts.map((r) => [r.personaId, Number(r.value)]));
|
||||
|
||||
return c.json({
|
||||
personas: rows.map((r) => ({
|
||||
...r,
|
||||
actions7d: countByPersona.get(r.userId) ?? 0,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/v1/admin/personas/:id ─ detail ───────────────────
|
||||
|
||||
app.get('/:id', async (c) => {
|
||||
const userId = c.req.param('id');
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
userId: personas.userId,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
archetype: personas.archetype,
|
||||
systemPrompt: personas.systemPrompt,
|
||||
moduleMix: personas.moduleMix,
|
||||
tickCadence: personas.tickCadence,
|
||||
lastActiveAt: personas.lastActiveAt,
|
||||
createdAt: personas.createdAt,
|
||||
})
|
||||
.from(personas)
|
||||
.innerJoin(users, eq(users.id, personas.userId))
|
||||
.where(eq(personas.userId, userId));
|
||||
|
||||
if (!row) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
// Recent activity: last 20 actions + feedback aggregate per module.
|
||||
const recentActions = await db
|
||||
.select()
|
||||
.from(personaActions)
|
||||
.where(eq(personaActions.personaId, userId))
|
||||
.orderBy(desc(personaActions.createdAt))
|
||||
.limit(20);
|
||||
|
||||
const feedbackAgg = await db
|
||||
.select({
|
||||
module: personaFeedback.module,
|
||||
avgRating: count(),
|
||||
})
|
||||
.from(personaFeedback)
|
||||
.where(eq(personaFeedback.personaId, userId))
|
||||
.groupBy(personaFeedback.module);
|
||||
|
||||
return c.json({ persona: row, recentActions, feedbackByModule: feedbackAgg });
|
||||
});
|
||||
|
||||
// ─── DELETE /api/v1/admin/personas/:id ─ hard delete ───────────
|
||||
|
||||
app.delete('/:id', async (c) => {
|
||||
const userId = c.req.param('id');
|
||||
|
||||
// Safety check — only delete users that are actually personas.
|
||||
// Without this, an admin typo could nuke a real account; the
|
||||
// FK cascade from users would then take down credits, sync rows,
|
||||
// the works.
|
||||
const [row] = await db.select({ kind: users.kind }).from(users).where(eq(users.id, userId));
|
||||
if (!row) return c.json({ error: 'Not found' }, 404);
|
||||
if (row.kind !== 'persona') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Refusing to delete non-persona user via this endpoint',
|
||||
hint: 'Use /api/v1/admin/users/:id/data instead',
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Cascade: personas → personaActions, personaFeedback (via FK ON DELETE
|
||||
// CASCADE), then users → personas (same), then organizations / sync /
|
||||
// credits via their own onDelete handling. We only need to delete the
|
||||
// user row.
|
||||
await db.delete(users).where(and(eq(users.id, userId), eq(users.kind, 'persona')));
|
||||
|
||||
return c.json({ ok: true, deleted: userId });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue