Skip to content

Commit 59e1d1f

Browse files
Merge pull request #48 from InstaNode-dev/feat/admin-path-prefix-ui-fresh
admin: read admin_path_prefix from /auth/me + build admin URLs from it
2 parents 4d2eb40 + e250dd9 commit 59e1d1f

6 files changed

Lines changed: 260 additions & 30 deletions

File tree

src/api/index.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,3 +1259,117 @@ describe('reportExperimentConverted()', () => {
12591259
})
12601260
})
12611261
})
1262+
1263+
// ─── Admin URL prefix wiring (Track A — unguessable path) ──────────────
1264+
//
1265+
// The admin URL builders must read the prefix the API serves on /auth/me
1266+
// and stitch it into the request path. Empty prefix → throw a clear
1267+
// error (programmer error: UI should have gated on getAdminPathPrefix
1268+
// first). logout() clears the stash so a re-login by a different user
1269+
// can't inherit the previous user's admin path.
1270+
1271+
describe('Admin URL prefix wiring', () => {
1272+
it('fetchMe stashes admin_path_prefix when the API serves it', async () => {
1273+
const m = installFetch()
1274+
m.mockResolvedValueOnce(jsonResponse({
1275+
ok: true,
1276+
user_id: 'u_admin',
1277+
team_id: 't_admin',
1278+
email: 'founder@instanode.dev',
1279+
tier: 'team',
1280+
trial_ends_at: null,
1281+
is_platform_admin: true,
1282+
admin_path_prefix: 'abcdefghijklmnopqrstuvwxyz012345',
1283+
}))
1284+
// Imported lazily so we get the freshly-mocked module instance.
1285+
const { fetchMe, getAdminPathPrefix } = await import('./index')
1286+
const me = await fetchMe()
1287+
expect(me.admin_path_prefix).toBe('abcdefghijklmnopqrstuvwxyz012345')
1288+
expect(getAdminPathPrefix()).toBe('abcdefghijklmnopqrstuvwxyz012345')
1289+
})
1290+
1291+
it('fetchMe leaves the prefix empty when the API omits the field', async () => {
1292+
const m = installFetch()
1293+
m.mockResolvedValueOnce(jsonResponse({
1294+
ok: true,
1295+
user_id: 'u_user',
1296+
team_id: 't_user',
1297+
email: 'alice@example.com',
1298+
tier: 'hobby',
1299+
trial_ends_at: null,
1300+
// is_platform_admin and admin_path_prefix both absent
1301+
}))
1302+
const { fetchMe, getAdminPathPrefix, setAdminPathPrefix } = await import('./index')
1303+
// Pre-seed a stale prefix to prove fetchMe resets it.
1304+
setAdminPathPrefix('stale_should_be_cleared_xxxxxxxx')
1305+
const me = await fetchMe()
1306+
expect(me.admin_path_prefix).toBeUndefined()
1307+
expect(getAdminPathPrefix()).toBe('')
1308+
})
1309+
1310+
it('logout clears the stashed admin prefix', async () => {
1311+
const { setAdminPathPrefix, getAdminPathPrefix } = await import('./index')
1312+
setAdminPathPrefix('prefix_set_by_prior_admin_session_abc')
1313+
expect(getAdminPathPrefix()).not.toBe('')
1314+
await logout()
1315+
expect(getAdminPathPrefix()).toBe('')
1316+
})
1317+
1318+
it('admin builders mint /api/v1/<prefix>/customers when the prefix is set', async () => {
1319+
const { setAdminPathPrefix, listAdminCustomers } = await import('./index')
1320+
setAdminPathPrefix('abcdefghijklmnopqrstuvwxyz012345')
1321+
const m = installFetch()
1322+
m.mockResolvedValueOnce(jsonResponse({ ok: true, customers: [], total: 0 }))
1323+
await listAdminCustomers()
1324+
const url = String(m.mock.calls[0][0])
1325+
expect(url).toContain('/api/v1/abcdefghijklmnopqrstuvwxyz012345/customers')
1326+
// The legacy guessable path must NOT appear in the request URL.
1327+
expect(url).not.toContain('/api/v1/admin/customers')
1328+
})
1329+
1330+
it('admin builders throw admin_endpoints_unavailable when the prefix is empty', async () => {
1331+
const { setAdminPathPrefix, listAdminCustomers, getAdminCustomer, setAdminCustomerTier, issueAdminCustomerPromo } =
1332+
await import('./index')
1333+
setAdminPathPrefix('') // closed-by-default
1334+
installFetch() // not actually called — every builder throws before fetch.
1335+
1336+
await expect(listAdminCustomers()).rejects.toMatchObject({
1337+
status: 403,
1338+
code: 'admin_endpoints_unavailable',
1339+
})
1340+
await expect(getAdminCustomer('t_x')).rejects.toMatchObject({
1341+
status: 403,
1342+
code: 'admin_endpoints_unavailable',
1343+
})
1344+
await expect(
1345+
setAdminCustomerTier('t_x', { tier: 'pro', reason: 'comp' } as any),
1346+
).rejects.toMatchObject({ status: 403, code: 'admin_endpoints_unavailable' })
1347+
await expect(
1348+
issueAdminCustomerPromo('t_x', {
1349+
kind: 'percent_off',
1350+
value: 10,
1351+
valid_for_days: 30,
1352+
} as any),
1353+
).rejects.toMatchObject({ status: 403, code: 'admin_endpoints_unavailable' })
1354+
})
1355+
1356+
it('admin builders never include /admin/ in the request URL', async () => {
1357+
// Belt-and-braces — even if the prefix happens to contain the
1358+
// substring "admin", the LEGACY path /api/v1/admin/customers must
1359+
// not appear in any request. Concretely: we set a prefix that
1360+
// contains "admin" and check the URL is built around the prefix
1361+
// verbatim, not the literal /api/v1/admin/.
1362+
const { setAdminPathPrefix, listAdminCustomers } = await import('./index')
1363+
const prefixWithAdminSubstring = 'preadminxxxxxxxxxxxxxxxxxxxxxxxx' // 32 chars, contains "admin"
1364+
setAdminPathPrefix(prefixWithAdminSubstring)
1365+
const m = installFetch()
1366+
m.mockResolvedValueOnce(jsonResponse({ ok: true, customers: [], total: 0 }))
1367+
await listAdminCustomers()
1368+
const url = String(m.mock.calls[0][0])
1369+
expect(url).toContain(`/api/v1/${prefixWithAdminSubstring}/customers`)
1370+
// The exact legacy path is /api/v1/admin/customers — i.e. /admin/
1371+
// bounded by slashes. A prefix containing "admin" as substring must
1372+
// NOT produce that exact path.
1373+
expect(url).not.toContain('/api/v1/admin/customers')
1374+
})
1375+
})

src/api/index.ts

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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-z0-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

230242
export 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 }

src/api/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,18 @@ export interface AuthMeResponse {
260260
* as a regular user (404 on the admin route, link hidden). The flag
261261
* is server-authoritative; the dashboard never elevates by itself. */
262262
is_platform_admin?: boolean
263+
/** Unguessable URL segment under which the founder-only customer-management
264+
* endpoints register on the API. The agent API serves this field ONLY
265+
* for callers on the ADMIN_EMAILS allowlist AND when the operator has
266+
* configured ADMIN_PATH_PREFIX. For every non-admin caller and every
267+
* deploy without an admin path, the field is absent (not empty — absent;
268+
* the field's mere presence would leak the surface's existence).
269+
*
270+
* The admin API URL builders in src/api/index.ts read this value at
271+
* fetchMe-time and stash it in a module-local var. URLs are built as
272+
* `/api/v1/${prefix}/customers/...`. With no prefix loaded, the
273+
* builders throw a clear error and the admin route hides itself. */
274+
admin_path_prefix?: string
263275
}
264276

265277
// ---------- Admin customers (Track A — founder console) ----------

src/layout/AppShell.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -223,22 +223,29 @@ export function AppShell() {
223223
<NavRow to="/app/billing" icon={icons.billing}>Billing</NavRow>
224224
<NavRow to="/app/settings" icon={icons.settings}>Settings</NavRow>
225225

226-
{/* Admin-only — only rendered when /auth/me sets
227-
is_platform_admin: true. Non-admin users never see the
228-
link, and the underlying page 404-redirects too, so the
229-
route's existence isn't leaked. */}
230-
{ctx.me?.is_platform_admin && (
231-
<>
232-
<div className="nav-section">platform admin</div>
233-
<NavRow
234-
to="/app/admin/customers"
235-
icon={icons.team}
236-
testId="nav-admin-customers"
237-
>
238-
Customers
239-
</NavRow>
240-
</>
241-
)}
226+
{/* Admin-only — rendered when BOTH server-authoritative
227+
signals fire: is_platform_admin (caller is on
228+
ADMIN_EMAILS) AND admin_path_prefix (the API has an
229+
unguessable admin URL prefix configured; without it, the
230+
admin URL builder can't construct a request and the page
231+
would be useless). Non-admin users + admin users on a
232+
deploy without an admin path both see no link, and the
233+
page 404-redirects, so the route's existence isn't
234+
leaked either way. */}
235+
{ctx.me?.is_platform_admin &&
236+
typeof ctx.me?.admin_path_prefix === 'string' &&
237+
ctx.me.admin_path_prefix.length > 0 && (
238+
<>
239+
<div className="nav-section">platform admin</div>
240+
<NavRow
241+
to="/app/admin/customers"
242+
icon={icons.team}
243+
testId="nav-admin-customers"
244+
>
245+
Customers
246+
</NavRow>
247+
</>
248+
)}
242249

243250
<div className="nav-section">design ref</div>
244251
{/* §10.21: removed the "11 gaps" badge — the contracts page is a

src/pages/AdminCustomersPage.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,21 @@ vi.mock('../api', async () => {
4242
})
4343

4444
// Stub useDashboardCtx so we control is_platform_admin per test.
45+
// We also surface admin_path_prefix because the page's gate is the
46+
// intersection of the two signals — both must be present for the
47+
// admin route to render. The fixture prefix is a 32-char alphanumeric
48+
// blob so it matches what the API would actually serve. Tests that
49+
// flip mockIsAdmin to false unset both signals at the same time.
4550
let mockIsAdmin = true
4651
let mockMeLoading = false
52+
const TEST_ADMIN_PATH_PREFIX = 'testfixturepathprefix0123456789ab'
4753
vi.mock('../hooks/useDashboardCtx', () => ({
4854
useDashboardCtx: () => ({
4955
me: {
5056
user: { id: 'u_admin', email: 'manas@instanode.dev', tier: 'team' },
5157
team: { id: 't_admin', slug: 'instanode', name: 'instanode', tier: 'team' },
5258
is_platform_admin: mockIsAdmin,
59+
admin_path_prefix: mockIsAdmin ? TEST_ADMIN_PATH_PREFIX : undefined,
5360
},
5461
meErr: null,
5562
meLoading: mockMeLoading,

src/pages/AdminCustomersPage.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,22 @@ export function AdminCustomersPage() {
119119
writeStoredCurrency(next)
120120
}
121121

122-
// Gate the entire page on the admin flag. We render Navigate(*) which
123-
// matches the dashboard's catch-all and redirects to "/" — i.e., the
124-
// route 404-equivalents instead of 403-ing. Non-admin users never
125-
// learn the URL exists.
122+
// Gate the entire page on TWO server-authoritative signals:
123+
// 1. is_platform_admin — caller is on the ADMIN_EMAILS allowlist.
124+
// 2. admin_path_prefix — present-and-non-empty means the operator
125+
// has configured ADMIN_PATH_PREFIX on the API. Without a prefix,
126+
// the admin URL builder in src/api/index.ts can't construct a
127+
// request anyway, so the page would be useless.
128+
//
129+
// We render Navigate(*) which matches the dashboard's catch-all and
130+
// redirects to "/" — the route 404-equivalents instead of 403-ing.
131+
// Non-admin users never learn the URL exists. Belt-and-braces: even if
132+
// is_platform_admin somehow flips true without a prefix, the route
133+
// still hides itself.
126134
const me = ctx.me
127135
const meLoading = ctx.meLoading
128-
const isAdmin = me?.is_platform_admin === true
136+
const hasAdminPrefix = typeof me?.admin_path_prefix === 'string' && me.admin_path_prefix.length > 0
137+
const isAdmin = me?.is_platform_admin === true && hasAdminPrefix
129138

130139
useEffect(() => {
131140
if (!isAdmin) return

0 commit comments

Comments
 (0)