From 859da7d03548a4d5d6f08dc67afce892576d7201 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 3 Mar 2026 15:38:03 -0500 Subject: [PATCH] =?UTF-8?q?Add=20user=20identity=20(sub=20claim)=20to=20JW?= =?UTF-8?q?T=20for=20arc-web=20=E2=86=92=20arc-api=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JWT now includes a `sub` claim containing the authenticated user's GitHub profile URL (e.g. https://github.com/brynary), enabling the backend to identify which user is making each request. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/arc-web/app/api-client.ts | 27 ++++++++++++++----- apps/arc-web/app/routes/insights.tsx | 6 ++--- apps/arc-web/app/routes/retros.tsx | 4 +-- apps/arc-web/app/routes/run-configuration.tsx | 6 ++--- apps/arc-web/app/routes/run-detail.tsx | 13 +++++---- apps/arc-web/app/routes/run-files-changed.tsx | 4 +-- apps/arc-web/app/routes/run-graph.tsx | 6 ++--- apps/arc-web/app/routes/run-overview.tsx | 8 +++--- apps/arc-web/app/routes/run-retro.tsx | 4 +-- apps/arc-web/app/routes/run-stages.tsx | 6 ++--- apps/arc-web/app/routes/run-usage.tsx | 4 +-- apps/arc-web/app/routes/run-verifications.tsx | 4 +-- apps/arc-web/app/routes/runs.tsx | 4 +-- apps/arc-web/app/routes/session-detail.tsx | 6 ++--- apps/arc-web/app/routes/settings.tsx | 4 +-- apps/arc-web/app/routes/start.tsx | 6 ++--- .../app/routes/verification-detail.tsx | 4 +-- apps/arc-web/app/routes/verifications.tsx | 4 +-- apps/arc-web/app/routes/workflow-detail.tsx | 4 +-- apps/arc-web/app/routes/workflow-runs.tsx | 4 +-- apps/arc-web/app/routes/workflows.tsx | 4 +-- 21 files changed, 75 insertions(+), 57 deletions(-) diff --git a/apps/arc-web/app/api-client.ts b/apps/arc-web/app/api-client.ts index eab22c95b..0c40a5fcf 100644 --- a/apps/arc-web/app/api-client.ts +++ b/apps/arc-web/app/api-client.ts @@ -1,5 +1,6 @@ import { importPKCS8, SignJWT } from "jose"; import { getAppConfig } from "./lib/config.server"; +import { getUser } from "./lib/session.server"; const ARC_JWT_PRIVATE_KEY = process.env.ARC_JWT_PRIVATE_KEY; @@ -19,27 +20,41 @@ async function getSigningKey(): Promise { return cachedKey; } -async function signToken(): Promise { +async function signToken(sub?: string): Promise { const key = await getSigningKey(); - return new SignJWT({ iss: "arc-web" }) + return new SignJWT({ iss: "arc-web", ...(sub ? { sub } : {}) }) .setProtectedHeader({ alg: "EdDSA" }) .setIssuedAt() .setExpirationTime("30s") .sign(key); } +export interface ApiOptions { + init?: RequestInit; + request?: Request; +} + /** * Fetch wrapper that signs requests with a JWT for service-to-service auth. + * When a request is provided, the authenticated user's URL is included as + * the JWT `sub` claim. */ export async function apiFetch( path: string, - init?: RequestInit + options?: ApiOptions ): Promise { const { base_url } = getAppConfig().api; + const { init, request } = options ?? {}; + + let sub: string | undefined; + if (request) { + const user = await getUser(request); + sub = user?.userUrl; + } const headers = new Headers(init?.headers); if (ARC_JWT_PRIVATE_KEY) { - const token = await signToken(); + const token = await signToken(sub); headers.set("Authorization", `Bearer ${token}`); } @@ -52,8 +67,8 @@ export async function apiFetch( /** * Typed JSON fetch helper. Calls apiFetch and parses the JSON response. */ -export async function apiJson(path: string, init?: RequestInit): Promise { - const res = await apiFetch(path, init); +export async function apiJson(path: string, options?: ApiOptions): Promise { + const res = await apiFetch(path, options); if (!res.ok) throw new Response(null, { status: res.status }); return res.json() as Promise; } diff --git a/apps/arc-web/app/routes/insights.tsx b/apps/arc-web/app/routes/insights.tsx index 9a9b22fdb..c0ea779a3 100644 --- a/apps/arc-web/app/routes/insights.tsx +++ b/apps/arc-web/app/routes/insights.tsx @@ -28,10 +28,10 @@ export interface HistoryEntry { rowsReturned: number; } -export async function loader() { +export async function loader({ request }: Route.LoaderArgs) { const [apiQueries, apiHistory] = await Promise.all([ - apiJson("/insights/queries"), - apiJson("/insights/history"), + apiJson("/insights/queries", { request }), + apiJson("/insights/history", { request }), ]); const savedQueries: SavedQuery[] = apiQueries.map((q) => ({ id: q.id, diff --git a/apps/arc-web/app/routes/retros.tsx b/apps/arc-web/app/routes/retros.tsx index ac0226dc9..807100ef2 100644 --- a/apps/arc-web/app/routes/retros.tsx +++ b/apps/arc-web/app/routes/retros.tsx @@ -17,8 +17,8 @@ interface RetroRow { friction_point_count: number; } -export async function loader() { - const apiRetros = await apiJson("/retros"); +export async function loader({ request }: Route.LoaderArgs) { + const apiRetros = await apiJson("/retros", { request }); const retros: RetroRow[] = apiRetros.map((r) => ({ run_id: r.run_id, workflow_name: r.workflow_name, diff --git a/apps/arc-web/app/routes/run-configuration.tsx b/apps/arc-web/app/routes/run-configuration.tsx index 1813b1603..614b5936a 100644 --- a/apps/arc-web/app/routes/run-configuration.tsx +++ b/apps/arc-web/app/routes/run-configuration.tsx @@ -25,10 +25,10 @@ const statusConfig: Record(`/runs/${params.id}/stages`), - apiFetch(`/runs/${params.id}/configuration`), + apiJson(`/runs/${params.id}/stages`, { request }), + apiFetch(`/runs/${params.id}/configuration`, { request }), ]); const stages: Stage[] = apiStages.map((s) => ({ id: s.id, diff --git a/apps/arc-web/app/routes/run-detail.tsx b/apps/arc-web/app/routes/run-detail.tsx index ee559669a..9a20210b8 100644 --- a/apps/arc-web/app/routes/run-detail.tsx +++ b/apps/arc-web/app/routes/run-detail.tsx @@ -20,8 +20,8 @@ const tabs = [ export const handle = { hideHeader: true }; -export async function loader({ params }: Route.LoaderArgs) { - const apiRuns = await apiJson("/runs"); +export async function loader({ request, params }: Route.LoaderArgs) { + const apiRuns = await apiJson("/runs", { request }); const apiRun = apiRuns.find((r) => r.id === params.id); if (!apiRun) return { run: null }; return { @@ -44,9 +44,12 @@ export async function action({ params, request }: Route.ActionArgs) { const port = formData.get("port"); const expiresInSecs = formData.get("expires_in_secs"); const result = await apiJson(`/runs/${params.id}/preview`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port: Number(port), expires_in_secs: Number(expiresInSecs) }), + request, + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ port: Number(port), expires_in_secs: Number(expiresInSecs) }), + }, }); return result; } diff --git a/apps/arc-web/app/routes/run-files-changed.tsx b/apps/arc-web/app/routes/run-files-changed.tsx index 2760ce08e..76648fcc6 100644 --- a/apps/arc-web/app/routes/run-files-changed.tsx +++ b/apps/arc-web/app/routes/run-files-changed.tsx @@ -13,8 +13,8 @@ import type { Route } from "./+types/run-files-changed"; export const handle = { wide: true }; -export async function loader({ params }: Route.LoaderArgs) { - const data = await apiJson(`/runs/${params.id}/files?checkpoint=all`); +export async function loader({ request, params }: Route.LoaderArgs) { + const data = await apiJson(`/runs/${params.id}/files?checkpoint=all`, { request }); return data; } diff --git a/apps/arc-web/app/routes/run-graph.tsx b/apps/arc-web/app/routes/run-graph.tsx index b55d09ce7..2c43ab7da 100644 --- a/apps/arc-web/app/routes/run-graph.tsx +++ b/apps/arc-web/app/routes/run-graph.tsx @@ -22,10 +22,10 @@ interface Stage { duration: string; } -export async function loader({ params }: Route.LoaderArgs) { +export async function loader({ request, params }: Route.LoaderArgs) { const [apiStages, graphRes] = await Promise.all([ - apiJson(`/runs/${params.id}/stages`), - apiFetch(`/runs/${params.id}/graph`), + apiJson(`/runs/${params.id}/stages`, { request }), + apiFetch(`/runs/${params.id}/graph`, { request }), ]); const stages: Stage[] = apiStages.map((s) => ({ id: s.id, diff --git a/apps/arc-web/app/routes/run-overview.tsx b/apps/arc-web/app/routes/run-overview.tsx index 71a89d492..205f8562f 100644 --- a/apps/arc-web/app/routes/run-overview.tsx +++ b/apps/arc-web/app/routes/run-overview.tsx @@ -21,10 +21,10 @@ interface Stage { duration: string; } -export async function loader({ params }: Route.LoaderArgs) { +export async function loader({ request, params }: Route.LoaderArgs) { const [apiStages, runs] = await Promise.all([ - apiJson(`/runs/${params.id}/stages`), - apiJson("/runs"), + apiJson(`/runs/${params.id}/stages`, { request }), + apiJson("/runs", { request }), ]); const stages: Stage[] = apiStages.map((s) => ({ id: s.id, @@ -36,7 +36,7 @@ export async function loader({ params }: Route.LoaderArgs) { let graphDot: string | null = null; if (run) { try { - const workflow = await apiJson(`/workflows/${run.workflow}`); + const workflow = await apiJson(`/workflows/${run.workflow}`, { request }); graphDot = workflow.graph; } catch { // workflow not found — leave graphDot null diff --git a/apps/arc-web/app/routes/run-retro.tsx b/apps/arc-web/app/routes/run-retro.tsx index 8af0a75fc..00cfba8e3 100644 --- a/apps/arc-web/app/routes/run-retro.tsx +++ b/apps/arc-web/app/routes/run-retro.tsx @@ -10,8 +10,8 @@ import type { Retro } from "../data/retros"; import { apiJson } from "../api-client"; import type { Route } from "./+types/run-retro"; -export async function loader({ params }: Route.LoaderArgs) { - const retro = await apiJson(`/runs/${params.id}/retro`); +export async function loader({ request, params }: Route.LoaderArgs) { + const retro = await apiJson(`/runs/${params.id}/retro`, { request }); return { retro }; } diff --git a/apps/arc-web/app/routes/run-stages.tsx b/apps/arc-web/app/routes/run-stages.tsx index 14b1cd6b8..fa2547cdc 100644 --- a/apps/arc-web/app/routes/run-stages.tsx +++ b/apps/arc-web/app/routes/run-stages.tsx @@ -19,8 +19,8 @@ interface Stage { duration: string; } -export async function loader({ params }: Route.LoaderArgs) { - const apiStages = await apiJson(`/runs/${params.id}/stages`); +export async function loader({ request, params }: Route.LoaderArgs) { + const apiStages = await apiJson(`/runs/${params.id}/stages`, { request }); const stages: Stage[] = apiStages.map((s) => ({ id: s.id, name: s.name, @@ -32,7 +32,7 @@ export async function loader({ params }: Route.LoaderArgs) { const selectedStageId = params.stageId ?? stages[0]?.id; let turns: ApiStageTurn[] = []; if (selectedStageId) { - turns = await apiJson(`/runs/${params.id}/stages/${selectedStageId}/turns`); + turns = await apiJson(`/runs/${params.id}/stages/${selectedStageId}/turns`, { request }); } return { stages, turns }; diff --git a/apps/arc-web/app/routes/run-usage.tsx b/apps/arc-web/app/routes/run-usage.tsx index baee0e166..e584d09d8 100644 --- a/apps/arc-web/app/routes/run-usage.tsx +++ b/apps/arc-web/app/routes/run-usage.tsx @@ -3,8 +3,8 @@ import { formatDurationSecs } from "../lib/format"; import type { RunUsage } from "@qltysh/arc-api-client"; import type { Route } from "./+types/run-usage"; -export async function loader({ params }: Route.LoaderArgs) { - const usage = await apiJson(`/runs/${params.id}/usage`); +export async function loader({ request, params }: Route.LoaderArgs) { + const usage = await apiJson(`/runs/${params.id}/usage`, { request }); const stages = usage.stages.map((s) => ({ stage: s.stage, model: s.model, diff --git a/apps/arc-web/app/routes/run-verifications.tsx b/apps/arc-web/app/routes/run-verifications.tsx index c35ec249c..756d78b05 100644 --- a/apps/arc-web/app/routes/run-verifications.tsx +++ b/apps/arc-web/app/routes/run-verifications.tsx @@ -23,8 +23,8 @@ import { apiJson } from "../api-client"; import type { RunVerification } from "@qltysh/arc-api-client"; import type { Route } from "./+types/run-verifications"; -export async function loader({ params }: Route.LoaderArgs) { - const apiCategories = await apiJson(`/runs/${params.id}/verifications`); +export async function loader({ request, params }: Route.LoaderArgs) { + const apiCategories = await apiJson(`/runs/${params.id}/verifications`, { request }); const categories: VerificationCategory[] = apiCategories.map((cat) => ({ name: cat.name, question: cat.question, diff --git a/apps/arc-web/app/routes/runs.tsx b/apps/arc-web/app/routes/runs.tsx index f7497cd42..551b103d1 100644 --- a/apps/arc-web/app/routes/runs.tsx +++ b/apps/arc-web/app/routes/runs.tsx @@ -66,8 +66,8 @@ const columnConfig: { { id: "merge", name: "Merge", accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] }, ]; -export async function loader() { - const apiRuns = await apiJson("/runs"); +export async function loader({ request }: Route.LoaderArgs) { + const apiRuns = await apiJson("/runs", { request }); const items = apiRuns.map(mapRunListItem); const grouped = new Map(); diff --git a/apps/arc-web/app/routes/session-detail.tsx b/apps/arc-web/app/routes/session-detail.tsx index db51fc973..e9bd1dd54 100644 --- a/apps/arc-web/app/routes/session-detail.tsx +++ b/apps/arc-web/app/routes/session-detail.tsx @@ -19,10 +19,10 @@ export function meta({}: Route.MetaArgs) { return [{ title: "Session — Arc" }]; } -export async function loader({ params }: Route.LoaderArgs) { +export async function loader({ request, params }: Route.LoaderArgs) { const [apiSession, apiGroups] = await Promise.all([ - apiJson(`/sessions/${params.sessionId}`), - apiJson("/sessions"), + apiJson(`/sessions/${params.sessionId}`, { request }), + apiJson("/sessions", { request }), ]); const session: Session = { id: apiSession.id, diff --git a/apps/arc-web/app/routes/settings.tsx b/apps/arc-web/app/routes/settings.tsx index 9e39c8837..bd17b243a 100644 --- a/apps/arc-web/app/routes/settings.tsx +++ b/apps/arc-web/app/routes/settings.tsx @@ -43,8 +43,8 @@ interface SettingGroupData { fields: SettingField[]; } -export async function loader() { - const apiGroups = await apiJson("/settings"); +export async function loader({ request }: Route.LoaderArgs) { + const apiGroups = await apiJson("/settings", { request }); const settingGroups: SettingGroupData[] = apiGroups.map((g) => ({ id: g.id, name: g.name, diff --git a/apps/arc-web/app/routes/start.tsx b/apps/arc-web/app/routes/start.tsx index 9179cf723..acc1097da 100644 --- a/apps/arc-web/app/routes/start.tsx +++ b/apps/arc-web/app/routes/start.tsx @@ -30,10 +30,10 @@ export function meta({}: Route.MetaArgs) { return [{ title: "Start — Arc" }]; } -export async function loader() { +export async function loader({ request }: Route.LoaderArgs) { const [apiProjects, apiSessions] = await Promise.all([ - apiJson("/projects"), - apiJson("/sessions"), + apiJson("/projects", { request }), + apiJson("/sessions", { request }), ]); const projects = apiProjects.map((p) => ({ id: p.id, name: p.name })); const sessionGroups = apiSessions.map((g) => ({ diff --git a/apps/arc-web/app/routes/verification-detail.tsx b/apps/arc-web/app/routes/verification-detail.tsx index 1fd44eba9..0b7061550 100644 --- a/apps/arc-web/app/routes/verification-detail.tsx +++ b/apps/arc-web/app/routes/verification-detail.tsx @@ -61,8 +61,8 @@ import type { Route } from "./+types/verification-detail"; export const handle = { hideHeader: true }; -export async function loader({ params }: Route.LoaderArgs) { - const data = await apiJson(`/verifications/${params.slug}`); +export async function loader({ request, params }: Route.LoaderArgs) { + const data = await apiJson(`/verifications/${params.slug}`, { request }); return { data }; } diff --git a/apps/arc-web/app/routes/verifications.tsx b/apps/arc-web/app/routes/verifications.tsx index 9dd9b7a2c..81b068cfd 100644 --- a/apps/arc-web/app/routes/verifications.tsx +++ b/apps/arc-web/app/routes/verifications.tsx @@ -66,8 +66,8 @@ import { apiJson } from "../api-client"; import type { VerificationCategory as ApiVerificationCategory } from "@qltysh/arc-api-client"; import type { Route } from "./+types/verifications"; -export async function loader() { - const apiCategories = await apiJson("/verifications"); +export async function loader({ request }: Route.LoaderArgs) { + const apiCategories = await apiJson("/verifications", { request }); const categories: VerificationCategory[] = apiCategories.map((cat) => ({ name: cat.name, question: cat.question, diff --git a/apps/arc-web/app/routes/workflow-detail.tsx b/apps/arc-web/app/routes/workflow-detail.tsx index 98e45723c..723b58bab 100644 --- a/apps/arc-web/app/routes/workflow-detail.tsx +++ b/apps/arc-web/app/routes/workflow-detail.tsx @@ -269,8 +269,8 @@ const tabs = [ export const handle = { hideHeader: true }; -export async function loader({ params }: Route.LoaderArgs) { - const apiWorkflow = await apiJson(`/workflows/${params.name}`); +export async function loader({ request, params }: Route.LoaderArgs) { + const apiWorkflow = await apiJson(`/workflows/${params.name}`, { request }); const workflow: WorkflowEntry = { title: apiWorkflow.title, slug: apiWorkflow.slug, diff --git a/apps/arc-web/app/routes/workflow-runs.tsx b/apps/arc-web/app/routes/workflow-runs.tsx index 074612ccd..8e34b1f6c 100644 --- a/apps/arc-web/app/routes/workflow-runs.tsx +++ b/apps/arc-web/app/routes/workflow-runs.tsx @@ -15,8 +15,8 @@ const columnNames: Record = { merge: "Merge", }; -export async function loader({ params }: Route.LoaderArgs) { - const apiRuns = await apiJson(`/workflows/${params.name}/runs`); +export async function loader({ request, params }: Route.LoaderArgs) { + const apiRuns = await apiJson(`/workflows/${params.name}/runs`, { request }); const runs: RunWithStatus[] = apiRuns.map((r) => ({ id: r.id, repo: r.repo, diff --git a/apps/arc-web/app/routes/workflows.tsx b/apps/arc-web/app/routes/workflows.tsx index fcef95a83..fa7ef32fd 100644 --- a/apps/arc-web/app/routes/workflows.tsx +++ b/apps/arc-web/app/routes/workflows.tsx @@ -104,8 +104,8 @@ interface WorkflowData { nextRun?: string; } -export async function loader() { - const apiWorkflows = await apiJson("/workflows"); +export async function loader({ request }: Route.LoaderArgs) { + const apiWorkflows = await apiJson("/workflows", { request }); const workflows: WorkflowData[] = apiWorkflows.map((w) => ({ name: w.name, slug: w.slug,