diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index d9a7455d9..8cdaa6837 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -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'], }); diff --git a/apps/api/src/modules/personas/admin-routes.ts b/apps/api/src/modules/personas/admin-routes.ts new file mode 100644 index 000000000..1e4783a42 --- /dev/null +++ b/apps/api/src/modules/personas/admin-routes.ts @@ -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; + 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( + 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 }); +}); diff --git a/apps/api/src/modules/personas/internal-routes.ts b/apps/api/src/modules/personas/internal-routes.ts new file mode 100644 index 000000000..26bb14fa7 --- /dev/null +++ b/apps/api/src/modules/personas/internal-routes.ts @@ -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; + 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; + 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 { + 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 Mon–Fri +// +// `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; diff --git a/apps/api/src/modules/personas/schema.ts b/apps/api/src/modules/personas/schema.ts new file mode 100644 index 000000000..48e6b8fbd --- /dev/null +++ b/apps/api/src/modules/personas/schema.ts @@ -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 — 1–5 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), + ] +); diff --git a/scripts/personas/cleanup.ts b/scripts/personas/cleanup.ts index dafcaeef3..5335edb6f 100644 --- a/scripts/personas/cleanup.ts +++ b/scripts/personas/cleanup.ts @@ -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 { - 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 { } async function deletePersona(opts: CliOptions, userId: string, email: string): Promise { - 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}` }, }); diff --git a/scripts/personas/seed.ts b/scripts/personas/seed.ts index eaef2e605..a07f7639f 100644 --- a/scripts/personas/seed.ts +++ b/scripts/personas/seed.ts @@ -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 { @@ -63,7 +71,7 @@ async function upsertPersona(opts: CliOptions, p: PersonaSpec): Promise { 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 { if (!res.ok) { const text = await res.text().catch(() => ''); - 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 { 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(''); diff --git a/services/mana-persona-runner/src/clients/mana-auth-internal.ts b/services/mana-persona-runner/src/clients/mana-auth-internal.ts index f4dac3d4b..5d20af179 100644 --- a/services/mana-persona-runner/src/clients/mana-auth-internal.ts +++ b/services/mana-persona-runner/src/clients/mana-auth-internal.ts @@ -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 { - 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 { 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 { 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 }), diff --git a/services/mana-persona-runner/src/config.ts b/services/mana-persona-runner/src/config.ts index 545cbd298..bb17d3ef6 100644 --- a/services/mana-persona-runner/src/config.ts +++ b/services/mana-persona-runner/src/config.ts @@ -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 ?? '', diff --git a/services/mana-persona-runner/src/index.ts b/services/mana-persona-runner/src/index.ts index 64d8579ee..238e17d8c 100644 --- a/services/mana-persona-runner/src/index.ts +++ b/services/mana-persona-runner/src/index.ts @@ -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();