19.5 KB730 lines
Blame
1/**
2 * Grove API client — used by the Next.js frontend.
3 *
4 * Auth and instance requests go to hub-api.
5 * Repo, diff, and Canopy requests go directly to grove-api.
6 * In production, Caddy routes /api/* to the correct backend.
7 * In local dev, Next.js rewrites handle the routing.
8 */
9
10const API_BASE = "/api";
11
12function getToken(): string | null {
13 return typeof window !== "undefined"
14 ? localStorage.getItem("grove_hub_token")
15 : null;
16}
17
18// ── Token refresh infrastructure ──────────────────────────────────
19
20let refreshInFlight: Promise<string | null> | null = null;
21
22/** Write token + user to localStorage and cross-subdomain cookies. */
23function persistToken(token: string, user: { id: number; username: string; display_name: string }) {
24 localStorage.setItem("grove_hub_token", token);
25 localStorage.setItem("grove_hub_user", JSON.stringify(user));
26
27 const hostname = window.location.hostname;
28 const secure = window.location.protocol === "https:" ? "; Secure" : "";
29 const maxAge = 60 * 60 * 24 * 30;
30
31 const domains: string[] = [];
32 if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
33 // no domain cookies for IPs
34 } else if (hostname === "localhost" || hostname.endsWith(".localhost")) {
35 domains.push("localhost", ".localhost");
36 } else {
37 const parts = hostname.split(".");
38 if (parts.length <= 2) domains.push(`.${hostname}`);
39 else domains.push(`.${parts.slice(-2).join(".")}`);
40 }
41
42 const base = (name: string, val: string) =>
43 `${name}=${encodeURIComponent(val)}; path=/; SameSite=Lax; max-age=${maxAge}${secure}`;
44
45 document.cookie = base("grove_hub_token", token);
46 document.cookie = base("grove_hub_user", JSON.stringify(user));
47 for (const domain of domains) {
48 document.cookie = `${base("grove_hub_token", token)}; domain=${domain}`;
49 document.cookie = `${base("grove_hub_user", JSON.stringify(user))}; domain=${domain}`;
50 }
51
52 window.dispatchEvent(new CustomEvent("grove:token-refreshed", { detail: { token, user } }));
53}
54
55/**
56 * Refresh the current session token.
57 * Deduplicates concurrent calls so only one /auth/refresh request is in-flight.
58 */
59export function refreshToken(): Promise<string | null> {
60 if (refreshInFlight) return refreshInFlight;
61
62 refreshInFlight = (async () => {
63 const currentToken = getToken();
64 if (!currentToken) return null;
65
66 try {
67 const res = await fetch(`${API_BASE}/auth/refresh`, {
68 method: "POST",
69 headers: { Authorization: `Bearer ${currentToken}` },
70 });
71
72 if (!res.ok) return null;
73
74 const data = await res.json();
75 if (!data.token) return null;
76
77 persistToken(data.token, data.user);
78 return data.token as string;
79 } catch {
80 return null;
81 } finally {
82 refreshInFlight = null;
83 }
84 })();
85
86 return refreshInFlight;
87}
88
89// ── Core fetch ────────────────────────────────────────────────────
90
91async function fetchApi<T>(
92 base: string,
93 path: string,
94 options: RequestInit = {}
95): Promise<T> {
96 const token = getToken();
97
98 const headers: Record<string, string> = {
99 ...(token ? { Authorization: `Bearer ${token}` } : {}),
100 };
101 if (options.body) {
102 headers["Content-Type"] = "application/json";
103 }
104
105 const res = await fetch(`${base}${path}`, {
106 ...options,
107 headers: {
108 ...headers,
109 ...options.headers,
110 },
111 });
112
113 if (!res.ok) {
114 // On 401, attempt a single token refresh and retry
115 if (res.status === 401 && token && !path.startsWith("/auth/")) {
116 const newToken = await refreshToken();
117 if (newToken) {
118 const retryHeaders: Record<string, string> = {
119 Authorization: `Bearer ${newToken}`,
120 };
121 if (options.body) {
122 retryHeaders["Content-Type"] = "application/json";
123 }
124
125 const retryRes = await fetch(`${base}${path}`, {
126 ...options,
127 headers: {
128 ...retryHeaders,
129 ...options.headers,
130 },
131 });
132
133 if (!retryRes.ok) {
134 const body = await retryRes.json().catch(() => ({}));
135 throw new ApiError(retryRes.status, body.error ?? "Request failed");
136 }
137
138 if (retryRes.status === 204) return null as T;
139 return retryRes.json();
140 }
141 }
142
143 const body = await res.json().catch(() => ({}));
144 throw new ApiError(res.status, body.error ?? "Request failed");
145 }
146
147 if (res.status === 204) return null as T;
148 return res.json();
149}
150
151function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
152 return fetchApi(API_BASE, path, options);
153}
154
155export class ApiError extends Error {
156 constructor(
157 public status: number,
158 message: string
159 ) {
160 super(message);
161 }
162}
163
164// Auth (WebAuthn passkeys)
165export const auth = {
166 registerBegin: (data: { username: string; display_name?: string }) =>
167 apiRequest<{ options: any }>(
168 "/auth/register/begin",
169 { method: "POST", body: JSON.stringify(data) }
170 ),
171
172 registerComplete: (data: { response: unknown; challenge: string }) =>
173 apiRequest<{ token: string; user: User }>("/auth/register/complete", {
174 method: "POST",
175 body: JSON.stringify(data),
176 }),
177
178 loginBegin: (data?: { username?: string }) =>
179 apiRequest<{ options: any }>(
180 "/auth/login/begin",
181 { method: "POST", body: JSON.stringify(data ?? {}) }
182 ),
183
184 loginComplete: (data: { response: unknown; challenge: string }) =>
185 apiRequest<{ token: string; user: User }>("/auth/login/complete", {
186 method: "POST",
187 body: JSON.stringify(data),
188 }),
189
190 me: () => apiRequest<{ user: User }>("/auth/me"),
191
192 // Personal Access Tokens
193 listTokens: () =>
194 apiRequest<{ tokens: ApiToken[] }>("/auth/tokens"),
195
196 createToken: (data: { name: string; expires_in?: "30d" | "90d" | "1y" }) =>
197 apiRequest<{ token: string; api_token: ApiToken }>("/auth/tokens", {
198 method: "POST",
199 body: JSON.stringify(data),
200 }),
201
202 deleteToken: (id: number) =>
203 apiRequest<void>(`/auth/tokens/${id}`, { method: "DELETE" }),
204};
205
206// Instances
207export const instances = {
208 list: () => apiRequest<{ instances: Instance[] }>("/instances"),
209
210 create: (data: { name?: string; domain?: string }) =>
211 apiRequest<{ instance: Instance }>("/instances", {
212 method: "POST",
213 body: JSON.stringify(data),
214 }),
215
216 update: (id: number, data: { status?: string; ip?: string }) =>
217 apiRequest<{ instance: Instance }>(`/instances/${id}`, {
218 method: "PATCH",
219 body: JSON.stringify(data),
220 }),
221
222 ready: (id: number, data?: { ip?: string; token?: string }) =>
223 apiRequest<{ instance: Instance }>(`/instances/${id}/ready`, {
224 method: "POST",
225 body: JSON.stringify(data ?? {}),
226 }),
227
228 delete: (id: number) =>
229 apiRequest<void>(`/instances/${id}`, { method: "DELETE" }),
230};
231
232// Organizations
233export const orgs = {
234 list: () => apiRequest<{ orgs: Org[] }>("/orgs"),
235
236 get: (name: string) =>
237 apiRequest<{ org: Org; members: OrgMember[] }>(`/orgs/${name}`),
238
239 create: (data: { name: string; display_name?: string }) =>
240 apiRequest<{ org: Org }>("/orgs", {
241 method: "POST",
242 body: JSON.stringify(data),
243 }),
244
245 addMember: (orgName: string, data: { username: string }) =>
246 apiRequest<{ member: OrgMember }>(`/orgs/${orgName}/members`, {
247 method: "POST",
248 body: JSON.stringify(data),
249 }),
250
251 removeMember: (orgName: string, username: string) =>
252 apiRequest<void>(`/orgs/${orgName}/members/${username}`, {
253 method: "DELETE",
254 }),
255
256 delete: (name: string) =>
257 apiRequest<void>(`/orgs/${name}`, { method: "DELETE" }),
258};
259
260// Repos
261export const repos = {
262 list: () => apiRequest<{ repos: Repo[] }>("/repos"),
263
264 create: (data: { name: string; description?: string; default_branch?: string; owner?: string; is_private?: boolean }) =>
265 apiRequest<{ repo: Repo }>("/repos", {
266 method: "POST",
267 body: JSON.stringify(data),
268 }),
269
270 get: (owner: string, repo: string) =>
271 apiRequest<{ repo: Repo; readme: string | null; branches: Bookmark[]; clone_url: string }>(
272 `/repos/${owner}/${repo}`
273 ),
274
275 update: (owner: string, repo: string, data: { description?: string; is_private?: boolean; require_diffs?: boolean; pages_enabled?: boolean; pages_domain?: string | null }) =>
276 apiRequest<{ repo: Repo }>(`/repos/${owner}/${repo}`, {
277 method: "PATCH",
278 body: JSON.stringify(data),
279 }),
280
281 delete: (owner: string, repo: string) =>
282 apiRequest<void>(`/repos/${owner}/${repo}`, { method: "DELETE" }),
283
284 tree: (owner: string, repo: string, ref: string, path: string = "") =>
285 apiRequest<{ path: string; ref: string; entries: TreeEntry[] }>(
286 `/repos/${owner}/${repo}/tree/${ref}${path ? `/${path}` : ""}`
287 ),
288
289 blob: (owner: string, repo: string, ref: string, path: string) =>
290 apiRequest<{ path: string; ref: string; content: string; size: number }>(
291 `/repos/${owner}/${repo}/blob/${ref}/${path}`
292 ),
293
294 commits: (
295 owner: string,
296 repo: string,
297 ref: string,
298 options?: { limit?: number }
299 ) =>
300 apiRequest<{ ref: string; commits: BridgeCommit[] }>(
301 `/repos/${owner}/${repo}/commits/${ref}?limit=${options?.limit ?? 30}`
302 ),
303
304 blame: (owner: string, repo: string, ref: string, path: string) =>
305 apiRequest<{ path: string; ref: string; blame: BlameLine[] }>(
306 `/repos/${owner}/${repo}/blame/${ref}/${path}`
307 ),
308
309 bookmarks: (owner: string, repo: string) =>
310 apiRequest<{ bookmarks: Bookmark[] }>(`/repos/${owner}/${repo}/branches`),
311
312 diff: (owner: string, repo: string, base: string, head: string) =>
313 apiRequest<{ diffs: DiffFile[] }>(
314 `/repos/${owner}/${repo}/diff?base=${base}&head=${head}`
315 ),
316};
317
318// Diffs
319export const diffs = {
320 list: (owner: string, repo: string, status: string = "open") =>
321 apiRequest<{ diffs: Diff[] }>(
322 `/repos/${owner}/${repo}/diffs?status=${status}`
323 ),
324
325 get: (owner: string, repo: string, number: number) =>
326 apiRequest<{
327 diff: Diff;
328 comments: Comment[];
329 reviews: Review[];
330 }>(`/repos/${owner}/${repo}/diffs/${number}`),
331
332 create: (
333 owner: string,
334 repo: string,
335 data: { title: string; head_commit: string; description?: string; base_commit?: string }
336 ) =>
337 apiRequest<{ diff: Diff }>(
338 `/repos/${owner}/${repo}/diffs`,
339 { method: "POST", body: JSON.stringify(data) }
340 ),
341
342 update: (
343 owner: string,
344 repo: string,
345 number: number,
346 data: { title?: string; description?: string; status?: string; head_commit?: string }
347 ) =>
348 apiRequest<{ diff: Diff }>(
349 `/repos/${owner}/${repo}/diffs/${number}`,
350 { method: "PATCH", body: JSON.stringify(data) }
351 ),
352
353 land: (owner: string, repo: string, number: number) =>
354 apiRequest<{ diff: Diff }>(
355 `/repos/${owner}/${repo}/diffs/${number}/land`,
356 { method: "POST" }
357 ),
358
359 comment: (
360 owner: string,
361 repo: string,
362 number: number,
363 data: { body: string; file_path?: string; line_number?: number; side?: "left" | "right"; commit_sha?: string; parent_id?: number }
364 ) =>
365 apiRequest<{ comment: Comment }>(
366 `/repos/${owner}/${repo}/diffs/${number}/comments`,
367 { method: "POST", body: JSON.stringify(data) }
368 ),
369
370 review: (
371 owner: string,
372 repo: string,
373 number: number,
374 data: { status: "approved" | "changes_requested"; body?: string }
375 ) =>
376 apiRequest<{ review: Review }>(
377 `/repos/${owner}/${repo}/diffs/${number}/reviews`,
378 { method: "POST", body: JSON.stringify(data) }
379 ),
380};
381
382// Types
383
384export interface User {
385 id: number;
386 username: string;
387 display_name: string;
388 created_at?: string;
389}
390
391export interface ApiToken {
392 id: number;
393 name: string;
394 expires_at: string;
395 last_used_at: string | null;
396 created_at: string;
397}
398
399export interface Org {
400 id: number;
401 name: string;
402 display_name: string;
403 created_by: number;
404 created_at: string;
405 updated_at: string;
406}
407
408export interface OrgMember {
409 user_id: number;
410 username: string;
411 display_name: string;
412 created_at: string;
413}
414
415export interface Instance {
416 id: number;
417 user_id: number;
418 name: string;
419 ip: string | null;
420 domain: string | null;
421 region: string;
422 size: string;
423 status: "creating" | "active" | "error" | "destroyed";
424 created_at: string;
425 updated_at: string;
426}
427
428export interface Repo {
429 id: number;
430 owner_id: number;
431 owner_name: string;
432 owner_type: "user" | "org";
433 name: string;
434 description: string | null;
435 default_branch: string;
436 is_private: boolean;
437 require_diffs: boolean;
438 pages_enabled: boolean;
439 pages_domain: string | null;
440 created_at: string;
441 updated_at: string;
442 last_commit_ts?: number | null;
443}
444
445export interface TreeEntry {
446 name: string;
447 type: "file" | "tree";
448}
449
450export interface BridgeCommit {
451 hash: string;
452 git_sha: string | null;
453 author: string;
454 timestamp: number;
455 message: string;
456 parents: string[];
457}
458
459export interface BlameLine {
460 hash: string;
461 original_line: number;
462 author: string;
463 timestamp: number;
464 content: string;
465}
466
467export interface Bookmark {
468 name: string;
469 commit_id: string;
470}
471
472export interface Diff {
473 id: number;
474 repo_id: number;
475 number: number;
476 title: string;
477 description: string;
478 author_id: number;
479 author_name: string;
480 author_display_name?: string;
481 head_commit: string;
482 base_commit: string | null;
483 status: "open" | "landed" | "closed";
484 created_at: string;
485 updated_at: string;
486 landed_at: string | null;
487 landed_by: number | null;
488}
489
490export interface Comment {
491 id: number;
492 diff_id: number;
493 author_id: number;
494 author_name: string;
495 author_display_name?: string;
496 body: string;
497 file_path: string | null;
498 line_number: number | null;
499 side: "left" | "right" | null;
500 commit_sha: string | null;
501 parent_id: number | null;
502 created_at: string;
503}
504
505export interface Review {
506 id: number;
507 diff_id: number;
508 reviewer_id: number;
509 reviewer_name: string;
510 reviewer_display_name?: string;
511 status: "pending" | "approved" | "changes_requested";
512 body: string | null;
513 created_at: string;
514}
515
516export interface DiffFile {
517 path: string;
518 diff: string;
519 is_binary: boolean;
520}
521
522// Canopy CI/CD
523
524export interface PipelineRun {
525 id: number;
526 repo_id: number;
527 pipeline_name: string;
528 pipeline_file: string;
529 trigger_type: "push" | "diff" | "manual" | "schedule";
530 trigger_ref: string | null;
531 commit_id: string | null;
532 commit_message: string | null;
533 status: "pending" | "running" | "passed" | "failed" | "cancelled";
534 started_at: string | null;
535 finished_at: string | null;
536 duration_ms: number | null;
537 created_at: string;
538}
539
540export interface PipelineStep {
541 id: number;
542 run_id: number;
543 step_index: number;
544 name: string;
545 image: string;
546 status: "pending" | "running" | "passed" | "failed" | "skipped";
547 exit_code: number | null;
548 started_at: string | null;
549 finished_at: string | null;
550 duration_ms: number | null;
551}
552
553export interface StepLog {
554 stream: "stdout" | "stderr";
555 content: string;
556 created_at: string;
557}
558
559export const canopy = {
560 listRuns: (
561 owner: string,
562 repo: string,
563 options?: { status?: string; limit?: number }
564 ) =>
565 apiRequest<{ runs: PipelineRun[] }>(
566 `/repos/${owner}/${repo}/canopy/runs?${new URLSearchParams({
567 ...(options?.status ? { status: options.status } : {}),
568 limit: String(options?.limit ?? 20),
569 })}`,
570 { cache: "no-store" }
571 ),
572
573 getRun: (owner: string, repo: string, runId: number) =>
574 apiRequest<{ run: PipelineRun; steps: PipelineStep[] }>(
575 `/repos/${owner}/${repo}/canopy/runs/${runId}`,
576 { cache: "no-store" }
577 ),
578
579 getStepLogs: (
580 owner: string,
581 repo: string,
582 runId: number,
583 stepIndex: number
584 ) =>
585 apiRequest<{ logs: StepLog[] }>(
586 `/repos/${owner}/${repo}/canopy/runs/${runId}/logs/${stepIndex}`,
587 { cache: "no-store" }
588 ),
589
590 cancelRun: (owner: string, repo: string, runId: number) =>
591 apiRequest<{ run: PipelineRun }>(
592 `/repos/${owner}/${repo}/canopy/runs/${runId}/cancel`,
593 { method: "POST" }
594 ),
595
596 triggerRun: (owner: string, repo: string, data?: { ref?: string }) =>
597 apiRequest<{ triggered: boolean; branch: string; commit_id: string }>(
598 `/repos/${owner}/${repo}/canopy/trigger`,
599 { method: "POST", body: JSON.stringify(data ?? {}) }
600 ),
601
602 listSecrets: (owner: string, repo: string) =>
603 apiRequest<{
604 secrets: Array<{ name: string; created_at: string; updated_at: string }>;
605 }>(`/repos/${owner}/${repo}/canopy/secrets`),
606
607 createSecret: (
608 owner: string,
609 repo: string,
610 data: { name: string; value: string }
611 ) =>
612 apiRequest<{ ok: true }>(`/repos/${owner}/${repo}/canopy/secrets`, {
613 method: "POST",
614 body: JSON.stringify(data),
615 }),
616
617 deleteSecret: (owner: string, repo: string, name: string) =>
618 apiRequest<void>(`/repos/${owner}/${repo}/canopy/secrets/${name}`, {
619 method: "DELETE",
620 }),
621
622 recentRuns: (options?: { limit?: number; owner?: string }) => {
623 const params: Record<string, string> = { limit: String(options?.limit ?? 20) };
624 if (options?.owner) params.owner = options.owner;
625 return apiRequest<{ runs: Array<PipelineRun & { repo_name: string; owner_name: string }> }>(
626 `/canopy/recent-runs?${new URLSearchParams(params)}`,
627 { cache: "no-store" }
628 );
629 },
630};
631
632export interface RingLogEntry {
633 ts: string;
634 source: string;
635 level: string;
636 message: string;
637 payload: unknown;
638 owner?: string;
639 repo?: string;
640}
641
642export interface RingInstanceSummary {
643 owner: string;
644 repo: string;
645 total: number;
646 last_ts: string | null;
647 last_level: string | null;
648 last_message: string | null;
649}
650
651// ── Collab (diagrams / notes) ──
652
653export interface CollabDiagram {
654 id: string;
655 title: string;
656 section?: string;
657 code: string;
658}
659
660export interface CollabNote {
661 id: string;
662 diagramId: string;
663 diagramTitle?: string;
664 author: string;
665 text: string;
666 x?: number;
667 y?: number;
668 targetNode?: string | null;
669 timestamp: string;
670 editedAt?: string;
671}
672
673export const collab = {
674 listDiagrams: (owner: string, repo: string) =>
675 fetchApi<{ sections: string[]; diagrams: CollabDiagram[] }>(
676 API_BASE,
677 `/collab/repos/${owner}/${repo}/diagrams`
678 ),
679
680 listNotes: (owner: string, repo: string) =>
681 fetchApi<Record<string, CollabNote[]>>(
682 API_BASE,
683 `/collab/repos/${owner}/${repo}/notes`
684 ),
685
686 exportNotes: async (owner: string, repo: string): Promise<string> => {
687 const token = getToken();
688 const headers: Record<string, string> = token
689 ? { Authorization: `Bearer ${token}` }
690 : {};
691 const res = await fetch(
692 `${API_BASE}/collab/repos/${owner}/${repo}/notes/llm`,
693 { headers }
694 );
695 if (!res.ok) throw new ApiError(res.status, "Export failed");
696 return res.text();
697 },
698};
699
700export const ring = {
701 listInstances: () =>
702 apiRequest<{ instances: RingInstanceSummary[] }>(`/ring/instances`),
703
704 listLogs: (options?: { limit?: number }) =>
705 apiRequest<{ entries: RingLogEntry[]; total: number }>(
706 `/ring/logs?${new URLSearchParams({
707 limit: String(options?.limit ?? 200),
708 })}`
709 ),
710
711 listRepoLogs: (owner: string, repo: string, options?: { limit?: number }) =>
712 apiRequest<{ entries: RingLogEntry[]; total: number }>(
713 `/repos/${owner}/${repo}/ring/logs?${new URLSearchParams({
714 limit: String(options?.limit ?? 200),
715 })}`
716 ),
717
718 ingestLog: (data: unknown) =>
719 apiRequest<{ ok: true; entry: RingLogEntry }>(`/ring/logs`, {
720 method: "POST",
721 body: JSON.stringify(data ?? {}),
722 }),
723
724 ingestRepoLog: (owner: string, repo: string, data: unknown) =>
725 apiRequest<{ ok: true; entry: RingLogEntry }>(`/repos/${owner}/${repo}/ring/logs`, {
726 method: "POST",
727 body: JSON.stringify(data ?? {}),
728 }),
729};
730