Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ pnpm build
pnpm start
```

## API transport and checks

Resource adapters live in `lib/recoup/` and share `requestAdminJson` for Bearer
requests and JSON errors. Each adapter owns its URL/query and response projection;
sandbox endpoints retain their message-only error contract. `useIsAdmin` keeps its
separate false-on-denial policy.

Run `pnpm test` with Node 22.15+ (or Node 24+) for mocked-network transport regression
checks. The suite loads the real TypeScript adapters and never contacts the API.
Run `pnpm exec tsc --noEmit` and `pnpm build` for application checks.

## Project Structure

```text
Expand Down
2 changes: 1 addition & 1 deletion hooks/useAdminSandboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useQuery } from "@tanstack/react-query";
import { usePrivy } from "@privy-io/react-auth";
import { fetchAdminSandboxes } from "@/lib/fetchAdminSandboxes";
import { fetchAdminSandboxes } from "@/lib/recoup/fetchAdminSandboxes";

/**
* Fetches account sandbox statistics from GET /api/admins/sandboxes
Expand Down
15 changes: 5 additions & 10 deletions lib/recoup/fetchAccountPulseEmails.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";

/**
Expand Down Expand Up @@ -35,15 +36,9 @@ export async function fetchAccountPulseEmails(
const url = new URL(`${API_BASE_URL}/api/admins/emails`);
url.searchParams.set("account_id", accountId);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

const data = await res.json();
const data = await requestAdminJson<{ emails?: PulseEmail[] }>(
url.toString(),
accessToken,
);
return data.emails ?? [];
}
15 changes: 5 additions & 10 deletions lib/recoup/fetchAccountTaskRuns.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { TaskRun } from "@/types/sandbox";

Expand All @@ -19,15 +20,9 @@ export async function fetchAccountTaskRuns(
url.searchParams.set("account_id", accountId);
url.searchParams.set("limit", String(limit));

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

const data = await res.json();
const data = await requestAdminJson<{ runs?: TaskRun[] }>(
url.toString(),
accessToken,
);
return data.runs ?? [];
}
16 changes: 6 additions & 10 deletions lib/recoup/fetchAdminSandboxOrgs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { OrgRepoRow } from "@/types/sandbox";

Expand All @@ -11,15 +12,10 @@ import type { OrgRepoRow } from "@/types/sandbox";
export async function fetchAdminSandboxOrgs(
accessToken: string,
): Promise<OrgRepoRow[]> {
const res = await fetch(`${API_BASE_URL}/api/admins/sandboxes/orgs`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? `HTTP ${res.status}`);
}

const data = await res.json();
const data = await requestAdminJson<{ repos?: OrgRepoRow[] }>(
`${API_BASE_URL}/api/admins/sandboxes/orgs`,
accessToken,
["message"],
);
return data.repos ?? [];
}
16 changes: 6 additions & 10 deletions lib/fetchAdminSandboxes.ts → lib/recoup/fetchAdminSandboxes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { AccountSandboxRow } from "@/types/sandbox";

Expand All @@ -11,15 +12,10 @@ import type { AccountSandboxRow } from "@/types/sandbox";
export async function fetchAdminSandboxes(
accessToken: string,
): Promise<AccountSandboxRow[]> {
const res = await fetch(`${API_BASE_URL}/api/admins/sandboxes`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? `HTTP ${res.status}`);
}

const data = await res.json();
const data = await requestAdminJson<{ accounts?: AccountSandboxRow[] }>(
`${API_BASE_URL}/api/admins/sandboxes`,
accessToken,
["message"],
);
return data.accounts ?? [];
}
12 changes: 2 additions & 10 deletions lib/recoup/fetchCodingPrStatus.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { CodingPrStatusResponse } from "@/types/coding-agent";

Expand All @@ -20,14 +21,5 @@ export async function fetchCodingPrStatus(
const url = new URL(`${API_BASE_URL}/api/admins/coding/pr`);
pullRequests.forEach((pr) => url.searchParams.append("pull_requests", pr));

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

return res.json();
return requestAdminJson<CodingPrStatusResponse>(url.toString(), accessToken);
}
12 changes: 2 additions & 10 deletions lib/recoup/fetchContentSlackTags.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { AdminPeriod } from "@/types/admin";
import type { ContentSlackResponse } from "@/types/contentSlack";
Expand All @@ -9,14 +10,5 @@ export async function fetchContentSlackTags(
const url = new URL(`${API_BASE_URL}/api/admins/content/slack`);
url.searchParams.set("period", period);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

return res.json();
return requestAdminJson<ContentSlackResponse>(url.toString(), accessToken);
}
12 changes: 2 additions & 10 deletions lib/recoup/fetchCreditsEvents.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { CreditsPeriod, CreditsEventsResponse } from "@/types/credits";

Expand Down Expand Up @@ -29,14 +30,5 @@ export async function fetchCreditsEvents({
url.searchParams.set("page", String(page));
url.searchParams.set("limit", String(limit));

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

return res.json();
return requestAdminJson<CreditsEventsResponse>(url.toString(), accessToken);
}
12 changes: 2 additions & 10 deletions lib/recoup/fetchCreditsRollup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { CreditsPeriod, CreditsRollupResponse } from "@/types/credits";

Expand All @@ -23,14 +24,5 @@ export async function fetchCreditsRollup({
url.searchParams.set("page", "1");
url.searchParams.set("limit", "500");

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

return res.json();
return requestAdminJson<CreditsRollupResponse>(url.toString(), accessToken);
}
12 changes: 2 additions & 10 deletions lib/recoup/fetchPrivyLogins.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { PrivyLoginsPeriod, PrivyLoginsResponse } from "@/types/privy";

Expand All @@ -16,14 +17,5 @@ export async function fetchPrivyLogins(
const url = new URL(`${API_BASE_URL}/api/admins/privy`);
url.searchParams.set("period", period);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

return res.json();
return requestAdminJson<PrivyLoginsResponse>(url.toString(), accessToken);
}
15 changes: 5 additions & 10 deletions lib/recoup/fetchPulseEmailById.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { PulseEmail } from "./fetchAccountPulseEmails";

Expand All @@ -15,15 +16,9 @@ export async function fetchPulseEmailById(
const url = new URL(`${API_BASE_URL}/api/admins/emails`);
url.searchParams.set("email_id", emailId);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

const data = await res.json();
const data = await requestAdminJson<{ emails?: PulseEmail[] }>(
url.toString(),
accessToken,
);
return data.emails?.[0] ?? null;
}
12 changes: 2 additions & 10 deletions lib/recoup/fetchSlackTags.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requestAdminJson } from "@/lib/recoup/requestAdminJson";
import { API_BASE_URL } from "@/lib/consts";
import type { SlackTagsPeriod, SlackTagsResponse } from "@/types/coding-agent";

Expand All @@ -16,14 +17,5 @@ export async function fetchSlackTags(
const url = new URL(`${API_BASE_URL}/api/admins/coding/slack`);
url.searchParams.set("period", period);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

return res.json();
return requestAdminJson<SlackTagsResponse>(url.toString(), accessToken);
}
24 changes: 24 additions & 0 deletions lib/recoup/requestAdminJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** Authenticated JSON transport; resource adapters own paths and response shapes. */
export async function requestAdminJson<T>(
url: string | URL,
accessToken: string,
errorFields: readonly string[] = ["error", "message"],
): Promise<T> {
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!response.ok) {
const body: unknown = await response.json().catch(() => null);
const errorBody =
typeof body === "object" && body !== null && !Array.isArray(body)
? (body as Record<string, unknown>)
: undefined;
const message = errorFields
.map((field) => errorBody?.[field])
.find((value) => value !== null && value !== undefined);
throw new Error(String(message ?? `HTTP ${response.status}`));
}

return response.json();
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint ."
"lint": "eslint .",
"test": "node --experimental-strip-types --import ./tests/register-paths.mjs --test tests/*.test.mjs"
},
"dependencies": {
"@privy-io/react-auth": "^1.88.4",
Expand Down
Loading
Loading