mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
84b2003a5a
commit
859da7d035
21 changed files with 75 additions and 57 deletions
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue