Add user identity (sub claim) to JWT for arc-web → arc-api auth

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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 15:38:03 -05:00
parent 84b2003a5a
commit 859da7d035
21 changed files with 75 additions and 57 deletions

View file

@ -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<CryptoKey> {
return cachedKey;
}
async function signToken(): Promise<string> {
async function signToken(sub?: string): Promise<string> {
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<Response> {
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<T>(path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(path, init);
export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T> {
const res = await apiFetch(path, options);
if (!res.ok) throw new Response(null, { status: res.status });
return res.json() as Promise<T>;
}

View file

@ -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<ApiSavedQuery[]>("/insights/queries"),
apiJson<ApiHistoryEntry[]>("/insights/history"),
apiJson<ApiSavedQuery[]>("/insights/queries", { request }),
apiJson<ApiHistoryEntry[]>("/insights/history", { request }),
]);
const savedQueries: SavedQuery[] = apiQueries.map((q) => ({
id: q.id,

View file

@ -17,8 +17,8 @@ interface RetroRow {
friction_point_count: number;
}
export async function loader() {
const apiRetros = await apiJson<RetroListItem[]>("/retros");
export async function loader({ request }: Route.LoaderArgs) {
const apiRetros = await apiJson<RetroListItem[]>("/retros", { request });
const retros: RetroRow[] = apiRetros.map((r) => ({
run_id: r.run_id,
workflow_name: r.workflow_name,

View file

@ -25,10 +25,10 @@ const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: s
failed: { icon: XCircleIcon, color: "text-coral" },
};
export async function loader({ params }: Route.LoaderArgs) {
export async function loader({ request, params }: Route.LoaderArgs) {
const [apiStages, configRes] = await Promise.all([
apiJson<RunStage[]>(`/runs/${params.id}/stages`),
apiFetch(`/runs/${params.id}/configuration`),
apiJson<RunStage[]>(`/runs/${params.id}/stages`, { request }),
apiFetch(`/runs/${params.id}/configuration`, { request }),
]);
const stages: Stage[] = apiStages.map((s) => ({
id: s.id,

View file

@ -20,8 +20,8 @@ const tabs = [
export const handle = { hideHeader: true };
export async function loader({ params }: Route.LoaderArgs) {
const apiRuns = await apiJson<RunListItem[]>("/runs");
export async function loader({ request, params }: Route.LoaderArgs) {
const apiRuns = await apiJson<RunListItem[]>("/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<PreviewUrlResponse>(`/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;
}

View file

@ -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<RunFiles>(`/runs/${params.id}/files?checkpoint=all`);
export async function loader({ request, params }: Route.LoaderArgs) {
const data = await apiJson<RunFiles>(`/runs/${params.id}/files?checkpoint=all`, { request });
return data;
}

View file

@ -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<RunStage[]>(`/runs/${params.id}/stages`),
apiFetch(`/runs/${params.id}/graph`),
apiJson<RunStage[]>(`/runs/${params.id}/stages`, { request }),
apiFetch(`/runs/${params.id}/graph`, { request }),
]);
const stages: Stage[] = apiStages.map((s) => ({
id: s.id,

View file

@ -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<RunStage[]>(`/runs/${params.id}/stages`),
apiJson<RunListItem[]>("/runs"),
apiJson<RunStage[]>(`/runs/${params.id}/stages`, { request }),
apiJson<RunListItem[]>("/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<WorkflowDetail>(`/workflows/${run.workflow}`);
const workflow = await apiJson<WorkflowDetail>(`/workflows/${run.workflow}`, { request });
graphDot = workflow.graph;
} catch {
// workflow not found — leave graphDot null

View file

@ -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<Retro>(`/runs/${params.id}/retro`);
export async function loader({ request, params }: Route.LoaderArgs) {
const retro = await apiJson<Retro>(`/runs/${params.id}/retro`, { request });
return { retro };
}

View file

@ -19,8 +19,8 @@ interface Stage {
duration: string;
}
export async function loader({ params }: Route.LoaderArgs) {
const apiStages = await apiJson<RunStage[]>(`/runs/${params.id}/stages`);
export async function loader({ request, params }: Route.LoaderArgs) {
const apiStages = await apiJson<RunStage[]>(`/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<ApiStageTurn[]>(`/runs/${params.id}/stages/${selectedStageId}/turns`);
turns = await apiJson<ApiStageTurn[]>(`/runs/${params.id}/stages/${selectedStageId}/turns`, { request });
}
return { stages, turns };

View file

@ -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<RunUsage>(`/runs/${params.id}/usage`);
export async function loader({ request, params }: Route.LoaderArgs) {
const usage = await apiJson<RunUsage>(`/runs/${params.id}/usage`, { request });
const stages = usage.stages.map((s) => ({
stage: s.stage,
model: s.model,

View file

@ -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<RunVerification[]>(`/runs/${params.id}/verifications`);
export async function loader({ request, params }: Route.LoaderArgs) {
const apiCategories = await apiJson<RunVerification[]>(`/runs/${params.id}/verifications`, { request });
const categories: VerificationCategory[] = apiCategories.map((cat) => ({
name: cat.name,
question: cat.question,

View file

@ -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<RunListItem[]>("/runs");
export async function loader({ request }: Route.LoaderArgs) {
const apiRuns = await apiJson<RunListItem[]>("/runs", { request });
const items = apiRuns.map(mapRunListItem);
const grouped = new Map<ColumnStatus, RunItem[]>();

View file

@ -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<ApiSessionDetail>(`/sessions/${params.sessionId}`),
apiJson<SessionGroup[]>("/sessions"),
apiJson<ApiSessionDetail>(`/sessions/${params.sessionId}`, { request }),
apiJson<SessionGroup[]>("/sessions", { request }),
]);
const session: Session = {
id: apiSession.id,

View file

@ -43,8 +43,8 @@ interface SettingGroupData {
fields: SettingField[];
}
export async function loader() {
const apiGroups = await apiJson<ApiSettingGroup[]>("/settings");
export async function loader({ request }: Route.LoaderArgs) {
const apiGroups = await apiJson<ApiSettingGroup[]>("/settings", { request });
const settingGroups: SettingGroupData[] = apiGroups.map((g) => ({
id: g.id,
name: g.name,

View file

@ -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<Project[]>("/projects"),
apiJson<SessionGroup[]>("/sessions"),
apiJson<Project[]>("/projects", { request }),
apiJson<SessionGroup[]>("/sessions", { request }),
]);
const projects = apiProjects.map((p) => ({ id: p.id, name: p.name }));
const sessionGroups = apiSessions.map((g) => ({

View file

@ -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<VerificationDetailResponse>(`/verifications/${params.slug}`);
export async function loader({ request, params }: Route.LoaderArgs) {
const data = await apiJson<VerificationDetailResponse>(`/verifications/${params.slug}`, { request });
return { data };
}

View file

@ -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<ApiVerificationCategory[]>("/verifications");
export async function loader({ request }: Route.LoaderArgs) {
const apiCategories = await apiJson<ApiVerificationCategory[]>("/verifications", { request });
const categories: VerificationCategory[] = apiCategories.map((cat) => ({
name: cat.name,
question: cat.question,

View file

@ -269,8 +269,8 @@ const tabs = [
export const handle = { hideHeader: true };
export async function loader({ params }: Route.LoaderArgs) {
const apiWorkflow = await apiJson<ApiWorkflowDetail>(`/workflows/${params.name}`);
export async function loader({ request, params }: Route.LoaderArgs) {
const apiWorkflow = await apiJson<ApiWorkflowDetail>(`/workflows/${params.name}`, { request });
const workflow: WorkflowEntry = {
title: apiWorkflow.title,
slug: apiWorkflow.slug,

View file

@ -15,8 +15,8 @@ const columnNames: Record<ColumnStatus, string> = {
merge: "Merge",
};
export async function loader({ params }: Route.LoaderArgs) {
const apiRuns = await apiJson<RunListItem[]>(`/workflows/${params.name}/runs`);
export async function loader({ request, params }: Route.LoaderArgs) {
const apiRuns = await apiJson<RunListItem[]>(`/workflows/${params.name}/runs`, { request });
const runs: RunWithStatus[] = apiRuns.map((r) => ({
id: r.id,
repo: r.repo,

View file

@ -104,8 +104,8 @@ interface WorkflowData {
nextRun?: string;
}
export async function loader() {
const apiWorkflows = await apiJson<WorkflowListItem[]>("/workflows");
export async function loader({ request }: Route.LoaderArgs) {
const apiWorkflows = await apiJson<WorkflowListItem[]>("/workflows", { request });
const workflows: WorkflowData[] = apiWorkflows.map((w) => ({
name: w.name,
slug: w.slug,