diff --git a/src/App.tsx b/src/App.tsx index 0404714..7bcc131 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 })), ) @@ -191,7 +191,6 @@ export function AppRoutes() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/api/index.test.ts b/src/api/index.test.ts index 9206676..2d5a244 100644 --- a/src/api/index.test.ts +++ b/src/api/index.test.ts @@ -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({}, '', '/') }) @@ -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 () => { diff --git a/src/api/index.ts b/src/api/index.ts index 17ab2e4..cb68d01 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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(value: T): Promise { 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, @@ -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) ────────────────────────────────────────────── @@ -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 @@ -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('/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('/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('/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('/api/v1/billing/invoices') + return { ok: true, invoices: r.invoices ?? [] } } export async function createCheckout( @@ -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(`/api/v1/vault/${encodeURIComponent(env)}`) const entries: VaultEntry[] = (r.keys ?? []).map((key) => ({ @@ -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: [] } } } @@ -602,6 +582,12 @@ export async function deleteVaultSecret(env: string, key: string): Promise // 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 @@ -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) => ({ @@ -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: [] } } } } diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx index 4df4825..d2e88cb 100644 --- a/src/layout/AppShell.tsx +++ b/src/layout/AppShell.tsx @@ -14,12 +14,13 @@ const m = (title: string, scope: Scope): PageMeta => ({ title, scope }) export const PAGE_META: Record = { '/': 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') @@ -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. @@ -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': @@ -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 @@ -200,25 +206,17 @@ export function AppShell() { Overview Resources 0 ? String(ctx.counts.deployments) : undefined}>Deployments - Stacks
platform
Vault - Team Billing Settings
design ref
- + {/* §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. */} + API contracts @@ -237,7 +235,7 @@ export function AppShell() {
-
A
+
A
diff --git a/src/pages/BillingPage.tsx b/src/pages/BillingPage.tsx index 795fb3b..a162a5c 100644 --- a/src/pages/BillingPage.tsx +++ b/src/pages/BillingPage.tsx @@ -234,9 +234,6 @@ export function BillingPage() { we don't expose a self-serve path on purpose. - - 6 endpoints live. GET /billing · POST /checkout · /cancel · GET /invoices · POST /update-payment · POST /change-plan. -
diff --git a/src/pages/ClaimPage.tsx b/src/pages/ClaimPage.tsx index 08d99ae..ec9224a 100644 --- a/src/pages/ClaimPage.tsx +++ b/src/pages/ClaimPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useSearchParams } from 'react-router-dom' +import { Link, useSearchParams } from 'react-router-dom' import { Brand, ResourceIcon } from '../components/Common' import { claim, createAPIKey, createCheckout, listResources, setToken } from '../api' import type { Resource, ResourceType } from '../api' @@ -63,6 +63,7 @@ export function ClaimPage() { const [stage, setStage] = useState('enter-email') const [err, setErr] = useState(null) const [preview, setPreview] = useState([]) + const [previewErr, setPreviewErr] = useState(null) const [resources, setResources] = useState([]) const [countdownMs, setCountdownMs] = useState(null) const [explainerOpen, setExplainerOpen] = useState(true) @@ -72,7 +73,15 @@ export function ClaimPage() { useEffect(() => { if (!token) return const decoded = decodeJWT(token) - if (!decoded) return + if (!decoded) { + // §10.21: previously this branch returned silently, leaving the page + // with an empty preview and the email form — looking like a normal + // claim flow. A malformed/expired token now surfaces a real banner + // so the user knows to ask their agent for a fresh link. + setPreviewErr('invalid_or_expired') + return + } + setPreviewErr(null) const types = (decoded.rt as ResourceType[]) ?? [] const tokens = decoded.tok ?? [] setPreview( @@ -166,6 +175,41 @@ export function ClaimPage() { ) } + // ── Invalid / expired claim token ──────────────────────────────────── + // Previously this state rendered a blank email form. Now we surface a + // real error banner with a clear next step. (§10.21.) + if (previewErr) { + return ( +
+
+
+

Invalid or expired claim link.

+

+ This claim link is invalid or expired. Provision a fresh resource to get a new one. +

+
+ Tokens are single-use and expire after 24 hours. Ask your agent to call + POST /db/new (or any /new endpoint) to mint a fresh link. +
+ + See plans → + +
+
+ ) + } + // ── Post-claim payment funnel ──────────────────────────────────────── // We're on this screen because POST /claim succeeded and a session was // minted. The resources we just claimed still carry their 24h TTL — only diff --git a/src/pages/DeployDetailPage.tsx b/src/pages/DeployDetailPage.tsx index 3f86365..0586085 100644 --- a/src/pages/DeployDetailPage.tsx +++ b/src/pages/DeployDetailPage.tsx @@ -97,9 +97,6 @@ export function DeployDetailPage() { Logs and status stream live, but mutations go through the agent. Common prompts: redeploy · rollback · stop · update env-vars · scale replicas. - - Redeploy / Rollback / Stop are partially wired. POST /api/v1/stacks/:slug/redeploy routes to the agent API. Rollback and Stop don't exist on the agent API yet. -
{TABS.map((t) => ( @@ -115,13 +112,7 @@ export function DeployDetailPage() { {tab === 'Logs' && } {tab === 'Env vars' && } {tab === 'Resources' && } - {(tab === 'Metrics' || tab === 'Audit') && ( - - {tab} tab unbuilt. See Contracts page for proposed shape. - - )} - - {canUseCustomDomains + {canUseCustomDomains ? : } diff --git a/src/pages/DeploymentsPage.tsx b/src/pages/DeploymentsPage.tsx index c657165..86c83bc 100644 --- a/src/pages/DeploymentsPage.tsx +++ b/src/pages/DeploymentsPage.tsx @@ -19,15 +19,7 @@ export function DeploymentsPage() { return ( <> - - "Deployments" in the brief = "Stacks" in the code. The agent API exposes GET /api/v1/stacks{' '} - (returns DashboardStack). UI keeps /deployments (user language); API stays /stacks (existing). - - - GET /api/v1/stacks · returns {`{"ok": true, "items": DashboardStack[], "total": number}`}. - Status enum: building | running | failed | stopped. -
diff --git a/src/pages/ResourceDetailPage.tsx b/src/pages/ResourceDetailPage.tsx index 61e4576..d73dd51 100644 --- a/src/pages/ResourceDetailPage.tsx +++ b/src/pages/ResourceDetailPage.tsx @@ -66,10 +66,6 @@ export function ResourceDetailPage() { The dashboard mirrors the resource — it never writes. To rotate, rename, or delete this Postgres, prompt your agent. The agent calls the same locked API endpoints listed below. - - GET /api/v1/resources/:id · returns the full Resource shape including connection_url. - The connection URL is single-use safe — frontend keeps it in memory only, masks by default, reveals on click. - {/* Time-remaining card — only when this resource has a TTL (claimed, not yet on an active subscription). Loud, near the top, with a @@ -248,10 +244,6 @@ export function ResourceDetailPage() { {/* METRICS — blocked */} {tab === 'Metrics' && ( <> - - Metrics tab is unbuilt. Backend has no GET /api/v1/resources/:id/metrics endpoint. - Brief §5.5 requires storage / connections / query-rate over 24h / 7d / 30d. Frontend is blocked until contract locks. See Contracts. - no data source}>
awaiting backend @@ -261,13 +253,7 @@ export function ResourceDetailPage() { )} {/* AUDIT — blocked */} - {tab === 'Audit' && ( - - Audit tab is unbuilt. No GET /api/v1/resources/:id/audit endpoint exists. Lock proposal in{' '} - Contracts. - - )} - + ) } diff --git a/src/pages/ResourcesPage.tsx b/src/pages/ResourcesPage.tsx index c86aac6..631971c 100644 --- a/src/pages/ResourcesPage.tsx +++ b/src/pages/ResourcesPage.tsx @@ -51,11 +51,6 @@ export function ResourcesPage() { return ( <> - - GET /api/v1/resources · returns {`{"ok": true, "items": Resource[], "total": number}`}. Backed by{' '} - resourcesH.List() which fans out to gRPC agent.ListResources(team_id). connection_url{' '} - is intentionally omitted from list responses — only GET /:id and /rotate include it. -
{TYPES.map((t) => ( diff --git a/src/pages/StacksPage.tsx b/src/pages/StacksPage.tsx deleted file mode 100644 index 94c6ccf..0000000 --- a/src/pages/StacksPage.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { ContractBanner, EnvPill, StatusPill, ResourceIcon, RelTime } from '../components/Common' - -export function StacksPage() { - return ( - <> - - The brief separates "Deployments" (single service) from "Stacks" (multi-service). The backend's{' '} - DashboardStack handles both — there's no "service" concept in the proto yet. - - -
-
-
-

acme-platform

- - -
-
-
- serviceimagestatus -
- {['api-gateway', 'worker', 'scheduler'].map((svc) => ( -
- - {svc} - - v1.4.0 - -
- ))} -
-
- - last deploy · build 52s - -
-
- -
-
+
-

One stack, many services

-

- Stacks group services that share an env, vault, and lifecycle. Deploy them as a unit with one compose.yml. -

- ask your agent · "create a new stack" -
-
- - ) -} diff --git a/src/pages/TeamPage.tsx b/src/pages/TeamPage.tsx index b050174..f56bd81 100644 --- a/src/pages/TeamPage.tsx +++ b/src/pages/TeamPage.tsx @@ -39,15 +39,7 @@ export function TeamPage() { return ( <> - - 7 endpoints live. GET /api/v1/team/members · POST .../invite ·{' '} - DELETE .../:user_id · POST .../leave · GET /invitations ·{' '} - DELETE /invitations/:id · POST /invitations/:id/accept. - - - PATCH /api/v1/team/members/:user_id for role changes is missing. Brief §5.8 requires Owner / Admin / Developer / Viewer with promotion/demotion. -