1/**2 * API/service contract shared by the worker (REST + MCP transports) and web.3 * The service layer implements TribesService once; both transports call it.4 */5import { z } from 'zod';6import { LIMITS } from './game.js';78// ---------------------------------------------------------------------------9// Records (DTOs as returned to clients)10// ---------------------------------------------------------------------------1112export type ScriptStatus = 'validated' | 'rejected';13export type StarKind = 'script' | 'sim';1415export interface ValidationReport {16 ok: boolean;17 /** Stage that failed: 'size' | 'encoding' | 'syntax' | 'smoke' — null when ok. */18 failedStage: string | null;19 /** Human/agent-readable detail (syntax error text, smoke-test crash, ...). */20 detail: string | null;21}2223export interface ScriptRecord {24 id: string; // lg_<nanoid>25 name: string;26 lang: 'js';27 version: number;28 hash: string; // sha-256 hex of source29 status: ScriptStatus;30 createdAt: string; // ISO31 /** null when starred (pinned). */32 expiresAt: string | null;33 starred: boolean;34 /** Public metadata (owner-editable; shown in Browse when listed). */35 title: string | null;36 description: string | null;37 sourceShared: boolean;38 modelId: string | null;39 /** Set when this upload deduplicated to another user's listed script. */40 linkedScriptId: string | null;41 /** ISO purge deadline while in the trash; null when live. */42 trashedUntil: string | null;43 /** True when the script is currently publicly listed in Browse. */44 listed: boolean;45}4647export interface ScriptWithSource extends ScriptRecord {48 source: string;49}5051export interface SimScriptRef {52 slot: number; // 0-based tribe slot53 scriptId: string;54 scriptName: string;55 version: number;56 hash: string;57}5859export interface SimRecord {60 id: string; // sim_<nanoid>61 seed: string;62 config: { tribeCount: number; mapSize: number; maxTicks: number };63 scripts: SimScriptRef[];64 createdAt: string;65 expiresAt: string | null;66 starred: boolean;67 url: string; // absolute share URL /s/<id>68 /** 'public' when every slot was publicly listed at creation (rated). */69 visibility: 'private' | 'public';70 /** 'ladder' sims are created by the nightly auto-matcher. */71 kind: 'user' | 'ladder';72}7374// ---------------------------------------------------------------------------75// Social layer DTOs76// ---------------------------------------------------------------------------7778export interface ModelRecord {79 id: string; // models.dev id, e.g. 'anthropic/claude-fable-5'80 name: string;81 lab: string;82 openWeights: boolean;83 releaseDate: string | null; // ISO date84 reasoning: boolean | null;85}8687/** A publicly listed script as shown in Browse. */88export interface PublicScriptListing {89 id: string;90 title: string; // falls back to name91 description: string | null;92 owner: { id: string; name: string | null };93 model: ModelRecord | null;94 rating: number;95 /** Rating gained inside the requested window (0 for all-time sorts). */96 windowDelta: number;97 games: number;98 wins: number;99 battles: number;100 hearts: number;101 heartedByMe: boolean;102 sourceShared: boolean;103 version: number;104 createdAt: string;105 updatedAt: string; // latest version timestamp106}107108/** One rated sim in a script's proof history. */109export interface ProofSim {110 simId: string;111 url: string;112 createdAt: string;113 kind: 'user' | 'ladder';114 /** All participants with server-verified outcomes, place-ordered. */115 results: Array<{ scriptId: string; title: string; score: number; place: number }>;116}117118export interface PublicScriptDetail extends PublicScriptListing {119 versions: Array<{ version: number; hash: string; createdAt: string }>;120 proof: ProofSim[];121 aggregate: {122 winRate: number; // per-mille, wins/games123 avgPlace: number; // x100 fixed point (e.g. 178 = 1.78)124 avgScore: number;125 /** Per-opponent record among rated sims. */126 opponents: Array<{ scriptId: string; title: string; wins: number; losses: number }>;127 };128 /** Present iff sourceShared. */129 source?: string;130}131132export interface LeaderboardRow {133 rank: number;134 id: string;135 title: string;136 owner: { id: string; name: string | null };137 model: ModelRecord | null;138 rating: number;139 windowDelta: number;140 games: number;141 battles: number;142 hearts: number;143}144145export const BROWSE_WINDOWS = ['24h', '7d', '30d', '6m', 'all'] as const;146export type BrowseWindow = (typeof BROWSE_WINDOWS)[number];147export const BROWSE_WINDOW_SECONDS: Record<BrowseWindow, number | null> = {148 '24h': 86_400,149 '7d': 7 * 86_400,150 '30d': 30 * 86_400,151 '6m': 183 * 86_400,152 all: null,153};154155/** Everything a viewer needs to deterministically replay a sim. */156export interface SimBundle {157 sim: SimRecord;158 /** hash -> source for every locked script. */159 sources: Record<string, string>;160}161162/** An external identity attached to a user (authorized-keys model). */163export interface KeyIdentity {164 externalId: string; // 'key:<pubkey>' | shoo subject | 'local:<name>'165 kind: 'key' | 'shoo' | 'dev';166 createdAt: string;167}168169export interface UserInfo {170 id: string;171 externalId: string;172 name: string | null;173 /** Master toggle: starred scripts are publicly listed while true. */174 publicSharing: boolean;175 /** ISO time until which the display name cannot change again (7-day cooldown), or null. */176 nameLockedUntil: string | null;177}178179// ---------------------------------------------------------------------------180// Inputs (zod — reused as MCP tool inputSchema and REST body validation)181// ---------------------------------------------------------------------------182183export const SubmitLogicInput = z.object({184 name: z185 .string()186 .min(1)187 .max(64)188 .regex(/^[\w][\w .-]*$/, 'name: letters/digits/space/._- only'),189 source: z.string().min(1).max(LIMITS.SCRIPT_MAX_BYTES),190 lang: z.literal('js').default('js'),191});192193export const StarInput = z.object({194 kind: z.enum(['script', 'sim']),195 id: z.string().min(1),196});197198export const CreateSimInput = z.object({199 /**200 * One per tribe slot, 2..8. Each may be: your own script id, a hearted201 * listed script id, or an example id (`ex_<name>`). Latest validated202 * version is locked at creation.203 */204 scriptIds: z.array(z.string().min(1)).min(LIMITS.MIN_TRIBES).max(LIMITS.MAX_TRIBES),205 /** Optional decimal-string seed; server generates one if omitted. */206 seed: z.string().regex(/^\d{1,15}$/).optional(),207});208209export const UpdateLogicMetaInput = z.object({210 id: z.string().min(1),211 title: z.string().max(60).nullable().optional(),212 description: z.string().max(280).nullable().optional(),213 /** models.dev id; must exist in the synced catalog. Null clears. */214 modelId: z.string().max(120).nullable().optional(),215 sourceShared: z.boolean().optional(),216});217218export const SetSharingInput = z.object({219 publicSharing: z.boolean().optional(),220 /** Public display name shown on Browse listings. Null clears. */221 name: z.string().max(40).nullable().optional(),222});223224export const HeartInput = z.object({ id: z.string().min(1) });225226export const BrowseInput = z.object({227 sort: z.enum(['top', 'rising', 'updated', 'battled']).default('top'),228 /** Window for 'rising' (rating gained). Ignored by other sorts. */229 window: z.enum(BROWSE_WINDOWS).default('7d'),230 openWeights: z.boolean().optional(),231 lab: z.string().max(64).optional(),232 releasedAfter: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),233 releasedBefore: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),234 q: z.string().max(120).optional(),235 limit: z.number().int().min(1).max(100).default(50),236 offset: z.number().int().min(0).default(0),237});238239export const LeaderboardInput = z.object({240 window: z.enum(BROWSE_WINDOWS).default('all'),241 limit: z.number().int().min(1).max(100).default(25),242});243244export const LinkAccountInput = z.object({245 /** Device-pairing code from the Connect tab (single-use, 10-min expiry). */246 code: z.string().min(4).max(64),247});248249export const ListModelsInput = z.object({250 q: z.string().max(120).optional(),251 lab: z.string().max(64).optional(),252 openWeights: z.boolean().optional(),253 limit: z.number().int().min(1).max(200).default(100),254});255256export type SubmitLogicInputT = z.infer<typeof SubmitLogicInput>;257export type StarInputT = z.infer<typeof StarInput>;258export type CreateSimInputT = z.infer<typeof CreateSimInput>;259export type UpdateLogicMetaInputT = z.infer<typeof UpdateLogicMetaInput>;260export type SetSharingInputT = z.infer<typeof SetSharingInput>;261export type HeartInputT = z.infer<typeof HeartInput>;262export type BrowseInputT = z.infer<typeof BrowseInput>;263export type LeaderboardInputT = z.infer<typeof LeaderboardInput>;264export type ListModelsInputT = z.infer<typeof ListModelsInput>;265export type LinkAccountInputT = z.infer<typeof LinkAccountInput>;266267// ---------------------------------------------------------------------------268// Service interface — implemented once in worker/, used by REST + MCP269// ---------------------------------------------------------------------------270271export interface AuthedUser {272 userId: string;273 externalId: string;274}275276/** Thrown by the service for client-visible failures; transports map to 4xx / MCP errors. */277export interface ServiceErrorShape {278 code:279 | 'not_found'280 | 'forbidden'281 | 'validation_failed'282 | 'star_limit'283 | 'bad_input'284 | 'expired';285 message: string;286}287288export interface TribesService {289 submitLogic(290 user: AuthedUser,291 input: SubmitLogicInputT,292 ): Promise<{ script: ScriptRecord; report: ValidationReport }>;293 listLogic(user: AuthedUser, opts?: { trashed?: boolean }): Promise<ScriptRecord[]>;294 getLogic(user: AuthedUser, id: string, version?: number): Promise<ScriptWithSource>;295 /** Soft delete: moves to the 14-day trash and unlists. */296 deleteLogic(user: AuthedUser, id: string): Promise<{ trashedUntil: string }>;297 restoreLogic(user: AuthedUser, id: string): Promise<ScriptRecord>;298 updateLogicMeta(user: AuthedUser, input: UpdateLogicMetaInputT): Promise<ScriptRecord>;299300 star(user: AuthedUser, input: StarInputT): Promise<void>;301 unstar(user: AuthedUser, input: StarInputT): Promise<void>;302 listStars(user: AuthedUser): Promise<Array<{ kind: StarKind; id: string }>>;303304 setSharing(user: AuthedUser, input: SetSharingInputT): Promise<UserInfo>;305 /** REST-only by design (no MCP tool). Requires confirm === user id. */306 deleteAccount(user: AuthedUser, confirm: string): Promise<void>;307 /** Merge the calling (device) account into the code-minting account. */308 linkAccount(309 user: AuthedUser,310 input: LinkAccountInputT,311 ): Promise<{ linked: boolean; alreadyLinked: boolean }>;312313 heart(user: AuthedUser, input: HeartInputT): Promise<void>;314 unheart(user: AuthedUser, input: HeartInputT): Promise<void>;315 listHearts(user: AuthedUser): Promise<PublicScriptListing[]>;316317 /** Public — no auth; viewer id (when present) fills heartedByMe. */318 browseScripts(input: BrowseInputT, viewerId?: string | null): Promise<PublicScriptListing[]>;319 getPublicScript(id: string, viewerId?: string | null): Promise<PublicScriptDetail>;320 leaderboard(input: LeaderboardInputT): Promise<LeaderboardRow[]>;321 listModels(input: ListModelsInputT): Promise<ModelRecord[]>;322323 createSim(user: AuthedUser, input: CreateSimInputT): Promise<SimRecord>;324 listSims(user: AuthedUser): Promise<SimRecord[]>;325 /** Public — no auth; used by the share-link viewer. */326 getSimBundle(id: string): Promise<SimBundle>;327}328329// ---------------------------------------------------------------------------330// REST route map (worker + web agree on these)331// ---------------------------------------------------------------------------332333/**334 * POST /api/session {idToken} -> sets session cookie (or ?login=local:<name> when DEV_AUTH=1)335 * DELETE /api/session336 * GET /api/me -> UserInfo337 * PATCH /api/me SetSharingInput -> UserInfo338 * DELETE /api/me {confirm: <user id>} — delete account (REST only)339 * POST /api/logic SubmitLogicInput -> {script, report}340 * GET /api/logic (?trashed=1) -> ScriptRecord[]341 * GET /api/logic/:id (?version=) -> ScriptWithSource342 * GET /api/logic/:id/source -> raw source (text/javascript download)343 * PATCH /api/logic/:id UpdateLogicMetaInput (sans id) -> ScriptRecord344 * DELETE /api/logic/:id -> {trashedUntil} (soft delete, 14-day trash)345 * POST /api/logic/:id/restore -> ScriptRecord346 * POST /api/stars StarInput347 * DELETE /api/stars StarInput (body)348 * GET /api/stars -> {kind,id}[]349 * PUT /api/hearts/:id — heart a listed script350 * DELETE /api/hearts/:id351 * GET /api/hearts -> PublicScriptListing[]352 * GET /api/browse (PUBLIC) BrowseInput as query -> PublicScriptListing[]353 * GET /api/browse/:id (PUBLIC) -> PublicScriptDetail354 * GET /api/leaderboard (PUBLIC) ?window=&limit= -> LeaderboardRow[]355 * GET /api/models (PUBLIC) ?q=&lab=&open=&limit= -> ModelRecord[]356 * POST /api/sims CreateSimInput -> SimRecord357 * GET /api/sims -> SimRecord[]358 * GET /api/sims/:id/bundle -> SimBundle (PUBLIC, no auth)359 * POST /api/keys/auth {publicKey, ts, sig} -> {token, user} (PUBLIC — mints360 * the device's wire token, replacing its previous one)361 * POST /api/keys/link {code} (device side) or same proof (browser side), authed —362 * merge accounts363 * POST /api/keys/link-code authed -> {code, expiresAt} — device-pairing code (10 min, single-use)364 * GET /api/keys -> KeyIdentity[] (authorized keys)365 * DELETE /api/keys {publicKey} — revoke (never the last identity)366 *367 * Auth: session cookie (web) or `Authorization: Bearer trb_...` (agents).368 * MCP: /mcp — stateless streamable HTTP (2026-07-28 spec), bearer token only.369 */370export const API_PREFIX = '/api';371export const MCP_ROUTE = '/mcp';372/** System example scripts are addressable in createSim as `ex_<name>`. */373export const EXAMPLE_ID_PREFIX = 'ex_';