mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
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:
parent
795b39e065
commit
546b94d472
9 changed files with 636 additions and 25 deletions
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
283
apps/api/src/modules/personas/admin-routes.ts
Normal file
283
apps/api/src/modules/personas/admin-routes.ts
Normal 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 });
|
||||
});
|
||||
211
apps/api/src/modules/personas/internal-routes.ts
Normal file
211
apps/api/src/modules/personas/internal-routes.ts
Normal 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 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;
|
||||
98
apps/api/src/modules/personas/schema.ts
Normal file
98
apps/api/src/modules/personas/schema.ts
Normal 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 — 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),
|
||||
]
|
||||
);
|
||||
|
|
@ -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}` },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue