Compare commits

..

15 Commits

Author SHA1 Message Date
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
12 changed files with 419 additions and 59 deletions

View File

@@ -14,10 +14,9 @@ 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
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
@@ -36,19 +35,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

View File

@@ -11,6 +11,8 @@
"test:passive-actions": "tsx scripts/passive-actions.test.ts",
"test:curator-static": "tsx scripts/curator-static-registry.test.ts",
"test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts",
"test:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts",
"test:user-profile": "tsx scripts/user-profile.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
@@ -21,8 +23,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": {

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

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
}
}

View File

@@ -0,0 +1,89 @@
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");
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");

64
scripts/staging-compose.sh Executable file
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

View File

@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
import { config } from "../src/config.js";
import { fetchOnboardingReadProfile, fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
/**
* Regression test for the PATCH /onboarding 500.
@@ -162,4 +163,124 @@ 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;
}
}
console.log("user-profile: all assertions passed");

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

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,
};
}

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

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

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) => {