Compare commits

...

2 Commits

Author SHA1 Message Date
-Puter
794fe29d8d add interview and roleplay parity adapters 2026-07-16 11:52:00 +05:30
-Puter
0bade06255 fix service gateway deadlines and roleplay controls 2026-07-16 10:00:45 +05:30
8 changed files with 275 additions and 17 deletions

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",

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");

View File

@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { INTERACTIVE_SERVICE_TIMEOUT_MS, serviceJson, roleplayService } from "../src/services/product-service-clients.js";
assert.equal(INTERACTIVE_SERVICE_TIMEOUT_MS, 240_000, "interactive service deadline must stay inside the 300s proxy budget");
const originalFetch = globalThis.fetch;
const originalTimeout = AbortSignal.timeout;
const requests: Request[] = [];
let capturedTimeout = 0;
AbortSignal.timeout = ((ms: number) => {
capturedTimeout = ms;
return new AbortController().signal;
}) as typeof AbortSignal.timeout;
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
try {
await roleplayService.plan("session-1", "user-1");
assert.equal(new URL(requests[0]!.url).pathname, "/api/v1/roleplays/sessions/session-1/plan");
assert.equal(capturedTimeout, INTERACTIVE_SERVICE_TIMEOUT_MS, "interactive plan calls must use the inner deadline");
requests.length = 0;
capturedTimeout = 0;
await serviceJson("http://service.test", "/slow", { timeoutMs: 17 });
assert.equal(capturedTimeout, 17);
} finally {
AbortSignal.timeout = originalTimeout;
globalThis.fetch = originalFetch;
}
console.log("deadline hierarchy tests passed");

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");

View File

@@ -12,9 +12,13 @@ globalThis.fetch = (async (input, init) => {
? { drafts: [{ session_id: "draft-1" }] }
: url.pathname.endsWith("/plan")
? { session_id: "draft-1", status: "draft" }
: url.pathname.endsWith("/configure/approve")
? { session_id: "draft-1", status: "configured", approved: true }
: { deleted: true, session_id: "draft-1" };
: url.pathname.endsWith("/video-usage")
? { used: 1, limit: 3, remaining: 2 }
: url.pathname.endsWith("/vip-check")
? { valid: true }
: url.pathname.endsWith("/configure/approve")
? { session_id: "draft-1", status: "configured", approved: true }
: { deleted: true, session_id: "draft-1" };
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
@@ -42,13 +46,16 @@ try {
assert.equal(deleted.status, 200);
assert.deepEqual(await deleted.json(), { deleted: true, session_id: "draft-1" });
assert.equal(requests.length, 4);
assert.match(requests[0]!.url, /\/api\/v1\/roleplays\/drafts\?user_id=user_test&limit=25$/);
assert.match(requests[1]!.url, /\/api\/v1\/roleplays\/sessions\/draft-1\/plan$/);
assert.match(requests[2]!.url, /\/api\/v1\/roleplays\/configure\/approve$/);
assert.equal(requests[3]!.method, "DELETE");
assert.match(requests[3]!.url, /\/api\/v1\/roleplays\/drafts\/draft-1$/);
for (const request of requests) {
const usage = await app.request("http://backend.test/roleplay/video-usage");
assert.equal(usage.status, 200);
assert.deepEqual(await usage.json(), { used: 1, limit: 3, remaining: 2 });
const vip = await app.request("http://backend.test/roleplay/vip-check?code=grow26");
assert.equal(vip.status, 200);
assert.deepEqual(await vip.json(), { valid: true });
assert.match(requests.find((request) => request.url.includes("/api/v1/roleplays/vip-check"))?.url ?? "", /code=grow26&user_id=user_test$/);
assert.match(requests.find((request) => request.url.includes("/api/v1/roleplays/video-usage"))?.url ?? "", /\/api\/v1\/roleplays\/video-usage\?user_id=user_test$/);
assert.ok(requests.length >= 5);
for (const request of requests.filter((request) => request.url.includes("/api/v1/"))) {
assert.equal(request.headers.get("x-growqr-user"), "user_test");
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
}

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");

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>();
@@ -1045,6 +1101,10 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 50) || 50));
return c.json(await roleplayService.drafts(c.get("userId"), limit).catch(serviceErrorResponse));
});
app.get("/roleplay/video-usage", async (c) =>
c.json(await roleplayService.videoUsage(c.get("userId")).catch(serviceErrorResponse)));
app.get("/roleplay/vip-check", async (c) =>
c.json(await roleplayService.vipCheck(c.req.query("code") ?? "", c.get("userId")).catch(serviceErrorResponse)));
app.delete("/roleplay/drafts/:sessionId", async (c) =>
c.json(await roleplayService.deleteDraft(c.req.param("sessionId"), c.get("userId")).catch(serviceErrorResponse)));
app.post("/roleplay/assignments", async (c) => c.json(await roleplayService.createAssignments(await c.req.json())));
@@ -1069,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");
@@ -1080,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"))));

View File

@@ -21,8 +21,8 @@ export class ProductServiceError extends Error {
}
}
const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 120000);
export const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
export const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 240000);
function userHeader(userId?: string): Record<string, string> | undefined {
return userId ? { "x-growqr-user": userId } : undefined;
@@ -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) =>
@@ -118,6 +130,7 @@ export const roleplayService = {
plan: (sessionId: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/sessions/${encodeURIComponent(sessionId)}/plan`, {
headers: userHeader(userId),
timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS,
}),
drafts: (userId: string, limit = 50) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/drafts?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
@@ -140,7 +153,19 @@ 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),
}),
vipCheck: (code: string, userId: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/vip-check?${new URLSearchParams({ code, user_id: userId })}`, {
headers: userHeader(userId),
}),
artifact: (sessionId: string, artifactType: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
headers: userHeader(userId),