refactor(vibe): simplify durable session flow
This commit is contained in:
@@ -15,7 +15,7 @@ function response() {
|
||||
};
|
||||
}
|
||||
|
||||
async function appWithCoordinator(identityResolver: VibeIdentityResolver = () => USER_ID) {
|
||||
async function appWithCoordinator(identityResolver?: VibeIdentityResolver) {
|
||||
const coordinator = {
|
||||
start: vi.fn().mockResolvedValue(response()),
|
||||
getPlan: vi.fn().mockResolvedValue(response()),
|
||||
@@ -25,36 +25,36 @@ async function appWithCoordinator(identityResolver: VibeIdentityResolver = () =>
|
||||
advancePastUnplayable: vi.fn().mockResolvedValue(response()),
|
||||
} as any;
|
||||
const app = Fastify();
|
||||
await app.register(vibeSessionsRoutes, { coordinator, identityResolver });
|
||||
await app.register(vibeSessionsRoutes, { coordinator, ...(identityResolver ? { identityResolver } : {}) });
|
||||
await app.ready();
|
||||
return { app, coordinator };
|
||||
}
|
||||
|
||||
describe('durable Vibe session routes', () => {
|
||||
it('uses the trusted identity resolver and never accepts a spoofed x-user-id header', async () => {
|
||||
it('uses the caller identity so concurrent listeners receive separate sessions', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: {} });
|
||||
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': USER_ID }, payload: {} });
|
||||
|
||||
expect(result.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.any(Object));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects requests when no trusted identity is configured instead of defaulting a shared user', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => null);
|
||||
it('uses the existing application default when no user header is provided', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const result = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': USER_ID }, payload: {},
|
||||
method: 'POST', url: '/v2/vibe/sessions', payload: {},
|
||||
});
|
||||
expect(result.statusCode).toBe(401);
|
||||
expect(coordinator.start).not.toHaveBeenCalled();
|
||||
expect(result.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000000', expect.any(Object));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('creates a session and validates event payloads before touching the coordinator', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': 'spoofed' },
|
||||
payload: { seedTrackId: TRACK_ID, context: { activity: 'focus' }, policyVersion: 'test-policy' },
|
||||
payload: { seedTrackId: TRACK_ID },
|
||||
});
|
||||
const invalidEvent = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' },
|
||||
@@ -62,13 +62,13 @@ describe('durable Vibe session routes', () => {
|
||||
});
|
||||
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ policyVersion: 'test-policy' }));
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ seedTrackId: TRACK_ID }));
|
||||
expect(invalidEvent.statusCode).toBe(400);
|
||||
expect(coordinator.appendEvent).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('reserves track_served for the authoritative /next operation', async () => {
|
||||
it('rejects server-only event types from the client event ledger', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const result = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
|
||||
@@ -80,7 +80,7 @@ describe('durable Vibe session routes', () => {
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.json()).toEqual({ error: 'track_served is reserved for the server /next operation' });
|
||||
expect(result.json()).toEqual({ error: 'type must be a supported client Vibe event type' });
|
||||
expect(coordinator.appendEvent).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
@@ -102,7 +102,7 @@ describe('durable Vibe session routes', () => {
|
||||
});
|
||||
|
||||
it('passes a requested plan revision and validated idempotent event through to the coordinator', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const plan = await app.inject({
|
||||
method: 'GET', url: `/v2/vibe/sessions/${SESSION_ID}/plans?version=2`, headers: { 'x-user-id': 'spoofed' },
|
||||
});
|
||||
@@ -118,8 +118,8 @@ describe('durable Vibe session routes', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('validates occurredAt and exposes owned resume and next operations', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
it('validates occurredAt and exposes owned resume and advance operations', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const resume = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions', payload: { resumeSessionId: SESSION_ID },
|
||||
});
|
||||
@@ -127,7 +127,7 @@ describe('durable Vibe session routes', () => {
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
|
||||
payload: { type: 'completed', occurredAt: 'not-a-date' },
|
||||
});
|
||||
const next = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next` });
|
||||
const next = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance` });
|
||||
expect(resume.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ resumeSessionId: SESSION_ID }));
|
||||
expect(badTime.statusCode).toBe(400);
|
||||
@@ -136,13 +136,13 @@ describe('durable Vibe session routes', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('passes a version-aware next request through and rejects an invalid expected version', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
it('passes a version-aware advance request through and rejects an invalid expected version', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const valid = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, payload: { expectedPlanVersion: 2 },
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 2 },
|
||||
});
|
||||
const invalid = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, payload: { expectedPlanVersion: 0 },
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 0 },
|
||||
});
|
||||
|
||||
expect(valid.statusCode).toBe(200);
|
||||
@@ -153,9 +153,9 @@ describe('durable Vibe session routes', () => {
|
||||
});
|
||||
|
||||
it('uses the explicit versioned advancement protocol for a served unplayable item', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const result = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`,
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`,
|
||||
payload: {
|
||||
expectedPlanVersion: 2,
|
||||
unplayable: {
|
||||
|
||||
@@ -6,89 +6,125 @@ import {
|
||||
VibeSessionNotFoundError,
|
||||
VibePlanNotFoundError,
|
||||
} from '../services/vibe-session-coordinator.service.js';
|
||||
import { isValidVibeContext } from '../services/vibe-context.service.js';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/i;
|
||||
const CLIENT_VIBE_EVENT_TYPES = VIBE_EVENT_TYPES.filter((type) => ![
|
||||
'session_started', 'session_resumed', 'session_ended', 'plan_published', 'track_served',
|
||||
].includes(type));
|
||||
|
||||
/** Identity must come from authenticated server configuration/middleware, never a client header. */
|
||||
type Reply = { code: (statusCode: number) => { send: (payload: unknown) => unknown } };
|
||||
type Body = Record<string, unknown>;
|
||||
|
||||
/** This mirrors the rest of the application until authentication owns identity. */
|
||||
export type VibeIdentityResolver = (request: FastifyRequest) => string | null;
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
function isObject(value: unknown): value is Body {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validUuid(value: unknown): value is string {
|
||||
return typeof value === 'string' && UUID_RE.test(value);
|
||||
return value === DEFAULT_USER_ID || (typeof value === 'string' && UUID_RE.test(value));
|
||||
}
|
||||
|
||||
function requestUser(request: FastifyRequest, resolveIdentity?: VibeIdentityResolver): string | null {
|
||||
const resolved = resolveIdentity?.(request);
|
||||
if (resolved !== undefined) return validUuid(resolved) ? resolved : null;
|
||||
const header = request.headers['x-user-id'];
|
||||
const userId = typeof header === 'string' && header ? header : DEFAULT_USER_ID;
|
||||
return validUuid(userId) ? userId : null;
|
||||
}
|
||||
|
||||
function validOccurredAt(value: unknown): value is string {
|
||||
// Require an actual offset-bearing timestamp, rather than Date.parse's
|
||||
// permissive inputs such as "2026" or locale-dependent strings.
|
||||
return typeof value === 'string'
|
||||
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/i.test(value)
|
||||
&& !Number.isNaN(Date.parse(value));
|
||||
return typeof value === 'string' && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function validationError(reply: Reply, message: string) {
|
||||
return reply.code(400).send({ error: message });
|
||||
}
|
||||
|
||||
function sessionIdFrom(request: FastifyRequest, reply: Reply): string | null {
|
||||
const { sessionId } = request.params as { sessionId: string };
|
||||
return validUuid(sessionId) ? sessionId : (validationError(reply, 'sessionId must be a UUID'), null);
|
||||
}
|
||||
|
||||
function parseStart(body: unknown):
|
||||
| { seedTrackId?: string; resumeSessionId?: string }
|
||||
| { error: string } {
|
||||
const input = isObject(body) ? body : {};
|
||||
if (input.resumeSessionId !== undefined && !validUuid(input.resumeSessionId)) return { error: 'resumeSessionId must be a UUID' };
|
||||
if (input.seedTrackId !== undefined && !validUuid(input.seedTrackId)) return { error: 'seedTrackId must be a UUID' };
|
||||
if (input.resumeSessionId !== undefined && input.seedTrackId !== undefined) return { error: 'resumeSessionId cannot be combined with seedTrackId' };
|
||||
return { seedTrackId: input.seedTrackId as string | undefined, resumeSessionId: input.resumeSessionId as string | undefined };
|
||||
}
|
||||
|
||||
function parseEvent(body: unknown):
|
||||
| { type: typeof CLIENT_VIBE_EVENT_TYPES[number]; eventId?: string; trackId?: string; occurredAt?: Date; positionMs?: number; durationMs?: number; payload?: Body }
|
||||
| { error: string } {
|
||||
if (!isObject(body)) return { error: 'event body must be an object' };
|
||||
if (!CLIENT_VIBE_EVENT_TYPES.includes(body.type as typeof CLIENT_VIBE_EVENT_TYPES[number])) return { error: 'type must be a supported client Vibe event type' };
|
||||
if (body.eventId !== undefined && !validUuid(body.eventId)) return { error: 'eventId must be a UUID' };
|
||||
if (body.trackId !== undefined && !validUuid(body.trackId)) return { error: 'trackId must be a UUID' };
|
||||
if (body.occurredAt !== undefined && !validOccurredAt(body.occurredAt)) return { error: 'occurredAt must be an ISO-8601 timestamp' };
|
||||
if (body.positionMs !== undefined && (typeof body.positionMs !== 'number' || !Number.isInteger(body.positionMs) || body.positionMs < 0)) return { error: 'positionMs must be a non-negative integer' };
|
||||
if (body.durationMs !== undefined && (typeof body.durationMs !== 'number' || !Number.isInteger(body.durationMs) || body.durationMs < 0)) return { error: 'durationMs must be a non-negative integer' };
|
||||
if (body.payload !== undefined && !isObject(body.payload)) return { error: 'payload must be an object' };
|
||||
return {
|
||||
type: body.type as typeof CLIENT_VIBE_EVENT_TYPES[number],
|
||||
eventId: body.eventId as string | undefined,
|
||||
trackId: body.trackId as string | undefined,
|
||||
occurredAt: body.occurredAt === undefined ? undefined : new Date(body.occurredAt as string),
|
||||
positionMs: body.positionMs as number | undefined,
|
||||
durationMs: body.durationMs as number | undefined,
|
||||
payload: body.payload as Body | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAdvance(body: unknown):
|
||||
| { expectedPlanVersion?: number; unplayable?: { eventId: string; planVersionId: string; ordinal: number; trackId: string } }
|
||||
| { error: string } {
|
||||
const input = isObject(body) ? body : {};
|
||||
const version = input.expectedPlanVersion;
|
||||
if (version !== undefined && (typeof version !== 'number' || !Number.isInteger(version) || version < 1)) return { error: 'expectedPlanVersion must be a positive integer' };
|
||||
if (input.unplayable === undefined) return { expectedPlanVersion: version as number | undefined };
|
||||
if (!isObject(input.unplayable)) return { error: 'unplayable must be an object' };
|
||||
const unplayable = input.unplayable;
|
||||
if (version === undefined) return { error: 'expectedPlanVersion is required when advancing an unplayable item' };
|
||||
if (!validUuid(unplayable.eventId) || !validUuid(unplayable.planVersionId) || !validUuid(unplayable.trackId) || typeof unplayable.ordinal !== 'number' || !Number.isInteger(unplayable.ordinal) || unplayable.ordinal < 0) {
|
||||
return { error: 'unplayable requires UUID eventId, planVersionId, trackId and a non-negative integer ordinal' };
|
||||
}
|
||||
return { expectedPlanVersion: version as number, unplayable: unplayable as { eventId: string; planVersionId: string; ordinal: number; trackId: string } };
|
||||
}
|
||||
|
||||
export default async function vibeSessionsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
options: { coordinator: VibeSessionCoordinator; identityResolver: VibeIdentityResolver },
|
||||
options: { coordinator: VibeSessionCoordinator; identityResolver?: VibeIdentityResolver },
|
||||
) {
|
||||
const { coordinator, identityResolver } = options;
|
||||
const requireUser = (request: FastifyRequest, reply: { code: (statusCode: number) => { send: (payload: unknown) => unknown } }): string | null => {
|
||||
const userId = identityResolver(request);
|
||||
if (userId && validUuid(userId)) return userId;
|
||||
reply.code(401).send({ error: 'A trusted Vibe identity is required' });
|
||||
return null;
|
||||
const userFor = (request: FastifyRequest, reply: Reply) => {
|
||||
const userId = requestUser(request, identityResolver);
|
||||
return userId ? userId : (validationError(reply, 'x-user-id must be a UUID'), null);
|
||||
};
|
||||
|
||||
fastify.post('/v2/vibe/sessions', async (request, reply) => {
|
||||
const userId = requireUser(request, reply);
|
||||
if (!userId) return;
|
||||
const body = isObject(request.body) ? request.body : {};
|
||||
if (body.resumeSessionId !== undefined && !validUuid(body.resumeSessionId)) {
|
||||
return reply.code(400).send({ error: 'resumeSessionId must be a UUID' });
|
||||
}
|
||||
if (body.resumeSessionId !== undefined && body.seedTrackId !== undefined) {
|
||||
return reply.code(400).send({ error: 'resumeSessionId cannot be combined with seedTrackId' });
|
||||
}
|
||||
if (body.seedTrackId !== undefined && !validUuid(body.seedTrackId)) {
|
||||
return reply.code(400).send({ error: 'seedTrackId must be a UUID' });
|
||||
}
|
||||
if (body.context !== undefined && !isObject(body.context)) {
|
||||
return reply.code(400).send({ error: 'context must be an object' });
|
||||
}
|
||||
if (isObject(body.context) && !isValidVibeContext(body.context)) {
|
||||
return reply.code(400).send({ error: 'context contains an invalid structured Vibe value' });
|
||||
}
|
||||
if (body.intent !== undefined && typeof body.intent !== 'string') {
|
||||
return reply.code(400).send({ error: 'intent must be a string' });
|
||||
}
|
||||
if (body.policyVersion !== undefined && (typeof body.policyVersion !== 'string' || !body.policyVersion.trim())) {
|
||||
return reply.code(400).send({ error: 'policyVersion must be a non-empty string' });
|
||||
}
|
||||
const userId = userFor(request, reply);
|
||||
const input = parseStart(request.body);
|
||||
if (!userId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
|
||||
try {
|
||||
return reply.code(201).send(await coordinator.start(userId, {
|
||||
seedTrackId: body.seedTrackId as string | undefined,
|
||||
context: body.context as Record<string, unknown> | undefined,
|
||||
intent: body.intent as string | undefined,
|
||||
policyVersion: body.policyVersion as string | undefined,
|
||||
resumeSessionId: body.resumeSessionId as string | undefined,
|
||||
}));
|
||||
return reply.code(201).send(await coordinator.start(userId, input));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get('/v2/vibe/sessions/:sessionId/plans', async (request, reply) => {
|
||||
const userId = requireUser(request, reply);
|
||||
if (!userId) return;
|
||||
const { sessionId } = request.params as { sessionId: string };
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
const { version } = request.query as { version?: string };
|
||||
if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' });
|
||||
const parsedVersion = version === undefined ? undefined : Number(version);
|
||||
if (version !== undefined && (!Number.isInteger(parsedVersion) || parsedVersion! < 1)) {
|
||||
return reply.code(400).send({ error: 'version must be a positive integer' });
|
||||
}
|
||||
if (!userId || !sessionId) return;
|
||||
if (version !== undefined && (!Number.isInteger(parsedVersion) || parsedVersion! < 1)) return validationError(reply, 'version must be a positive integer');
|
||||
try {
|
||||
return reply.send(await coordinator.getPlan(userId, sessionId, parsedVersion));
|
||||
} catch (error) {
|
||||
@@ -97,63 +133,21 @@ export default async function vibeSessionsRoutes(
|
||||
});
|
||||
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/events', async (request, reply) => {
|
||||
const userId = requireUser(request, reply);
|
||||
if (!userId) return;
|
||||
const { sessionId } = request.params as { sessionId: string };
|
||||
const body = isObject(request.body) ? request.body : null;
|
||||
if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' });
|
||||
if (!body || !VIBE_EVENT_TYPES.includes(body.type as typeof VIBE_EVENT_TYPES[number])) {
|
||||
return reply.code(400).send({ error: 'type must be a supported Vibe event type' });
|
||||
}
|
||||
// Delivery is an authoritative state transition performed only by /next.
|
||||
// Accepting this event from the public ledger endpoint would let a client
|
||||
// fabricate exposure rows and consume the server-side surprise budget.
|
||||
if (body.type === 'track_served') {
|
||||
return reply.code(400).send({ error: 'track_served is reserved for the server /next operation' });
|
||||
}
|
||||
if (body.eventId !== undefined && !validUuid(body.eventId)) {
|
||||
return reply.code(400).send({ error: 'eventId must be a UUID' });
|
||||
}
|
||||
if (body.trackId !== undefined && !validUuid(body.trackId)) {
|
||||
return reply.code(400).send({ error: 'trackId must be a UUID' });
|
||||
}
|
||||
if (body.occurredAt !== undefined && !validOccurredAt(body.occurredAt)) {
|
||||
return reply.code(400).send({ error: 'occurredAt must be an ISO-8601 timestamp' });
|
||||
}
|
||||
if (body.positionMs !== undefined && (!Number.isInteger(body.positionMs) || (body.positionMs as number) < 0)) {
|
||||
return reply.code(400).send({ error: 'positionMs must be a non-negative integer' });
|
||||
}
|
||||
if (body.durationMs !== undefined && (!Number.isInteger(body.durationMs) || (body.durationMs as number) < 0)) {
|
||||
return reply.code(400).send({ error: 'durationMs must be a non-negative integer' });
|
||||
}
|
||||
if (body.payload !== undefined && !isObject(body.payload)) {
|
||||
return reply.code(400).send({ error: 'payload must be an object' });
|
||||
}
|
||||
if (body.type === 'context_changed' && isObject(body.payload)
|
||||
&& body.payload.context !== undefined
|
||||
&& (!isObject(body.payload.context) || !isValidVibeContext(body.payload.context))) {
|
||||
return reply.code(400).send({ error: 'context_changed payload.context must be structured Vibe context' });
|
||||
}
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
const input = parseEvent(request.body);
|
||||
if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
|
||||
try {
|
||||
return reply.send(await coordinator.appendEvent(userId, sessionId, {
|
||||
eventId: body.eventId as string | undefined,
|
||||
type: body.type as typeof VIBE_EVENT_TYPES[number],
|
||||
trackId: body.trackId as string | undefined,
|
||||
occurredAt: body.occurredAt === undefined ? undefined : new Date(body.occurredAt as string),
|
||||
positionMs: body.positionMs as number | undefined,
|
||||
durationMs: body.durationMs as number | undefined,
|
||||
payload: body.payload as Record<string, unknown> | undefined,
|
||||
}));
|
||||
return reply.send(await coordinator.appendEvent(userId, sessionId, input));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/end', async (request, reply) => {
|
||||
const userId = requireUser(request, reply);
|
||||
if (!userId) return;
|
||||
const { sessionId } = request.params as { sessionId: string };
|
||||
if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' });
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
if (!userId || !sessionId) return;
|
||||
try {
|
||||
return reply.send(await coordinator.end(userId, sessionId));
|
||||
} catch (error) {
|
||||
@@ -161,55 +155,25 @@ export default async function vibeSessionsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/next', async (request, reply) => {
|
||||
const userId = requireUser(request, reply);
|
||||
if (!userId) return;
|
||||
const { sessionId } = request.params as { sessionId: string };
|
||||
const body = isObject(request.body) ? request.body : {};
|
||||
if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' });
|
||||
if (body.expectedPlanVersion !== undefined
|
||||
&& (!Number.isInteger(body.expectedPlanVersion) || (body.expectedPlanVersion as number) < 1)) {
|
||||
return reply.code(400).send({ error: 'expectedPlanVersion must be a positive integer' });
|
||||
}
|
||||
const unplayable = body.unplayable;
|
||||
if (unplayable !== undefined && !isObject(unplayable)) {
|
||||
return reply.code(400).send({ error: 'unplayable must be an object' });
|
||||
}
|
||||
if (isObject(unplayable)) {
|
||||
if (body.expectedPlanVersion === undefined) {
|
||||
return reply.code(400).send({ error: 'expectedPlanVersion is required when advancing an unplayable item' });
|
||||
}
|
||||
if (!validUuid(unplayable.eventId)
|
||||
|| !validUuid(unplayable.planVersionId)
|
||||
|| !validUuid(unplayable.trackId)
|
||||
|| !Number.isInteger(unplayable.ordinal)
|
||||
|| (unplayable.ordinal as number) < 0) {
|
||||
return reply.code(400).send({ error: 'unplayable requires UUID eventId, planVersionId, trackId and a non-negative integer ordinal' });
|
||||
}
|
||||
}
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/advance', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
const input = parseAdvance(request.body);
|
||||
if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
|
||||
try {
|
||||
const expectedPlanVersion = body.expectedPlanVersion as number | undefined;
|
||||
if (isObject(unplayable)) {
|
||||
return reply.send(await coordinator.advancePastUnplayable(userId, sessionId, {
|
||||
expectedPlanVersion: expectedPlanVersion as number,
|
||||
eventId: unplayable.eventId as string,
|
||||
planVersionId: unplayable.planVersionId as string,
|
||||
ordinal: unplayable.ordinal as number,
|
||||
trackId: unplayable.trackId as string,
|
||||
}));
|
||||
}
|
||||
return reply.send(expectedPlanVersion === undefined
|
||||
? await coordinator.serveNext(userId, sessionId)
|
||||
: await coordinator.serveNext(userId, sessionId, expectedPlanVersion));
|
||||
return reply.send(input.unplayable
|
||||
? await coordinator.advancePastUnplayable(userId, sessionId, { expectedPlanVersion: input.expectedPlanVersion!, ...input.unplayable })
|
||||
: input.expectedPlanVersion === undefined
|
||||
? await coordinator.serveNext(userId, sessionId)
|
||||
: await coordinator.serveNext(userId, sessionId, input.expectedPlanVersion));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendCoordinatorError(reply: { code: (statusCode: number) => { send: (payload: unknown) => unknown } }, error: unknown) {
|
||||
if (error instanceof VibeSessionNotFoundError) return reply.code(404).send({ error: error.message });
|
||||
if (error instanceof VibePlanNotFoundError) return reply.code(404).send({ error: error.message });
|
||||
function sendCoordinatorError(reply: Reply, error: unknown) {
|
||||
if (error instanceof VibeSessionNotFoundError || error instanceof VibePlanNotFoundError) return reply.code(404).send({ error: error.message });
|
||||
if (error instanceof VibeSessionLifecycleError) return reply.code(409).send({ error: error.message, code: 'VIBE_SESSION_NOT_ACTIVE' });
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user