add interview and roleplay parity adapters

This commit is contained in:
-Puter
2026-07-16 11:52:00 +05:30
parent 0bade06255
commit 794fe29d8d
6 changed files with 211 additions and 5 deletions
+3 -1
View File
@@ -18,8 +18,10 @@
"test:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts",
"test:interview-user-identity": "tsx scripts/interview-user-identity.test.ts",
"test:ws-ticket": "tsx scripts/ws-ticket.test.ts",
"test:roleplay-drafts": "tsx scripts/roleplay-drafts.test.ts",
"test:artifact-transport": "tsx scripts/artifact-transport.test.ts",
"test:resume-gateway": "tsx scripts/resume-gateway.test.ts",
"test:roleplay-leaderboard": "tsx scripts/roleplay-leaderboard.test.ts",
"test:interview-leaderboard": "tsx scripts/interview-leaderboard.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
const path = new URL(request.url).pathname;
if (path.endsWith("/api/v1/artifacts/sess-1/session_video")) {
return new Response(JSON.stringify({ url: "http://s3.test/obj", mime_type: "video/webm" }), { headers: { "content-type": "application/json" } });
}
if (path === "/obj") return new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "video/webm", "content-length": "3" } });
if (path.endsWith("/api/v1/sessions/sess-1/video/upload-url")) {
return new Response(JSON.stringify({ url: "http://s3.test/put", mime_type: "video/webm" }), { headers: { "content-type": "application/json" } });
}
if (path === "/put") return new Response(null, { status: 200 });
throw new Error(`unexpected upstream ${request.method} ${request.url}`);
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
for (const product of ["interview", "roleplay"] as const) {
const download = await app.request(`http://backend.test/${product}/artifacts/sess-1/session_video/download`);
assert.equal(download.status, 200);
const downloadedBytes = new Uint8Array(await download.arrayBuffer());
assert.deepEqual(Array.from(downloadedBytes), [1, 2, 3]);
assert.equal(download.headers.get("content-type"), "video/webm");
assert.equal(new TextDecoder().decode(downloadedBytes).includes("s3.test"), false);
const uploadBody = new Uint8Array([9, 8, 7]);
const upload = await app.request(`http://backend.test/${product}/sessions/sess-1/video/upload`, {
method: "PUT",
headers: { "content-type": "video/webm" },
body: uploadBody,
});
assert.equal(upload.status, 204);
const put = requests.at(-1)!;
assert.equal(put.method, "PUT");
assert.equal(put.headers.get("content-type"), "video/webm");
assert.deepEqual(Array.from(new Uint8Array(await put.arrayBuffer())), [9, 8, 7]);
}
} finally {
globalThis.fetch = originalFetch;
}
console.log("artifact transport gateway tests passed");
+39
View File
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(JSON.stringify({ entries: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const leaderboard = await app.request("http://backend.test/interview/leaderboard?org_id=org-X&limit=100");
assert.equal(leaderboard.status, 200);
assert.deepEqual(await leaderboard.json(), { entries: [] });
const upstream = new URL(requests[0]!.url);
assert.equal(upstream.pathname, "/api/v1/leaderboard");
assert.equal(upstream.searchParams.get("limit"), "100");
assert.equal(upstream.searchParams.get("org_id"), "org-X");
assert.match(requests[0]!.headers.get("authorization") ?? "", /^Bearer /);
const clamped = await app.request("http://backend.test/interview/leaderboard?limit=101");
assert.equal(clamped.status, 200);
assert.equal(new URL(requests[1]!.url).searchParams.get("limit"), "100");
const defaulted = await app.request("http://backend.test/interview/leaderboard?limit=not-a-number");
assert.equal(defaulted.status, 200);
assert.equal(new URL(requests[2]!.url).searchParams.get("limit"), "10");
} finally {
globalThis.fetch = originalFetch;
}
console.log("interview leaderboard gateway: all assertions passed");
+39
View File
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(JSON.stringify({ entries: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const leaderboard = await app.request("http://backend.test/roleplay/leaderboard?org_id=org-X&limit=100");
assert.equal(leaderboard.status, 200);
assert.deepEqual(await leaderboard.json(), { entries: [] });
const upstream = new URL(requests[0]!.url);
assert.equal(upstream.pathname, "/api/v1/roleplays/leaderboard");
assert.equal(upstream.searchParams.get("limit"), "100");
assert.equal(upstream.searchParams.get("org_id"), "org-X");
assert.match(requests[0]!.headers.get("authorization") ?? "", /^Bearer /);
const clamped = await app.request("http://backend.test/roleplay/leaderboard?limit=101");
assert.equal(clamped.status, 200);
assert.equal(new URL(requests[1]!.url).searchParams.get("limit"), "100");
const defaulted = await app.request("http://backend.test/roleplay/leaderboard?limit=not-a-number");
assert.equal(defaulted.status, 200);
assert.equal(new URL(requests[2]!.url).searchParams.get("limit"), "10");
} finally {
globalThis.fetch = originalFetch;
}
console.log("roleplay leaderboard gateway: all assertions passed");
+64 -2
View File
@@ -788,6 +788,46 @@ async function proxySocialRequest(req: Request, rest: string, userId: string) {
});
}
async function streamServiceArtifact(input: { service: "interview" | "roleplay"; sessionId: string; artifactType: string; userId: string; request: Request }) {
const metadata = input.service === "interview"
? await interviewService.artifact(input.sessionId, input.artifactType, input.userId)
: await roleplayService.artifact(input.sessionId, input.artifactType, input.userId);
const url = isRecord(metadata) ? getString(metadata.url) : undefined;
if (!url) throw new HTTPException(502, { message: "artifact URL missing" });
const headers = new Headers({ accept: input.request.headers.get("accept") ?? "*/*" });
const range = input.request.headers.get("range");
if (range) headers.set("range", range);
const upstream = await fetch(url, { headers });
if (!upstream.ok && upstream.status !== 206) {
throw new HTTPException(upstream.status as never, { message: `artifact download failed: ${upstream.status}` });
}
const responseHeaders = new Headers();
for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified"]) {
const value = upstream.headers.get(name);
if (value) responseHeaders.set(name, value);
}
if (!responseHeaders.has("content-type") && isRecord(metadata) && typeof metadata.mime_type === "string") responseHeaders.set("content-type", metadata.mime_type);
responseHeaders.set("content-disposition", `inline; filename="${input.artifactType}"`);
responseHeaders.set("cache-control", "private, max-age=300");
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
}
async function proxyVideoUpload(input: { service: "interview" | "roleplay"; sessionId: string; userId: string; request: Request }) {
const payload = input.service === "interview"
? await interviewService.createVideoUploadUrl(input.sessionId, input.userId)
: await roleplayService.createVideoUploadUrl(input.sessionId, input.userId);
const url = isRecord(payload) ? getString(payload.url) : undefined;
if (!url) throw new HTTPException(502, { message: "video upload URL missing" });
const headers = new Headers();
const contentType = input.request.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
const contentLength = input.request.headers.get("content-length");
if (contentLength) headers.set("content-length", contentLength);
const upstream = await fetch(url, { method: "PUT", headers, body: input.request.body, duplex: "half" } as RequestInit & { duplex: "half" });
if (!upstream.ok) throw new HTTPException(upstream.status as never, { message: `video upload failed: ${upstream.status}` });
return new Response(null, { status: 204 });
}
export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
const app = new Hono<AuthContext>();
if (!options.skipAuth) app.use("*", requireUser);
@@ -915,6 +955,11 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
linkedin_available: hasLinkedInContext(userContext),
});
});
app.get("/interview/sessions", async (c) => {
const requested = Number(c.req.query("limit") ?? 100);
const limit = Math.min(100, Math.max(1, Number.isFinite(requested) ? Math.trunc(requested) : 100));
return c.json(await interviewService.listSessions(c.get("userId"), limit).catch(serviceErrorResponse));
});
app.post("/interview/configure", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>();
@@ -969,8 +1014,13 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
}).catch((err) => log.warn({ err }, "failed to record interview review event"));
return c.json(result);
});
app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard()));
app.get("/interview/leaderboard", async (c) => {
const requestedLimit = Number(c.req.query("limit"));
const limit = Number.isInteger(requestedLimit) ? Math.min(100, Math.max(1, requestedLimit)) : 10;
return c.json(await interviewService.leaderboard(c.req.query("org_id"), limit));
});
app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.get("/interview/artifacts/:sessionId/:artifactType/download", async (c) => streamServiceArtifact({ service: "interview", sessionId: c.req.param("sessionId"), artifactType: c.req.param("artifactType"), userId: c.get("userId"), request: c.req.raw }));
app.post("/interview/sessions/:sessionId/ticket", async (c) => {
if (!config.sessionTicketSecret) throw new HTTPException(503, { message: "session ticket signing is not configured" });
const sessionId = c.req.param("sessionId");
@@ -980,6 +1030,7 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt });
});
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.put("/interview/sessions/:sessionId/video/upload", async (c) => proxyVideoUpload({ service: "interview", sessionId: c.req.param("sessionId"), userId: c.get("userId"), request: c.req.raw }));
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
app.get("/roleplay/page-state", async (c) => {
@@ -997,6 +1048,11 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
linkedin_available: hasLinkedInContext(userContext),
});
});
app.get("/roleplay/sessions", async (c) => {
const requested = Number(c.req.query("limit") ?? 200);
const limit = Math.min(200, Math.max(1, Number.isFinite(requested) ? Math.trunc(requested) : 200));
return c.json(await roleplayService.listSessions(c.get("userId"), limit).catch(serviceErrorResponse));
});
app.post("/roleplay/configure", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>();
@@ -1073,8 +1129,13 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
}).catch((err) => log.warn({ err }, "failed to record roleplay review event"));
return c.json(result);
});
app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard()));
app.get("/roleplay/leaderboard", async (c) => {
const requestedLimit = Number(c.req.query("limit"));
const limit = Number.isInteger(requestedLimit) ? Math.min(100, Math.max(1, requestedLimit)) : 10;
return c.json(await roleplayService.leaderboard(c.req.query("org_id"), limit));
});
app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.get("/roleplay/artifacts/:sessionId/:artifactType/download", async (c) => streamServiceArtifact({ service: "roleplay", sessionId: c.req.param("sessionId"), artifactType: c.req.param("artifactType"), userId: c.get("userId"), request: c.req.raw }));
app.post("/roleplay/sessions/:sessionId/ticket", async (c) => {
if (!config.sessionTicketSecret) throw new HTTPException(503, { message: "session ticket signing is not configured" });
const sessionId = c.req.param("sessionId");
@@ -1084,6 +1145,7 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt });
});
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.put("/roleplay/sessions/:sessionId/video/upload", async (c) => proxyVideoUpload({ service: "roleplay", sessionId: c.req.param("sessionId"), userId: c.get("userId"), request: c.req.raw }));
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId"))));
+18 -2
View File
@@ -60,6 +60,10 @@ export const interviewService = {
serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
listSessions: (userId: string, limit = 100) =>
serviceJson(config.interviewServiceUrl, `/api/v1/sessions?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject, userId?: string) =>
serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject, userId?: string) =>
@@ -80,7 +84,11 @@ export const interviewService = {
serviceJson(config.interviewServiceUrl, `/api/v1/review/${encodeURIComponent(sessionId)}`, {
headers: userHeader(userId),
}),
leaderboard: () => serviceJson(config.interviewServiceUrl, "/api/v1/leaderboard"),
leaderboard: (orgId?: string, limit = 10) =>
serviceJson(config.interviewServiceUrl, `/api/v1/leaderboard?${new URLSearchParams({
...(orgId ? { org_id: orgId } : {}),
limit: String(limit),
})}`),
artifact: (sessionId: string, artifactType: string, userId?: string) =>
serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
headers: userHeader(userId),
@@ -109,6 +117,10 @@ export const roleplayService = {
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
listSessions: (userId: string, limit = 200) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/sessions?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject, userId?: string) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject, userId?: string) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }, userId?: string) =>
@@ -141,7 +153,11 @@ export const roleplayService = {
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/review/${encodeURIComponent(sessionId)}`, {
headers: userHeader(userId),
}),
leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"),
leaderboard: (orgId?: string, limit = 10) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/leaderboard?${new URLSearchParams({
...(orgId ? { org_id: orgId } : {}),
limit: String(limit),
})}`),
videoUsage: (userId: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/video-usage?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),