diff --git a/apps/admin/lib/admin-client.ts b/apps/admin/lib/admin-client.ts index f90988d7d8..e300e97fdd 100644 --- a/apps/admin/lib/admin-client.ts +++ b/apps/admin/lib/admin-client.ts @@ -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[0], init?: Parameters[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(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(API_BASE, { fetcher: adminFetcher }).api.admin; +}).api.admin; diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts index 5f0c124a4e..0a25369b14 100644 --- a/apps/admin/lib/api.ts +++ b/apps/admin/lib/api.ts @@ -1,16 +1,38 @@ -import { adminClient } from './admin-client'; - -function unwrap( - 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(path: string, init?: RequestInit): Promise { + 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 call-sites above. + return res.json() as Promise; // safe-cast: fetch boundary — caller provides T } // ─── Stats ──────────────────────────────────────────────────────────────────── @@ -21,9 +43,8 @@ export interface AdminStats { items: number; } -export async function getStats(): Promise { - const { data, error } = await adminClient.stats.get(); - return unwrap({ data, error }, '/admin/stats'); +export function getStats(): Promise { + return adminFetch('/stats'); } // ─── Users ──────────────────────────────────────────────────────────────────── @@ -38,7 +59,7 @@ export interface AdminUser { createdAt: string | null; } -export async function getUsers({ +export function getUsers({ limit = 100, offset = 0, q, @@ -47,15 +68,13 @@ export async function getUsers({ offset?: number; q?: string; } = {}): Promise { - 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(`/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 ──────────────────────────────────────────────────────────────────── @@ -70,7 +89,7 @@ export interface AdminPack { userEmail: string | null; } -export async function getPacks({ +export function getPacks({ limit = 100, offset = 0, q, @@ -79,15 +98,13 @@ export async function getPacks({ offset?: number; q?: string; } = {}): Promise { - 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(`/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 ──────────────────────────────────────────────────────────── @@ -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, @@ -122,23 +139,23 @@ export async function getCatalogItems({ offset?: number; q?: string; } = {}): Promise { - 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(`/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 ───────────────────────────────────────────────────── @@ -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 { - 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 { + return adminFetch(`/analytics/platform/growth?period=${period}`); } -export async function getPlatformActivity( - period: 'day' | 'week' | 'month', - range = 12, -): Promise { - 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 { + return adminFetch(`/analytics/platform/activity?period=${period}`); } -export async function getPlatformBreakdown(): Promise { - const { data, error } = await adminClient.analytics.platform.breakdown.get(); - return unwrap({ data, error }, '/admin/analytics/platform/breakdown'); +export function getPlatformBreakdown(): Promise { + return adminFetch('/analytics/platform/breakdown'); } // ─── Analytics — Catalog ───────────────────────────────────────────────────── @@ -222,31 +226,22 @@ export type EmbeddingStats = { coveragePct: number; }; -export async function getCatalogOverview(): Promise { - const { data, error } = await adminClient.analytics.catalog.overview.get(); - return unwrap({ data, error }, '/admin/analytics/catalog/overview'); +export function getCatalogOverview(): Promise { + return adminFetch('/analytics/catalog/overview'); } -export async function getCatalogBrands(limit = 20): Promise { - 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 { + return adminFetch(`/analytics/catalog/brands?limit=${limit}`); } -export async function getCatalogPrices(): Promise { - const { data, error } = await adminClient.analytics.catalog.prices.get(); - return unwrap({ data, error }, '/admin/analytics/catalog/prices'); +export function getCatalogPrices(): Promise { + return adminFetch('/analytics/catalog/prices'); } -export async function getCatalogEtl(limit = 20): Promise { - 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 { + return adminFetch(`/analytics/catalog/etl?limit=${limit}`); } -export async function getCatalogEmbeddings(): Promise { - const { data, error } = await adminClient.analytics.catalog.embeddings.get(); - return unwrap({ data, error }, '/admin/analytics/catalog/embeddings'); +export function getCatalogEmbeddings(): Promise { + return adminFetch('/analytics/catalog/embeddings'); } diff --git a/apps/admin/lib/cfAccess.ts b/apps/admin/lib/cfAccess.ts index 1dad14431d..aeffab874f 100644 --- a/apps/admin/lib/cfAccess.ts +++ b/apps/admin/lib/cfAccess.ts @@ -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'; @@ -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; @@ -44,12 +44,6 @@ export function getCFAccessIdentity(): Promise return identityPromise; } -/** Returns the CF Access JWT assertion, or null when not behind CF Access. */ -export async function getCFAccessJWT(): Promise { - 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 { return (await getCFAccessIdentity()) !== null; diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index 89789adc3f..de830ef27c 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -74,7 +74,12 @@ async function adminAuthGuard(request: Request): Promise { // 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; } @@ -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. diff --git a/packages/api/test/admin-auth-guard.test.ts b/packages/api/test/admin-auth-guard.test.ts index 957c6e8c0c..8d465f2d26 100644 --- a/packages/api/test/admin-auth-guard.test.ts +++ b/packages/api/test/admin-auth-guard.test.ts @@ -286,9 +286,7 @@ describe('bypass attempts', () => { }); 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); }); @@ -324,9 +322,7 @@ describe('bypass attempts', () => { }); 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); });