Wire gateway user context through user service
This commit is contained in:
@@ -116,6 +116,7 @@ services:
|
||||
ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
|
||||
QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
|
||||
RESUME_SERVICE_URL: ${RESUME_SERVICE_URL:-http://host.docker.internal:8002}
|
||||
USER_SERVICE_URL: ${USER_SERVICE_URL:-http://host.docker.internal:8003}
|
||||
COURSES_SERVICE_URL: ${COURSES_SERVICE_URL:-http://host.docker.internal:8060}
|
||||
ASSESSMENT_SERVICE_URL: ${ASSESSMENT_SERVICE_URL:-http://host.docker.internal:8070}
|
||||
MATCHMAKING_SERVICE_URL: ${MATCHMAKING_SERVICE_URL:-http://host.docker.internal:8006}
|
||||
|
||||
+5
-18
@@ -3,38 +3,25 @@ import { config } from "../config.js";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { dismissHomeNotification, getHomeFeed, getHomeFeedDebugCounts } from "../home/home-feed.js";
|
||||
import { seedDemoHome } from "../home/seed-demo-home.js";
|
||||
import { getRequestUserProfile } from "../services/user-context.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
function canSeedDemo(userId: string) {
|
||||
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
|
||||
}
|
||||
|
||||
async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> }> {
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return {};
|
||||
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const preferences = userProfile?.preferences;
|
||||
return {
|
||||
userProfile: userProfile ?? undefined,
|
||||
preferences: preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function homeRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.get("/feed", async (c) => {
|
||||
const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true";
|
||||
const profile = await getUserServiceProfile(c.req.raw).catch((err) => {
|
||||
log.warn({ err, userId: c.get("userId") }, "home feed continuing without user-service profile");
|
||||
const userId = c.get("userId");
|
||||
const profile = await getRequestUserProfile(c.req.raw, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "home feed continuing without user-service profile");
|
||||
return {};
|
||||
});
|
||||
return c.json(await getHomeFeed(c.get("userId"), { refresh, ...profile }));
|
||||
return c.json(await getHomeFeed(userId, { refresh, ...profile }));
|
||||
});
|
||||
|
||||
app.post("/notifications/:id/dismiss", async (c) => {
|
||||
|
||||
+2
-13
@@ -12,6 +12,7 @@ import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js
|
||||
import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { getRequestUserPreferences } from "../services/user-context.js";
|
||||
import { missionDetailHref } from "../missions/reducer-helpers.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
@@ -105,18 +106,6 @@ async function getMissionSnapshot(userId: string, active: GrowActiveMission): Pr
|
||||
return missionActorFor(userId, active.instanceId, active.actorType).getState();
|
||||
}
|
||||
|
||||
async function getUserPreferences(req: Request): Promise<Record<string, unknown>> {
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return {};
|
||||
const user = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const preferences = user?.preferences;
|
||||
return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
export function missionRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -183,7 +172,7 @@ export function missionRoutes() {
|
||||
|
||||
const windowEnd = new Date();
|
||||
const windowStart = new Date(windowEnd.getTime() - 24 * 60 * 60 * 1000);
|
||||
const preferences = await getUserPreferences(c.req.raw);
|
||||
const preferences = await getRequestUserPreferences(c.req.raw, userId) ?? {};
|
||||
const run = await createMissionCoachRunPg({
|
||||
userId,
|
||||
missionInstanceId: active.mission.instanceId,
|
||||
|
||||
+3
-21
@@ -9,6 +9,7 @@ import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schem
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
const LANDING_AGENTS = [
|
||||
@@ -208,25 +209,6 @@ function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> }> {
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return {};
|
||||
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const preferences = userProfile?.preferences;
|
||||
return {
|
||||
userProfile: userProfile ?? undefined,
|
||||
preferences: isRecord(preferences) ? preferences : {},
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserServicePreferences(req: Request): Promise<Record<string, unknown> | undefined> {
|
||||
return (await getUserServiceProfile(req)).preferences;
|
||||
}
|
||||
|
||||
async function getServiceState(baseUrl: string, path: string): Promise<Record<string, unknown> | undefined> {
|
||||
const target = new URL(path, baseUrl.replace(/\/$/, ""));
|
||||
const res = await fetch(target, {
|
||||
@@ -253,7 +235,7 @@ function mergeUniqueSkills(existing: unknown, incoming: unknown): string[] {
|
||||
}
|
||||
|
||||
async function resolveGrowUserContext(req: Request, userId: string): Promise<Record<string, unknown>> {
|
||||
const { userProfile } = await getUserServiceProfile(req);
|
||||
const { userProfile } = await getRequestUserProfile(req, userId);
|
||||
const userContext: Record<string, unknown> = { ...(userProfile ?? {}) };
|
||||
userContext.clerk_id = String(userContext.clerk_id ?? userId);
|
||||
|
||||
@@ -491,7 +473,7 @@ export function serviceRoutes() {
|
||||
app.get("/qscore/current", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
try {
|
||||
await ensureOnboardingBaselineQscore(userId, await getUserServicePreferences(c.req.raw));
|
||||
await ensureOnboardingBaselineQscore(userId, await getRequestUserPreferences(c.req.raw, userId));
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { config } from "../config.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
|
||||
export type UserProfileContext = {
|
||||
userProfile?: Record<string, unknown>;
|
||||
preferences?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function bearerToken(req: Request): string {
|
||||
return (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "").trim();
|
||||
}
|
||||
|
||||
function isTrustedServiceToken(token: string): boolean {
|
||||
return Boolean(token && (token === config.serviceToken || token === config.a2aAllowedKey));
|
||||
}
|
||||
|
||||
function splitDisplayName(displayName: string | null | undefined) {
|
||||
const parts = (displayName ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
return {
|
||||
firstName: parts[0] || undefined,
|
||||
lastName: parts.length > 1 ? parts.slice(1).join(" ") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProfile(
|
||||
base: UserProfileContext,
|
||||
incoming: Record<string, unknown> | null | undefined,
|
||||
userId: string,
|
||||
): UserProfileContext {
|
||||
const userProfile: Record<string, unknown> = { ...(base.userProfile ?? {}) };
|
||||
if (incoming) {
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (value !== null && value !== undefined) userProfile[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
userProfile.clerk_id = String(userProfile.clerk_id ?? userId);
|
||||
const preferences = isRecord(incoming?.preferences) ? incoming.preferences : base.preferences ?? {};
|
||||
return { userProfile, preferences };
|
||||
}
|
||||
|
||||
async function backendMirrorProfile(userId: string): Promise<UserProfileContext> {
|
||||
const row = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
||||
const displayName = row?.displayName ?? userId;
|
||||
const { firstName, lastName } = splitDisplayName(displayName);
|
||||
return {
|
||||
userProfile: {
|
||||
clerk_id: row?.id ?? userId,
|
||||
email: row?.email ?? `${userId}@service.local`,
|
||||
display_name: displayName,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
preferences: {},
|
||||
metadata: { source: "backend_user_mirror" },
|
||||
},
|
||||
preferences: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUserServiceJson(path: string, headers: Headers): 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;
|
||||
const json = await res.json().catch(() => null);
|
||||
return isRecord(json) ? json : null;
|
||||
}
|
||||
|
||||
async function a2aUserState(userId: string): Promise<Record<string, unknown> | null> {
|
||||
const headers = new Headers();
|
||||
headers.set("authorization", `Bearer ${config.a2aAllowedKey}`);
|
||||
return fetchUserServiceJson(`/api/state/${encodeURIComponent(userId)}`, headers);
|
||||
}
|
||||
|
||||
async function clerkUserProfile(req: Request): Promise<Record<string, unknown> | null> {
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
return fetchUserServiceJson("/api/v1/users/me", headers);
|
||||
}
|
||||
|
||||
export async function getRequestUserProfile(req: Request, userId: string): Promise<UserProfileContext> {
|
||||
const base = await backendMirrorProfile(userId);
|
||||
const token = bearerToken(req);
|
||||
|
||||
if (token && !isTrustedServiceToken(token)) {
|
||||
const profile = await clerkUserProfile(req);
|
||||
if (profile) return mergeProfile(base, profile, userId);
|
||||
}
|
||||
|
||||
const state = await a2aUserState(userId);
|
||||
return mergeProfile(base, state, userId);
|
||||
}
|
||||
|
||||
export async function getRequestUserPreferences(req: Request, userId: string): Promise<Record<string, unknown> | undefined> {
|
||||
return (await getRequestUserProfile(req, userId)).preferences;
|
||||
}
|
||||
Reference in New Issue
Block a user