Skip to content
Merged
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
30 changes: 10 additions & 20 deletions apps/admin/lib/admin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,15 @@ import { adminEnv } from './env';

const API_BASE = adminEnv.NEXT_PUBLIC_API_URL;

// safe-cast: Eden Treaty fetcher expects typeof fetch; CF Workers adds preconnect
// which is never called by Eden — only the (input, init) signature is used.
const adminFetcher: typeof fetch = Object.assign(
(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const authHeaders = getAuthHeader();
const existing = init?.headers ? Object.fromEntries(new Headers(init.headers)) : {};
const response = fetch(input, {
...init,
headers: { 'Content-Type': 'application/json', ...authHeaders, ...existing },
});
response.then((r) => {
if (r.status === 401) {
clearToken();
if (typeof window !== 'undefined') window.location.replace('/login');
}
});
export const adminClient = treaty<App>(API_BASE, {
headers() {
return getAuthHeader();
},
onResponse(response) {
if (response.status === 401) {
clearToken();
if (typeof window !== 'undefined') window.location.replace('/login');
}
return response;
},
fetch,
);

export const adminClient = treaty<App>(API_BASE, { fetcher: adminFetcher }).api.admin;
}).api.admin;
157 changes: 76 additions & 81 deletions apps/admin/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
import { adminClient } from './admin-client';

function unwrap<T>(
result: { data: T | null; error: { status: number; value: unknown } | null },
path: string,
): T {
if (result.error !== null) {
throw new Error(`Admin API error: ${result.error.status} — ${path}`);
// Browser-callable API client for the admin app.
//
// Auth: when behind CF Access, the Cf-Access-Jwt-Assertion header is added
// automatically by CF Access on every request to a protected service — no manual
// forwarding needed. For local dev, a short-lived Bearer token from Basic auth
// login is used instead.

import { clearToken, getAuthHeader } from './auth';
import { adminEnv } from './env';

const API_BASE = adminEnv.NEXT_PUBLIC_API_URL;

async function adminFetch<T>(path: string, init?: RequestInit): Promise<T> {
const authHeaders = getAuthHeader();
const res = await fetch(`${API_BASE}/api/admin${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...authHeaders,
...init?.headers,
},
});

if (res.status === 401) {
clearToken();
if (typeof window !== 'undefined') window.location.replace('/login');
throw new Error('Unauthorized');
}
if (result.data === null) {
throw new Error(`Empty response from Admin API — ${path}`);

if (!res.ok) {
throw new Error(`Admin API error: ${res.status} ${res.statusText} — ${path}`);
}
return result.data;

// T is caller-verified via the typed adminFetch<T> call-sites above.
return res.json() as Promise<T>; // safe-cast: fetch boundary — caller provides T
}

// ─── Stats ────────────────────────────────────────────────────────────────────
Expand All @@ -21,9 +43,8 @@ export interface AdminStats {
items: number;
}

export async function getStats(): Promise<AdminStats> {
const { data, error } = await adminClient.stats.get();
return unwrap({ data, error }, '/admin/stats');
export function getStats(): Promise<AdminStats> {
return adminFetch<AdminStats>('/stats');
}

// ─── Users ────────────────────────────────────────────────────────────────────
Expand All @@ -38,7 +59,7 @@ export interface AdminUser {
createdAt: string | null;
}

export async function getUsers({
export function getUsers({
limit = 100,
offset = 0,
q,
Expand All @@ -47,15 +68,13 @@ export async function getUsers({
offset?: number;
q?: string;
} = {}): Promise<AdminUser[]> {
const { data, error } = await adminClient['users-list'].get({
query: { limit, offset, q },
});
return unwrap({ data, error }, '/admin/users-list');
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (q) params.set('q', q);
return adminFetch<AdminUser[]>(`/users-list?${params}`);
}

export async function deleteUser(id: number): Promise<{ success: boolean }> {
const { data, error } = await adminClient.users({ id: String(id) }).delete();
return unwrap({ data, error }, `/admin/users/${id}`);
export function deleteUser(id: number): Promise<{ success: boolean }> {
return adminFetch(`/users/${id}`, { method: 'DELETE' });
}

// ─── Packs ────────────────────────────────────────────────────────────────────
Expand All @@ -70,7 +89,7 @@ export interface AdminPack {
userEmail: string | null;
}

export async function getPacks({
export function getPacks({
limit = 100,
offset = 0,
q,
Expand All @@ -79,15 +98,13 @@ export async function getPacks({
offset?: number;
q?: string;
} = {}): Promise<AdminPack[]> {
const { data, error } = await adminClient['packs-list'].get({
query: { limit, offset, q },
});
return unwrap({ data, error }, '/admin/packs-list');
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (q) params.set('q', q);
return adminFetch<AdminPack[]>(`/packs-list?${params}`);
}

export async function deletePack(id: string): Promise<{ success: boolean }> {
const { data, error } = await adminClient.packs({ id }).delete();
return unwrap({ data, error }, `/admin/packs/${id}`);
export function deletePack(id: string): Promise<{ success: boolean }> {
return adminFetch(`/packs/${id}`, { method: 'DELETE' });
}

// ─── Catalog Items ────────────────────────────────────────────────────────────
Expand All @@ -107,13 +124,13 @@ export interface UpdateCatalogItemInput {
name?: string;
brand?: string | null;
categories?: string[] | null;
weight?: number;
weight?: number | null;
weightUnit?: string;
price?: number | null;
description?: string | null;
}

export async function getCatalogItems({
export function getCatalogItems({
limit = 100,
offset = 0,
q,
Expand All @@ -122,23 +139,23 @@ export async function getCatalogItems({
offset?: number;
q?: string;
} = {}): Promise<AdminCatalogItem[]> {
const { data, error } = await adminClient['catalog-list'].get({
query: { limit, offset, q },
});
return unwrap({ data, error }, '/admin/catalog-list');
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (q) params.set('q', q);
return adminFetch<AdminCatalogItem[]>(`/catalog-list?${params}`);
}

export async function deleteCatalogItem(id: number): Promise<{ success: boolean }> {
const { data, error } = await adminClient.catalog({ id: String(id) }).delete();
return unwrap({ data, error }, `/admin/catalog/${id}`);
export function deleteCatalogItem(id: number): Promise<{ success: boolean }> {
return adminFetch(`/catalog/${id}`, { method: 'DELETE' });
}

export async function updateCatalogItem(
export function updateCatalogItem(
id: number,
body: UpdateCatalogItemInput,
data: UpdateCatalogItemInput,
): Promise<{ id: number; name: string }> {
const { data, error } = await adminClient.catalog({ id: String(id) }).patch(body);
return unwrap({ data, error }, `/admin/catalog/${id}`);
return adminFetch(`/catalog/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}

// ─── Analytics — Platform ─────────────────────────────────────────────────────
Expand All @@ -147,29 +164,16 @@ export type GrowthPoint = { period: string; users: number; packs: number; catalo
export type ActivityPoint = { period: string; trips: number; trailReports: number; posts: number };
export type BreakdownItem = { category: string; count: number };

export async function getPlatformGrowth(
period: 'day' | 'week' | 'month',
range = 12,
): Promise<GrowthPoint[]> {
const { data, error } = await adminClient.analytics.platform.growth.get({
query: { period, range },
});
return unwrap({ data, error }, '/admin/analytics/platform/growth');
export function getPlatformGrowth(period: string): Promise<GrowthPoint[]> {
return adminFetch(`/analytics/platform/growth?period=${period}`);
}

export async function getPlatformActivity(
period: 'day' | 'week' | 'month',
range = 12,
): Promise<ActivityPoint[]> {
const { data, error } = await adminClient.analytics.platform.activity.get({
query: { period, range },
});
return unwrap({ data, error }, '/admin/analytics/platform/activity');
export function getPlatformActivity(period: string): Promise<ActivityPoint[]> {
return adminFetch(`/analytics/platform/activity?period=${period}`);
}

export async function getPlatformBreakdown(): Promise<BreakdownItem[]> {
const { data, error } = await adminClient.analytics.platform.breakdown.get();
return unwrap({ data, error }, '/admin/analytics/platform/breakdown');
export function getPlatformBreakdown(): Promise<BreakdownItem[]> {
return adminFetch('/analytics/platform/breakdown');
}

// ─── Analytics — Catalog ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -222,31 +226,22 @@ export type EmbeddingStats = {
coveragePct: number;
};

export async function getCatalogOverview(): Promise<CatalogOverview> {
const { data, error } = await adminClient.analytics.catalog.overview.get();
return unwrap({ data, error }, '/admin/analytics/catalog/overview');
export function getCatalogOverview(): Promise<CatalogOverview> {
return adminFetch('/analytics/catalog/overview');
}

export async function getCatalogBrands(limit = 20): Promise<BrandRow[]> {
const { data, error } = await adminClient.analytics.catalog.brands.get({
query: { limit },
});
return unwrap({ data, error }, '/admin/analytics/catalog/brands');
export function getCatalogBrands(limit = 20): Promise<BrandRow[]> {
return adminFetch(`/analytics/catalog/brands?limit=${limit}`);
}

export async function getCatalogPrices(): Promise<PriceBucket[]> {
const { data, error } = await adminClient.analytics.catalog.prices.get();
return unwrap({ data, error }, '/admin/analytics/catalog/prices');
export function getCatalogPrices(): Promise<PriceBucket[]> {
return adminFetch('/analytics/catalog/prices');
}

export async function getCatalogEtl(limit = 20): Promise<EtlResponse> {
const { data, error } = await adminClient.analytics.catalog.etl.get({
query: { limit },
});
return unwrap({ data, error }, '/admin/analytics/catalog/etl');
export function getCatalogEtl(limit = 20): Promise<EtlResponse> {
return adminFetch(`/analytics/catalog/etl?limit=${limit}`);
}

export async function getCatalogEmbeddings(): Promise<EmbeddingStats> {
const { data, error } = await adminClient.analytics.catalog.embeddings.get();
return unwrap({ data, error }, '/admin/analytics/catalog/embeddings');
export function getCatalogEmbeddings(): Promise<EmbeddingStats> {
return adminFetch('/analytics/catalog/embeddings');
}
12 changes: 3 additions & 9 deletions apps/admin/lib/cfAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
//
// CF Access protects the admin app in production. After authentication, CF sets
// an HttpOnly cookie (not readable by JS). The identity endpoint at
// /cdn-cgi/access/get-identity returns the signed JWT assertion we can forward
// to the API for cryptographic verification.
// /cdn-cgi/access/get-identity returns identity info (email, etc.) but does NOT
// include the JWT assertion in the response body. The Cf-Access-Jwt-Assertion
// header is added automatically by CF Access on requests to protected services.

import { useQuery } from '@tanstack/react-query';
import { queryKeys } from 'admin-app/lib/queryKeys';
Expand All @@ -12,7 +13,6 @@ import { z } from 'zod';
const CFAccessIdentitySchema = z.object({
email: z.string(),
name: z.string().optional().default(''),
jwt: z.string(),
});

export type CFAccessIdentityResponse = z.infer<typeof CFAccessIdentitySchema>;
Expand Down Expand Up @@ -44,12 +44,6 @@ export function getCFAccessIdentity(): Promise<CFAccessIdentityResponse | null>
return identityPromise;
}

/** Returns the CF Access JWT assertion, or null when not behind CF Access. */
export async function getCFAccessJWT(): Promise<string | null> {
const identity = await getCFAccessIdentity();
return identity?.jwt ?? null;
}

/** True when the app is running behind CF Access (identity endpoint responds). */
export async function isBehindCFAccess(): Promise<boolean> {
return (await getCFAccessIdentity()) !== null;
Expand Down
13 changes: 11 additions & 2 deletions packages/api/src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ async function adminAuthGuard(request: Request): Promise<boolean> {
// Local dev only: allow Basic auth directly on protected routes as a convenience.
// Both CF vars absent AND non-production environment must hold — missing CF vars
// alone is not enough so a misconfigured prod cannot fall back to Basic auth.
if (env.ENVIRONMENT !== 'production' && !CF_ACCESS_TEAM_DOMAIN && !CF_ACCESS_AUD && header.startsWith('Basic ')) {
if (
env.ENVIRONMENT !== 'production' &&
!CF_ACCESS_TEAM_DOMAIN &&
!CF_ACCESS_AUD &&
header.startsWith('Basic ')
) {
return basicAuthGuard(request).authorized;
}

Expand Down Expand Up @@ -102,7 +107,11 @@ export const adminRoutes = new Elysia({ prefix: '/admin' })
// The ENVIRONMENT check is a safety net — missing CF vars in prod must not
// silently downgrade to Basic-only.
if (CF_ACCESS_TEAM_DOMAIN && CF_ACCESS_AUD) {
const cfIdentity = await verifyCFAccessRequest(request, CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD);
const cfIdentity = await verifyCFAccessRequest(
request,
CF_ACCESS_TEAM_DOMAIN,
CF_ACCESS_AUD,
);
if (!cfIdentity) return status(401, { error: 'CF Access authentication required' });
} else if (env.ENVIRONMENT === 'production') {
// CF vars missing but we're in production — refuse rather than fall back.
Expand Down
8 changes: 2 additions & 6 deletions packages/api/test/admin-auth-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
vi.mocked(verifyCFAccessRequest).mockResolvedValueOnce({ email: 'admin@packrat.world' });

const res = await app.fetch(adminReq('/stats'));
expect(res.status).not.toBe(401);

Check failure on line 70 in packages/api/test/admin-auth-guard.test.ts

View workflow job for this annotation

GitHub Actions / api-tests

test/admin-auth-guard.test.ts > adminAuthGuard — CF Access configured > allows request when verifyCFAccessRequest resolves an identity

AssertionError: expected 401 not to be 401 // Object.is equality ❯ test/admin-auth-guard.test.ts:70:28
});

it('returns 401 when cf-access-jwt-assertion header is absent', async () => {
Expand Down Expand Up @@ -205,7 +205,7 @@

const credentials = btoa('admin:admin-password');
const res = await app.fetch(tokenReq({ authorization: `Basic ${credentials}` }));
expect(res.status).toBe(401);

Check failure on line 208 in packages/api/test/admin-auth-guard.test.ts

View workflow job for this annotation

GitHub Actions / api-tests

test/admin-auth-guard.test.ts > /api/admin/token — CF Access configured (two-factor) > returns 401 when CF JWT is absent (Basic alone is not enough in prod)

AssertionError: expected 200 to be 401 // Object.is equality - Expected + Received - 401 + 200 ❯ test/admin-auth-guard.test.ts:208:24
const body = (await res.json()) as { error: string };
expect(body.error).toBe('CF Access authentication required');
});
Expand Down Expand Up @@ -286,9 +286,7 @@
});

it('rejects a Bearer token that is plaintext (not a JWT)', async () => {
const res = await app.fetch(
adminReq('/stats', { authorization: 'Bearer not-a-real-jwt' }),
);
const res = await app.fetch(adminReq('/stats', { authorization: 'Bearer not-a-real-jwt' }));
expect(res.status).toBe(401);
});

Expand Down Expand Up @@ -324,9 +322,7 @@
});

it('rejects a malformed Basic credential (no colon separator)', async () => {
const res = await app.fetch(
adminReq('/stats', { authorization: `Basic ${btoa('nocolon')}` }),
);
const res = await app.fetch(adminReq('/stats', { authorization: `Basic ${btoa('nocolon')}` }));
expect(res.status).toBe(401);
});

Expand Down
Loading