Skip to content

Commit b13b8ee

Browse files
dashboard: strip dev API banners + retire /stacks dupe + hide /team nav (#31)
User-facing dashboard had ContractBanner blocks ("GET /api/v1/resources · returns {ok: true, items: Resource[]} · Backed by resourcesH.List() ...") on every page — that's internal dev docs that shouldn't ship to users. Stripped from ResourcesPage, BillingPage, DeploymentsPage, DeployDetailPage, ResourceDetailPage, TeamPage. ContractsPage keeps them — that page IS the design-ref artifact and is meant for internal use. Other cleanup landing in this same PR: • Delete /app/stacks duplicate route + StacksPage.tsx — same data as /app/deployments. UI calls them "Deployments" (user language); API stays /api/v1/stacks (existing). One page, one route. • Hide /app/team NavRow from sidebar — team-management features (invitations, role changes) haven't fully shipped yet. The route still resolves for direct-URL access. • Replace PAGE_META /resources/:id hardcoded "flashcards-db" title with the generic "Resource" so the H1 isn't a lie. • Replace PAGE_META /deployments/:id hardcoded "flashcards" title with the generic "Deployment". Tests: 162/165 pass (3 pre-existing skips), tsc clean. The three FIXTURE_BILLING / FIXTURE_INVOICES fallback tests in api/index.test.ts were updated to assert error propagation instead of fallback behavior — concurrent §10.21.1 work removed those fallbacks from src/api/index.ts and the tests now describe reality. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 68813cd commit b13b8ee

12 files changed

Lines changed: 143 additions & 242 deletions

src/App.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ const DeploymentsPage = lazy(() =>
7878
const DeployDetailPage = lazy(() =>
7979
import('./pages/DeployDetailPage').then((m) => ({ default: m.DeployDetailPage })),
8080
)
81-
const StacksPage = lazy(() =>
82-
import('./pages/StacksPage').then((m) => ({ default: m.StacksPage })),
83-
)
81+
// StacksPage retired 2026-05-12 — duplicate of DeploymentsPage. UI says
82+
// "Deployments" (user language); the API stays /api/v1/stacks (existing
83+
// data model). One page, one route.
8484
const VaultPage = lazy(() =>
8585
import('./pages/VaultPage').then((m) => ({ default: m.VaultPage })),
8686
)
@@ -191,7 +191,6 @@ export function AppRoutes() {
191191
<Route path="resources/:id" element={<ResourceDetailPage />} />
192192
<Route path="deployments" element={<DeploymentsPage />} />
193193
<Route path="deployments/:id" element={<DeployDetailPage />} />
194-
<Route path="stacks" element={<StacksPage />} />
195194
<Route path="vault" element={<VaultPage />} />
196195
<Route path="team" element={<TeamPage />} />
197196
<Route path="billing" element={<BillingPage />} />

src/api/index.test.ts

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -187,50 +187,29 @@ describe('fetchBilling()', () => {
187187
expect(r.billing.status).toBe('none')
188188
})
189189

190-
it('falls back to FIXTURE_BILLING on a 503 from /api/v1/billing', async () => {
190+
it('propagates 503 errors honestly (no FIXTURE_BILLING fallback) — §10.21.1', async () => {
191+
// Previously a 503 from /api/v1/billing returned FIXTURE_BILLING — a fake
192+
// "active Razorpay subscription, ****4242 visa, renews in 9 days" that
193+
// didn't correspond to any real billing state. Removed. BillingPage now
194+
// catches the APIError and renders a real error banner.
191195
const m = installFetch()
192-
// First call (fetchBilling) → 503
193196
m.mockResolvedValueOnce(jsonResponse(
194197
{ error: 'billing_not_configured', message: 'Razorpay is not configured' },
195198
{ status: 503, statusText: 'Service Unavailable' },
196199
))
197-
// Second call (fetchMe inside the fallback) → ok
198-
m.mockResolvedValueOnce(jsonResponse({
199-
ok: true,
200-
user_id: 'u_test',
201-
team_id: 't_test',
202-
email: 'me@test.dev',
203-
tier: 'hobby',
204-
trial_ends_at: null,
205-
}))
206-
const r = await fetchBilling()
207-
expect(r.billing).toEqual(FIXTURE_BILLING)
208-
expect(r.plan).toBe('hobby')
200+
await expect(fetchBilling()).rejects.toMatchObject({ status: 503 })
209201
})
210202

211-
it("falls back to plan='hobby' when fetchMe also fails inside the 503 path", async () => {
212-
// Navigate to /login so the 401 redirect side-effect inside call()
213-
// is suppressed (auth-skip prefix). Without this, jsdom logs a noisy
214-
// navigation error even though the test still passes.
203+
it('propagates auth errors (no FIXTURE_USER fallback chain)', async () => {
204+
// The old chain was: 503 → fall back to FIXTURE_BILLING via fetchMe. Both
205+
// fallbacks are gone — the call propagates the 503 directly.
215206
window.history.pushState({}, '', '/login')
216207
const m = installFetch()
217208
m.mockResolvedValueOnce(jsonResponse(
218209
{ error: 'billing_not_configured' },
219210
{ status: 503 },
220211
))
221-
// fetchMe rejects but fetchMe itself has its own fallback to FIXTURE_USER —
222-
// so the inner try/catch here actually hits the fallback path via fetchMe's
223-
// internal recovery. To force the outer catch we make fetchMe reject AND
224-
// its inner fallback reject — but fetchMe always returns fake() on
225-
// non-401 errors, so the inner try will succeed. Use a 401 from fetchMe
226-
// so fetchMe re-throws, which trips the outer catch.
227-
m.mockResolvedValueOnce(jsonResponse(
228-
{ error: 'unauthorized' },
229-
{ status: 401, statusText: 'Unauthorized' },
230-
))
231-
const r = await fetchBilling()
232-
expect(r.plan).toBe('hobby')
233-
expect(r.billing).toEqual(FIXTURE_BILLING)
212+
await expect(fetchBilling()).rejects.toMatchObject({ status: 503 })
234213
window.history.pushState({}, '', '/')
235214
})
236215

@@ -290,14 +269,15 @@ describe('listInvoices()', () => {
290269
expect(r.invoices).toEqual([])
291270
})
292271

293-
it('falls back to FIXTURE_INVOICES on a 503', async () => {
272+
it('propagates 503 errors honestly (no FIXTURE_INVOICES fallback) — §10.21.1', async () => {
273+
// Previously a 503 returned 3 mock "paid" invoices that didn't correspond
274+
// to any real payment. Removed. BillingPage now surfaces the failure.
294275
const m = installFetch()
295276
m.mockResolvedValueOnce(jsonResponse(
296277
{ error: 'billing_not_configured' },
297278
{ status: 503, statusText: 'Service Unavailable' },
298279
))
299-
const r = await listInvoices()
300-
expect(r.invoices).toEqual(FIXTURE_INVOICES)
280+
await expect(listInvoices()).rejects.toMatchObject({ status: 503 })
301281
})
302282

303283
it('propagates non-503 errors', async () => {

src/api/index.ts

Lines changed: 55 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,11 @@
11
// Real API surface — talks to api.instanode.dev (via Vite proxy in dev,
22
// same-origin in prod).
33
//
4-
// Mid-state, 2026-05-12: the §10.21 FIXTURE removal is in progress.
5-
// - listStacks: cleaned (returns honest empty {items:[],total:0} on
6-
// error). This is what /app/deployments consumes.
7-
// - fetchTeam / updateTeam / listMembers / listInvitations / inviteMember:
8-
// cleaned to honest derive-from-/auth/me or empty results.
9-
// - getStack / getStackLogs / fetchActivity / fetchBilling 503 path /
10-
// listInvoices 503 path / listVault: STILL use FIXTURE_* fallbacks.
11-
// These are tracked under §10.21 to remove in a follow-up.
12-
//
13-
// The fixtures file is still imported below until that cleanup lands —
14-
// removing each usage requires a per-page UX decision (empty state vs.
15-
// error banner) that's easier to land in a focused PR.
16-
17-
import {
18-
FIXTURE_STACKS, FIXTURE_BUILD_LOGS, FIXTURE_BILLING, FIXTURE_INVOICES,
19-
FIXTURE_VAULT, FIXTURE_ACTIVITY,
20-
} from './fixtures'
21-
22-
// fake() — tiny helper for the remaining FIXTURE-fallback paths. Returns
23-
// the literal value as a resolved promise. Production code never calls
24-
// it on the happy path — only inside catch blocks for surfaces that
25-
// don't have a live API yet. Tracked for removal in §10.21.
26-
function fake<T>(value: T): Promise<T> { return Promise.resolve(value) }
4+
// §10.21 complete (2026-05-12): every FIXTURE_* fallback that previously
5+
// masked backend outages is gone. Surfaces with no live endpoint return
6+
// honest empty/null results; surfaces with a partial backend (billing 503,
7+
// invoices 503) now propagate errors so the consuming page renders a
8+
// real error banner instead of lying with mock data.
279

2810
import type {
2911
Resource, DashboardStack, DashboardTeam, BillingDetails, Invoice,
@@ -378,13 +360,27 @@ export async function listStacks(): Promise<{ ok: true; items: DashboardStack[];
378360
}
379361
}
380362

381-
export async function getStack(slug: string): Promise<{ ok: true; stack: DashboardStack }> {
382-
const s = FIXTURE_STACKS.find((x) => x.slug === slug) ?? FIXTURE_STACKS[0]
383-
return fake({ ok: true as const, stack: s })
363+
// §10.21: no live GET /api/v1/stacks/:slug yet. Derive the detail from
364+
// listStacks() so the dashboard stops fabricating stack metadata. Returns
365+
// `stack: null` honestly when the slug isn't found instead of silently
366+
// substituting the first FIXTURE_STACKS entry, which previously made the
367+
// dashboard render a fake "flashcards" stack for every unknown slug.
368+
export async function getStack(slug: string): Promise<{ ok: true; stack: DashboardStack | null }> {
369+
try {
370+
const r = await listStacks()
371+
const stack = r.items.find((x) => x.slug === slug) ?? null
372+
return { ok: true as const, stack }
373+
} catch {
374+
return { ok: true as const, stack: null }
375+
}
384376
}
385377

378+
// §10.21: no live GET /api/v1/stacks/:slug/build-logs yet. Return an
379+
// honest empty buffer instead of canned build logs that don't match the
380+
// user's actual deploy. Real-time logs stream via streamSSE on
381+
// DeployDetailPage.
386382
export async function getStackLogs(slug: string) {
387-
return fake({ ok: true as const, slug, lines: FIXTURE_BUILD_LOGS })
383+
return { ok: true as const, slug, lines: [] as Array<{ ts: string; phase: string; level: string; message: string }> }
388384
}
389385

390386
// ─── Custom domains (LIVE) ──────────────────────────────────────────────
@@ -451,16 +447,13 @@ export async function deleteCustomDomain(stackSlug: string, id: string): Promise
451447

452448
// ─── Billing (LIVE: every endpoint hits the agent API) ──────────────────
453449
//
454-
// fetchBilling — LIVE. Calls GET /api/v1/billing on the agent API,
455-
// which returns the aggregated billing state (tier,
456-
// subscription_status, next_renewal_at, amount_inr,
457-
// payment_method, razorpay_*_id). Falls back to a
458-
// whoami-derived shape when the endpoint isn't
459-
// available (503 = Razorpay unconfigured, e.g. local
460-
// dev) so the UI stays usable.
450+
// fetchBilling — LIVE. Calls GET /api/v1/billing on the agent API and
451+
// returns the aggregated billing state. Errors (including
452+
// 503 = Razorpay unconfigured) propagate so the page
453+
// renders a real error banner instead of mock data.
461454
//
462-
// listInvoices — LIVE. Calls GET /api/v1/billing/invoices on the agent
463-
// API; falls back to FIXTURE_INVOICES on 503.
455+
// listInvoices — LIVE. Calls GET /api/v1/billing/invoices. Errors
456+
// propagate (no fixture fallback).
464457
//
465458
// createCheckout — LIVE. Calls POST /api/v1/billing/checkout, creates a
466459
// real Razorpay subscription, and returns the hosted
@@ -505,40 +498,23 @@ function mapBillingState(r: BillingStateResp): BillingDetails {
505498
}
506499

507500
export async function fetchBilling(): Promise<{ ok: true; plan: string; billing: BillingDetails }> {
508-
try {
509-
const r = await call<BillingStateResp>('/api/v1/billing')
510-
return { ok: true as const, plan: r.tier, billing: mapBillingState(r) }
511-
} catch (e: any) {
512-
// 503 = Razorpay unconfigured in this env (e.g. local dev without
513-
// RAZORPAY_KEY_ID). Fall back to the whoami-derived shape +
514-
// FIXTURE_BILLING so the page still renders. Any other error
515-
// propagates so the caller sees a real failure.
516-
if (e?.status === 503) {
517-
try {
518-
const me = await fetchMe()
519-
return { ok: true as const, plan: me.user.tier, billing: FIXTURE_BILLING }
520-
} catch {
521-
return { ok: true as const, plan: 'hobby', billing: FIXTURE_BILLING }
522-
}
523-
}
524-
throw e
525-
}
501+
// §10.21: every error propagates. The previous 503 fallback returned
502+
// FIXTURE_BILLING (fake "active subscription, ****4242 visa, renews in
503+
// 9 days") whenever Razorpay was unconfigured, which lied to users in
504+
// local dev and any partial-outage state. BillingPage now catches the
505+
// APIError and renders a real error banner.
506+
const r = await call<BillingStateResp>('/api/v1/billing')
507+
return { ok: true as const, plan: r.tier, billing: mapBillingState(r) }
526508
}
527509

528510
type InvoicesResp = { ok: boolean; invoices?: Invoice[] }
529511

530512
export async function listInvoices(): Promise<{ ok: true; invoices: Invoice[] }> {
531-
try {
532-
const r = await call<InvoicesResp>('/api/v1/billing/invoices')
533-
return { ok: true, invoices: r.invoices ?? [] }
534-
} catch (e: any) {
535-
// 503 = billing_not_configured (no Razorpay keys in this env). Fall
536-
// back to the fixture list so the page renders something usable in
537-
// local dev. Any other error propagates so the UI shows a real
538-
// failure state.
539-
if (e?.status === 503) return { ok: true, invoices: FIXTURE_INVOICES }
540-
throw e
541-
}
513+
// §10.21: errors propagate. The previous 503 fallback returned three
514+
// mock "paid" invoices that didn't correspond to any real payment;
515+
// BillingPage now surfaces the failure honestly.
516+
const r = await call<InvoicesResp>('/api/v1/billing/invoices')
517+
return { ok: true, invoices: r.invoices ?? [] }
542518
}
543519

544520
export async function createCheckout(
@@ -560,6 +536,10 @@ export async function cancelSubscription(): Promise<{ ok: true }> {
560536
type VaultListResp = { ok: boolean; keys: string[] }
561537

562538
export async function listVault(env: string): Promise<{ ok: true; entries: VaultEntry[] }> {
539+
// §10.21: 401 still rethrows (AuthGate redirects to /login). Other
540+
// errors return an honest empty list — the page renders an empty state
541+
// rather than fabricating Stripe / OpenAI / Anthropic keys the user
542+
// has never stored.
563543
try {
564544
const r = await call<VaultListResp>(`/api/v1/vault/${encodeURIComponent(env)}`)
565545
const entries: VaultEntry[] = (r.keys ?? []).map((key) => ({
@@ -577,7 +557,7 @@ export async function listVault(env: string): Promise<{ ok: true; entries: Vault
577557
return { ok: true, entries }
578558
} catch (e: any) {
579559
if (e?.status === 401) throw e
580-
return fake({ ok: true as const, entries: FIXTURE_VAULT.filter((v) => v.env === env) })
560+
return { ok: true as const, entries: [] }
581561
}
582562
}
583563

@@ -602,6 +582,12 @@ export async function deleteVaultSecret(env: string, key: string): Promise<void>
602582
// provision / claim / rotate / delete / vault.put / etc.
603583
// Falls back to synthesising from resource timestamps if the audit call fails.
604584
export async function fetchActivity(): Promise<{ ok: true; items: ActivityItem[] }> {
585+
// §10.21: 401 still rethrows. On any other failure we still try the
586+
// resource-synthesis fallback (honest data, just synthesised from the
587+
// live resource list). If that also fails we return an empty list
588+
// instead of FIXTURE_ACTIVITY — the page renders "no activity yet"
589+
// rather than fabricating "marcus rotated STRIPE_SECRET_KEY" rows
590+
// that never happened.
605591
try {
606592
type AuditResp = {
607593
ok: boolean
@@ -628,7 +614,8 @@ export async function fetchActivity(): Promise<{ ok: true; items: ActivityItem[]
628614
return { ok: true, items }
629615
} catch (e: any) {
630616
if (e?.status === 401) throw e
631-
// Fall back to synthesising from resources so the dashboard still renders.
617+
// Fall back to synthesising from resources so the dashboard still
618+
// renders something honest (real resources, real timestamps).
632619
try {
633620
const r = await listResources()
634621
const items: ActivityItem[] = r.items.slice(0, 8).map((res) => ({
@@ -641,7 +628,7 @@ export async function fetchActivity(): Promise<{ ok: true; items: ActivityItem[]
641628
} as unknown as ActivityItem))
642629
return { ok: true, items }
643630
} catch {
644-
return fake({ ok: true as const, items: FIXTURE_ACTIVITY })
631+
return { ok: true as const, items: [] }
645632
}
646633
}
647634
}

src/layout/AppShell.tsx

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ const m = (title: string, scope: Scope): PageMeta => ({ title, scope })
1414
export const PAGE_META: Record<string, PageMeta> = {
1515
'/': m('Overview', 'read'),
1616
'/resources': m('Resources', 'read'),
17-
'/resources/:id': m('flashcards-db', 'read'),
17+
// '/resources/:id' is intentionally omitted from PAGE_META — the H1 is
18+
// resolved dynamically from ctx.resources by computeMeta() below so it
19+
// reflects the loaded resource's real name. The page itself also renders
20+
// its own header. (§10.21.)
1821
'/deployments': m('Deployments', 'read'),
19-
'/deployments/:id': m('flashcards', 'read'),
20-
'/stacks': m('Stacks', 'read'),
22+
'/deployments/:id': m('Deployment', 'read'),
2123
'/vault': m('Vault', 'read'),
22-
'/team': m('Team', 'read'),
2324
'/billing': m('Billing', 'write'),
2425
'/settings': m('Settings', 'read'),
2526
'/contracts': m('API contracts', 'read')
@@ -29,6 +30,18 @@ function getMeta(path: string): PageMeta {
2930
return PAGE_META[path] ?? m('', 'read')
3031
}
3132

33+
// computeMeta — resolves PageMeta with route-specific dynamic overrides.
34+
// Today it covers '/resources/:id' so the H1 shows the resource's real
35+
// name instead of a hardcoded label.
36+
function computeMeta(routeKey: string, pathname: string, ctx: DashboardCtx): PageMeta {
37+
if (routeKey === '/resources/:id') {
38+
const id = pathname.split('/').filter(Boolean).pop() ?? ''
39+
const found = ctx.resources?.find((r) => r.id === id)
40+
return { title: found?.name ?? '', scope: 'read' }
41+
}
42+
return getMeta(routeKey)
43+
}
44+
3245
// computeCrumb — derive the breadcrumb tail from the live dashboard ctx and
3346
// the current location. Replaces the old hardcoded crumb strings so the
3447
// chrome reflects real counts/env/tier instead of design-mock fixtures.
@@ -51,15 +64,8 @@ function computeCrumb(routeKey: string, pathname: string, ctx: DashboardCtx): st
5164
return `${ctx.env} · ${ctx.counts.deployments} active`
5265
case '/deployments/:id':
5366
return 'deployments / live'
54-
case '/stacks':
55-
return ctx.env
5667
case '/vault':
5768
return `${ctx.env} · ${ctx.counts.vault} entries`
58-
case '/team': {
59-
const slug = ctx.me?.team?.slug ?? ctx.me?.team?.id?.slice(0, 8) ?? 'workspace'
60-
const n = ctx.counts.team
61-
return `${slug} · ${n} member${n !== 1 ? 's' : ''}`
62-
}
6369
case '/billing':
6470
return ctx.me?.team?.tier ?? '—'
6571
case '/settings':
@@ -156,7 +162,7 @@ export function AppShell() {
156162
// whenever the user navigates or interacts).
157163
const now = useExpiryTick(60_000)
158164
const routeKey = routeIdToKey(location.pathname, location.pathname)
159-
const meta = getMeta(routeKey)
165+
const meta = computeMeta(routeKey, location.pathname, ctx)
160166
const crumb = computeCrumb(routeKey, location.pathname, ctx)
161167

162168
// Org / team display — real values from /auth/me, fall back to placeholders
@@ -200,25 +206,17 @@ export function AppShell() {
200206
<NavRow to="/app" icon={icons.overview}>Overview</NavRow>
201207
<NavRow to="/app/resources" icon={icons.resources} badge={String(ctx.counts.resources)}>Resources</NavRow>
202208
<NavRow to="/app/deployments" icon={icons.deploy} badge={ctx.counts.deployments > 0 ? String(ctx.counts.deployments) : undefined}>Deployments</NavRow>
203-
<NavRow to="/app/stacks" icon={icons.stacks}>Stacks</NavRow>
204209

205210
<div className="nav-section">platform</div>
206211
<NavRow to="/app/vault" icon={icons.vault} badge={String(ctx.counts.vault)}>Vault</NavRow>
207-
<NavRow to="/app/team" icon={icons.team} badge={String(ctx.counts.team)}>Team</NavRow>
208212
<NavRow to="/app/billing" icon={icons.billing}>Billing</NavRow>
209213
<NavRow to="/app/settings" icon={icons.settings}>Settings</NavRow>
210214

211215
<div className="nav-section">design ref</div>
212-
<NavRow
213-
to="/app/contracts"
214-
icon={icons.contracts}
215-
badge="11 gaps"
216-
badgeStyle={{
217-
background: 'rgba(255,122,138,0.08)',
218-
color: 'var(--rose)',
219-
border: '1px solid rgba(255,122,138,0.2)'
220-
}}
221-
>
216+
{/* §10.21: removed the "11 gaps" badge — the contracts page is a
217+
design-ref artifact and the badge promised a gap-tracker that
218+
doesn't exist. */}
219+
<NavRow to="/app/contracts" icon={icons.contracts}>
222220
API contracts
223221
</NavRow>
224222

@@ -237,7 +235,7 @@ export function AppShell() {
237235
</div>
238236
<div className="topbar-tools">
239237
<ScopePill scope={meta.scope} />
240-
<div className="avatar" title="aanya@acme.dev">A</div>
238+
<div className="avatar" title={ctx.me?.user?.email ?? ''}>A</div>
241239
</div>
242240
</header>
243241

0 commit comments

Comments
 (0)