web: forward auth token from SSR; consolidate api client; api: enforce private repo visibility on list

Anton Kaminsky15d agobc1b2bafe67cparent 0565998
16 files changed+214-196
api/src/routes/repos.ts
@@ -89,13 +89,14 @@
8989 .prepare(`SELECT * FROM repos_with_owner ORDER BY updated_at DESC`)
9090 .all() as any[];
9191
92 // Filter out private repos the user can't access
93 const repos = allRepos.filter(
94 (r) => !r.is_private || (userId != null && (
95 (r.owner_type === "user" && r.owner_id === userId) ||
96 r.owner_type === "org" // org membership checked lazily; show to any authed user for now
97 ))
92 // Filter out private repos the user can't access. Org membership is
93 // resolved via canAccessRepo (hub API roundtrip per private org repo),
94 // not lazily — listing private repo names to non-members leaks
95 // existence and is a real auth bug.
96 const accessChecks = await Promise.all(
97 allRepos.map(async (r) => ({ r, ok: await canAccessRepo(r, userId) })),
9898 );
99 const repos = accessChecks.filter((x) => x.ok).map((x) => x.r);
99100
100101 const reposWithActivity = await Promise.all(
101102 repos.map(async (repo) => {
102103
web/app/[owner]/[repo]/(tabs)/commits/page.tsx
@@ -1,6 +1,7 @@
11import type { Metadata } from "next";
22import Link from "next/link";
3import { groveApiUrl, timeAgo } from "@/lib/utils";
3import { timeAgo } from "@/lib/utils";
4import { getRepoCommits } from "@/lib/grove-api";
45
56interface Props {
67 params: Promise<{ owner: string; repo: string }>;
@@ -12,21 +13,13 @@
1213 return { title: `Commits · ${repo}` };
1314}
1415
15async function getCommits(owner: string, repo: string, ref: string) {
16 const res = await fetch(
17 `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${ref}?limit=50`,
18 { cache: "no-store" }
19 );
20 if (!res.ok) return null;
21 return res.json();
22}
2316
2417export default async function CommitsPage({ params, searchParams }: Props) {
2518 const { owner, repo } = await params;
2619 const { ref: refParam } = await searchParams;
2720 const ref = refParam ?? "main";
2821
29 const data = await getCommits(owner, repo, ref);
22 const data = await getRepoCommits(owner, repo, ref, { limit: 50 });
3023
3124 if (!data) {
3225 return (
3326
web/app/[owner]/[repo]/(tabs)/diffs/page.tsx
@@ -1,6 +1,7 @@
11import type { Metadata } from "next";
22import Link from "next/link";
3import { timeAgoFromDate, groveApiUrl } from "@/lib/utils";
3import { timeAgoFromDate } from "@/lib/utils";
4import { getRepoDiffs } from "@/lib/grove-api";
45
56interface Props {
67 params: Promise<{ owner: string; repo: string }>;
@@ -12,18 +13,6 @@
1213 return { title: `Diffs · ${repo}` };
1314}
1415
15async function getDiffs(owner: string, repo: string, status: string) {
16 try {
17 const res = await fetch(
18 `${groveApiUrl}/api/repos/${owner}/${repo}/diffs?status=${status}`,
19 { cache: "no-store" }
20 );
21 if (!res.ok) return null;
22 return res.json();
23 } catch {
24 return null;
25 }
26}
2716
2817const statusStyles: Record<string, { bg: string; text: string; border: string }> = {
2918 open: { bg: "var(--status-open-bg)", text: "var(--status-open-text)", border: "var(--status-open-border)" },
@@ -39,7 +28,7 @@
3928 const { status: statusParam } = await searchParams;
4029 const status = statusParam ?? "open";
4130
42 const data = await getDiffs(owner, repo, status);
31 const data = await getRepoDiffs(owner, repo, status);
4332
4433 return (
4534 <>
4635
web/app/[owner]/[repo]/(tabs)/page.tsx
@@ -3,7 +3,13 @@
33import { FileIcon } from "@/app/components/file-icon";
44import { Markdown } from "@/app/components/markdown";
55import { GitImportForm } from "@/app/components/git-import-form";
6import { groveApiUrl, encodePath, timeAgo } from "@/lib/utils";
6import { encodePath, timeAgo } from "@/lib/utils";
7import {
8 getRepoBranches,
9 getRepoTree,
10 getRepoBlob,
11 getRepoCommits,
12} from "@/lib/grove-api";
713
814interface Props {
915 params: Promise<{ owner: string; repo: string }>;
@@ -14,46 +20,13 @@
1420 return { title: `Grove · ${repo}` };
1521}
1622
17async function getTree(owner: string, repo: string, ref: string) {
18 const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/tree/${ref}`, {
19 cache: "no-store",
20 });
21 if (!res.ok) return null;
22 return res.json();
23}
24
25async function getBlob(owner: string, repo: string, ref: string, path: string) {
26 const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/blob/${ref}/${path}`, {
27 cache: "no-store",
28 });
29 if (!res.ok) return null;
30 return res.json();
31}
32
33async function getBookmarks(owner: string, repo: string) {
34 const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/branches`, {
35 cache: "no-store",
36 });
37 if (!res.ok) return null;
38 return res.json();
39}
40
41async function getLatestCommit(owner: string, repo: string, ref: string) {
42 const res = await fetch(
43 `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${ref}?limit=1`,
44 { cache: "no-store" }
45 );
46 if (!res.ok) return null;
47 return res.json();
48}
49
5023async function findReadme(owner: string, repo: string, ref: string, entries: any[]) {
5124 const readmeNames = ["README.md", "README", "readme.md", "README.txt"];
5225 const readmeEntry = entries.find(
5326 (e: any) => e.type !== "tree" && readmeNames.includes(e.name)
5427 );
5528 if (!readmeEntry) return null;
56 const blob = await getBlob(owner, repo, ref, readmeEntry.name);
29 const blob = await getRepoBlob(owner, repo, ref, readmeEntry.name);
5730 return blob?.content ?? null;
5831}
5932
@@ -67,7 +40,7 @@
6740export default async function RepoPage({ params }: Props) {
6841 const { owner, repo } = await params;
6942
70 const bookmarks = await getBookmarks(owner, repo);
43 const bookmarks = await getRepoBranches(owner, repo);
7144 if (!bookmarks) {
7245 return (
7346 <div className="py-10">
@@ -85,8 +58,8 @@
8558 const mainBookmark = branches.find((b: any) => b.name === "main");
8659 const ref = mainBookmark?.name ?? branches[0]?.name ?? "main";
8760 const [tree, latestCommitData] = await Promise.all([
88 getTree(owner, repo, ref),
89 getLatestCommit(owner, repo, ref),
61 getRepoTree(owner, repo, ref),
62 getRepoCommits(owner, repo, ref, { limit: 1 }),
9063 ]);
9164 const readme = tree ? await findReadme(owner, repo, ref, tree.entries) : null;
9265 const isMarkdown = readme !== null;
9366
web/app/[owner]/[repo]/blame/[...path]/page.tsx
@@ -1,6 +1,7 @@
11import type { Metadata } from "next";
22import Link from "next/link";
3import { groveApiUrl, timeAgo, encodePath } from "@/lib/utils";
3import { timeAgo, encodePath } from "@/lib/utils";
4import { getRepoBlame } from "@/lib/grove-api";
45
56interface Props {
67 params: Promise<{ owner: string; repo: string; path: string[] }>;
@@ -13,21 +14,20 @@
1314 return { title: `Blame · ${fileName} · ${repo}` };
1415}
1516
16async function getBlame(owner: string, repo: string, ref: string, path: string) {
17 const res = await fetch(
18 `${groveApiUrl}/api/repos/${owner}/${repo}/blame/${ref}/${path}`,
19 { cache: "no-store" }
20 );
21 if (!res.ok) return null;
22 return res.json();
23}
2417
2518export default async function BlamePage({ params }: Props) {
2619 const { owner, repo, path: pathParts } = await params;
2720 const ref = pathParts[0] ?? "main";
2821 const path = pathParts.slice(1).join("/");
2922
30 const data = await getBlame(owner, repo, ref, path);
23 type BlameLine = {
24 hash: string;
25 author?: string;
26 timestamp?: number;
27 line_no: number;
28 content: string;
29 };
30 const data = await getRepoBlame<{ blame: BlameLine[] }>(owner, repo, ref, path);
3131
3232 if (!data) {
3333 return (
3434
web/app/[owner]/[repo]/blob/[...path]/page.tsx
@@ -1,7 +1,8 @@
11import type { Metadata } from "next";
22import Link from "next/link";
33import { createHighlighter, type Highlighter } from "shiki";
4import { groveApiUrl, formatSize, encodePath } from "@/lib/utils";
4import { formatSize, encodePath } from "@/lib/utils";
5import { getRepoBlob } from "@/lib/grove-api";
56
67let highlighter: Highlighter | null = null;
78
@@ -31,14 +32,6 @@
3132 return { title: `${fileName} · ${repo}` };
3233}
3334
34async function getBlob(owner: string, repo: string, ref: string, path: string) {
35 const res = await fetch(
36 `${groveApiUrl}/api/repos/${owner}/${repo}/blob/${ref}/${path}`,
37 { cache: "no-store" }
38 );
39 if (!res.ok) return null;
40 return res.json();
41}
4235
4336function getLang(filename: string): string {
4437 const ext = filename.split(".").pop()?.toLowerCase() ?? "";
@@ -92,7 +85,7 @@
9285 const path = pathParts.slice(1).join("/");
9386 const filename = pathParts[pathParts.length - 1];
9487
95 const blob = await getBlob(owner, repo, ref, path);
88 const blob = await getRepoBlob(owner, repo, ref, path);
9689
9790 if (!blob) {
9891 return (
9992
web/app/[owner]/[repo]/commit/[sha]/page.tsx
@@ -1,7 +1,8 @@
11import type { Metadata } from "next";
22import Link from "next/link";
33import { createHighlighter, type Highlighter } from "shiki";
4import { groveApiUrl, timeAgo } from "@/lib/utils";
4import { timeAgo } from "@/lib/utils";
5import { getRepoCommits, getRepoDiff } from "@/lib/grove-api";
56import { DiffViewer } from "./diff-viewer";
67
78let highlighter: Highlighter | null = null;
@@ -98,31 +99,8 @@
9899}
99100
100101async function getCommit(owner: string, repo: string, sha: string) {
101 try {
102 const res = await fetch(
103 `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${sha}?limit=1`,
104 { cache: "no-store" }
105 );
106 if (!res.ok) return null;
107 const data = await res.json();
108 return data.commits?.[0] ?? null;
109 } catch {
110 return null;
111 }
112}
113
114
115async function getDiff(owner: string, repo: string, base: string, head: string) {
116 try {
117 const res = await fetch(
118 `${groveApiUrl}/api/repos/${owner}/${repo}/diff?base=${base}&head=${head}`,
119 { cache: "no-store" }
120 );
121 if (!res.ok) return null;
122 return res.json();
123 } catch {
124 return null;
125 }
102 const data = await getRepoCommits(owner, repo, sha, { limit: 1 });
103 return data?.commits?.[0] ?? null;
126104}
127105
128106export default async function CommitPage({ params }: Props) {
@@ -142,7 +120,7 @@
142120
143121 const gitSha = commit.hash ?? sha;
144122 const parentSha = commit.parents?.[0] ?? null;
145 const diffData = parentSha ? await getDiff(owner, repo, parentSha, gitSha) : null;
123 const diffData = parentSha ? await getRepoDiff(owner, repo, parentSha, gitSha) : null;
146124
147125 const files: ParsedFile[] = [];
148126 if (diffData?.diffs) {
149127
web/app/[owner]/[repo]/tree/[...path]/page.tsx
@@ -1,7 +1,8 @@
11import type { Metadata } from "next";
22import Link from "next/link";
33import { FileIcon } from "@/app/components/file-icon";
4import { groveApiUrl, encodePath } from "@/lib/utils";
4import { encodePath } from "@/lib/utils";
5import { getRepoTree } from "@/lib/grove-api";
56
67interface Props {
78 params: Promise<{ owner: string; repo: string; path: string[] }>;
@@ -13,14 +14,6 @@
1314 return { title: `${path || "/"} · ${repo}` };
1415}
1516
16async function getTree(owner: string, repo: string, ref: string, path: string) {
17 const url = path
18 ? `${groveApiUrl}/api/repos/${owner}/${repo}/tree/${ref}/${path}`
19 : `${groveApiUrl}/api/repos/${owner}/${repo}/tree/${ref}`;
20 const res = await fetch(url, { cache: "no-store" });
21 if (!res.ok) return null;
22 return res.json();
23}
2417
2518function sortEntries(entries: any[]) {
2619 return [...entries].sort((a, b) => {
@@ -34,7 +27,7 @@
3427 const ref = pathParts[0] ?? "main";
3528 const path = pathParts.slice(1).join("/");
3629
37 const tree = await getTree(owner, repo, ref, path);
30 const tree = await getRepoTree(owner, repo, ref, path);
3831
3932 if (!tree) {
4033 return (
4134
web/app/canopy/[owner]/[repo]/builds/[runId]/page.tsx
@@ -1,6 +1,6 @@
11import type { Metadata } from "next";
22import { PipelineRunDetail } from "@/app/components/pipeline-run-detail";
3import { groveApiUrl } from "@/lib/utils";
3import { getCanopyRun } from "@/lib/grove-api";
44
55interface Props {
66 params: Promise<{ owner: string; repo: string; runId: string }>;
@@ -29,13 +29,8 @@
2929 const title = `Build #${runId} · ${repo}`;
3030
3131 try {
32 const res = await fetch(
33 `${groveApiUrl}/api/repos/${owner}/${repo}/canopy/runs/${runId}`,
34 { cache: "no-store" }
35 );
36 if (!res.ok) return { title };
37
38 const data = (await res.json()) as { run?: { status?: string } };
32 const data = (await getCanopyRun(owner, repo, runId)) as { run?: { status?: string } } | null;
33 if (!data) return { title };
3934 const status = data.run?.status ?? "";
4035 const color = statusFaviconColor[status] ?? "#4d8a78";
4136 const svg = getCanopyStatusFaviconSvg(color);
4237
web/app/canopy/[owner]/[repo]/page.tsx
@@ -1,6 +1,6 @@
11import type { Metadata } from "next";
22import Link from "next/link";
3import { groveApiUrl } from "@/lib/utils";
3import { getCanopyRecentRuns } from "@/lib/grove-api";
44import { Badge } from "@/app/components/ui/badge";
55import { TimeAgo } from "@/app/components/time-ago";
66import {
@@ -44,18 +44,9 @@
4444};
4545
4646async function getRepoRuns(owner: string, repo: string): Promise<Run[]> {
47 try {
48 const res = await fetch(
49 `${groveApiUrl}/api/canopy/recent-runs?owner=${encodeURIComponent(owner)}&limit=50`,
50 { cache: "no-store" },
51 );
52 if (!res.ok) return [];
53 const data = await res.json();
54 const runs: Run[] = data.runs ?? [];
55 return runs.filter((r) => r.repo_name === repo);
56 } catch {
57 return [];
58 }
47 const data = await getCanopyRecentRuns<Run>({ owner, limit: 50 });
48 const runs = data?.runs ?? [];
49 return runs.filter((r) => r.repo_name === repo);
5950}
6051
6152export default async function CanopyRepoPage({ params }: Props) {
6253
web/app/canopy/[owner]/[repo]/runs/[runSlug]/page.tsx
@@ -1,6 +1,6 @@
11import type { Metadata } from "next";
22import { redirect } from "next/navigation";
3import { groveApiUrl } from "@/lib/utils";
3import { getCanopyRunsForRepo } from "@/lib/grove-api";
44import {
55 formatTriggerType,
66 getInvocationRunSlug,
@@ -36,17 +36,8 @@
3636}
3737
3838async function getRuns(owner: string, repo: string): Promise<Run[]> {
39 try {
40 const res = await fetch(
41 `${groveApiUrl}/api/repos/${owner}/${repo}/canopy/runs?limit=200`,
42 { cache: "no-store" }
43 );
44 if (!res.ok) return [];
45 const data = await res.json();
46 return data.runs ?? [];
47 } catch {
48 return [];
49 }
39 const data = await getCanopyRunsForRepo<Run>(owner, repo, { limit: 200 });
40 return data?.runs ?? [];
5041}
5142
5243export default async function CanopyInvocationPage({ params }: Props) {
5344
web/app/canopy/[owner]/page.tsx
@@ -1,6 +1,6 @@
11import type { Metadata } from "next";
22import Link from "next/link";
3import { groveApiUrl } from "@/lib/utils";
3import { getCanopyRecentRuns } from "@/lib/grove-api";
44import { Badge } from "@/app/components/ui/badge";
55import { TimeAgo } from "@/app/components/time-ago";
66import {
@@ -44,17 +44,8 @@
4444};
4545
4646async function getOwnerRuns(owner: string): Promise<Run[]> {
47 try {
48 const res = await fetch(
49 `${groveApiUrl}/api/canopy/recent-runs?owner=${encodeURIComponent(owner)}&limit=50`,
50 { cache: "no-store" },
51 );
52 if (!res.ok) return [];
53 const data = await res.json();
54 return data.runs ?? [];
55 } catch {
56 return [];
57 }
47 const data = await getCanopyRecentRuns<Run>({ owner, limit: 50 });
48 return data?.runs ?? [];
5849}
5950
6051function groupByRepo(runs: Run[]) {
6152
web/app/canopy/page.tsx
@@ -2,7 +2,7 @@
22import Link from "next/link";
33import { cookies } from "next/headers";
44import { headers } from "next/headers";
5import { groveApiUrl } from "@/lib/utils";
5import { serverFetch } from "@/lib/server-fetch";
66import { Badge } from "@/app/components/ui/badge";
77import { TimeAgo } from "@/app/components/time-ago";
88import { CanopyLogo } from "@/app/components/canopy-logo";
@@ -48,12 +48,11 @@
4848
4949async function getRecentRuns(): Promise<RecentRunsResult> {
5050 try {
51 const res = await fetch(`${groveApiUrl}/api/canopy/recent-runs?per_repo=20`, {
52 cache: "no-store",
53 });
54 if (res.status === 401) {
55 return { runs: [], unauthorized: true };
56 }
51 // Note: this page needs to distinguish 401 (signed out) from
52 // other failures, so it uses serverFetch directly rather than the
53 // higher-level helpers in grove-api.ts.
54 const res = await serverFetch(`/api/canopy/recent-runs?per_repo=20`);
55 if (res.status === 401) return { runs: [], unauthorized: true };
5756 if (!res.ok) return { runs: [], unauthorized: false };
5857 const data = await res.json();
5958 return { runs: data.runs ?? [], unauthorized: false };
6059
web/app/collab/page.tsx
@@ -1,8 +1,7 @@
11import type { Metadata } from "next";
2import { cookies } from "next/headers";
3import { headers } from "next/headers";
2import { cookies, headers } from "next/headers";
43import Link from "next/link";
5import { groveApiUrl } from "@/lib/utils";
4import { listRepos } from "@/lib/grove-api";
65import { CollabLogo } from "@/app/components/collab-logo";
76import { CollabRepoList } from "./collab-repo-list";
87
@@ -18,21 +17,9 @@
1817 updated_at: string | null;
1918}
2019
21async function getRepos(token: string | undefined): Promise<Repo[]> {
22 try {
23 const headers: Record<string, string> = token
24 ? { Authorization: `Bearer ${token}` }
25 : {};
26 const res = await fetch(`${groveApiUrl}/api/repos`, {
27 headers,
28 cache: "no-store",
29 });
30 if (!res.ok) return [];
31 const data = await res.json();
32 return data.repos ?? [];
33 } catch {
34 return [];
35 }
20async function getRepos(): Promise<Repo[]> {
21 const data = await listRepos<Repo>();
22 return data?.repos ?? [];
3623}
3724
3825export default async function CollabHomePage() {
@@ -95,7 +82,7 @@
9582 );
9683 }
9784
98 const repos = await getRepos(token);
85 const repos = await getRepos();
9986
10087 return <CollabRepoList repos={repos} />;
10188}
10289
web/lib/grove-api.ts
@@ -0,0 +1,103 @@
1// Server-side API client. Every named function here forwards the user's
2// auth token via serverFetch, so private-repo access works the same on
3// SSR as it does in the browser.
4//
5// Pages should NOT call `fetch(${groveApiUrl}/...)` directly — that path
6// is anonymous, and private repos look like 404s to their actual owners.
7// Add a function here instead.
8
9import { serverFetch } from "./server-fetch";
10
11/** All non-OK responses become null. Pages decide whether to render
12 * "not found" or "could not load". */
13async function getJson<T>(path: string): Promise<T | null> {
14 try {
15 const res = await serverFetch(path);
16 if (!res.ok) return null;
17 return (await res.json()) as T;
18 } catch {
19 return null;
20 }
21}
22
23// ---- Repos ----------------------------------------------------------
24
25export type RepoBranches = {
26 branches?: Array<{ name: string }>;
27 bookmarks?: Array<{ name: string }>;
28};
29
30export const getRepoBranches = (owner: string, repo: string) =>
31 getJson<RepoBranches>(`/api/repos/${owner}/${repo}/branches`);
32
33export type RepoTree = {
34 entries: Array<{ name: string; type: string; size?: number }>;
35};
36
37export const getRepoTree = (owner: string, repo: string, ref: string, path?: string) =>
38 getJson<RepoTree>(
39 path
40 ? `/api/repos/${owner}/${repo}/tree/${ref}/${path}`
41 : `/api/repos/${owner}/${repo}/tree/${ref}`,
42 );
43
44// Note: callers assume content + size are present (i.e. text files). For
45// binary blobs the API includes is_binary: true and may omit content;
46// no current page handles that branch. Tighten the type when it does.
47export type RepoBlob = { content: string; size: number; is_binary?: boolean };
48
49export const getRepoBlob = (owner: string, repo: string, ref: string, path: string) =>
50 getJson<RepoBlob>(`/api/repos/${owner}/${repo}/blob/${ref}/${path}`);
51
52export type RepoCommitList = { commits: any[] };
53
54export const getRepoCommits = (
55 owner: string,
56 repo: string,
57 ref: string,
58 opts: { limit?: number } = {},
59) => {
60 const qs = opts.limit ? `?limit=${opts.limit}` : "";
61 return getJson<RepoCommitList>(`/api/repos/${owner}/${repo}/commits/${ref}${qs}`);
62};
63
64export type RepoDiff = { diffs: Array<{ path: string; diff: string; is_binary?: boolean }> };
65
66export const getRepoDiff = (owner: string, repo: string, base: string, head: string) =>
67 getJson<RepoDiff>(`/api/repos/${owner}/${repo}/diff?base=${base}&head=${head}`);
68
69// The endpoints below return nested objects whose shapes are richer than
70// what each caller cares about. The wrapper types below are generic over
71// the row/run/blame element so callers pass their own local Repo/Run/etc.
72// types: `await listRepos<Repo>()` → `{ repos?: Repo[] }`. Defaults to
73// `unknown` so passing nothing forces an explicit cast at the use site.
74
75export const getRepoDiffs = <T = unknown>(owner: string, repo: string, status: string) =>
76 getJson<{ diffs?: T[] } & Record<string, unknown>>(
77 `/api/repos/${owner}/${repo}/diffs?status=${status}`,
78 );
79
80export const getRepoBlame = <T = unknown>(owner: string, repo: string, ref: string, path: string) =>
81 getJson<T>(`/api/repos/${owner}/${repo}/blame/${ref}/${path}`);
82
83export const listRepos = <T = unknown>() =>
84 getJson<{ repos?: T[] }>(`/api/repos`);
85
86export const getCanopyRecentRuns = <T = unknown>(
87 opts: { per_repo?: number; owner?: string; limit?: number } = {},
88) => {
89 const params = new URLSearchParams();
90 if (opts.per_repo) params.set("per_repo", String(opts.per_repo));
91 if (opts.owner) params.set("owner", opts.owner);
92 if (opts.limit) params.set("limit", String(opts.limit));
93 const qs = params.toString();
94 return getJson<{ runs?: T[] }>(`/api/canopy/recent-runs${qs ? `?${qs}` : ""}`);
95};
96
97export const getCanopyRunsForRepo = <T = unknown>(owner: string, repo: string, opts: { limit?: number } = {}) => {
98 const qs = opts.limit ? `?limit=${opts.limit}` : "";
99 return getJson<{ runs?: T[] }>(`/api/repos/${owner}/${repo}/canopy/runs${qs}`);
100};
101
102export const getCanopyRun = <T = unknown>(owner: string, repo: string, runId: string) =>
103 getJson<{ run?: T }>(`/api/repos/${owner}/${repo}/canopy/runs/${runId}`);
0104
web/lib/server-fetch.ts
@@ -0,0 +1,41 @@
1// SSR-side fetch helpers that forward the user's auth token to the API.
2//
3// Auth lives in `grove_hub_token` (issued by hub-api, JWT verified by both
4// hub-api and the grove api with the shared JWT_SECRET). On the client it
5// also lives in localStorage but Next.js server components can only see
6// the cookie. Without forwarding it, every SSR fetch is anonymous, which
7// makes private repos look like 404s to their actual owners.
8
9import { cookies } from "next/headers";
10import { groveApiUrl } from "./utils";
11
12const TOKEN_COOKIE = "grove_hub_token";
13
14/** Read the user's bearer token from cookies. Returns null if missing. */
15export async function readAuthToken(): Promise<string | null> {
16 const store = await cookies();
17 return store.get(TOKEN_COOKIE)?.value ?? null;
18}
19
20/** Build auth headers for a server-side fetch. Empty if no token. */
21export async function authHeaders(): Promise<Record<string, string>> {
22 const token = await readAuthToken();
23 return token ? { authorization: `Bearer ${token}` } : {};
24}
25
26/** Fetch a Grove API path forwarding the user's auth token. Defaults to
27 * no-store so per-user data isn't cached across requests. */
28export async function serverFetch(
29 path: string,
30 init: RequestInit = {},
31): Promise<Response> {
32 const headers = {
33 ...(init.headers ?? {}),
34 ...(await authHeaders()),
35 };
36 return fetch(`${groveApiUrl}${path}`, {
37 cache: "no-store",
38 ...init,
39 headers,
40 });
41}
042