feat(personas): move admin + internal endpoints from mana-auth to apps/api

Schließt die platform/product-split-Lücke: HEAD's apps/api/src/index.ts
referenziert seit dem Forms-M10d-Commit personasInternalRoutes /
personasAdminRoutes — die Implementierung lag aber noch nicht im Repo.
Build war strukturell broken bis hierhin.

Was wandert von mana-auth nach apps/api:

  apps/api/src/modules/personas/
    ├── schema.ts          — pgSchema('personas') mit personas /
    │                        persona_actions / persona_feedback;
    │                        userId ist plain text (Cross-DB-FK auf
    │                        mana-auth's auth.users geht nach Split nicht).
    ├── internal-routes.ts — service-key gated GET /due, POST /:id/actions
    │                        und POST /:id/feedback. Append-only +
    │                        idempotent über deterministische row-ids
    │                        (tickId-i-tool / tickId-module).
    └── admin-routes.ts    — admin-JWT gated CRUD; ruft mana-auth via
                             /api/v1/admin/users + /api/v1/auth/register
                             + /api/v1/internal/users/:id/persona-stamp
                             für den User-Lifecycle.

Persona-runner-Client zeigt jetzt auf apps/api:

  - config.ts: neues apiUrl-Feld (default http://localhost:3060,
    Env MANA_API_URL); authUrl bleibt für /api/v1/auth/login + spaces.
  - clients/mana-auth-internal.ts: drei Calls treffen jetzt
    /api/v1/personas/internal/* statt mana-auth's
    /api/v1/internal/personas/* — Datei-Name bleibt um Call-Site-Diff
    klein zu halten.
  - index.ts: ManaAuthInternalClient bekommt config.apiUrl statt authUrl.

Seed/Cleanup-Skripte:

  - --api= als bevorzugter Flag, --auth= als Legacy-Alias (cached
    Shell-History würde sonst hart brechen).
  - default http://localhost:3060, Env MANA_API_URL.
  - Endpoint-Pfade umgeschrieben:
      POST   /api/v1/admin/personas        → /api/v1/personas/admin
      DELETE /api/v1/admin/personas/:id    → /api/v1/personas/admin/:id

drizzle.config.ts: schema-Array + schemaFilter um 'personas' erweitert.
DB-push ist Pflicht-Schritt vor erstem Boot, sonst 42P01 auf /due.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-06 20:38:29 +02:00
parent 795b39e065
commit 546b94d472
9 changed files with 636 additions and 25 deletions

View file

@ -7,18 +7,19 @@ import { defineConfig } from 'drizzle-kit';
* schema's generated SQL lives under `drizzle/{schema}/`. Expand the
* `schema` array and `schemaFilter` when a new module joins.
*
* Currently managed: `research`, `website`, `unlisted`.
* Currently managed: `research`, `website`, `unlisted`, `personas`.
*/
export default defineConfig({
schema: [
'./src/modules/research/schema.ts',
'./src/modules/website/schema.ts',
'./src/modules/unlisted/schema.ts',
'./src/modules/personas/schema.ts',
],
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform',
},
schemaFilter: ['research', 'website', 'unlisted'],
schemaFilter: ['research', 'website', 'unlisted', 'personas'],
});

View file

@ -0,0 +1,283 @@
/**
* Personas admin endpoints.
*
* Mounted at `/api/v1/personas/admin/*`. Caller must hold an admin-role
* JWT (validated by the global authMiddleware in apps/api). All routes
* include an inline role check as defence-in-depth.
*
* POST / create-or-update by email (idempotent)
* GET / list with action counts
* GET /:id detail + recent actions + feedback aggregate
* DELETE /:id hard delete (persona row + user via mana-auth)
*
* Persona creation flow:
* 1. Resolve user by email via mana-auth (`/api/v1/admin/users?search=`)
* 2. If absent, register via mana-auth (`/api/v1/auth/register`)
* 3. Stamp tier=founder via mana-auth (`/api/v1/admin/users/:id/tier`)
* 4. Stamp emailVerified + kind via mana-auth's internal stamp endpoint
* 5. Upsert the persona row locally with email denormalised
*
* Step 4 needs a service-key endpoint on platform mana-auth added
* alongside this migration as `POST /api/v1/internal/users/:id/persona-stamp`.
*/
import { Hono } from 'hono';
import { count, desc, eq, gte } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import type { AuthVariables } from '@mana/shared-hono';
import { getConnection } from '../../lib/db';
import { personas, personaActions, personaFeedback } from './schema';
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']);
function authBaseUrl(): string {
return process.env.MANA_AUTH_URL ?? 'http://localhost:3001';
}
function serviceKey(): string {
const key = process.env.MANA_SERVICE_KEY;
if (!key) throw new Error('MANA_SERVICE_KEY is not set in apps/api env');
return key;
}
async function manaAuth<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
body?: unknown
): Promise<{ ok: boolean; status: number; body: T }> {
const res = await fetch(`${authBaseUrl()}${path}`, {
method,
headers: {
'content-type': 'application/json',
'x-service-key': serviceKey(),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed: T;
try {
parsed = text ? (JSON.parse(text) as T) : ({} as T);
} catch {
parsed = {} as T;
}
return { ok: res.ok, status: res.status, body: parsed };
}
export const personasAdminRoutes = new Hono<{ Variables: AuthVariables }>();
// Defence-in-depth: the global authMiddleware already ran before we
// got here, but inline-checking role keeps this file self-contained
// if mounted under a different prefix later.
personasAdminRoutes.use('*', async (c, next) => {
const role = c.get('userRole');
if (role !== 'admin') {
return c.json({ error: 'Forbidden', message: 'Admin access required' }, 403);
}
await next();
});
const db = () =>
drizzle(getConnection(), { schema: { personas, personaActions, personaFeedback } });
// ─── POST / ─ create or update ──────────────────────────────────────
personasAdminRoutes.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);
// 1) Look up by email
let userId: string | undefined;
const lookup = await manaAuth<{ users?: Array<{ id: string; email: string }> }>(
'GET',
`/api/v1/admin/users?search=${encodeURIComponent(body.email)}`
);
if (lookup.ok) {
userId = lookup.body.users?.find((u) => u.email === body.email)?.id;
}
// 2) Register if absent (public endpoint, no service-key needed but
// we send it anyway — mana-auth ignores it on public routes).
if (!userId) {
const reg = await manaAuth<{ user?: { id: string }; userId?: string }>(
'POST',
'/api/v1/auth/register',
{
email: body.email,
password: body.password,
name: body.name ?? body.email.split('@')[0],
}
);
if (!reg.ok) {
return c.json({ error: 'Sign-up failed', detail: reg.body }, 500);
}
userId = reg.body.user?.id ?? reg.body.userId;
if (!userId) return c.json({ error: 'Sign-up returned no user id' }, 500);
}
// 3) Stamp persona kind + emailVerified + tier via mana-auth.
// The endpoint is service-key gated (added in platform mana-auth as
// part of the personas migration).
const stamp = await manaAuth(
'POST',
`/api/v1/internal/users/${encodeURIComponent(userId)}/persona-stamp`,
{ accessTier: 'founder' }
);
if (!stamp.ok) {
return c.json({ error: 'Persona stamp failed', detail: stamp.body }, 500);
}
// 4) Upsert persona row locally
await db()
.insert(personas)
.values({
userId,
email: body.email,
archetype: body.archetype,
systemPrompt: body.systemPrompt,
moduleMix: body.moduleMix,
tickCadence: body.tickCadence ?? 'daily',
})
.onConflictDoUpdate({
target: personas.userId,
set: {
email: body.email,
archetype: body.archetype,
systemPrompt: body.systemPrompt,
moduleMix: body.moduleMix,
tickCadence: body.tickCadence ?? 'daily',
},
});
return c.json({ ok: true, userId, email: body.email });
});
// ─── GET / ─ list ───────────────────────────────────────────────────
personasAdminRoutes.get('/', async (c) => {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const rows = await db()
.select({
userId: personas.userId,
email: personas.email,
archetype: personas.archetype,
tickCadence: personas.tickCadence,
lastActiveAt: personas.lastActiveAt,
createdAt: personas.createdAt,
})
.from(personas)
.orderBy(desc(personas.createdAt));
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 /:id ─ detail ──────────────────────────────────────────────
personasAdminRoutes.get('/:id', async (c) => {
const userId = c.req.param('id');
const [row] = await db()
.select({
userId: personas.userId,
email: personas.email,
archetype: personas.archetype,
systemPrompt: personas.systemPrompt,
moduleMix: personas.moduleMix,
tickCadence: personas.tickCadence,
lastActiveAt: personas.lastActiveAt,
createdAt: personas.createdAt,
})
.from(personas)
.where(eq(personas.userId, userId));
if (!row) return c.json({ error: 'Not found' }, 404);
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 /:id ─ hard delete ──────────────────────────────────────
//
// Safety-checked by "row exists in personas" — apps/api owns this
// table, so the existence of a personas row IS the persona-marker.
// Real human users are never reachable here (no row → 404).
personasAdminRoutes.delete('/:id', async (c) => {
const userId = c.req.param('id');
const [row] = await db()
.select({ userId: personas.userId })
.from(personas)
.where(eq(personas.userId, userId));
if (!row) return c.json({ error: 'Not found' }, 404);
// Local cascade: deleting the personas row also cascades to
// persona_actions/persona_feedback via... wait — Drizzle doesn't
// declare onDelete cascade across tables here, since they reference
// personas.userId by plain text not FK. Delete each table explicitly.
await db().delete(personaActions).where(eq(personaActions.personaId, userId));
await db().delete(personaFeedback).where(eq(personaFeedback.personaId, userId));
await db().delete(personas).where(eq(personas.userId, userId));
// Then delete the user via mana-auth's admin DELETE.
const del = await manaAuth('DELETE', `/api/v1/admin/users/${encodeURIComponent(userId)}/data`);
if (!del.ok) {
// Persona row is already gone locally; mana-auth deletion failed.
// Return success-with-warning so the admin UI can show a cleanup hint.
return c.json({
ok: true,
deleted: userId,
warning: 'persona row deleted but mana-auth user deletion failed',
detail: del.body,
});
}
return c.json({ ok: true, deleted: userId });
});

View file

@ -0,0 +1,211 @@
/**
* Personas internal/service-key endpoints for the persona-runner.
*
* Mounted at `/api/v1/personas/internal/*`. All requests must carry
* `X-Service-Key`; no user JWT is involved.
*
* GET /due list personas due for a tick
* POST /:id/actions batch of tool-call rows ( 500 per call)
* POST /:id/feedback batch of rating rows ( 100 per call)
*
* Both write endpoints are append-only and idempotent by deterministic
* row IDs derived from (tickId, ) so retries don't duplicate.
*
* Originally lived in mana-auth (`/api/v1/internal/personas/*`) but
* moved here when the platform/product split landed: personas are
* test-infrastructure for the Mana product, not a platform concern.
*/
import { Hono } from 'hono';
import { eq, isNull, lte, or, sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import { serviceAuthMiddleware } from '@mana/shared-hono';
import { getConnection } from '../../lib/db';
import { personas, personaActions, personaFeedback } from './schema';
interface ActionRow {
tickId: string;
toolName: string;
inputHash?: string;
result: 'ok' | 'error';
errorMessage?: string;
latencyMs?: number;
}
interface FeedbackRow {
tickId: string;
module: string;
rating: 1 | 2 | 3 | 4 | 5;
notes?: string;
}
function isValidAction(row: unknown): row is ActionRow {
if (!row || typeof row !== 'object') return false;
const r = row as Record<string, unknown>;
return (
typeof r.tickId === 'string' &&
typeof r.toolName === 'string' &&
(r.result === 'ok' || r.result === 'error')
);
}
function isValidFeedback(row: unknown): row is FeedbackRow {
if (!row || typeof row !== 'object') return false;
const r = row as Record<string, unknown>;
return (
typeof r.tickId === 'string' &&
typeof r.module === 'string' &&
typeof r.rating === 'number' &&
r.rating >= 1 &&
r.rating <= 5
);
}
export const personasInternalRoutes = new Hono();
personasInternalRoutes.use('*', serviceAuthMiddleware());
const db = () =>
drizzle(getConnection(), { schema: { personas, personaActions, personaFeedback } });
async function personaExists(personaId: string): Promise<boolean> {
const [row] = await db()
.select({ userId: personas.userId })
.from(personas)
.where(eq(personas.userId, personaId));
return !!row;
}
// ─── GET /api/v1/personas/internal/due ──────────────────────────────
//
// hourly — due if lastActiveAt is null or > 1 hour ago
// daily — due if lastActiveAt is null or > 24 hours ago
// weekdays — daily + server clock is MonFri
//
// `email` is denormalized on the personas row at upsert time, so the
// response shape is identical to the legacy mana-auth endpoint without
// needing a cross-database join.
personasInternalRoutes.get('/due', async (c) => {
const now = new Date();
const dow = now.getUTCDay();
const isWeekday = dow >= 1 && dow <= 5;
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const rows = await db()
.select({
userId: personas.userId,
email: personas.email,
archetype: personas.archetype,
systemPrompt: personas.systemPrompt,
moduleMix: personas.moduleMix,
tickCadence: personas.tickCadence,
lastActiveAt: personas.lastActiveAt,
})
.from(personas)
.where(
or(
sql`${personas.tickCadence} = 'hourly' AND (${personas.lastActiveAt} IS NULL OR ${personas.lastActiveAt} <= ${oneHourAgo})`,
sql`${personas.tickCadence} = 'daily' AND (${personas.lastActiveAt} IS NULL OR ${personas.lastActiveAt} <= ${oneDayAgo})`,
isWeekday
? sql`${personas.tickCadence} = 'weekdays' AND (${personas.lastActiveAt} IS NULL OR ${personas.lastActiveAt} <= ${oneDayAgo})`
: sql`false`
)
)
.orderBy(sql`${personas.lastActiveAt} NULLS FIRST`);
return c.json({ personas: rows, serverTime: now.toISOString() });
});
// ─── POST /:id/actions ──────────────────────────────────────────────
personasInternalRoutes.post('/:id/actions', async (c) => {
const personaId = c.req.param('id');
if (!(await personaExists(personaId))) {
return c.json({ error: 'Persona not found' }, 404);
}
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json({ error: 'Invalid JSON' }, 400);
}
const rawActions = (body as { actions?: unknown[] })?.actions;
if (!Array.isArray(rawActions) || rawActions.length === 0) {
return c.json({ error: '`actions` array required' }, 400);
}
if (rawActions.length > 500) {
return c.json({ error: '`actions` batch size must be ≤ 500' }, 400);
}
if (!rawActions.every(isValidAction)) {
return c.json({ error: 'One or more action rows failed validation' }, 400);
}
const now = new Date();
const values = rawActions.map((a, i) => ({
// Deterministic per (tickId, index, toolName) so a retried batch
// hits onConflictDoNothing instead of duplicating.
id: `${a.tickId}-${i}-${a.toolName}`.slice(0, 255),
personaId,
tickId: a.tickId,
toolName: a.toolName,
inputHash: a.inputHash ?? null,
result: a.result,
errorMessage: a.errorMessage ?? null,
latencyMs: a.latencyMs ?? null,
createdAt: now,
}));
await db().insert(personaActions).values(values).onConflictDoNothing();
await db().update(personas).set({ lastActiveAt: now }).where(eq(personas.userId, personaId));
return c.json({ ok: true, written: values.length });
});
// ─── POST /:id/feedback ─────────────────────────────────────────────
personasInternalRoutes.post('/:id/feedback', async (c) => {
const personaId = c.req.param('id');
if (!(await personaExists(personaId))) {
return c.json({ error: 'Persona not found' }, 404);
}
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json({ error: 'Invalid JSON' }, 400);
}
const rawFeedback = (body as { feedback?: unknown[] })?.feedback;
if (!Array.isArray(rawFeedback) || rawFeedback.length === 0) {
return c.json({ error: '`feedback` array required' }, 400);
}
if (rawFeedback.length > 100) {
return c.json({ error: '`feedback` batch size must be ≤ 100' }, 400);
}
if (!rawFeedback.every(isValidFeedback)) {
return c.json({ error: 'One or more feedback rows failed validation' }, 400);
}
const now = new Date();
const values = rawFeedback.map((f) => ({
id: `${f.tickId}-${f.module}`.slice(0, 255),
personaId,
tickId: f.tickId,
module: f.module,
rating: f.rating,
notes: f.notes ?? null,
createdAt: now,
}));
await db().insert(personaFeedback).values(values).onConflictDoNothing();
return c.json({ ok: true, written: values.length });
});
void isNull;
void lte;

View file

@ -0,0 +1,98 @@
/**
* Personas DB schema (Drizzle / pgSchema 'personas').
*
* Test-infrastructure for the persona-runner (`services/mana-persona-runner`):
* a Persona is a real Mana user (managed by mana-auth) with extra
* behavioural metadata describing how the runner should drive them
* archetype, system prompt, module mix, tick cadence.
*
* Why here (apps/api) and not in mana-auth?
* `userId` references mana-auth's `auth.users`, which lives in a
* different database after the platform split. Cross-DB FKs aren't
* possible, so we own this product-specific schema in mana_platform
* and use plain UUIDs as references the standard cross-service
* pattern documented in `Code/mana/docs/PLAN.md` §4.
*
* Three tables:
* - personas per-persona descriptor (one row per user that
* is actually a persona)
* - persona_actions audit trail of every tool call the runner made
* - persona_feedback 15 module ratings the runner emits per tick
*/
import { jsonb, integer, smallint, text, timestamp, index, pgSchema } from 'drizzle-orm/pg-core';
export const personasSchema = pgSchema('personas');
export const personas = personasSchema.table(
'personas',
{
userId: text('user_id').primaryKey(),
// Denormalized from mana-auth's auth.users.email so the runner's
// /due endpoint doesn't need a cross-DB join. Set on upsert by
// the admin route after it creates/finds the user via mana-auth's
// HTTP API. Kept in sync on email change is out of scope — when
// it happens, re-upsert the persona.
email: text('email').notNull(),
archetype: text('archetype').notNull(),
systemPrompt: text('system_prompt').notNull(),
moduleMix: jsonb('module_mix').notNull(),
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)]
);
export const personaActions = personasSchema.table(
'persona_actions',
{
id: text('id').primaryKey(),
personaId: text('persona_id').notNull(),
tickId: text('tick_id').notNull(),
toolName: text('tool_name').notNull(),
inputHash: text('input_hash'),
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),
]
);
export const personaFeedback = personasSchema.table(
'persona_feedback',
{
id: text('id').primaryKey(),
personaId: text('persona_id').notNull(),
tickId: text('tick_id').notNull(),
module: text('module').notNull(),
rating: smallint('rating').notNull(),
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),
]
);

View file

@ -12,13 +12,13 @@
*
* Usage:
* pnpm seed:personas:cleanup
* pnpm seed:personas:cleanup --auth=https://auth.mana.how --jwt=eyJ…
* pnpm seed:personas:cleanup --api=https://api.mana.how --jwt=eyJ…
*/
import { loadCatalog } from './catalog';
interface CliOptions {
authUrl: string;
apiUrl: string;
adminJwt: string;
}
@ -29,14 +29,14 @@ function parseArgs(): CliOptions {
return found?.slice(`--${key}=`.length);
};
const authUrl = get('auth') ?? process.env.MANA_AUTH_URL ?? 'http://localhost:3001';
const apiUrl = get('api') ?? get('auth') ?? process.env.MANA_API_URL ?? 'http://localhost:3060';
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 };
return { apiUrl, adminJwt };
}
interface PersonaListEntry {
@ -46,7 +46,7 @@ interface PersonaListEntry {
}
async function listPersonas(opts: CliOptions): Promise<PersonaListEntry[]> {
const res = await fetch(`${opts.authUrl}/api/v1/admin/personas`, {
const res = await fetch(`${opts.apiUrl}/api/v1/personas/admin`, {
headers: { authorization: `Bearer ${opts.adminJwt}` },
});
if (!res.ok) {
@ -57,7 +57,7 @@ async function listPersonas(opts: CliOptions): Promise<PersonaListEntry[]> {
}
async function deletePersona(opts: CliOptions, userId: string, email: string): Promise<void> {
const res = await fetch(`${opts.authUrl}/api/v1/admin/personas/${userId}`, {
const res = await fetch(`${opts.apiUrl}/api/v1/personas/admin/${userId}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${opts.adminJwt}` },
});

View file

@ -1,26 +1,32 @@
#!/usr/bin/env bun
/**
* Seed the persona catalog into mana-auth.
* Seed the persona catalog via apps/api.
*
* Idempotent: re-running upserts metadata, never duplicates users. New
* personas in catalog.json get registered; existing ones get their
* descriptor refreshed.
*
* After the platform/product split, the personas admin endpoint lives
* in apps/api (which talks to mana-auth via service-key for user
* lifecycle). The script targets MANA_API_URL, defaulting to
* http://localhost:3060.
*
* Requires:
* - mana-auth running (default http://localhost:3001)
* - apps/api running (default http://localhost:3060)
* - mana-auth running (apps/api calls it for register + persona-stamp)
* - 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…
* pnpm seed:personas --api=https://api.mana.how --jwt=eyJ…
*/
import { loadCatalog, type PersonaSpec } from './catalog';
import { personaPassword } from './password';
interface CliOptions {
authUrl: string;
apiUrl: string;
adminJwt: string;
dryRun: boolean;
}
@ -32,7 +38,9 @@ function parseArgs(): CliOptions {
return found?.slice(`--${key}=`.length);
};
const authUrl = get('auth') ?? process.env.MANA_AUTH_URL ?? 'http://localhost:3001';
// Accept --api= going forward; --auth= still works as a legacy alias
// since some shells/scripts already cache the old flag.
const apiUrl = get('api') ?? get('auth') ?? process.env.MANA_API_URL ?? 'http://localhost:3060';
const adminJwt = get('jwt') ?? process.env.MANA_ADMIN_JWT ?? '';
const dryRun = args.includes('--dry-run');
@ -43,7 +51,7 @@ function parseArgs(): CliOptions {
process.exit(1);
}
return { authUrl, adminJwt, dryRun };
return { apiUrl, adminJwt, dryRun };
}
async function upsertPersona(opts: CliOptions, p: PersonaSpec): Promise<void> {
@ -63,7 +71,7 @@ async function upsertPersona(opts: CliOptions, p: PersonaSpec): Promise<void> {
return;
}
const res = await fetch(`${opts.authUrl}/api/v1/admin/personas`, {
const res = await fetch(`${opts.apiUrl}/api/v1/personas/admin`, {
method: 'POST',
headers: {
'content-type': 'application/json',
@ -74,7 +82,7 @@ async function upsertPersona(opts: CliOptions, p: PersonaSpec): Promise<void> {
if (!res.ok) {
const text = await res.text().catch(() => '<unreadable body>');
throw new Error(`POST /admin/personas → ${res.status}: ${text.slice(0, 300)}`);
throw new Error(`POST /personas/admin${res.status}: ${text.slice(0, 300)}`);
}
const result = (await res.json()) as { ok: true; userId: string; email: string };
@ -86,7 +94,7 @@ async function main(): Promise<void> {
const { personas } = loadCatalog();
console.log(`▸ Persona catalog: ${personas.length} entries`);
console.log(`▸ Auth URL: ${opts.authUrl}`);
console.log(`▸ API URL: ${opts.apiUrl}`);
if (opts.dryRun) console.log('▸ DRY-RUN — no requests will be sent');
console.log('');

View file

@ -1,8 +1,13 @@
/**
* Service-to-service client for mana-auth's internal persona endpoints.
* Service-to-service client for the personas internal endpoints.
*
* Three calls: list due personas, post actions batch, post feedback
* batch. All gated by `X-Service-Key` (not a user JWT).
*
* After the platform/product split, personas live in apps/api
* (`mana-monorepo/apps/api`), not in mana-auth. The constructor takes
* the apps/api URL the file name stays the same to keep the
* callsite diff small (one import path), but the destination changed.
*/
import type { ActionRow, FeedbackRow } from '../runner/types.ts';
@ -19,7 +24,7 @@ export interface DuePersona {
export class ManaAuthInternalClient {
constructor(
private readonly authUrl: string,
private readonly apiUrl: string,
private readonly serviceKey: string
) {
if (!serviceKey) {
@ -35,7 +40,7 @@ export class ManaAuthInternalClient {
}
async listDuePersonas(): Promise<DuePersona[]> {
const res = await fetch(`${this.authUrl}/api/v1/internal/personas/due`, {
const res = await fetch(`${this.apiUrl}/api/v1/personas/internal/due`, {
headers: this.headers(),
});
if (!res.ok) {
@ -47,7 +52,7 @@ export class ManaAuthInternalClient {
async postActions(personaId: string, actions: ActionRow[]): Promise<void> {
if (actions.length === 0) return;
const res = await fetch(`${this.authUrl}/api/v1/internal/personas/${personaId}/actions`, {
const res = await fetch(`${this.apiUrl}/api/v1/personas/internal/${personaId}/actions`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ actions }),
@ -59,7 +64,7 @@ export class ManaAuthInternalClient {
async postFeedback(personaId: string, feedback: FeedbackRow[]): Promise<void> {
if (feedback.length === 0) return;
const res = await fetch(`${this.authUrl}/api/v1/internal/personas/${personaId}/feedback`, {
const res = await fetch(`${this.apiUrl}/api/v1/personas/internal/${personaId}/feedback`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ feedback }),

View file

@ -6,12 +6,16 @@
export interface Config {
port: number;
/** mana-auth base URL — for login, spaces list, persistence callbacks. */
/** mana-auth base URL — for login + spaces list. */
authUrl: string;
/** apps/api base URL for /api/v1/personas/internal/* callbacks
* (due, actions, feedback). Personas live in mana_platform now,
* not in mana-auth. */
apiUrl: string;
/** mana-mcp base URL — where Claude talks to the tool registry. */
mcpUrl: string;
/** Service key for /api/v1/internal/* callbacks into mana-auth. */
/** Service key for /api/v1/personas/internal/* callbacks into apps/api. */
serviceKey: string;
/** Anthropic API key that drives each persona's Claude call. */
@ -60,6 +64,7 @@ export function loadConfig(): Config {
return {
port: intEnv('PORT', 3070),
authUrl: process.env.MANA_AUTH_URL ?? 'http://localhost:3001',
apiUrl: process.env.MANA_API_URL ?? 'http://localhost:3060',
mcpUrl: process.env.MANA_MCP_URL ?? 'http://localhost:3069',
serviceKey: process.env.MANA_SERVICE_KEY ?? '',
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',

View file

@ -23,7 +23,7 @@ assertProductionSecrets(config);
const authClient = new AuthClient(config.authUrl);
const internalClient = config.serviceKey
? new ManaAuthInternalClient(config.authUrl, config.serviceKey)
? new ManaAuthInternalClient(config.apiUrl, config.serviceKey)
: null;
const app = new Hono();