feat(social): emit GrowQR events from social-branding proxy routes

The social-branding proxy was a raw passthrough that never called
recordGatewayEvent — so no grow_events rows were created for any social
action (account connect, profile polish, post publish, etc.), breaking
the curator/streak/QScore loop.

Follow the proxyResumeRequest pattern exactly:
- GET/HEAD pass through directly (no event)
- POST/PUT/PATCH/DELETE buffer response, record gateway event, return
- Add socialEventTypeForRest() mapping 10+ social routes to event types
- Add 'social' to canonicalSubjectServiceId mapping
- Event payload is strictly {path, method, status, action} — no raw
  cookies, tokens, passwords, or LinkedIn session data
- Extract account_id from path/response for correlation.externalId
This commit is contained in:
-Puter
2026-07-09 14:30:04 +05:30
parent f61742b38f
commit ee964a5d13
+67 -3
View File
@@ -85,6 +85,7 @@ function canonicalSubjectServiceId(source: string) {
if (source.includes("interview")) return "interview";
if (source.includes("roleplay")) return "roleplay";
if (source.includes("resume")) return "resume";
if (source.includes("social")) return "social";
if (source.includes("qscore")) return "qscore";
if (source.includes("matchmaking")) return "matchmaking";
if (source.includes("analytics")) return "analytics";
@@ -218,6 +219,23 @@ function resumeEventTypeForRest(method: string, rest: string, ok: boolean) {
return "resume.loaded";
}
function socialEventTypeForRest(method: string, rest: string, ok: boolean) {
if (!ok) return "social.request_failed";
const m = method.toUpperCase();
const path = rest.replace(/^\/+/, "").split("?")[0] ?? rest;
if (m === "POST" && /^accounts\/connect/.test(path)) return "social.account.connected";
if (m === "POST" && /^accounts\/[^/]+\/profile\/polish/.test(path)) return "social.profile.polished";
if (m === "POST" && /^accounts\/[^/]+\/profile\/refresh/.test(path)) return "social.profile.refreshed";
if (m === "POST" && /^accounts\/[^/]+\/profile\/apply/.test(path)) return "social.profile.updated";
if (m === "POST" && /^accounts\/[^/]+\/profile\/refine-experience/.test(path)) return "social.experience.refined";
if (m === "POST" && /^profiles\/fetch-by-url/.test(path)) return "social.profile.fetched";
if (m === "POST" && /^accounts\/[^/]+\/(content|posts)/.test(path)) return "social.content.generated";
if (m !== "GET" && m !== "HEAD" && /^accounts\/[^/]+\/schedule/.test(path)) return "social.post.scheduled";
if (m !== "GET" && m !== "HEAD" && /^accounts\/[^/]+\/publish/.test(path)) return "social.post.published";
if (m === "POST" || m === "PUT" || m === "PATCH") return "social.updated";
return "social.updated";
}
function agentDataAction(result: Record<string, unknown>) {
const messages = Array.isArray(result.messages) ? result.messages : [];
for (const item of messages) {
@@ -581,8 +599,13 @@ async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: Jso
async function proxySocialRequest(req: Request, rest: string, userId: string) {
const incoming = new URL(req.url);
const normalizedRest = rest.replace(/^\/+/, "");
const forwardedQuery = new URLSearchParams(incoming.searchParams);
forwardedQuery.delete("missionInstanceId");
forwardedQuery.delete("missionId");
forwardedQuery.delete("stageId");
forwardedQuery.delete("source");
const target = new URL(
`/api/v1/${normalizedRest}${incoming.search}`,
`/api/v1/${normalizedRest}${forwardedQuery.toString() ? `?${forwardedQuery.toString()}` : ""}`,
config.socialBrandingServiceUrl.replace(/\/$/, ""),
);
@@ -593,9 +616,50 @@ async function proxySocialRequest(req: Request, rest: string, userId: string) {
const method = req.method.toUpperCase();
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
const res = await fetch(target, { method, headers, body });
const requestJson = parseJsonBody(body, headers);
const mission = missionFromRequest(req, requestJson);
// Strip mission fields from the forwarded body, mirroring the resume proxy,
// so orchestration metadata is not passed downstream.
const forwardBody =
body && headers.get("content-type")?.includes("application/json")
? Buffer.from(JSON.stringify(stripMissionFromBody(requestJson)))
: body;
if (forwardBody !== body) headers.delete("content-length");
const res = await fetch(target, { method, headers, body: forwardBody });
return new Response(res.body, {
if (method === "GET" || method === "HEAD") {
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers });
}
// Security note: the social-branding service handles LinkedIn sessions, tokens,
// cookies, and credentials. The gateway event payload intentionally carries ONLY
// canonical metadata (path/method/status/action) — never raw request/response bodies.
const responseBuffer = await res.arrayBuffer();
const responseText = Buffer.from(responseBuffer).toString("utf8");
let responseJson: unknown;
try { responseJson = responseText ? JSON.parse(responseText) : undefined; } catch { responseJson = undefined; }
const responseObj = isRecord(responseJson) ? responseJson : {};
const eventType = socialEventTypeForRest(method, normalizedRest, res.ok);
const derivedAction = eventType.split(".").slice(1).join(".");
const accountMatch = normalizedRest.match(/^accounts\/(?!connect\/?$)[^/]+\/.+/);
const externalId =
(accountMatch ? accountMatch[0].split("/")[1] : undefined) ??
getString(responseObj.account_id ?? responseObj.accountId ?? responseObj.id);
await recordGatewayEvent({
userId,
source: "agent-social-branding",
type: eventType,
payload: { path: normalizedRest, method, status: res.status, action: derivedAction },
correlation: {
externalId: externalId ?? `${canonicalSubjectServiceId("agent-social-branding")}:${eventType}`,
taskId: curatorTaskIdFromRequest(req, requestJson),
},
mission,
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record social gateway event"));
return new Response(responseBuffer, {
status: res.status,
statusText: res.statusText,
headers: res.headers,