@@ -166,6 +166,12 @@ export async function fetchMe(): Promise<AuthMeResponse> {
166166 * dashboard surfaces the `/app/admin/customers` console only when
167167 * this is `true`. Absent on older API builds → treat as `false`. */
168168 is_platform_admin ?: boolean
169+ /** Unguessable URL prefix for the admin customer-management surface.
170+ * Sent by the API only when (a) the caller is on the ADMIN_EMAILS
171+ * allowlist AND (b) the deploy has ADMIN_PATH_PREFIX configured.
172+ * Absent for every other caller / configuration. Treat as "no admin
173+ * surface available" when undefined or empty. */
174+ admin_path_prefix ?: string
169175 }
170176 // No try/catch — errors propagate. The previous fixture fallback masked
171177 // backend outages by serving the `aanya@acme.dev` mock identity, which
@@ -179,6 +185,11 @@ export async function fetchMe(): Promise<AuthMeResponse> {
179185 // human-readable identity we have until a real team table exposes a slug.
180186 const localPart = me . email ?. split ( '@' ) [ 0 ] ?? ''
181187 const slug = localPart . toLowerCase ( ) . replace ( / [ ^ a - z 0 - 9 - ] / g, '-' ) || me . team_id . slice ( 0 , 8 )
188+ // Stash the admin path prefix in a module-local var so the admin URL
189+ // builders below can mint `/api/v1/${prefix}/customers/...` requests
190+ // without forcing every caller to plumb it through manually. The prefix
191+ // is a secret — see setAdminPathPrefix() — never log, never echo to UI.
192+ setAdminPathPrefix ( me . admin_path_prefix ?? '' )
182193 return {
183194 user : {
184195 id : me . user_id ,
@@ -198,6 +209,7 @@ export async function fetchMe(): Promise<AuthMeResponse> {
198209 } ,
199210 experiments : me . experiments ,
200211 is_platform_admin : me . is_platform_admin === true ,
212+ admin_path_prefix : me . admin_path_prefix ,
201213 }
202214}
203215
@@ -229,6 +241,12 @@ export async function reportExperimentConverted(input: {
229241
230242export async function logout ( ) : Promise < { ok : true } > {
231243 clearToken ( )
244+ // Drop the admin URL prefix on logout. A stale prefix in module-local
245+ // state would survive across a re-login by a different user (admin →
246+ // non-admin same tab), and the non-admin's first /auth/me would race
247+ // with their first admin-page render. Belt-and-braces: also clears it
248+ // in tests that mock fetchMe but exercise logout afterwards.
249+ setAdminPathPrefix ( '' )
232250 return { ok : true }
233251}
234252
@@ -1230,16 +1248,78 @@ export async function fetchQuotaWall(): Promise<QuotaWallResponse> {
12301248
12311249// ─── Admin Customers (Track A — founder console) ────────────────────────
12321250//
1233- // Four endpoints back the /app/admin/customers page:
1234- // listAdminCustomers — GET /api/v1/admin/customers
1235- // getAdminCustomer — GET /api/v1/admin/customers/:team_id
1236- // setAdminCustomerTier — POST /api/v1/admin/customers/:team_id/tier
1237- // issueAdminCustomerPromo — POST /api/v1/admin/customers/:team_id/promo
1251+ // Four endpoints back the /app/admin/customers page. They register on the
1252+ // API under an UNGUESSABLE PATH PREFIX (env var ADMIN_PATH_PREFIX), not
1253+ // the legacy /api/v1/admin/customers path. The prefix is delivered to
1254+ // admin clients in the /auth/me response (`admin_path_prefix` field) and
1255+ // stashed module-locally by fetchMe().
1256+ //
1257+ // listAdminCustomers — GET /api/v1/<prefix>/customers
1258+ // getAdminCustomer — GET /api/v1/<prefix>/customers/:team_id
1259+ // setAdminCustomerTier — POST /api/v1/<prefix>/customers/:team_id/tier
1260+ // issueAdminCustomerPromo — POST /api/v1/<prefix>/customers/:team_id/promo
12381261//
12391262// Track A returns 403 with `agent_action` for non-admin callers; the
12401263// dashboard's route guard turns the page into a 404 for those users so
12411264// the route's existence isn't leaked. Other errors propagate so the
12421265// page renders a real banner instead of silently failing.
1266+ //
1267+ // SECURITY: the prefix is a credential with the same blast radius as a
1268+ // session token. NEVER log it. NEVER echo it into rendered UI text. NEVER
1269+ // hand it to a third-party analytics tool. The module-local var is the
1270+ // canonical store; treat reads through getAdminPathPrefix() as "I am
1271+ // about to build an admin URL right now."
1272+
1273+ /** Module-local cache of the admin URL prefix. Populated by fetchMe()
1274+ * from the /auth/me response (`admin_path_prefix`) and reset to '' by
1275+ * logout(). The two reader entry-points are:
1276+ *
1277+ * - getAdminPathPrefix() — used by tests + the route gate to check
1278+ * "is the admin surface available to this session?"
1279+ * - buildAdminURL(...) — used by every admin API function to mint
1280+ * a request URL; throws if the prefix is empty.
1281+ *
1282+ * Stored at module scope so the four admin builders below stay free of
1283+ * per-call arguments. Bundle-scoped, not module-scoped-per-bundle: the
1284+ * Vite build leaves one instance of this module per build, so all four
1285+ * builders + the route guard see the same cache. */
1286+ let _adminPathPrefix = ''
1287+
1288+ /** setAdminPathPrefix is called by fetchMe() with the value from the
1289+ * /auth/me response. Idempotent; safe to call on every fetchMe(). */
1290+ export function setAdminPathPrefix ( prefix : string ) : void {
1291+ _adminPathPrefix = prefix
1292+ }
1293+
1294+ /** getAdminPathPrefix returns the currently stashed admin URL prefix, or
1295+ * the empty string if /auth/me has not yet loaded or returned no value.
1296+ * Components use this to decide "should I render the admin route?" — an
1297+ * empty result means "no", regardless of why (no prefix configured on
1298+ * the server, the caller isn't on ADMIN_EMAILS, fetchMe hasn't run yet,
1299+ * or the session was just logged out). */
1300+ export function getAdminPathPrefix ( ) : string {
1301+ return _adminPathPrefix
1302+ }
1303+
1304+ /** buildAdminURL is the only place that turns the stashed prefix into an
1305+ * HTTP path. Throws with a clear, copy-and-paste-able error message when
1306+ * the prefix is empty — admin functions should never be called from UI
1307+ * that hasn't already gated on getAdminPathPrefix(), so an empty here
1308+ * is a programmer error, not a user-visible state.
1309+ *
1310+ * Note: we deliberately omit the prefix from the error message to avoid
1311+ * the case where the empty-state error gets logged with a non-empty
1312+ * prefix value next to it. */
1313+ function buildAdminURL ( suffix : string ) : string {
1314+ if ( _adminPathPrefix === '' ) {
1315+ throw new APIError (
1316+ 403 ,
1317+ 'admin_endpoints_unavailable' ,
1318+ 'admin endpoints unavailable: not authorized or session not loaded' ,
1319+ )
1320+ }
1321+ return `/api/v1/${ _adminPathPrefix } ${ suffix } `
1322+ }
12431323
12441324/** Filter / sort options accepted by GET /api/v1/admin/customers. The
12451325 * query string is built up only for the fields the caller actually sets
@@ -1267,7 +1347,8 @@ export async function listAdminCustomers(
12671347 if ( input . limit !== undefined ) params . set ( 'limit' , String ( input . limit ) )
12681348 if ( input . offset !== undefined ) params . set ( 'offset' , String ( input . offset ) )
12691349 const qs = params . toString ( )
1270- const path = qs ? `/api/v1/admin/customers?${ qs } ` : '/api/v1/admin/customers'
1350+ const base = buildAdminURL ( '/customers' )
1351+ const path = qs ? `${ base } ?${ qs } ` : base
12711352 const r = await call < {
12721353 ok : boolean
12731354 customers ?: AdminCustomerListResponse [ 'customers' ]
@@ -1288,7 +1369,7 @@ export async function getAdminCustomer(
12881369 deploys ?: AdminCustomerDetailResponse [ 'deploys' ]
12891370 subscription ?: AdminCustomerDetailResponse [ 'subscription' ]
12901371 promos ?: AdminCustomerDetailResponse [ 'promos' ]
1291- } > ( `/api/v1/admin/ customers/${ encodeURIComponent ( teamID ) } `)
1372+ } > ( buildAdminURL ( `/ customers/${ encodeURIComponent ( teamID ) } `) )
12921373 return {
12931374 ok : true ,
12941375 team : r . team ?? ( { id : teamID } as AdminCustomerDetailResponse [ 'team' ] ) ,
@@ -1306,7 +1387,7 @@ export async function setAdminCustomerTier(
13061387 input : AdminSetTierInput ,
13071388) : Promise < AdminSetTierResponse > {
13081389 const r = await call < { ok : boolean ; team : DashboardTeam } > (
1309- `/api/v1/admin/ customers/${ encodeURIComponent ( teamID ) } /tier`,
1390+ buildAdminURL ( `/ customers/${ encodeURIComponent ( teamID ) } /tier`) ,
13101391 { method : 'POST' , body : JSON . stringify ( input ) } ,
13111392 )
13121393 return { ok : true , team : r . team }
@@ -1317,7 +1398,7 @@ export async function issueAdminCustomerPromo(
13171398 input : AdminIssuePromoInput ,
13181399) : Promise < AdminIssuePromoResponse > {
13191400 const r = await call < { ok : boolean ; code : string ; expires_at : string | null } > (
1320- `/api/v1/admin/ customers/${ encodeURIComponent ( teamID ) } /promo`,
1401+ buildAdminURL ( `/ customers/${ encodeURIComponent ( teamID ) } /promo`) ,
13211402 { method : 'POST' , body : JSON . stringify ( input ) } ,
13221403 )
13231404 return { ok : true , code : r . code , expires_at : r . expires_at ?? null }
0 commit comments