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
7 changes: 3 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ const DeploymentsPage = lazy(() =>
const DeployDetailPage = lazy(() =>
import('./pages/DeployDetailPage').then((m) => ({ default: m.DeployDetailPage })),
)
const StacksPage = lazy(() =>
import('./pages/StacksPage').then((m) => ({ default: m.StacksPage })),
)
// StacksPage retired 2026-05-12 — duplicate of DeploymentsPage. UI says
// "Deployments" (user language); the API stays /api/v1/stacks (existing
// data model). One page, one route.
const VaultPage = lazy(() =>
import('./pages/VaultPage').then((m) => ({ default: m.VaultPage })),
)
Expand Down Expand Up @@ -191,7 +191,6 @@ export function AppRoutes() {
<Route path="resources/:id" element={<ResourceDetailPage />} />
<Route path="deployments" element={<DeploymentsPage />} />
<Route path="deployments/:id" element={<DeployDetailPage />} />
<Route path="stacks" element={<StacksPage />} />
<Route path="vault" element={<VaultPage />} />
<Route path="team" element={<TeamPage />} />
<Route path="billing" element={<BillingPage />} />
Expand Down
48 changes: 14 additions & 34 deletions src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,50 +187,29 @@ describe('fetchBilling()', () => {
expect(r.billing.status).toBe('none')
})

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

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

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

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

it('propagates non-503 errors', async () => {
Expand Down
123 changes: 55 additions & 68 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,11 @@
// Real API surface — talks to api.instanode.dev (via Vite proxy in dev,
// same-origin in prod).
//
// Mid-state, 2026-05-12: the §10.21 FIXTURE removal is in progress.
// - listStacks: cleaned (returns honest empty {items:[],total:0} on
// error). This is what /app/deployments consumes.
// - fetchTeam / updateTeam / listMembers / listInvitations / inviteMember:
// cleaned to honest derive-from-/auth/me or empty results.
// - getStack / getStackLogs / fetchActivity / fetchBilling 503 path /
// listInvoices 503 path / listVault: STILL use FIXTURE_* fallbacks.
// These are tracked under §10.21 to remove in a follow-up.
//
// The fixtures file is still imported below until that cleanup lands —
// removing each usage requires a per-page UX decision (empty state vs.
// error banner) that's easier to land in a focused PR.

import {
FIXTURE_STACKS, FIXTURE_BUILD_LOGS, FIXTURE_BILLING, FIXTURE_INVOICES,
FIXTURE_VAULT, FIXTURE_ACTIVITY,
} from './fixtures'

// fake() — tiny helper for the remaining FIXTURE-fallback paths. Returns
// the literal value as a resolved promise. Production code never calls
// it on the happy path — only inside catch blocks for surfaces that
// don't have a live API yet. Tracked for removal in §10.21.
function fake<T>(value: T): Promise<T> { return Promise.resolve(value) }
// §10.21 complete (2026-05-12): every FIXTURE_* fallback that previously
// masked backend outages is gone. Surfaces with no live endpoint return
// honest empty/null results; surfaces with a partial backend (billing 503,
// invoices 503) now propagate errors so the consuming page renders a
// real error banner instead of lying with mock data.

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

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

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

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

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

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

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

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

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

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

Expand All @@ -602,6 +582,12 @@ export async function deleteVaultSecret(env: string, key: string): Promise<void>
// provision / claim / rotate / delete / vault.put / etc.
// Falls back to synthesising from resource timestamps if the audit call fails.
export async function fetchActivity(): Promise<{ ok: true; items: ActivityItem[] }> {
// §10.21: 401 still rethrows. On any other failure we still try the
// resource-synthesis fallback (honest data, just synthesised from the
// live resource list). If that also fails we return an empty list
// instead of FIXTURE_ACTIVITY — the page renders "no activity yet"
// rather than fabricating "marcus rotated STRIPE_SECRET_KEY" rows
// that never happened.
try {
type AuditResp = {
ok: boolean
Expand All @@ -628,7 +614,8 @@ export async function fetchActivity(): Promise<{ ok: true; items: ActivityItem[]
return { ok: true, items }
} catch (e: any) {
if (e?.status === 401) throw e
// Fall back to synthesising from resources so the dashboard still renders.
// Fall back to synthesising from resources so the dashboard still
// renders something honest (real resources, real timestamps).
try {
const r = await listResources()
const items: ActivityItem[] = r.items.slice(0, 8).map((res) => ({
Expand All @@ -641,7 +628,7 @@ export async function fetchActivity(): Promise<{ ok: true; items: ActivityItem[]
} as unknown as ActivityItem))
return { ok: true, items }
} catch {
return fake({ ok: true as const, items: FIXTURE_ACTIVITY })
return { ok: true as const, items: [] }
}
}
}
Expand Down
48 changes: 23 additions & 25 deletions src/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ const m = (title: string, scope: Scope): PageMeta => ({ title, scope })
export const PAGE_META: Record<string, PageMeta> = {
'/': m('Overview', 'read'),
'/resources': m('Resources', 'read'),
'/resources/:id': m('flashcards-db', 'read'),
// '/resources/:id' is intentionally omitted from PAGE_META — the H1 is
// resolved dynamically from ctx.resources by computeMeta() below so it
// reflects the loaded resource's real name. The page itself also renders
// its own header. (§10.21.)
'/deployments': m('Deployments', 'read'),
'/deployments/:id': m('flashcards', 'read'),
'/stacks': m('Stacks', 'read'),
'/deployments/:id': m('Deployment', 'read'),
'/vault': m('Vault', 'read'),
'/team': m('Team', 'read'),
'/billing': m('Billing', 'write'),
'/settings': m('Settings', 'read'),
'/contracts': m('API contracts', 'read')
Expand All @@ -29,6 +30,18 @@ function getMeta(path: string): PageMeta {
return PAGE_META[path] ?? m('', 'read')
}

// computeMeta — resolves PageMeta with route-specific dynamic overrides.
// Today it covers '/resources/:id' so the H1 shows the resource's real
// name instead of a hardcoded label.
function computeMeta(routeKey: string, pathname: string, ctx: DashboardCtx): PageMeta {
if (routeKey === '/resources/:id') {
const id = pathname.split('/').filter(Boolean).pop() ?? ''
const found = ctx.resources?.find((r) => r.id === id)
return { title: found?.name ?? '', scope: 'read' }
}
return getMeta(routeKey)
}

// computeCrumb — derive the breadcrumb tail from the live dashboard ctx and
// the current location. Replaces the old hardcoded crumb strings so the
// chrome reflects real counts/env/tier instead of design-mock fixtures.
Expand All @@ -51,15 +64,8 @@ function computeCrumb(routeKey: string, pathname: string, ctx: DashboardCtx): st
return `${ctx.env} · ${ctx.counts.deployments} active`
case '/deployments/:id':
return 'deployments / live'
case '/stacks':
return ctx.env
case '/vault':
return `${ctx.env} · ${ctx.counts.vault} entries`
case '/team': {
const slug = ctx.me?.team?.slug ?? ctx.me?.team?.id?.slice(0, 8) ?? 'workspace'
const n = ctx.counts.team
return `${slug} · ${n} member${n !== 1 ? 's' : ''}`
}
case '/billing':
return ctx.me?.team?.tier ?? '—'
case '/settings':
Expand Down Expand Up @@ -156,7 +162,7 @@ export function AppShell() {
// whenever the user navigates or interacts).
const now = useExpiryTick(60_000)
const routeKey = routeIdToKey(location.pathname, location.pathname)
const meta = getMeta(routeKey)
const meta = computeMeta(routeKey, location.pathname, ctx)
const crumb = computeCrumb(routeKey, location.pathname, ctx)

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

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

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

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

Expand Down
Loading
Loading