Compare commits

...
36 Commits
Author SHA1 Message Date
sai karthik 34920f6a09 Merge staging into QScore raw-event refactor 2026-07-14 14:20:00 +05:30
-Puter 7b35d1e0c5 fix(curator): enforce correlated task completion 2026-07-14 10:20:29 +05:30
-Puter 41f9f0dfec fix(events): correlate service completions to curator tasks 2026-07-14 09:55:52 +05:30
-Puter 8d43bea58e fix(resume): proxy empty success responses 2026-07-14 02:22:32 +05:30
-Puter a2292dd155 fix(roleplay): register resumed sessions for tickets 2026-07-14 01:38:40 +05:30
-Puter 4985605638 fix(roleplay): expose saved draft controls 2026-07-14 01:25:25 +05:30
-Puter 626684ea7e fix: forward user identity to resume service 2026-07-13 22:49:59 +05:30
-Puter 5eaf4ae1ea fix backend service user identity forwarding 2026-07-13 21:19:55 +05:30
-Puter a56172c7ac fix: enforce websocket ticket session ownership 2026-07-13 20:20:12 +05:30
-Puter eafca3aafb feat: issue scoped websocket session tickets 2026-07-13 20:19:11 +05:30
-Puter 8c0f8e1d57 Make Course A2A gateway transport-only 2026-07-13 20:13:57 +05:30
-Puter 4c59151e37 Avoid duplicate Course scoring events 2026-07-13 20:13:53 +05:30
-Puter a3c4a91cbe Project Course swipe gateway events 2026-07-13 20:13:53 +05:30
-Puter 4fa4a8bbd1 test: remove tautological service token assertion 2026-07-13 20:12:59 +05:30
-Puter d71b7ede08 test: cover service token config resolution 2026-07-13 20:12:59 +05:30
-Puter 1f778fe3ea Prevent Course gateway score double counting 2026-07-13 20:12:59 +05:30
-Puter fc06317832 fix(auth): remove compose service token fallback 2026-07-13 20:12:59 +05:30
-Puter a1e05b0337 Add focused Course and Matchmaking route tests 2026-07-13 20:12:59 +05:30
-Puter 7a0c762fde Add Course A2A gateway and prevent matchmaking duplicates 2026-07-13 20:12:59 +05:30
-Puter 70c3634178 Remove backend dev A2A auth fallback 2026-07-13 20:12:59 +05:30
-Puter 26e0e54488 fix(staging): pin verified models and resume route 2026-07-13 18:04:49 +05:30
Sai-karthik 38939c2fd0 Merge curator task handoff fixes into staging 2026-07-13 10:11:42 +00:00
Sai-karthik 54b546bba3 Fix compressed resume proxy responses 2026-07-13 10:11:27 +00:00
Sai-karthik 3a7a6c46a8 Cover task-aware curator handoffs 2026-07-13 10:11:27 +00:00
Sai-karthik 25d92f67be Preserve task details on unavailable handoffs 2026-07-13 10:11:27 +00:00
Sai-karthik 51c9eb0167 Keep presentation handoffs task-focused 2026-07-13 10:11:27 +00:00
Sai-karthik 59482d4747 Expose profile asset state in Day 1 tasks 2026-07-13 10:11:27 +00:00
Sai-karthik 6b3c6d0b0c Remove invented interview role fallback 2026-07-13 10:11:27 +00:00
Sai-karthik 301dba8c50 Fix curator task handoffs and profile state 2026-07-13 10:11:27 +00:00
-Puter 9cb1027f4c fix(staging): make compose guard host-portable 2026-07-13 05:12:20 +05:30
-Puter d2e3f49519 chore(staging): guard backend compose topology 2026-07-13 05:10:54 +05:30
-Puter 80884fc0b6 fix(staging): pin canonical service networks 2026-07-13 05:04:37 +05:30
-Puter b93cc81d93 fix curator sprint to use canonical onboarding 2026-07-13 04:28:27 +05:30
-Puter 982942fa6e fix onboarding GET service auth seam 2026-07-13 04:15:01 +05:30
-Puter fdc320b995 fix onboarding GET service network and rev10 serialization 2026-07-13 03:59:13 +05:30
-Puter 4b652358d3 fix curator sprint ICP reconciliation 2026-07-13 03:43:37 +05:30
34 changed files with 1326 additions and 201 deletions
+1
View File
@@ -23,6 +23,7 @@ MAX_AGENT_TOKENS=4096
# Shared secret for actor → backend service calls (rotate in prod)
SERVICE_TOKEN=dev-service-token-REPLACE_ME
A2A_ALLOWED_KEY=***********
SESSION_TICKET_SECRET=
# ── Central Gitea (shared org-wide, changes.md §2A) ──
# Public URL is used for Git remotes and must be reachable by OpenCode containers.
+10 -13
View File
@@ -14,18 +14,18 @@ services:
qscore-service-staging:
matchmaking-v2-staging:
user-service-staging:
social-branding-staging:
environment:
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr
SOCIAL_BRANDING_SERVICE_URL: http://social-branding-api-1:8011
RESUME_SERVICE_URL: http://growqr_resume_api:8000
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:18015
SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf
PRODUCT_SERVICE_TIMEOUT_MS: 10000
PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS: 240000
LLM_PROVIDER: opencode
LLM_BASE_URL: https://opencode.ai/zen/v1
LLM_MODEL: minimax-m3
GROW_AGENT_MODEL: minimax-m3
CONVERSATION_ACTOR_MODEL: minimax-m3
LLM_MODEL: kimi-k2.6
GROW_AGENT_MODEL: kimi-k2.6
CONVERSATION_ACTOR_MODEL: kimi-k2.6
networks:
interview-service:
@@ -36,19 +36,16 @@ networks:
name: roleplay-service_default
resume-builder-staging:
external: true
name: resume-builder_default
name: resume-builder-staging_default
course-service:
external: true
name: courses_service_default
name: course-service_default
qscore-service-staging:
external: true
name: qscore-service_default
name: qscore-service-staging_default
matchmaking-v2-staging:
external: true
name: matchmaking-service_default
name: matchmaking-v2_default
user-service-staging:
external: true
name: growqr-app_growqr-net
social-branding-staging:
external: true
name: social-branding_default
name: user-service-staging_default
+3 -2
View File
@@ -97,8 +97,9 @@ services:
# Auth
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key}
SERVICE_TOKEN: ${SERVICE_TOKEN:-}
SESSION_TICKET_SECRET: ${SESSION_TICKET_SECRET:?SESSION_TICKET_SECRET must be set}
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-}
# LLM
OPENCODE_API_KEY: ${OPENCODE_API_KEY}
LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
+9 -2
View File
@@ -12,6 +12,11 @@
"test:curator-static": "tsx scripts/curator-static-registry.test.ts",
"test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts",
"test:qscore-raw-events": "tsx scripts/qscore-raw-event-contract.test.ts",
"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:resume-gateway": "tsx scripts/resume-gateway.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
@@ -22,8 +27,10 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"compose:up": "docker compose up -d",
"compose:down": "docker compose down",
"compose:up": "scripts/staging-compose.sh up",
"compose:down": "scripts/staging-compose.sh down",
"staging:preflight": "scripts/staging-compose.sh preflight",
"staging:verify": "scripts/staging-compose.sh verify",
"docker:opencode:build": "docker build --build-arg GROWQR_IMAGE_VERSION=${OPENCODE_IMAGE_VERSION:-dev} --build-arg GROWQR_PROMPT_VERSION=${PROMPT_VERSION:-4} --build-arg GROWQR_MIGRATION_VERSION=${MIGRATION_VERSION:-1} -f docker/opencode/Dockerfile -t growqr/opencode:dev ."
},
"dependencies": {
+30
View File
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
globalThis.fetch = (async (input, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
calls.push({ url: String(input), body });
return new Response(JSON.stringify({ task_id: "a2a-task", status: "completed", messages: [] }), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const courseResponse = await app.request("http://backend.test/courses/a2a?missionId=mission-1&missionInstanceId=instance-1&stageId=stage-1&curatorTaskId=task-1", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "record_swipe", params: { video_id: "video-1", anchor_id: "anchor-1" } }) });
assert.equal(courseResponse.status, 200);
assert.ok(calls[0]?.url.endsWith("/a2a/tasks"));
assert.equal(calls[0]?.body.user_id, "user_test");
const params = calls[0]?.body.params as Record<string, unknown>;
assert.equal(params.missionId, "mission-1");
assert.equal(params.missionInstanceId, "instance-1");
assert.equal(params.stageId, "stage-1");
assert.equal(params.curatorTaskId, "task-1");
calls.length = 0;
const matchmakingResponse = await app.request("http://backend.test/matchmaking/a2a", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "mark_applied", params: { opportunity_id: "job-1" } }) });
assert.equal(matchmakingResponse.status, 200);
assert.ok(calls[0]?.url.endsWith("/a2a/tasks"));
} finally {
globalThis.fetch = originalFetch;
}
console.log("course-matchmaking HTTP route tests passed");
+16 -1
View File
@@ -85,9 +85,24 @@ if (!databaseAvailable) {
expectedTasks(startDate, 1), "Day 1 must expose experienced trial tasks");
assert.deepEqual(day1.plan.days[1]?.tasks, [], "Day 1 must hide future Day 2 tasks");
const noCompletionDay2 = await buildCuratorSprint(userId, addDays(startDate, 1));
const noCompletionTitles = noCompletionDay2.plan.days[1]?.tasks.map((task) => task.title) ?? [];
assert.ok(noCompletionTitles.includes("Executive Leadership Interview"), "zero-completion Day 2 keeps experienced recovery behavior");
assert.ok(!noCompletionTitles.some((title) => ["Complete GrowQR Profile", "Career Baseline", "First AI Resume", "Self-Introduction"].includes(title)), "recovery must not reintroduce student tasks");
await db.insert(growEvents).values((day1.plan.days[0]?.tasks ?? []).map((task, index) => ({
userId,
source: "curator-v1",
type: "curator.task.completed",
category: "mission" as const,
occurredAt: new Date(Date.now() + index),
dedupeKey: `${userId}:curator.task.completed:${task.id}`,
payload: { taskId: task.id },
})));
const day2 = await buildCuratorSprint(userId, addDays(startDate, 1));
assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })),
expectedTasks(startDate, 2), "Day 2 must expose experienced trial tasks after advance");
expectedTasks(startDate, 2), "completed Day 1 must expose exact experienced Day 2 registry tasks");
const second = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
assert.equal(second.status, "already_ready", "compatible replacement ready event must be reused");
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { getCompletionEvents } from "../src/services/service-registry.js";
import { isTaskCompletionEvent, planSeedsForVariant, type CuratorCompletionEvent } from "../src/v1/curator/curator-store.js";
import { templateSetFor } from "../src/v1/curator/task-registry.js";
import type { CuratorIcpId } from "../src/v1/curator/icp-registry.js";
const taskId = "curator-task-1";
const event = (type: string, correlatedTaskId = taskId): CuratorCompletionEvent => ({
type,
payload: {},
correlation: { taskId: correlatedTaskId, curatorTaskId: correlatedTaskId },
});
const roleplayEvents = getCompletionEvents("roleplay-service");
assert.deepEqual(roleplayEvents, ["roleplay.scenario.completed", "roleplay.feedback.generated"]);
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.configured")), false);
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed")), true);
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.feedback.generated")), true);
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed", "other-task")), false);
const courseEvents = getCompletionEvents("courses-service");
assert.deepEqual(courseEvents, ["course.completed"]);
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.generated")), false);
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.progress.recorded")), false);
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.completed")), true);
const socialEvents = getCompletionEvents("social-branding-service");
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("social.post.scheduled")), true);
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.content.published")), true);
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.post.drafted")), false);
const resumeEvents = getCompletionEvents("resume-service");
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.parsed")), false);
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.analysis.completed")), true);
const assessmentEvents = getCompletionEvents("assessment-service");
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.started")), false);
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.completed")), true);
const matchmakingEvents = getCompletionEvents("matchmaking-service");
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.generated")), false);
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.match.dismissed")), false);
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.reviewed")), true);
const qscoreEvents = getCompletionEvents("qscore-service");
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.review.opened")), false);
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.weak_driver.reviewed")), true);
const icpIds: CuratorIcpId[] = [
"student_recent_grad",
"intern",
"fresher_early_professional",
"experienced_professional",
"freelancer",
"founder",
"enterprise",
];
for (const icpId of icpIds) {
const trialDays = planSeedsForVariant(templateSetFor(icpId), "2026-07-14", 2, "trial");
assert.equal(trialDays.length, 2, `${icpId} must expose exactly two trial days`);
for (const day of trialDays) {
assert.equal(day.plannedTasks.length, 4, `${icpId} day ${day.dayIndex} must expose four tasks`);
for (const [index, task] of day.plannedTasks.entries()) {
const completionEvents = getCompletionEvents(task.serviceId);
const syntheticTaskId = `${icpId}:day-${day.dayIndex}:task-${index + 1}`;
assert.ok(completionEvents.length > 0, `${syntheticTaskId} must have completion evidence`);
assert.equal(
isTaskCompletionEvent(syntheticTaskId, completionEvents, event(completionEvents[0]!, syntheticTaskId)),
true,
`${syntheticTaskId} must complete from its exact correlated service event`,
);
}
}
}
for (const serviceId of [
"resume-service",
"interview-service",
"roleplay-service",
"courses-service",
"social-branding-service",
"matchmaking-service",
"qscore-service",
]) {
assert.ok(getCompletionEvents(serviceId).length > 0, `${serviceId} must define completion evidence`);
}
console.log("curator service completion contract tests passed");
+47
View File
@@ -0,0 +1,47 @@
{
"onboarding": {
"import": {
"method": "manual",
"status": "not_started",
"resume_id": null,
"linkedin_url": null,
"resume_summary": null,
"resume_filename": null,
"linkedin_profile_id": null
},
"status": "completed",
"consent": {
"accepted_at": "2026-07-12T21:31:40.189Z",
"terms_version": "2026-07",
"privacy_accepted": true
},
"profile": {
"icp": "experienced",
"mode": "individual",
"intent": "grow",
"question_branch": "professional"
},
"progress": {
"stage": "reveal",
"updated_at": "2026-07-12T21:33:01.914Z",
"branch_index": 4
},
"revision": 10,
"responses": {
"target_role": "Senior Software Engineer",
"target_field": "technical",
"work_context": "clients",
"career_barriers": ["offers"],
"desired_outcomes": ["role"],
"experience_level": "5+",
"target_milestone": null,
"venture_industry": null,
"current_situation": "employed",
"weekly_time_commitment": "steady"
},
"qx_estimate": 100,
"completed_at": "2026-07-12T21:31:42.957Z",
"access_choice": "trial",
"schema_version": 3
}
}
+31
View File
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { interviewService } from "../src/services/product-service-clients.js";
const originalFetch = globalThis.fetch;
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
globalThis.fetch = async (input, init) => {
const request = new Request(input, init);
requests.push({
url: request.url,
headers: request.headers,
body: await request.text(),
});
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
};
try {
await interviewService.configure({ user_id: "user_from_payload" }, "user_canonical");
await interviewService.preview({ user_id: "user_from_payload" }, "user_canonical");
await interviewService.editQuestions({ session_id: "session-1", questions: ["q1"] }, "user_canonical");
await interviewService.approve("session-1", "user_canonical");
assert.equal(requests.length, 4);
for (const request of requests) {
assert.equal(request.headers.get("x-growqr-user"), "user_canonical");
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
}
console.log("interview user identity forwarding: all assertions passed");
} finally {
globalThis.fetch = originalFetch;
}
+94
View File
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { serializeOnboardingState } from "../src/services/onboarding-serialization.js";
type StoredFixture = { onboarding: Record<string, unknown> };
const fixturePath = fileURLToPath(new URL("./fixtures/onboarding-rev10.json", import.meta.url));
const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as StoredFixture;
const response = serializeOnboardingState("user_3EX1LG3gBk3KY6kfD9PiTkWubs4", fixture.onboarding);
assert.equal(response.id, "user_3EX1LG3gBk3KY6kfD9PiTkWubs4");
assert.equal(response.revision, 10);
assert.equal(response.data.revision, 10);
assert.equal(response.data.status, "completed");
assert.equal(response.data.profile.icp, "experienced");
assert.equal(response.data.access_choice, "trial");
assert.equal(response.data.completed_at, "2026-07-12T21:31:42.957Z");
assert.equal(response.updatedAt, "2026-07-12T21:33:01.914Z");
assert.deepEqual(response.payload, {
schema_version: 1,
source_revision: 10,
primary_intent: "grow",
mode: "individual",
question_branch: "professional",
onboarding_icp: "experienced",
curator_registry_icp: null,
target_role: "Senior Software Engineer",
target_field: "technical",
experience_context: "5+ · clients",
goals: ["role"],
barriers: ["offers"],
priority: "role",
weekly_time_commitment: "steady",
});
const serialized = JSON.parse(JSON.stringify(response));
assert.deepEqual(serialized, response, "GET response must be JSON serializable without losing rev10 state");
const compose = readFileSync(fileURLToPath(new URL("../docker-compose.override.yml", import.meta.url)), "utf8");
assert.match(
compose,
/user-service-staging:\s+external:\s+true\s+name:\s+user-service-staging_default/s,
"backend must join the canonical user-service staging network",
);
assert.doesNotMatch(compose, /name:\s+growqr-app_growqr-net/, "obsolete substitute user network must not be configured");
assert.match(compose, /RESUME_SERVICE_URL:\s+http:\/\/growqr_resume_api:8000/, "backend must route Resume through canonical Docker DNS");
for (const modelKey of ["LLM_MODEL", "GROW_AGENT_MODEL", "CONVERSATION_ACTOR_MODEL"]) {
assert.match(compose, new RegExp(`${modelKey}:\\s+kimi-k2\\.6`), `${modelKey} must stay on the verified staging model`);
}
assert.doesNotMatch(compose, /minimax-m3/, "staging compose must not restore the broken MiniMax model pin");
const canonicalServiceNetworks = [
["interview-service", "interview-service_default"],
["roleplay-service", "roleplay-service_default"],
["resume-builder-staging", "resume-builder-staging_default"],
["course-service", "course-service_default"],
["qscore-service-staging", "qscore-service-staging_default"],
["matchmaking-v2-staging", "matchmaking-v2_default"],
["user-service-staging", "user-service-staging_default"],
] as const;
for (const [key, name] of canonicalServiceNetworks) {
assert.match(
compose,
new RegExp(`${key}:\\s+external:\\s+true\\s+name:\\s+${name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`, "s"),
`backend must join canonical network ${name}`,
);
}
for (const obsoleteName of [
"resume-builder_default",
"courses_service_default",
"qscore-service_default",
"matchmaking-service_default",
"growqr-app_growqr-net",
]) {
assert.doesNotMatch(compose, new RegExp(`name:\\s+${obsoleteName}`), `obsolete substitute network ${obsoleteName} must not be configured`);
}
const stagingCompose = readFileSync(fileURLToPath(new URL("./staging-compose.sh", import.meta.url)), "utf8");
assert.match(stagingCompose, /PROJECT=growqr-backend-staging/, "staging wrapper must pin the compose project name");
for (const [, name] of canonicalServiceNetworks) {
assert.match(stagingCompose, new RegExp(name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")), `staging wrapper must verify ${name}`);
}
const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")) as {
scripts?: Record<string, string>;
};
assert.equal(packageJson.scripts?.["compose:up"], "scripts/staging-compose.sh up");
assert.equal(packageJson.scripts?.["compose:down"], "scripts/staging-compose.sh down");
assert.equal(packageJson.scripts?.["staging:preflight"], "scripts/staging-compose.sh preflight");
assert.equal(packageJson.scripts?.["staging:verify"], "scripts/staging-compose.sh verify");
console.log("onboarding-rev10-read: fixture serialization and staging network assertions passed");
@@ -29,6 +29,11 @@ const input = {
};
const normalized = normalizeGrowEvent(input, { userId: "clerk-user-real" });
assert.equal(
normalized.type,
"interview.feedback.generated",
"backend-only consumers may retain staging's legacy event alias",
);
const canonical = canonicalGrowEventEnvelope(normalized, "clerk-user-real");
assert.deepEqual(canonical, {
...input,
+37
View File
@@ -0,0 +1,37 @@
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 (request.method === "DELETE" && path.endsWith("/api/v1/resumes/resume-1")) {
return new Response(null, { status: 204 });
}
return new Response("<html>preview</html>", {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const preview = await app.request("http://backend.test/resume/export/resumes/resume-1/preview");
assert.equal(preview.status, 200);
assert.equal(await preview.text(), "<html>preview</html>");
assert.equal(new URL(requests[0]!.url).pathname, "/api/v1/export/resumes/resume-1/preview");
assert.equal(requests[0]!.headers.get("x-growqr-user"), "user_test");
assert.match(requests[0]!.headers.get("authorization") ?? "", /^Bearer /);
const deleted = await app.request("http://backend.test/resume/resumes/resume-1", { method: "DELETE" });
assert.equal(deleted.status, 204);
assert.equal(await deleted.text(), "");
assert.equal(new URL(requests[1]!.url).pathname, "/api/v1/resumes/resume-1");
} finally {
globalThis.fetch = originalFetch;
}
console.log("resume gateway proxy tests passed");
+30
View File
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { resumeService } from "../src/services/product-service-clients.js";
const originalFetch = globalThis.fetch;
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
globalThis.fetch = async (input, init) => {
const request = new Request(input, init);
requests.push({
url: request.url,
headers: request.headers,
body: await request.text(),
});
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
};
try {
await resumeService.state("user_canonical");
await resumeService.listResumes("user_canonical");
await resumeService.createResume({ user_id: "user_from_payload" }, "user_canonical");
assert.equal(requests.length, 3);
for (const request of requests) {
assert.equal(request.headers.get("x-growqr-user"), "user_canonical");
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
}
console.log("resume user identity forwarding: all assertions passed");
} finally {
globalThis.fetch = originalFetch;
}
+58
View File
@@ -0,0 +1,58 @@
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 url = new URL(request.url);
const body = url.pathname.endsWith("/drafts")
? { 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" };
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const drafts = await app.request("http://backend.test/roleplay/drafts?limit=25");
assert.equal(drafts.status, 200);
assert.deepEqual(await drafts.json(), { drafts: [{ session_id: "draft-1" }] });
const plan = await app.request("http://backend.test/roleplay/plan/draft-1");
assert.equal(plan.status, 200);
assert.deepEqual(await plan.json(), { session_id: "draft-1", status: "draft" });
const approved = await app.request("http://backend.test/roleplay/approve", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: "draft-1" }),
});
assert.equal(approved.status, 200);
assert.deepEqual(await approved.json(), { session_id: "draft-1", status: "configured", approved: true });
const deleted = await app.request("http://backend.test/roleplay/drafts/draft-1", { method: "DELETE" });
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) {
assert.equal(request.headers.get("x-growqr-user"), "user_test");
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
}
console.log("roleplay draft gateway: all assertions passed");
} finally {
globalThis.fetch = originalFetch;
}
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { correlateEventWithServiceSession, normalizeServiceId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
import { canonicalGrowEventType } from "../src/events/normalize.js";
import type { GrowEventEnvelope } from "../src/events/envelope.js";
const event: GrowEventEnvelope = {
id: "event-1",
userId: "user-a",
source: "interview-service",
type: "interview.session.completed",
category: "service",
occurredAt: "2026-07-14T00:00:00.000Z",
correlation: { sessionId: "session-1" },
payload: { status: "completed" },
};
const link = {
userId: "user-a",
missionInstanceId: "sprint-1",
missionId: "curator-sprint",
stageId: "day-1:interview",
metadata: { taskId: "curator-task-1" },
};
{
const correlated = correlateEventWithServiceSession(event, "user-a", link);
assert.deepEqual(correlated.correlation, {
sessionId: "session-1",
taskId: "curator-task-1",
curatorTaskId: "curator-task-1",
missionInstanceId: "sprint-1",
missionId: "curator-sprint",
stageId: "day-1:interview",
});
assert.deepEqual(correlated.mission, {
instanceId: "sprint-1",
missionId: "curator-sprint",
stageId: "day-1:interview",
});
}
{
const foreign = correlateEventWithServiceSession(event, "user-a", { ...link, userId: "user-b" });
assert.strictEqual(foreign, event, "a service session owned by another user must never enrich the event");
}
{
const unresolved = correlateEventWithServiceSession(event, undefined, link);
assert.strictEqual(unresolved, event, "an unresolved event must never inherit session context");
}
{
const explicit = correlateEventWithServiceSession({
...event,
mission: { missionId: "explicit-mission", stageId: "explicit-stage" },
correlation: { sessionId: "session-1", taskId: "explicit-task" },
}, "user-a", link);
assert.equal(explicit.correlation?.taskId, "explicit-task");
assert.deepEqual(explicit.mission, { missionId: "explicit-mission", stageId: "explicit-stage" });
}
{
assert.deepEqual(serviceEventExternalIds({
...event,
correlation: { courseId: "course-1", lesson_id: "lesson-1", externalId: "course-1" },
subject: { serviceId: "courses-service", externalId: "lesson-1" },
payload: { resume_id: "resume-1", assessmentId: "assessment-1" },
}), ["course-1", "lesson-1", "resume-1", "assessment-1"]);
}
{
assert.equal(normalizeServiceId("social-branding-service"), "brand");
assert.equal(normalizeServiceId("courses-service"), "course");
assert.equal(normalizeServiceId("cover-letter-service"), "cover_letter");
assert.equal(normalizeServiceId("matchmaking-v2"), "matchmaking");
}
{
assert.equal(canonicalGrowEventType("roleplay.session.completed"), "roleplay.scenario.completed");
assert.equal(canonicalGrowEventType("roleplay.review.completed"), "roleplay.feedback.generated");
assert.equal(canonicalGrowEventType("course.video.completed"), "course.completed");
assert.equal(canonicalGrowEventType("social.profile.synced"), "brand.profile.updated");
assert.equal(canonicalGrowEventType("resume.analysis.completed"), "resume.analysis.completed");
}
console.log("service session correlation tests passed");
+11
View File
@@ -0,0 +1,11 @@
import assert from "node:assert/strict";
import { resolveServiceToken } from "../src/config.js";
assert.equal(resolveServiceToken({}), "", "missing SERVICE_TOKEN must resolve to an empty config value");
assert.equal(
resolveServiceToken({ SERVICE_TOKEN: "explicit" }),
"explicit",
"explicit SERVICE_TOKEN must be preserved",
);
console.log("service-token-config: all assertions passed");
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
set -eu
PROJECT=growqr-backend-staging
COMPOSE="docker compose -p $PROJECT -f docker-compose.yml -f docker-compose.override.yml"
REQUIRED_NETWORKS="growqr-backend-staging_default interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
EXTERNAL_NETWORKS="interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
preflight() {
for network in $EXTERNAL_NETWORKS; do
docker network inspect "$network" >/dev/null
done
$COMPOSE config --quiet
}
verify() {
actual=$(docker inspect growqr-backend --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}')
for network in $REQUIRED_NETWORKS; do
case " $actual " in
*" $network "*) ;;
*) echo "missing backend network: $network" >&2; exit 1 ;;
esac
done
for network in $actual; do
case " $REQUIRED_NETWORKS " in
*" $network "*) ;;
*) echo "unexpected backend network: $network" >&2; exit 1 ;;
esac
done
docker exec growqr-backend node --input-type=module -e '
const checks = [
["user-service", "http://growqr_user_service:8003/health"],
["qscore", "http://qscore-service-staging-api-1:8000/health"],
["course", "http://growqr-course-service:8010/health"],
["resume", "http://growqr_resume_api:8000/health"],
["interview", "http://interview-service-api-1:8000/health"],
["roleplay", "http://roleplay-service-api-1:8000/health"],
];
let failed = false;
for (const [name, url] of checks) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
console.log(name, response.status);
if (response.status !== 200) failed = true;
} catch (error) {
console.error(name, "ERROR", error.message);
failed = true;
}
}
if (failed) process.exit(1);
'
docker exec growqr-backend node --input-type=module -e "const response=await fetch('http://127.0.0.1:4000/healthz',{signal:AbortSignal.timeout(3000)});console.log('backend',response.status);if(response.status!==200)process.exit(1)"
}
action=${1:-}
[ "$#" -gt 0 ] && shift
case "$action" in
preflight) preflight ;;
verify) verify ;;
up) preflight; $COMPOSE up -d "$@"; verify ;;
down) $COMPOSE down "$@" ;;
*) echo "Usage: scripts/staging-compose.sh <preflight|verify|up|down> [compose args]" >&2; exit 2 ;;
esac
+151 -1
View File
@@ -1,5 +1,8 @@
import assert from "node:assert/strict";
import { fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
import { readFileSync } from "node:fs";
import { config } from "../src/config.js";
import { resolveTrustedServiceTokens } from "../src/auth/clerk.js";
import { fetchOnboardingReadProfile, fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
/**
* Regression test for the PATCH /onboarding 500.
@@ -162,4 +165,151 @@ async function runFetchUserProfileAfterBodyConsumed(): Promise<{
assert.notEqual(sentBody.length, Buffer.byteLength(inboundBody), "synthetic body length must differ from inbound (else the test proves nothing)");
}
// ── 7. trusted service auth uses A2A state with scoped identity ─────────────
{
const originalFetch = globalThis.fetch;
const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string };
const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv };
Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" });
const userId = "user_3EX1LG3gBk3KY6kfD9PiTkWubs4/scope";
let target = "";
let auth = "";
let forwardedScope: string | null = null;
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
target = String(input instanceof URL ? input : input.url);
const headers = new Headers(init?.headers ?? {});
auth = headers.get("authorization") ?? "";
forwardedScope = headers.get("x-growqr-user");
return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10, status: "completed", access_choice: "trial", profile: { icp: "experienced" } } } }), { status: 200 });
}) as typeof globalThis.fetch;
try {
const req = new Request("https://backend.test/users/onboarding", {
headers: { authorization: "Bearer test-service-token", "x-growqr-user": userId },
});
const profile = await fetchOnboardingReadProfile(req, userId);
assert.ok(target.endsWith(`/api/state/${encodeURIComponent(userId)}`), `A2A target must be scoped to user: ${target}`);
assert.equal(auth, "Bearer test-a2a-key", "A2A key must be used downstream");
assert.equal(forwardedScope, null, "user scope must be encoded in URL, not forwarded as trust header");
assert.equal((profile.preferences as Record<string, any>).onboarding.revision, 10);
const onboarding = (profile.preferences as Record<string, any>).onboarding;
assert.equal(onboarding.status, "completed");
assert.equal(onboarding.profile.icp, "experienced");
assert.equal(onboarding.access_choice, "trial");
} finally {
Object.assign(mutableConfig, savedConfig);
globalThis.fetch = originalFetch;
}
}
// Trusted auth without a matching scope is rejected before any downstream call.
{
const originalFetch = globalThis.fetch;
let calls = 0;
const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string };
const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv };
Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" });
globalThis.fetch = (async () => { calls += 1; return new Response("unexpected", { status: 200 }); }) as typeof globalThis.fetch;
try {
const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer test-service-token" } });
await assert.rejects(() => fetchOnboardingReadProfile(req, "user_spoof"), /matching x-growqr-user/);
assert.equal(calls, 0, "unscoped trusted auth must not call user-service");
} finally {
Object.assign(mutableConfig, savedConfig);
globalThis.fetch = originalFetch;
}
}
// Production never treats A2A_ALLOWED_KEY as an inbound backend token.
{
const originalFetch = globalThis.fetch;
const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string };
const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv };
Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" });
let target = "";
let auth = "";
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
target = String(input instanceof URL ? input : input.url);
auth = new Headers(init?.headers ?? {}).get("authorization") ?? "";
return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10 } } }), { status: 200 });
}) as typeof globalThis.fetch;
try {
const req = new Request("https://backend.test/users/onboarding", {
headers: { authorization: "Bearer test-a2a-key", "x-growqr-user": "user_spoof" },
});
await fetchOnboardingReadProfile(req, "user_real");
assert.ok(target.endsWith("/api/v1/users/me"), "production A2A key must use Clerk /me path");
assert.equal(auth, "Bearer test-a2a-key");
} finally {
Object.assign(mutableConfig, savedConfig);
globalThis.fetch = originalFetch;
}
}
// Clerk JWTs always use /me, even when a spoofed x-growqr-user is present.
{
const originalFetch = globalThis.fetch;
let target = "";
let auth = "";
let forwardedScope: string | null = null;
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
target = String(input instanceof URL ? input : input.url);
const headers = new Headers(init?.headers ?? {});
auth = headers.get("authorization") ?? "";
forwardedScope = headers.get("x-growqr-user");
return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10 } } }), { status: 200 });
}) as typeof globalThis.fetch;
try {
const req = new Request("https://backend.test/users/onboarding", {
headers: { authorization: "Bearer eyJ.clerk.jwt", "x-growqr-user": "user_spoof" },
});
await fetchOnboardingReadProfile(req, "user_real");
assert.ok(target.endsWith("/api/v1/users/me"), `Clerk target must be /me: ${target}`);
assert.equal(auth, "Bearer eyJ.clerk.jwt", "Clerk JWT must be forwarded unchanged");
assert.equal(forwardedScope, null, "spoofable x-growqr-user must not be forwarded to /me");
} finally {
globalThis.fetch = originalFetch;
}
}
// An invalid Clerk JWT remains a /me error and never falls back to A2A.
{
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => { calls += 1; return new Response('{"detail":"invalid"}', { status: 401 }); }) as typeof globalThis.fetch;
try {
const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer invalid.jwt" } });
await assert.rejects(() => fetchOnboardingReadProfile(req, "user_real"), /user-service \/me fetch failed: 401/);
assert.equal(calls, 1, "invalid Clerk JWT must not trigger an A2A fallback");
} finally {
globalThis.fetch = originalFetch;
}
}
// ── 8. trusted token resolution excludes A2A in production ────────────────
{
assert.equal(config.a2aAllowedKey, process.env.A2A_ALLOWED_KEY ?? "", "A2A config must not have a dev fallback");
const production = resolveTrustedServiceTokens({
nodeEnv: "production",
serviceToken: "service-token",
a2aAllowedKey: "a2a-token",
});
assert.equal(production.has("service-token"), true);
assert.equal(production.has("a2a-token"), false, "production must exclude A2A token");
const development = resolveTrustedServiceTokens({
nodeEnv: "development",
serviceToken: "service-token",
a2aAllowedKey: "a2a-token",
});
assert.equal(development.has("service-token"), true);
assert.equal(development.has("a2a-token"), true, "development must include configured A2A token");
}
// Compose must never inject a development service token in production.
{
const compose = readFileSync(new URL("../docker-compose.yml", import.meta.url), "utf8");
assert.doesNotMatch(compose, /dev-service-token/, "compose must not contain a development service token fallback");
assert.match(compose, /SERVICE_TOKEN:\s*\$\{SERVICE_TOKEN:-\}/, "compose SERVICE_TOKEN default must be explicitly empty");
}
console.log("user-profile: all assertions passed");
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { signWsTicket, verifyWsTicket } from "../src/services/ws-tickets.js";
const issued = signWsTicket({ secret: "ticket-secret", audience: "interview", subject: "user-1", sessionId: "session-1", nowSeconds: 1_000 });
assert.equal(verifyWsTicket({ secret: "ticket-secret", ticket: issued.ticket, audience: "interview", sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 }).sid, "session-1");
for (const invalid of [
{ audience: "roleplay" as const, sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 },
{ audience: "interview" as const, sessionId: "session-2", subject: "user-1", nowSeconds: 1_001 },
{ audience: "interview" as const, sessionId: "session-1", subject: "user-2", nowSeconds: 1_001 },
{ audience: "interview" as const, sessionId: "session-1", subject: "user-1", nowSeconds: 1_061 },
]) assert.throws(() => verifyWsTicket({ secret: "ticket-secret", ticket: issued.ticket, ...invalid }));
assert.throws(() => verifyWsTicket({ secret: "ticket-secret", ticket: "not-a-ticket", audience: "interview", sessionId: "session-1", nowSeconds: 1_001 }));
assert.throws(() => verifyWsTicket({ secret: "a2a-key", ticket: issued.ticket, audience: "interview", sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 }));
assert.throws(() => signWsTicket({ secret: "", audience: "interview", subject: "user-1", sessionId: "session-1" }));
console.log("ws-ticket tests passed");
+12 -6
View File
@@ -14,6 +14,17 @@ export type AuthContext = {
};
};
/** Resolve inbound service tokens without allowing A2A in production. */
export function resolveTrustedServiceTokens(input: {
nodeEnv: string;
serviceToken: string;
a2aAllowedKey: string;
}): ReadonlySet<string> {
return new Set(
[input.serviceToken, input.nodeEnv !== "production" ? input.a2aAllowedKey : ""].filter(Boolean),
);
}
export const clerk = config.clerkSecretKey
? createClerkClient({ secretKey: config.clerkSecretKey })
: null;
@@ -28,12 +39,7 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
// Service-to-service path (Grow stack calling backend).
// Header `x-growqr-user` is REQUIRED so we can scope the call.
const trustedServiceTokens = new Set(
[
config.serviceToken,
config.nodeEnv !== "production" ? config.a2aAllowedKey : "",
].filter(Boolean),
);
const trustedServiceTokens = resolveTrustedServiceTokens(config);
if (
token &&
+9 -2
View File
@@ -13,6 +13,10 @@ function parseBool(value: string | undefined, fallback: boolean): boolean {
}
/** Resolve the optional service-to-service token without implicit defaults. */
export function resolveServiceToken(env: Record<string, string | undefined>): string {
return env.SERVICE_TOKEN ?? "";
}
// ── Redis config resolver (pure, testable without module reload) ────────────
// Exported so tests can assert gating/URL-inheritance behavior with synthetic
// env objects, avoiding ESM module-cache hacks.
@@ -56,8 +60,11 @@ export const config = {
clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "",
clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "",
// Optional: lock service-to-service calls (actor → backend).
serviceToken: process.env.SERVICE_TOKEN ?? "",
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
serviceToken: resolveServiceToken(process.env),
// A2A is intentionally unset unless an operator configures it.
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "",
// Dedicated browser WebSocket ticket signing secret; never reuse A2A credentials.
sessionTicketSecret: process.env.SESSION_TICKET_SECRET ?? "",
// Service → backend event stream. Redis is optional; HTTP /events/ingest/service is always available.
// Explicit opt-in flags: both Redis ingestion paths are default-off. The REST
+18 -1
View File
@@ -41,6 +41,23 @@ function normalizeCorrelation(value: unknown) {
return Object.keys(correlation).length ? correlation : undefined;
}
const CANONICAL_EVENT_TYPE: Record<string, string> = {
"interview.review.completed": "interview.feedback.generated",
"roleplay.session.completed": "roleplay.scenario.completed",
"roleplay.review.completed": "roleplay.feedback.generated",
"course.video.completed": "course.completed",
"course.lesson.completed": "course.completed",
"course.progress_recorded": "course.progress.recorded",
"social.profile.synced": "brand.profile.updated",
"social.profile.updated": "brand.profile.updated",
"social.post.scheduled": "brand.post.exported",
"social.post.published": "brand.content.published",
};
export function canonicalGrowEventType(type: string): string {
return CANONICAL_EVENT_TYPE[type] ?? type;
}
export function normalizeGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): GrowEventEnvelope {
const raw = asRecord(input);
const payload = asRecord(raw.payload ?? raw.data ?? raw.event_payload);
@@ -60,7 +77,7 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string;
})();
const source = overrides.source ?? getString(raw.source) ?? "unknown-service";
const type = getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event";
const type = canonicalGrowEventType(getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event");
const categoryRaw = raw.category ?? payload.category;
const category = isGrowEventCategory(categoryRaw) ? categoryRaw : "service";
const userId = overrides.userId ?? getString(raw.userId ?? raw.user_id ?? payload.userId ?? payload.user_id);
+184 -33
View File
@@ -1,34 +1,176 @@
import { and, eq } from "drizzle-orm";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { eventOutbox, growEvents, missionServiceSessions, type GrowEventRow } from "../db/schema.js";
import { eventOutbox, growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
import { normalizeGrowEvent } from "./normalize.js";
import { QSCORE_GROW_EVENT_DESTINATION, QSCORE_GROW_EVENT_TOPIC } from "./qscore-event-outbox.js";
import { DEFAULT_QSCORE_ORG_ID } from "../services/qscore-proxy.js";
async function ensureUser(userId: string) {
await db
.insert(users)
.values({ id: userId, email: `${userId}@service.local`, displayName: userId })
.onConflictDoNothing();
}
type ServiceSessionLink = Pick<
typeof missionServiceSessions.$inferSelect,
"userId" | "missionInstanceId" | "missionId" | "stageId" | "metadata"
>;
export function correlateEventWithServiceSession(
event: GrowEventEnvelope,
resolvedUserId: string | undefined,
link: ServiceSessionLink | undefined,
): GrowEventEnvelope {
if (!resolvedUserId || !link || link.userId !== resolvedUserId) return event;
const metadata = asRecord(link.metadata);
const taskId = getString(metadata.taskId ?? metadata.curatorTaskId ?? metadata.curator_task_id);
const missionInstanceId = getString(link.missionInstanceId ?? metadata.missionInstanceId ?? metadata.mission_instance_id);
const missionId = getString(link.missionId ?? metadata.missionId ?? metadata.mission_id);
const stageId = getString(link.stageId ?? metadata.stageId ?? metadata.stage_id);
const eventCorrelation = event.correlation ?? {};
const explicitTaskId = getString(eventCorrelation.taskId ?? eventCorrelation.curatorTaskId ?? eventCorrelation.curator_task_id);
const correlation = {
...(taskId && !explicitTaskId ? { taskId, curatorTaskId: taskId } : {}),
...(missionInstanceId && !getString(eventCorrelation.missionInstanceId) ? { missionInstanceId } : {}),
...(missionId && !getString(eventCorrelation.missionId) ? { missionId } : {}),
...(stageId && !getString(eventCorrelation.stageId) ? { stageId } : {}),
...eventCorrelation,
};
const mission = event.mission ?? (
missionInstanceId || missionId || stageId
? { instanceId: missionInstanceId, missionId, stageId }
: undefined
);
return { ...event, correlation, mission };
}
export function serviceEventExternalIds(event: GrowEventEnvelope): string[] {
const correlation = event.correlation ?? {};
const payload = event.payload ?? {};
const subject = event.subject ?? {};
return [...new Set([
correlation.sessionId,
correlation.session_id,
correlation.externalId,
correlation.external_id,
correlation.resumeId,
correlation.resume_id,
correlation.courseId,
correlation.course_id,
correlation.lessonId,
correlation.lesson_id,
correlation.assessmentId,
correlation.assessment_id,
subject.externalId,
payload.sessionId,
payload.session_id,
payload.externalId,
payload.external_id,
payload.resumeId,
payload.resume_id,
payload.courseId,
payload.course_id,
payload.lessonId,
payload.lesson_id,
payload.assessmentId,
payload.assessment_id,
].map(getString).filter((value): value is string => Boolean(value)))];
}
function eventHasExternalId(event: Pick<GrowEventRow, "correlation" | "payload" | "subject">, externalIds: Set<string>) {
return serviceEventExternalIds({ correlation: asRecord(event.correlation), payload: asRecord(event.payload), subject: asRecord(event.subject) } as GrowEventEnvelope)
.some((value) => externalIds.has(value));
}
async function resolveUserId(event: GrowEventEnvelope): Promise<string | undefined> {
if (event.userId) return event.userId;
const sessionId = getString(event.correlation?.sessionId ?? event.correlation?.session_id);
const externalIds = serviceEventExternalIds(event);
const serviceId = normalizeServiceId(event.source);
if (sessionId) {
const [row] = await db
.select({ userId: missionServiceSessions.userId })
.from(missionServiceSessions)
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
.limit(1);
if (row?.userId) return row.userId;
}
return undefined;
if (!externalIds.length) return undefined;
const [row] = await db
.select({ userId: missionServiceSessions.userId })
.from(missionServiceSessions)
.where(and(
eq(missionServiceSessions.serviceId, serviceId),
inArrayValues(missionServiceSessions.externalId, externalIds),
))
.limit(1);
return row?.userId ?? undefined;
}
function inArrayValues(column: typeof missionServiceSessions.externalId, values: string[]) {
return or(...values.map((value) => eq(column, value)))!;
}
async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: string | undefined): Promise<ServiceSessionLink | undefined> {
if (!userId) return undefined;
const externalIds = serviceEventExternalIds(event);
if (!externalIds.length) return undefined;
const serviceId = normalizeServiceId(event.source);
const [row] = await db
.select({
userId: missionServiceSessions.userId,
missionInstanceId: missionServiceSessions.missionInstanceId,
missionId: missionServiceSessions.missionId,
stageId: missionServiceSessions.stageId,
metadata: missionServiceSessions.metadata,
})
.from(missionServiceSessions)
.where(and(
eq(missionServiceSessions.userId, userId),
eq(missionServiceSessions.serviceId, serviceId),
inArrayValues(missionServiceSessions.externalId, externalIds),
))
.limit(1);
const rowMetadata = asRecord(row?.metadata);
const rowTaskId = getString(rowMetadata.taskId ?? rowMetadata.curatorTaskId ?? rowMetadata.curator_task_id);
if (rowTaskId) return row;
const externalIdSet = new Set(externalIds);
const candidates = await db
.select({ source: growEvents.source, correlation: growEvents.correlation, payload: growEvents.payload, mission: growEvents.mission, subject: growEvents.subject })
.from(growEvents)
.where(and(
eq(growEvents.userId, userId),
sql`coalesce(${growEvents.correlation}->>'taskId', ${growEvents.correlation}->>'curatorTaskId', ${growEvents.payload}->>'taskId', ${growEvents.payload}->>'curatorTaskId') is not null`,
))
.orderBy(desc(growEvents.occurredAt))
.limit(100);
const prior = candidates.find((candidate) => (
normalizeServiceId(candidate.source) === serviceId && eventHasExternalId(candidate, externalIdSet)
));
if (!prior) return row;
const priorCorrelation = asRecord(prior.correlation);
const priorPayload = asRecord(prior.payload);
const priorMission = asRecord(prior.mission);
return {
userId,
missionInstanceId: row?.missionInstanceId ?? getString(priorMission.instanceId ?? priorCorrelation.missionInstanceId) ?? null,
missionId: row?.missionId ?? getString(priorMission.missionId ?? priorCorrelation.missionId) ?? null,
stageId: row?.stageId ?? getString(priorMission.stageId ?? priorCorrelation.stageId) ?? null,
metadata: {
...priorPayload,
...priorCorrelation,
...rowMetadata,
},
};
}
export function normalizeServiceId(source: string): string {
if (source.includes("interview")) return "interview";
if (source.includes("roleplay")) return "roleplay";
if (source.includes("resume")) return "resume";
if (source.includes("course")) return "course";
if (source.includes("courses")) return "course";
if (source.includes("qscore")) return "qscore";
return source;
const normalized = source.toLowerCase();
if (normalized.includes("interview")) return "interview";
if (normalized.includes("roleplay")) return "roleplay";
if (normalized.includes("resume")) return "resume";
if (normalized.includes("cover-letter") || normalized.includes("cover_letter")) return "cover_letter";
if (normalized.includes("course")) return "course";
if (normalized.includes("qscore") || normalized.includes("q-score")) return "qscore";
if (normalized.includes("assessment")) return "assessment";
if (normalized.includes("matchmaking")) return "matchmaking";
if (normalized.includes("social") || normalized.includes("brand")) return "brand";
return normalized;
}
export async function recordGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<GrowEventRow> {
@@ -39,23 +181,26 @@ export async function recordGrowEvent(input: unknown, overrides: { userId?: stri
export async function recordGrowEventWithResult(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<{ event: GrowEventRow; inserted: boolean }> {
const normalized = normalizeGrowEvent(input, overrides);
const resolvedUserId = await resolveUserId(normalized);
const sessionLink = await resolveServiceSessionLink(normalized, resolvedUserId);
const correlated = correlateEventWithServiceSession(normalized, resolvedUserId, sessionLink);
if (resolvedUserId) await ensureUser(resolvedUserId);
const processingStatus = resolvedUserId ? "pending" : "unresolved";
const values = {
id: normalized.id,
id: correlated.id,
userId: resolvedUserId,
orgId: normalized.orgId ?? DEFAULT_QSCORE_ORG_ID,
source: normalized.source,
type: normalized.type,
category: normalized.category,
occurredAt: new Date(normalized.occurredAt),
mission: normalized.mission as Record<string, unknown> | undefined,
subject: normalized.subject as Record<string, unknown> | undefined,
correlation: normalized.correlation as Record<string, unknown> | undefined,
payload: normalized.payload,
raw: normalized.raw ?? asRecord(input),
dedupeKey: normalized.dedupeKey,
orgId: correlated.orgId ?? DEFAULT_QSCORE_ORG_ID,
source: correlated.source,
type: correlated.type,
category: correlated.category,
occurredAt: new Date(correlated.occurredAt),
mission: correlated.mission as Record<string, unknown> | undefined,
subject: correlated.subject as Record<string, unknown> | undefined,
correlation: correlated.correlation as Record<string, unknown> | undefined,
payload: correlated.payload,
raw: correlated.raw ?? asRecord(input),
dedupeKey: correlated.dedupeKey,
processingStatus,
} satisfies typeof growEvents.$inferInsert;
@@ -73,7 +218,7 @@ export async function recordGrowEventWithResult(input: unknown, overrides: { use
//
// NULL dedupeKey never collides (Postgres treats NULLs as distinct under a
// unique index), so events with no dedupe key always insert fresh.
const canonicalEvent = canonicalGrowEventEnvelope(normalized, resolvedUserId);
const canonicalEvent = canonicalGrowEventEnvelope(correlated, resolvedUserId);
const insertedRows = await db.transaction(async (tx) => {
const rows = await tx
@@ -133,12 +278,18 @@ export async function recordGrowEventWithResult(input: unknown, overrides: { use
}
export function canonicalGrowEventEnvelope(normalized: GrowEventEnvelope, resolvedUserId?: string) {
// `normalizeGrowEvent` may translate legacy producer aliases for backend-only
// consumers (for example curator completion matching). QScore owns signal
// interpretation, so its generic outbox must retain the producer's event type
// and unmodified payload instead of publishing that backend alias.
const producer = asRecord(normalized.raw);
const producerType = getString(producer.type ?? producer.event_type ?? producer.action);
return compactRecord({
id: normalized.id,
userId: resolvedUserId,
orgId: normalized.orgId ?? DEFAULT_QSCORE_ORG_ID,
source: normalized.source,
type: normalized.type,
type: producerType ?? normalized.type,
category: normalized.category,
occurredAt: normalized.occurredAt,
mission: normalized.mission,
+88 -25
View File
@@ -5,13 +5,13 @@ import { and, desc, eq } from "drizzle-orm";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { config } from "../config.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import { interviewService, ProductServiceError, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js";
import { interviewService, ProductServiceError, resumeService, roleplayService, courseService, type JsonObject } from "../services/product-service-clients.js";
import { signWsTicket } from "../services/ws-tickets.js";
import { db } from "../db/client.js";
import { events, growEvents, missionServiceSessions } from "../db/schema.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
import { buildMatchmakingGatewayEvent } from "../services/matchmaking-events.js";
import { log } from "../log.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../services/qscore-proxy.js";
@@ -79,6 +79,15 @@ type SessionMissionContext = {
mission?: Record<string, unknown>;
taskId?: string;
};
async function assertSessionOwnership(serviceId: "interview" | "roleplay", sessionId: string, userId: string): Promise<void> {
const [row] = await db
.select({ userId: missionServiceSessions.userId })
.from(missionServiceSessions)
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
.limit(1);
if (!row) throw new HTTPException(404, { message: "session not found" });
if (row.userId !== userId) throw new HTTPException(403, { message: "session ownership mismatch" });
}
async function sessionMissionContext(serviceId: "interview" | "roleplay", sessionId: string): Promise<SessionMissionContext> {
const [row] = await db
@@ -460,7 +469,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
mission,
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record resume gateway event"));
return new Response(responseBuffer, {
return new Response(res.status === 204 || res.status === 205 || res.status === 304 ? null : responseBuffer, {
status: res.status,
statusText: res.statusText,
headers: decodedProxyHeaders(res.headers),
@@ -743,9 +752,10 @@ async function proxySocialRequest(req: Request, rest: string, userId: string) {
});
}
export function serviceRoutes() {
export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
if (!options.skipAuth) app.use("*", requireUser);
if (options.skipAuth) app.use("*", async (c, next) => { c.set("userId", "user_test"); c.set("userEmail", "user_test@example.com"); await next(); });
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities({ public: true }) }));
@@ -874,7 +884,7 @@ export function serviceRoutes() {
const body = await c.req.json<JsonObject>();
const mission = missionFromRequest(c.req.raw, body);
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
const result = await interviewService.configure(payload).catch(serviceErrorResponse);
const result = await interviewService.configure(payload, userId).catch(serviceErrorResponse);
const resultObj = result as Record<string, unknown>;
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
const orchestration = orchestrationFromMission(mission, taskId);
@@ -891,12 +901,15 @@ export function serviceRoutes() {
app.post("/interview/preview", async (c) => {
const body = await c.req.json<JsonObject>();
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, c.get("userId"));
return c.json(await interviewService.preview(payload));
return c.json(await interviewService.preview(payload, c.get("userId")));
});
app.post("/interview/questions", async (c) => {
const body = await c.req.json<{ session_id: string; questions: Array<JsonObject | string> }>();
return c.json(await interviewService.editQuestions(body, c.get("userId")));
});
app.post("/interview/questions", async (c) => c.json(await interviewService.editQuestions(await c.req.json())));
app.post("/interview/approve", async (c) => {
const body = await c.req.json<{ session_id: string }>();
return c.json(await interviewService.approve(body.session_id));
return c.json(await interviewService.approve(body.session_id, c.get("userId")));
});
app.post("/interview/assignments", async (c) => c.json(await interviewService.createAssignments(await c.req.json())));
app.get("/interview/assignments", async (c) => c.json(await interviewService.listAssignments(c.req.query("email") ?? "", c.req.query("status") ?? "pending", Number(c.req.query("limit") ?? 20))));
@@ -922,6 +935,14 @@ export function serviceRoutes() {
});
app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard()));
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.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");
const userId = c.get("userId");
await assertSessionOwnership("interview", sessionId, userId);
const issued = signWsTicket({ secret: config.sessionTicketSecret, audience: "interview", subject: userId, sessionId });
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.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
@@ -945,7 +966,7 @@ export function serviceRoutes() {
const body = await c.req.json<JsonObject>();
const mission = missionFromRequest(c.req.raw, body);
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
const result = await roleplayService.configure(payload).catch(serviceErrorResponse);
const result = await roleplayService.configure(payload, userId).catch(serviceErrorResponse);
const resultObj = result as Record<string, unknown>;
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
const orchestration = orchestrationFromMission(mission, taskId);
@@ -962,13 +983,34 @@ export function serviceRoutes() {
app.post("/roleplay/preview", async (c) => {
const body = await c.req.json<JsonObject>();
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, c.get("userId"));
return c.json(await roleplayService.preview(payload));
return c.json(await roleplayService.preview(payload, c.get("userId")));
});
app.post("/roleplay/questions", async (c) => {
const body = await c.req.json<{ session_id: string; questions: Array<JsonObject | string> }>();
return c.json(await roleplayService.editQuestions(body, c.get("userId")));
});
app.post("/roleplay/questions", async (c) => c.json(await roleplayService.editQuestions(await c.req.json())));
app.post("/roleplay/approve", async (c) => {
const body = await c.req.json<{ session_id: string }>();
return c.json(await roleplayService.approve(body.session_id));
const userId = c.get("userId");
const result = await roleplayService.approve(body.session_id, userId).catch(serviceErrorResponse);
const resultObj = result as Record<string, unknown>;
await recordGatewayEvent({
userId,
source: "roleplay-service",
type: "roleplay.scenario.configured",
payload: { request: body, result: resultObj },
correlation: { sessionId: getSessionId(resultObj) ?? body.session_id },
}).catch((err) => log.warn({ err }, "failed to record resumed roleplay configured event"));
return c.json(result);
});
app.get("/roleplay/plan/:sessionId", async (c) =>
c.json(await roleplayService.plan(c.req.param("sessionId"), c.get("userId")).catch(serviceErrorResponse)));
app.get("/roleplay/drafts", async (c) => {
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.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())));
app.get("/roleplay/assignments", async (c) => c.json(await roleplayService.listAssignments(c.req.query("email") ?? "", c.req.query("status") ?? "pending", Number(c.req.query("limit") ?? 20))));
app.post("/roleplay/assignments/unassign", async (c) => c.json(await roleplayService.unassign(await c.req.json())));
@@ -993,6 +1035,14 @@ export function serviceRoutes() {
});
app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard()));
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.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");
const userId = c.get("userId");
await assertSessionOwnership("roleplay", sessionId, userId);
const issued = signWsTicket({ secret: config.sessionTicketSecret, audience: "roleplay", subject: userId, sessionId });
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.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
@@ -1033,21 +1083,34 @@ export function serviceRoutes() {
const rest = c.req.path.split("/social/")[1] ?? "";
return proxySocialRequest(c.req.raw, rest, c.get("userId"));
});
app.post("/courses/a2a", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>().catch((): JsonObject => ({}));
const mission = missionFromRequest(c.req.raw, body);
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
const missionParams = mission
? orchestrationFromMission(mission, taskId)
: taskId
? { taskId, curatorTaskId: taskId }
: {};
const params = isRecord(body.params) ? body.params : {};
const action = getString(body.action) ?? (body.session_start ? "session_start" : "");
const result = await courseService.task({
...body,
action,
user_id: userId,
params: { ...missionParams, ...params },
});
return c.json(result);
});
app.post("/matchmaking/a2a", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>().catch(() => ({}));
const { action, result } = await callMatchmakingA2a(body, userId);
const event = buildMatchmakingGatewayEvent({ userId, action, body, result });
await recordGatewayEvent({
userId,
source: event.source,
type: event.type,
payload: event.payload,
correlation: event.correlation,
mission: event.mission ?? missionFromRequest(c.req.raw, body),
subject: event.subject,
}).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event"));
const body = await c.req.json<JsonObject>().catch((): JsonObject => ({}));
const { result } = await callMatchmakingA2a({ ...body, user_id: userId }, userId);
// matchmaking-service emits canonical GrowEvents itself. The gateway is a
// transport proxy only, avoiding a second event for the same action.
return c.json(result);
});
+5 -17
View File
@@ -4,7 +4,8 @@ import { db } from "../db/client.js";
import { onboarding, users, userStacks, type UserStack } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { log } from "../log.js";
import { fetchUserProfile, patchPreferencesRequest, userServiceTarget } from "../services/user-profile.js";
import { fetchOnboardingReadProfile, fetchUserProfile, patchPreferencesRequest, userServiceTarget } from "../services/user-profile.js";
import { serializeOnboardingState } from "../services/onboarding-serialization.js";
import {
onboardingCompletedAtFromPreferences,
} from "../v1/curator/curator-onboarding-loop.js";
@@ -12,7 +13,6 @@ import {
getLatestValidOnboardingLedgerEvent,
recordAndProcessOnboardingCompletion,
extractOnboardingData,
deriveOnboardingPayload,
assertOnboardingRevision,
mergeOnboardingPatch,
resetOnboardingLedger,
@@ -123,17 +123,6 @@ function recordPreferences(profile: Record<string, unknown>): Record<string, unk
return isPlainObject(profile.preferences) ? profile.preferences : {};
}
/** Build the { id, data, payload, revision, updatedAt } response shape. */
function onboardingStateResponse(userId: string, data: OnboardingData) {
const updatedAt = data.progress.updated_at ?? data.completed_at;
return {
id: userId,
data,
payload: deriveOnboardingPayload(data),
revision: data.revision,
updatedAt,
};
}
type OnboardingResetResult =
@@ -312,10 +301,9 @@ export function userRoutes() {
// side effects when status is "completed".
app.get("/onboarding", async (c) => {
const userId = c.get("userId");
const profile = await fetchUserProfile(c.req.raw);
const profile = await fetchOnboardingReadProfile(c.req.raw, userId);
const preferences = recordPreferences(profile);
const data = extractOnboardingData(preferences.onboarding);
return c.json(onboardingStateResponse(userId, data));
return c.json(serializeOnboardingState(userId, preferences.onboarding));
});
app.patch("/onboarding", async (c) => {
@@ -389,7 +377,7 @@ export function userRoutes() {
}
}
return c.json(onboardingStateResponse(userId, nextData));
return c.json(serializeOnboardingState(userId, nextPreferences.onboarding));
});
// DELETE /onboarding — authenticated per-user reset. Reopens the onboarding
+18
View File
@@ -0,0 +1,18 @@
import {
deriveOnboardingPayload,
extractOnboardingData,
} from "../events/onboarding-ledger.js";
/** Build the authenticated GET /users/onboarding response from persisted JSON. */
export function serializeOnboardingState(userId: string, stored: unknown) {
const data = extractOnboardingData(stored);
const payload = deriveOnboardingPayload(data);
const updatedAt = data.progress.updated_at ?? data.completed_at;
return {
id: userId,
data,
payload,
revision: data.revision,
updatedAt,
};
}
+48 -27
View File
@@ -60,12 +60,14 @@ export const interviewService = {
serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
serviceJson(config.interviewServiceUrl, "/api/v1/configure/questions", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
approve: (sessionId: string) =>
serviceJson(config.interviewServiceUrl, "/api/v1/configure/approve", { body: { session_id: sessionId }, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
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) =>
serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }, userId?: string) =>
serviceJson(config.interviewServiceUrl, "/api/v1/configure/questions", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
approve: (sessionId: string, userId?: string) =>
serviceJson(config.interviewServiceUrl, "/api/v1/configure/approve", { body: { session_id: sessionId }, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
createAssignments: (payload: { organization_id: string; role: string; round: "warm_up" | "behavioral" | "technical"; assignee_emails: string[] }) =>
serviceJson(config.interviewServiceUrl, "/api/v1/interviews/assignments", { body: payload }),
listAssignments: (email: string, status = "pending", limit = 20) =>
@@ -107,12 +109,25 @@ export const roleplayService = {
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/questions", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
approve: (sessionId: string) =>
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/approve", { body: { session_id: sessionId }, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
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) =>
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/questions", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
approve: (sessionId: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/approve", { body: { session_id: sessionId }, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
plan: (sessionId: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/sessions/${encodeURIComponent(sessionId)}/plan`, {
headers: userHeader(userId),
}),
drafts: (userId: string, limit = 50) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/drafts?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
headers: userHeader(userId),
}),
deleteDraft: (sessionId: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/drafts/${encodeURIComponent(sessionId)}`, {
method: "DELETE",
headers: userHeader(userId),
}),
createAssignments: (payload: { organization_id: string; name: string; scenario: string; assignee_emails: string[] }) =>
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/assignments", { body: payload }),
listAssignments: (email: string, status = "pending", limit = 20) =>
@@ -148,24 +163,30 @@ export const roleplayService = {
},
};
export const courseService = {
health: () => serviceJson(config.coursesServiceUrl, "/health"),
task: (payload: { action?: string; params?: JsonObject; user_id: string; user_context?: JsonObject; session_start?: boolean; task_id?: string }) =>
serviceJson(config.coursesServiceUrl, "/a2a/tasks", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
};
export const resumeService = {
health: () => serviceJson(config.resumeServiceUrl, "/health"),
state: (clerkId: string) => serviceJson(config.resumeServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`),
state: (clerkId: string) => serviceJson(config.resumeServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`, { headers: userHeader(clerkId) }),
templates: () => serviceJson(config.resumeServiceUrl, "/api/v1/templates"),
task: (payload: { action?: string; params?: JsonObject; user_id: string; user_context?: JsonObject; session_start?: boolean }) =>
serviceJson(config.resumeServiceUrl, "/a2a/tasks", { body: payload }),
listResumes: (clerkId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes?${new URLSearchParams({ clerk_id: clerkId })}`),
createResume: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/resumes", { body: payload }),
getResume: (resumeId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}`),
updateResume: (resumeId: string, payload: JsonObject) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}`, { method: "PUT", body: payload }),
analyzeResume: (resumeId: string, payload: JsonObject = {}) => serviceJson(config.resumeServiceUrl, `/api/v1/ai/analyze/${encodeURIComponent(resumeId)}`, { body: payload }),
suggestions: (resumeId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/ai/suggestions/${encodeURIComponent(resumeId)}`),
copilot: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/copilot", { body: payload }),
optimizeSummary: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/optimize-summary", { body: payload }),
optimizeExperience: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/optimize-experience", { body: payload }),
suggestSkills: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/suggest-skills", { body: payload }),
generateSummary: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/generate-summary", { body: payload }),
listVersions: (resumeId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}/versions`),
createVersion: (resumeId: string, payload: JsonObject) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}/versions`, { body: payload }),
preview: (resumeId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/export/resumes/${encodeURIComponent(resumeId)}/preview`),
listResumes: (clerkId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes?${new URLSearchParams({ clerk_id: clerkId })}`, { headers: userHeader(clerkId) }),
createResume: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/resumes", { body: payload, headers: userHeader(userId ?? (typeof payload.user_id === "string" ? payload.user_id : undefined)) }),
getResume: (resumeId: string, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}`, { headers: userHeader(userId) }),
updateResume: (resumeId: string, payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}`, { method: "PUT", body: payload, headers: userHeader(userId) }),
analyzeResume: (resumeId: string, payload: JsonObject = {}, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/ai/analyze/${encodeURIComponent(resumeId)}`, { body: payload, headers: userHeader(userId) }),
suggestions: (resumeId: string, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/ai/suggestions/${encodeURIComponent(resumeId)}`, { headers: userHeader(userId) }),
copilot: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/copilot", { body: payload, headers: userHeader(userId) }),
optimizeSummary: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/optimize-summary", { body: payload, headers: userHeader(userId) }),
optimizeExperience: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/optimize-experience", { body: payload, headers: userHeader(userId) }),
suggestSkills: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/suggest-skills", { body: payload, headers: userHeader(userId) }),
generateSummary: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/generate-summary", { body: payload, headers: userHeader(userId) }),
listVersions: (resumeId: string, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}/versions`, { headers: userHeader(userId) }),
createVersion: (resumeId: string, payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}/versions`, { body: payload, headers: userHeader(userId) }),
preview: (resumeId: string, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/export/resumes/${encodeURIComponent(resumeId)}/preview`, { headers: userHeader(userId) }),
};
+8 -16
View File
@@ -284,7 +284,7 @@ const serviceRegistry: ServiceRecord[] = [
mode: "video",
},
toolName: "prepare_roleplay_preview",
completionEvents: ["roleplay.scenario.configured", "roleplay.scenario.completed", "roleplay.feedback.generated"],
completionEvents: ["roleplay.scenario.completed", "roleplay.feedback.generated"],
qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"],
usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.",
},
@@ -372,7 +372,7 @@ const serviceRegistry: ServiceRecord[] = [
tab: "resumes",
},
toolName: "prepare_resume_upload",
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated", "resume.exported"],
completionEvents: ["resume.analysis.completed", "resume.updated", "resume.exported"],
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
usage: "Include mission state and optional section when linking into resume work.",
},
@@ -524,7 +524,7 @@ const serviceRegistry: ServiceRecord[] = [
start: "Start course",
},
toolName: "prepare_course_handoff",
completionEvents: ["course.generated", "course.started", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
completionEvents: ["course.completed"],
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
usage: "Use for skill-gap tasks that should become learning work.",
},
@@ -534,7 +534,7 @@ const serviceRegistry: ServiceRecord[] = [
openEvents: ["task.opened"],
startedEvents: ["service.started", "course.started"],
configuredEvents: ["course.generated"],
completionEvents: ["service.completed", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
completionEvents: ["service.completed", "course.completed"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["courses.started", "courses.completed", "courses.completion_rate", "courses.difficulty", "courses.pathway_relevance"],
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
@@ -583,7 +583,7 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review assessment",
},
toolName: "prepare_assessment_handoff",
completionEvents: ["assessment.started", "assessment.completed"],
completionEvents: ["assessment.completed"],
qscoreSignals: ["skills.assessment", "readiness.measurement"],
usage: "Use when a task needs a scoreable assessment rather than practice.",
},
@@ -646,14 +646,9 @@ const serviceRegistry: ServiceRecord[] = [
},
toolName: "prepare_matchmaking_handoff",
completionEvents: [
"matchmaking.matches.generated",
"matchmaking.feed.viewed",
"matchmaking.match.viewed",
"matchmaking.match.saved",
"matchmaking.match.dismissed",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
"matchmaking.application.started",
"matchmaking.application.completed",
],
qscoreSignals: ["matching.jobs_viewed", "matching.applications_submitted", "matching.match_rate"],
@@ -670,10 +665,7 @@ const serviceRegistry: ServiceRecord[] = [
configuredEvents: [],
completionEvents: [
"service.completed",
"matchmaking.matches.generated",
"matchmaking.match.viewed",
"matchmaking.match.saved",
"matchmaking.match.dismissed",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
"matchmaking.application.completed",
@@ -725,7 +717,7 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review QScore",
},
toolName: "prepare_qscore_review",
completionEvents: ["qscore.baseline.created", "qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
completionEvents: ["qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
usage: "Use for measurement and projected readiness review tasks.",
},
@@ -785,7 +777,7 @@ const serviceRegistry: ServiceRecord[] = [
review: "Review profile",
},
toolName: "prepare_social_branding_handoff",
completionEvents: ["brand.post.drafted", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
completionEvents: ["brand.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
},
@@ -795,7 +787,7 @@ const serviceRegistry: ServiceRecord[] = [
openEvents: ["task.opened"],
startedEvents: ["service.started"],
configuredEvents: ["brand.post.drafted"],
completionEvents: ["service.completed", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
completionEvents: ["service.completed", "brand.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
abandonmentEvents: ["service.abandoned"],
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
+20 -6
View File
@@ -63,18 +63,32 @@ async function backendMirrorProfile(userId: string): Promise<UserProfileContext>
};
}
async function fetchUserServiceJson(path: string, headers: Headers): Promise<Record<string, unknown> | null> {
async function fetchUserServiceJson(path: string, headers: Headers, strict = false): Promise<Record<string, unknown> | null> {
const target = new URL(path, config.userServiceUrl.replace(/\/$/, ""));
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) return null;
if (!res.ok) {
if (strict) {
const text = await res.text().catch(() => "");
throw new Error(`user-service A2A state fetch failed: ${res.status} ${text}`);
}
return null;
}
const json = await res.json().catch(() => null);
return isRecord(json) ? json : null;
if (!isRecord(json)) {
if (strict) throw new Error("user-service A2A state fetch failed: invalid JSON");
return null;
}
return json;
}
async function a2aUserState(userId: string): Promise<Record<string, unknown> | null> {
export async function fetchA2AUserState(userId: string, options: { strict?: boolean } = {}): Promise<Record<string, unknown> | null> {
const headers = new Headers();
headers.set("authorization", `Bearer ${config.a2aAllowedKey}`);
return fetchUserServiceJson(`/api/state/${encodeURIComponent(userId)}`, headers);
return fetchUserServiceJson(`/api/state/${encodeURIComponent(userId)}`, headers, options.strict === true);
}
export async function fetchA2AUserStateStrict(userId: string): Promise<Record<string, unknown>> {
return (await fetchA2AUserState(userId, { strict: true }))!;
}
async function clerkUserProfile(req: Request): Promise<Record<string, unknown> | null> {
@@ -93,7 +107,7 @@ export async function getRequestUserProfile(req: Request, userId: string): Promi
if (profile) return mergeProfile(base, profile, userId);
}
const state = await a2aUserState(userId);
const state = await fetchA2AUserState(userId);
return mergeProfile(base, state, userId);
}
+14 -14
View File
@@ -1,4 +1,5 @@
import { config } from "../config.js";
import { fetchA2AUserStateStrict } from "./user-context.js";
/**
* Build the absolute user-service URL for a given sub-path.
@@ -9,20 +10,7 @@ export function userServiceTarget(path: string, search = ""): URL {
return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, ""));
}
/**
* Fetch the user-service profile via an explicit GET /me.
*
* This is a READ and must never replay the inbound request method/body. The
* PATCH /onboarding handler parses its own JSON payload with `c.req.json()`,
* which consumes `c.req.raw` (marks bodyUsed). Reusing that Request here would
* (a) wrongly issue a PATCH /me write for a read, and (b) call
* `arrayBuffer()` on an already-consumed body —
* `TypeError: Body is unusable: Body has already been read` — which bubbles
* out of the handler as an uncaught 500 `{"error":"internal"}`.
*
* Building a fresh GET sidesteps both: no body replay, and the inbound
* Request's body is never touched.
*/
/** Fetch the canonical profile with an explicit GET /me using the inbound Clerk JWT. */
export async function fetchUserProfile(req: Request): Promise<Record<string, unknown>> {
const target = userServiceTarget("/me", new URL(req.url).search);
const headers = new Headers(req.headers);
@@ -31,6 +19,7 @@ export async function fetchUserProfile(req: Request): Promise<Record<string, unk
headers.delete("content-type");
headers.delete("content-length");
headers.delete("transfer-encoding");
headers.delete("x-growqr-user");
const res = await fetch(target, { method: "GET", headers });
if (!res.ok) {
const text = await res.text().catch(() => "");
@@ -39,6 +28,17 @@ export async function fetchUserProfile(req: Request): Promise<Record<string, unk
return res.json() as Promise<Record<string, unknown>>;
}
/** Read onboarding through user-service A2A for backend service-auth calls. */
export async function fetchOnboardingReadProfile(req: Request, userId: string): Promise<Record<string, unknown>> {
const token = (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "").trim();
const trusted = token === config.serviceToken || (config.nodeEnv !== "production" && token === config.a2aAllowedKey);
if (!trusted) return fetchUserProfile(req);
if (req.headers.get("x-growqr-user") !== userId) {
throw new Error("trusted user-service read requires matching x-growqr-user");
}
return fetchA2AUserStateStrict(userId);
}
/**
* Build a synthetic PATCH /me request carrying only `{ preferences }` so the
* backend can persist the merged onboarding blob without disturbing other
+68
View File
@@ -0,0 +1,68 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
export type WsTicketAudience = "interview" | "roleplay";
type TicketClaims = {
aud: WsTicketAudience;
sub: string;
sid: string;
iat: number;
exp: number;
nonce: string;
};
const MAX_TTL_SECONDS = 60;
function b64url(value: string | Uint8Array): string {
return Buffer.from(value).toString("base64url");
}
function decodePart(value: string): string {
return Buffer.from(value, "base64url").toString("utf8");
}
export function signWsTicket(input: {
secret: string;
audience: WsTicketAudience;
subject: string;
sessionId: string;
nowSeconds?: number;
ttlSeconds?: number;
}): { ticket: string; expiresAt: string; claims: TicketClaims } {
if (!input.secret) throw new Error("SESSION_TICKET_SECRET is not configured");
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
const ttl = Math.min(Math.max(Math.floor(input.ttlSeconds ?? MAX_TTL_SECONDS), 1), MAX_TTL_SECONDS);
const claims: TicketClaims = {
aud: input.audience,
sub: input.subject,
sid: input.sessionId,
iat: now,
exp: now + ttl,
nonce: randomBytes(16).toString("hex"),
};
const encoded = b64url(JSON.stringify(claims));
const signature = b64url(createHmac("sha256", input.secret).update(encoded).digest());
return { ticket: `${encoded}.${signature}`, expiresAt: new Date(claims.exp * 1000).toISOString(), claims };
}
export function verifyWsTicket(input: {
secret: string;
ticket: string;
audience: WsTicketAudience;
sessionId: string;
subject?: string;
nowSeconds?: number;
}): TicketClaims {
if (!input.secret) throw new Error("SESSION_TICKET_SECRET is not configured");
const parts = input.ticket.split(".");
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("invalid ticket format");
const expected = createHmac("sha256", input.secret).update(parts[0]).digest();
const provided = Buffer.from(parts[1], "base64url");
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) throw new Error("invalid ticket signature");
let claims: TicketClaims;
try { claims = JSON.parse(decodePart(parts[0])) as TicketClaims; } catch { throw new Error("invalid ticket claims"); }
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
if (claims.aud !== input.audience || claims.sid !== input.sessionId || (input.subject && claims.sub !== input.subject)) throw new Error("ticket scope mismatch");
if (!claims.sub || !claims.nonce || !Number.isInteger(claims.iat) || !Number.isInteger(claims.exp) || claims.exp <= now || claims.exp - claims.iat > MAX_TTL_SECONDS) throw new Error("ticket expired or invalid");
return claims;
}
+12 -8
View File
@@ -1,11 +1,9 @@
import { Hono } from "hono";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { createClient, type Client } from "rivetkit/client";
import { requireUser, type AuthContext } from "../../auth/clerk.js";
import { config } from "../../config.js";
import { db } from "../../db/client.js";
import { onboarding } from "../../db/schema.js";
import { fetchOnboardingReadProfile } from "../../services/user-profile.js";
import type { Registry } from "../../actors/registry.js";
import { buildCuratorSprint, buildCuratorSprintReport, buildCuratorTasks } from "./curator-store.js";
@@ -38,13 +36,19 @@ export function v1CuratorRoutes() {
app.get("/sprint", async (c) => {
const userId = c.get("userId");
const savedOnboarding = await db.query.onboarding.findFirst({
where: eq(onboarding.userId, userId),
});
if (savedOnboarding?.data?.status !== "completed") {
const profile = await fetchOnboardingReadProfile(c.req.raw, userId);
const preferences = profile.preferences && typeof profile.preferences === "object" && !Array.isArray(profile.preferences)
? profile.preferences as Record<string, unknown>
: {};
const onboarding = preferences.onboarding && typeof preferences.onboarding === "object" && !Array.isArray(preferences.onboarding)
? preferences.onboarding as Record<string, unknown>
: {};
if (onboarding.status !== "completed") {
return c.json({ error: "onboarding_incomplete" }, 409);
}
return c.json(await buildCuratorSprint(userId, c.req.query("date")));
return c.json(await buildCuratorSprint(userId, c.req.query("date"), {
onboardingContext: { preferences, onboarding },
}));
});
app.get("/sprint/report", async (c) => {
+32 -25
View File
@@ -1,6 +1,6 @@
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, missionArtifacts, onboarding, users } from "../../db/schema.js";
import { growEvents, missionArtifacts, onboarding, users, type GrowEventRow } from "../../db/schema.js";
import { listMissionDefinitions } from "../../missions/registry.js";
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
@@ -39,26 +39,25 @@ import { addDaysIso, computeStreakFromDays, todayIso } from "./streak-utils.js";
const VALID_COMPLETION_TYPES = [
"service.completed",
"resume.analysis.completed",
"resume.parsed",
"resume.updated",
"resume.exported",
"interview.session.completed",
"interview.feedback.generated",
"roleplay.scenario.configured",
"roleplay.scenario.completed",
"roleplay.feedback.generated",
"assessment.completed",
"course.progress_recorded",
"course.completed",
"course.lesson.completed",
"brand.post.exported",
"brand.profile.updated",
"matchmaking.matches.generated",
"matchmaking.feed.viewed",
"brand.content.published",
"social.profile.updated",
"social.post.scheduled",
"social.post.published",
"matchmaking.match.saved",
"matchmaking.matches.reviewed",
"matchmaking.match.applied",
"matchmaking.application.completed",
"qscore.weak_driver.reviewed",
"qscore.updated",
"qscore.signal.projected",
"curator.task.completed",
@@ -859,6 +858,25 @@ async function loadCurrentQScore(userId: string) {
return result?.q_score ?? null;
}
export type CuratorCompletionEvent = Pick<GrowEventRow, "type" | "payload" | "correlation">;
export function isTaskCompletionEvent(taskId: string, completionEvents: string[], row: CuratorCompletionEvent) {
const payload = row.payload ?? {};
const correlation = row.correlation ?? {};
const eventTaskId =
payload.taskId ??
payload.curatorTaskId ??
payload.curator_task_id ??
correlation.taskId ??
correlation.curatorTaskId ??
correlation.curator_task_id;
return eventTaskId === taskId && (
row.type === "curator.task.completed" ||
row.type === "service.completed" ||
completionEvents.includes(row.type)
);
}
async function loadCompletionRows(userId: string, sinceDate: string) {
return db
.select()
@@ -871,23 +889,8 @@ async function loadCompletionRows(userId: string, sinceDate: string) {
.orderBy(desc(growEvents.occurredAt));
}
function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: Awaited<ReturnType<typeof loadCompletionRows>>) {
return rows.some((row) => {
const payload = row.payload ?? {};
const correlation = row.correlation ?? {};
const eventTaskId =
payload.taskId ??
payload.curatorTaskId ??
payload.curator_task_id ??
correlation.taskId ??
correlation.curatorTaskId ??
correlation.curator_task_id;
return eventTaskId === taskId && (
row.type === "curator.task.completed" ||
row.type === "service.completed" ||
completionEvents.includes(row.type)
);
});
function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: CuratorCompletionEvent[]) {
return rows.some((row) => isTaskCompletionEvent(taskId, completionEvents, row));
}
function taskCompletedByEvents(task: CuratorTask, rows: Awaited<ReturnType<typeof loadCompletionRows>>) {
@@ -1285,7 +1288,11 @@ export async function buildCuratorStreak(userId: string): Promise<CuratorStreak>
day: sql<string>`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), inArray(growEvents.type, [...VALID_COMPLETION_TYPES])))
.where(and(
eq(growEvents.userId, userId),
inArray(growEvents.type, [...VALID_COMPLETION_TYPES]),
sql`coalesce(${growEvents.correlation}->>'taskId', ${growEvents.correlation}->>'curatorTaskId', ${growEvents.payload}->>'taskId', ${growEvents.payload}->>'curatorTaskId') is not null`,
))
.groupBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`)
.orderBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD') desc`)
.limit(90);
+3 -2
View File
@@ -184,6 +184,7 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
fetchProfileState(config.resumeServiceUrl, userId),
fetchProfileState(config.socialBrandingServiceUrl, userId),
]);
const breakdown = qscoreResult?.breakdown ?? {};
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId })) ?? null;
const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase();
@@ -225,8 +226,8 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
qscore: {
score: qscoreResult?.q_score ?? null,
signalCount: qscoreEvidence?.length ?? 0,
summary: null,
dimensions: qscoreResult ? qscoreResult.quotients : null,
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : (qscoreResult ? qscoreResult.quotients : null),
},
};
}