Skip to content

Commit 67df0f0

Browse files
feat(dashboard): W9-B2 stack-create form (#59)
- New StackCreatePage (/app/stacks/new) wizard for human-driven stack creation. POST /stacks/new is multipart-only (agents can't tar source either), so this is the one place the dashboard drives a write itself. - createStack(file, opts) + fetchStackStatus(slug) client helpers. - '+ Create stack' link on DeploymentsPage. - Comprehensive unit tests for createStack tier-wall / size limit / multipart handling.
1 parent d721d14 commit 67df0f0

6 files changed

Lines changed: 1450 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ const DeployDetailPage = lazy(() =>
9191
// StacksPage retired 2026-05-12 — duplicate of DeploymentsPage. UI says
9292
// "Deployments" (user language); the API stays /api/v1/stacks (existing
9393
// data model). One page, one route.
94+
//
95+
// StackCreatePage (W9) — human-driven /stacks/new wizard. Lives behind
96+
// /app/stacks/new because POST /stacks/new is a multipart-only endpoint
97+
// that's hostile to curl-shy customers. This is the one place the
98+
// dashboard intentionally drives a write itself (vs. the agent-driven
99+
// PromptCard pattern) because agents can't tar up source either.
100+
const StackCreatePage = lazy(() =>
101+
import('./pages/StackCreatePage').then((m) => ({ default: m.StackCreatePage })),
102+
)
94103
const VaultPage = lazy(() =>
95104
import('./pages/VaultPage').then((m) => ({ default: m.VaultPage })),
96105
)
@@ -222,6 +231,12 @@ export function AppRoutes() {
222231
<Route path="resources/:id" element={<ResourceDetailPage />} />
223232
<Route path="deployments" element={<DeploymentsPage />} />
224233
<Route path="deployments/:id" element={<DeployDetailPage />} />
234+
{/* W9: /app/stacks/new is the human-driven "Create stack"
235+
wizard. We deliberately keep the path under /stacks/ (not
236+
/deployments/) because the underlying api endpoint is
237+
POST /stacks/new — preserving the user→api vocabulary
238+
parity helps when someone hits the "open in curl" path. */}
239+
<Route path="stacks/new" element={<StackCreatePage />} />
225240
<Route path="vault" element={<VaultPage />} />
226241
<Route path="team" element={<TeamPage />} />
227242
<Route path="billing" element={<BillingPage />} />

src/api/index.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
updateDeploymentAccess,
3535
reportExperimentConverted,
3636
validatePromotion,
37+
createStack,
38+
fetchStackStatus,
3739
} from './index'
3840
// §10.21: FIXTURE_BILLING / FIXTURE_INVOICES imports retired. The 503
3941
// fallback paths in fetchBilling() and listInvoices() were removed —
@@ -1390,3 +1392,166 @@ describe('Admin URL prefix wiring', () => {
13901392
expect(url).not.toContain('/api/v1/admin/customers')
13911393
})
13921394
})
1395+
1396+
// ─── createStack() — POST /stacks/new multipart upload ───────────────────
1397+
// W9: the human-driven "Create stack" form. The api endpoint is multipart-
1398+
// only; this helper builds the FormData body. We lock the field names
1399+
// because the api parser is strict about them.
1400+
describe('createStack()', () => {
1401+
function fakeFile(name: string, size: number): File {
1402+
const f = new File([new ArrayBuffer(size)], name, { type: 'application/gzip' })
1403+
return f
1404+
}
1405+
1406+
it('POSTs to /stacks/new with a multipart body containing the tarball', async () => {
1407+
const m = installFetch()
1408+
m.mockResolvedValueOnce(jsonResponse({
1409+
ok: true, slug: 'sunny-cat-9', status: 'building', url: null, name: 'sunny-cat-9', env: 'development',
1410+
}))
1411+
const f = fakeFile('app.tar.gz', 2048)
1412+
const r = await createStack(f, {
1413+
name: 'my-app',
1414+
port: 3000,
1415+
env: 'staging',
1416+
env_vars: { API_KEY: 'secret' },
1417+
})
1418+
expect(r.ok).toBe(true)
1419+
expect(r.stack.slug).toBe('sunny-cat-9')
1420+
expect(r.stack.status).toBe('building')
1421+
1422+
const [url, init] = m.mock.calls[0]
1423+
expect(String(url)).toContain('/stacks/new')
1424+
expect(init?.method).toBe('POST')
1425+
// Body is a FormData instance; assert the fields we appended.
1426+
const body = init!.body as FormData
1427+
expect(body).toBeInstanceOf(FormData)
1428+
expect(body.get('name')).toBe('my-app')
1429+
expect(body.get('port')).toBe('3000')
1430+
expect(body.get('env')).toBe('staging')
1431+
expect(body.get('env_vars')).toBe(JSON.stringify({ API_KEY: 'secret' }))
1432+
expect(body.get('tarball')).toBe(f)
1433+
// CRITICAL — Content-Type must NOT be set on the request headers; the
1434+
// browser generates a multipart boundary automatically. Set it
1435+
// ourselves and the api parser bails.
1436+
const headers = init?.headers as Headers
1437+
expect(headers.has('Content-Type')).toBe(false)
1438+
})
1439+
1440+
it('defaults env to "development" when caller omits it (matches platform memory 2026-05-13)', async () => {
1441+
const m = installFetch()
1442+
m.mockResolvedValueOnce(jsonResponse({
1443+
ok: true, slug: 's', status: 'building', url: null,
1444+
}))
1445+
await createStack(fakeFile('a.tar.gz', 100), {})
1446+
const body = m.mock.calls[0][1]!.body as FormData
1447+
expect(body.get('env')).toBe('development')
1448+
})
1449+
1450+
it('omits name + port + env_vars from the body when not provided', async () => {
1451+
const m = installFetch()
1452+
m.mockResolvedValueOnce(jsonResponse({
1453+
ok: true, slug: 's', status: 'building', url: null,
1454+
}))
1455+
await createStack(fakeFile('a.tar.gz', 100), {})
1456+
const body = m.mock.calls[0][1]!.body as FormData
1457+
expect(body.has('name')).toBe(false)
1458+
expect(body.has('port')).toBe(false)
1459+
expect(body.has('env_vars')).toBe(false)
1460+
// env always lands (defaults to 'development').
1461+
expect(body.has('env')).toBe(true)
1462+
})
1463+
1464+
it('sends an Authorization: Bearer header when a token is present', async () => {
1465+
const m = installFetch()
1466+
setToken('test-token-xyz')
1467+
m.mockResolvedValueOnce(jsonResponse({ ok: true, slug: 's', status: 'building', url: null }))
1468+
await createStack(fakeFile('a.tar.gz', 100), {})
1469+
const headers = m.mock.calls[0][1]?.headers as Headers
1470+
expect(headers.get('Authorization')).toBe('Bearer test-token-xyz')
1471+
})
1472+
1473+
it('propagates 402 (tier wall) so the page can show the upgrade banner', async () => {
1474+
const m = installFetch()
1475+
m.mockResolvedValueOnce(jsonResponse(
1476+
{ error: 'tier_limit', message: 'upgrade to pro for more stacks', agent_action: 'upgrade_to_pro' },
1477+
{ status: 402 },
1478+
))
1479+
await expect(createStack(fakeFile('a.tar.gz', 100), {}))
1480+
.rejects.toMatchObject({ status: 402 })
1481+
})
1482+
1483+
it('propagates 400 (invalid_tarball) so the form can render the message inline', async () => {
1484+
const m = installFetch()
1485+
m.mockResolvedValueOnce(jsonResponse(
1486+
{ error: 'invalid_tarball', message: 'missing Dockerfile' },
1487+
{ status: 400 },
1488+
))
1489+
await expect(createStack(fakeFile('a.tar.gz', 100), {}))
1490+
.rejects.toMatchObject({ status: 400, message: 'missing Dockerfile' })
1491+
})
1492+
1493+
it('propagates 413 (payload too large)', async () => {
1494+
const m = installFetch()
1495+
m.mockResolvedValueOnce(jsonResponse({ error: 'too_large' }, { status: 413 }))
1496+
await expect(createStack(fakeFile('a.tar.gz', 100), {}))
1497+
.rejects.toMatchObject({ status: 413 })
1498+
})
1499+
1500+
it('returns slug + status from the 202 response shape', async () => {
1501+
const m = installFetch()
1502+
m.mockResolvedValueOnce(jsonResponse({
1503+
ok: true,
1504+
slug: 'rainy-tree-3',
1505+
status: 'building',
1506+
url: null,
1507+
name: 'rainy-tree-3',
1508+
env: 'production',
1509+
}, { status: 202 }))
1510+
const r = await createStack(fakeFile('a.tar.gz', 100), {})
1511+
expect(r.stack.slug).toBe('rainy-tree-3')
1512+
expect(r.stack.status).toBe('building')
1513+
expect(r.stack.url).toBeNull()
1514+
})
1515+
})
1516+
1517+
// ─── fetchStackStatus() — GET /api/v1/stacks/:slug polling helper ───────
1518+
describe('fetchStackStatus()', () => {
1519+
it('GETs /api/v1/stacks/:slug and adapts the response shape', async () => {
1520+
const m = installFetch()
1521+
m.mockResolvedValueOnce(jsonResponse({
1522+
ok: true,
1523+
stack: {
1524+
stack_id: 's1', slug: 's1', name: 'my-stack', status: 'running',
1525+
url: 'https://s1.deployment.instanode.dev', env: 'production', tier: 'pro',
1526+
created_at: '2026-05-13T00:00:00Z',
1527+
},
1528+
}))
1529+
const r = await fetchStackStatus('s1')
1530+
expect(r.ok).toBe(true)
1531+
expect(r.stack?.slug).toBe('s1')
1532+
expect(r.stack?.status).toBe('running')
1533+
expect(r.stack?.url).toBe('https://s1.deployment.instanode.dev')
1534+
expect(String(m.mock.calls[0][0])).toContain('/api/v1/stacks/s1')
1535+
})
1536+
1537+
it('returns stack=null on 404 instead of throwing', async () => {
1538+
const m = installFetch()
1539+
m.mockResolvedValueOnce(jsonResponse({ error: 'not_found' }, { status: 404 }))
1540+
const r = await fetchStackStatus('missing')
1541+
expect(r.ok).toBe(true)
1542+
expect(r.stack).toBeNull()
1543+
})
1544+
1545+
it('URI-encodes the slug', async () => {
1546+
const m = installFetch()
1547+
m.mockResolvedValueOnce(jsonResponse({ ok: true, stack: { slug: 'a b', status: 'building' } }))
1548+
await fetchStackStatus('a b')
1549+
expect(String(m.mock.calls[0][0])).toContain('/api/v1/stacks/a%20b')
1550+
})
1551+
1552+
it('propagates 5xx (not 404) so the polling caller can decide to retry', async () => {
1553+
const m = installFetch()
1554+
m.mockResolvedValueOnce(jsonResponse({ error: 'internal' }, { status: 500 }))
1555+
await expect(fetchStackStatus('s1')).rejects.toMatchObject({ status: 500 })
1556+
})
1557+
})

src/api/index.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
// real error banner instead of lying with mock data.
99

1010
import type {
11-
Resource, DashboardStack, DashboardDeployment, DeploymentStatus,
11+
Resource, DashboardStack, StackStatus,
12+
DashboardDeployment, DeploymentStatus,
1213
DashboardTeam, BillingDetails, Invoice,
1314
TeamMember, TeamInvitation, AuthMeResponse, VaultEntry, ActivityItem,
1415
AdminCustomerListResponse, AdminCustomerDetailResponse,
@@ -840,6 +841,174 @@ export async function getStack(slug: string): Promise<{ ok: true; stack: Dashboa
840841
}
841842
}
842843

844+
// ─── createStack — POST /stacks/new (multipart tarball upload) ───────────
845+
//
846+
// W9: the StackCreatePage at /app/stacks/new lets a human upload a
847+
// .tar.gz (Dockerfile + source) and ship it without touching curl. The
848+
// agent API endpoint is multipart-only — fields ride alongside the
849+
// `tarball` file part.
850+
//
851+
// Tarball size limit: 50 MB (per platform CLAUDE.md). Validated on the
852+
// caller side too so the user sees an inline error before the upload
853+
// starts; the api enforces the same limit at the edge.
854+
//
855+
// Response shape (synchronous: 202 Accepted on success):
856+
// { ok: true, slug, status: "building", url?, name?, env? }
857+
// The dashboard polls GET /api/v1/stacks/:slug afterwards (via
858+
// fetchStackStatus) until status flips to running / healthy / failed.
859+
//
860+
// Tier-wall: anonymous gets 0 stacks, hobby gets 1, pro+ gets more. The
861+
// api returns 402 with agent_action; the caller surfaces an upgrade prompt.
862+
export interface CreateStackInput {
863+
/** Optional human-readable name (max 32, lowercase + hyphens). Empty →
864+
* server auto-generates a slug like `tender-sky-9421`. */
865+
name?: string
866+
/** Container HTTP port. Default 8080. */
867+
port?: number
868+
/** Env scope (production / staging / development). Default 'development'
869+
* per the 2026-05-13 platform memory (default env flipped). */
870+
env?: string
871+
/** Map of env vars handed to the container. Keys must be uppercase +
872+
* underscore + alphanumeric — validated by the form. */
873+
env_vars?: Record<string, string>
874+
}
875+
876+
export interface CreateStackResponse {
877+
/** Stack slug — used in /api/v1/stacks/:slug polling and the final URL. */
878+
slug: string
879+
/** Current build status. 'building' on synchronous 202; the caller polls
880+
* until this flips to 'running' / 'failed'. */
881+
status: StackStatus
882+
/** Final live URL once status is 'running'. May be null while building. */
883+
url: string | null
884+
/** Echoed name (server-generated if input.name was empty). */
885+
name?: string
886+
/** Echoed env scope. */
887+
env?: string
888+
}
889+
890+
/**
891+
* Upload a tarball + metadata to POST /stacks/new. The body is multipart;
892+
* env_vars (a JS object) is serialized to JSON for the matching form field.
893+
*
894+
* Authentication: pulls the bearer token from localStorage (same as call()).
895+
* 401 propagates so AuthGate redirects — we don't replicate the redirect
896+
* logic here because the route is auth-gated already and the caller's page
897+
* mount has already passed the gate.
898+
*
899+
* Errors:
900+
* - 400 with { error: 'invalid_tarball', message }: surface inline
901+
* - 402 with { error: 'tier_limit', message, agent_action }: tier wall
902+
* - 413: tarball too large (the api enforces ≤ 50 MB)
903+
* - any other 4xx/5xx: propagate as APIError so the page renders a banner
904+
*/
905+
export async function createStack(
906+
file: File,
907+
opts: CreateStackInput = {},
908+
): Promise<{ ok: true; stack: CreateStackResponse }> {
909+
const fd = new FormData()
910+
fd.append('tarball', file)
911+
if (opts.name) fd.append('name', opts.name)
912+
if (opts.port !== undefined) fd.append('port', String(opts.port))
913+
// Default env: 'development' per the 2026-05-13 platform memory. Caller
914+
// can override to production / staging / custom.
915+
fd.append('env', opts.env ?? 'development')
916+
if (opts.env_vars && Object.keys(opts.env_vars).length > 0) {
917+
fd.append('env_vars', JSON.stringify(opts.env_vars))
918+
}
919+
920+
const headers = new Headers()
921+
const tok = getToken()
922+
if (tok) headers.set('Authorization', `Bearer ${tok}`)
923+
// CRITICAL: do NOT set Content-Type for FormData — the browser must
924+
// generate its own boundary. Setting it here would break the upload at
925+
// the multipart parser.
926+
927+
const base = getAPIBaseURL()
928+
const url = new URL(
929+
'/stacks/new',
930+
base || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost'),
931+
)
932+
const res = await fetch(url.toString(), { method: 'POST', headers, body: fd })
933+
const ct = res.headers.get('content-type') ?? ''
934+
const body: any = ct.includes('application/json')
935+
? await res.json().catch(() => null)
936+
: await res.text()
937+
938+
if (!res.ok) {
939+
const code = (body && body.error) || `http_${res.status}`
940+
const msg = (body && (body.message || body.error_description)) || res.statusText
941+
throw new APIError(res.status, code, msg)
942+
}
943+
944+
// Synchronous 202 / 200 — the server hands back the slug + initial state.
945+
// Fields tolerated as optional because the api may add/drop them across
946+
// versions; the page polls /api/v1/stacks/:slug for the canonical state.
947+
const stack: CreateStackResponse = {
948+
slug: body?.slug ?? body?.stack_id ?? '',
949+
status: (body?.status as StackStatus) ?? 'building',
950+
url: body?.url ?? null,
951+
name: body?.name,
952+
env: body?.env ?? body?.environment,
953+
}
954+
return { ok: true as const, stack }
955+
}
956+
957+
/**
958+
* Poll a single stack's current state. Returns null when the slug is not
959+
* found (server returns 404 if the stack was deleted mid-poll, or if the
960+
* caller doesn't own it). Other errors propagate so the page can show a
961+
* real banner instead of pretending the build is still in flight.
962+
*/
963+
export async function fetchStackStatus(
964+
slug: string,
965+
): Promise<{ ok: true; stack: DashboardStack | null }> {
966+
try {
967+
type StackGetResp = {
968+
ok: boolean
969+
stack?: {
970+
stack_id?: string
971+
slug?: string
972+
name?: string
973+
status?: string
974+
tier?: string
975+
url?: string | null
976+
env?: string
977+
created_at?: string
978+
}
979+
// Some api builds return the row at the top level under `item`.
980+
item?: {
981+
stack_id?: string
982+
slug?: string
983+
name?: string
984+
status?: string
985+
tier?: string
986+
url?: string | null
987+
env?: string
988+
created_at?: string
989+
}
990+
}
991+
const r = await call<StackGetResp>(`/api/v1/stacks/${encodeURIComponent(slug)}`)
992+
const s = r.stack ?? r.item
993+
if (!s) return { ok: true as const, stack: null }
994+
const stack: DashboardStack = {
995+
id: s.stack_id ?? s.slug ?? slug,
996+
slug: s.slug ?? s.stack_id ?? slug,
997+
name: s.name ?? '',
998+
status: (s.status as DashboardStack['status']) ?? 'building',
999+
url: s.url ?? null,
1000+
created_at: s.created_at ?? '',
1001+
team_id: '',
1002+
env: (s.env as DashboardStack['env']) ?? 'production',
1003+
tier: (s.tier as DashboardStack['tier']) ?? 'free',
1004+
}
1005+
return { ok: true as const, stack }
1006+
} catch (e: any) {
1007+
if (e?.status === 404) return { ok: true as const, stack: null }
1008+
throw e
1009+
}
1010+
}
1011+
8431012
// §10.21: no live GET /api/v1/stacks/:slug/build-logs yet. Return an
8441013
// honest empty buffer instead of canned build logs that don't match the
8451014
// user's actual deploy. Real-time logs stream via streamSSE on

0 commit comments

Comments
 (0)