Skip to content

Commit f05493e

Browse files
Merge pull request #44 from InstaNode-dev/feat/private-deploy-ui-fresh
DeployCreatePage: private deploys + IP allow-list UI (Pro+)
2 parents d762eef + 7d23c83 commit f05493e

11 files changed

Lines changed: 1375 additions & 4 deletions

src/api/index.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
fetchStackFamily,
3131
listDeployments,
3232
getDeployment,
33+
createDeploy,
34+
updateDeploymentAccess,
3335
reportExperimentConverted,
3436
validatePromotion,
3537
} from './index'
@@ -963,6 +965,173 @@ describe('getDeployment()', () => {
963965
m.mockResolvedValueOnce(jsonResponse({ error: 'boom' }, { status: 500 }))
964966
await expect(getDeployment('d1')).rejects.toMatchObject({ status: 500 })
965967
})
968+
969+
it('surfaces private + allowed_ips when the API returns them', async () => {
970+
const m = installFetch()
971+
m.mockResolvedValueOnce(jsonResponse({
972+
ok: true,
973+
item: {
974+
id: 'd1', app_id: 'd1', status: 'running', port: 8080, tier: 'pro',
975+
env: {}, environment: 'production', created_at: 'x', updated_at: 'x',
976+
private: true,
977+
allowed_ips: ['8.8.8.8', '10.0.0.0/8'],
978+
},
979+
}))
980+
const r = await getDeployment('d1')
981+
expect(r.deployment?.private).toBe(true)
982+
expect(r.deployment?.allowed_ips).toEqual(['8.8.8.8', '10.0.0.0/8'])
983+
})
984+
985+
it('defaults private=false and allowed_ips=[] when the API omits both', async () => {
986+
// Older Track A builds don't expose the privacy fields. The adapter
987+
// must NOT silently inherit `private` from a stale frontend cache —
988+
// it should normalise to false.
989+
const m = installFetch()
990+
m.mockResolvedValueOnce(jsonResponse({
991+
ok: true,
992+
item: {
993+
id: 'd1', app_id: 'd1', status: 'running', port: 8080, tier: 'pro',
994+
env: {}, environment: 'production', created_at: 'x', updated_at: 'x',
995+
},
996+
}))
997+
const r = await getDeployment('d1')
998+
expect(r.deployment?.private).toBe(false)
999+
expect(r.deployment?.allowed_ips).toEqual([])
1000+
})
1001+
})
1002+
1003+
// ─── createDeploy() — POST /deploy/new with private + allowed_ips ────────
1004+
// The dashboard's createDeploy helper is the wire-shape source of truth
1005+
// for the private-deploy fields. The agent prompt on DeploymentsPage
1006+
// renders these same keys, so a contract drift here would silently leak
1007+
// into the prompt copy. We lock the field names + path explicitly.
1008+
describe('createDeploy()', () => {
1009+
it('POSTs to /deploy/new with private + allowed_ips in the body', async () => {
1010+
const m = installFetch()
1011+
m.mockResolvedValueOnce(jsonResponse({
1012+
ok: true,
1013+
item: {
1014+
id: 'd1', app_id: 'd1', status: 'building', port: 8080, tier: 'pro',
1015+
env: { FOO: 'bar' }, environment: 'production',
1016+
created_at: 'x', updated_at: 'x',
1017+
private: true, allowed_ips: ['8.8.8.8'],
1018+
},
1019+
}))
1020+
const r = await createDeploy({
1021+
name: 'my-app',
1022+
port: 8080,
1023+
env: 'production',
1024+
env_vars: { FOO: 'bar' },
1025+
private: true,
1026+
allowed_ips: ['8.8.8.8'],
1027+
})
1028+
expect(r.ok).toBe(true)
1029+
expect(r.deployment.private).toBe(true)
1030+
expect(r.deployment.allowed_ips).toEqual(['8.8.8.8'])
1031+
1032+
const [url, init] = m.mock.calls[0]
1033+
expect(String(url)).toContain('/deploy/new')
1034+
expect(init?.method).toBe('POST')
1035+
const sent = JSON.parse(String(init!.body))
1036+
expect(sent.private).toBe(true)
1037+
expect(sent.allowed_ips).toEqual(['8.8.8.8'])
1038+
// env_vars rides under the server's legacy `env` alias; the env scope
1039+
// goes in `environment`.
1040+
expect(sent.env).toEqual({ FOO: 'bar' })
1041+
expect(sent.environment).toBe('production')
1042+
})
1043+
1044+
it('omits private + allowed_ips when caller does not pass them (public deploy)', async () => {
1045+
const m = installFetch()
1046+
m.mockResolvedValueOnce(jsonResponse({
1047+
ok: true,
1048+
item: {
1049+
id: 'd1', app_id: 'd1', status: 'building', port: 8080, tier: 'free',
1050+
env: {}, environment: 'production',
1051+
created_at: 'x', updated_at: 'x',
1052+
},
1053+
}))
1054+
await createDeploy({ name: 'my-app', port: 8080, env: 'production' })
1055+
const sent = JSON.parse(String(m.mock.calls[0][1]!.body))
1056+
expect(sent).not.toHaveProperty('private')
1057+
expect(sent).not.toHaveProperty('allowed_ips')
1058+
})
1059+
1060+
it('propagates 402 (tier gate) so the page can render an upgrade prompt', async () => {
1061+
const m = installFetch()
1062+
m.mockResolvedValueOnce(jsonResponse(
1063+
{ error: 'upgrade_required', agent_action: 'upgrade_to_pro' },
1064+
{ status: 402 },
1065+
))
1066+
await expect(
1067+
createDeploy({ private: true, allowed_ips: ['8.8.8.8'] }),
1068+
).rejects.toMatchObject({ status: 402 })
1069+
})
1070+
1071+
it('propagates 400 (validation_error) so the page can show inline IP errors', async () => {
1072+
const m = installFetch()
1073+
m.mockResolvedValueOnce(jsonResponse(
1074+
{ error: 'validation_error', message: 'allowed_ips empty when private=true' },
1075+
{ status: 400 },
1076+
))
1077+
await expect(
1078+
createDeploy({ private: true, allowed_ips: [] }),
1079+
).rejects.toMatchObject({ status: 400 })
1080+
})
1081+
})
1082+
1083+
// ─── updateDeploymentAccess() — PATCH /api/v1/deployments/:id ────────────
1084+
// Track A's PATCH endpoint is still in flight. The dashboard helper still
1085+
// issues the request — a 404 means "endpoint not yet shipped" and the
1086+
// caller (PrivacyPanel on DeployDetailPage) surfaces a friendly hint.
1087+
describe('updateDeploymentAccess()', () => {
1088+
it('PATCHes /api/v1/deployments/:id with private + allowed_ips', async () => {
1089+
const m = installFetch()
1090+
m.mockResolvedValueOnce(jsonResponse({
1091+
ok: true,
1092+
item: {
1093+
id: 'd1', app_id: 'd1', status: 'running', port: 8080, tier: 'pro',
1094+
env: {}, environment: 'production',
1095+
created_at: 'x', updated_at: 'y',
1096+
private: true, allowed_ips: ['1.1.1.1'],
1097+
},
1098+
}))
1099+
const r = await updateDeploymentAccess('d1', true, ['1.1.1.1'])
1100+
expect(r.deployment.private).toBe(true)
1101+
expect(r.deployment.allowed_ips).toEqual(['1.1.1.1'])
1102+
const [url, init] = m.mock.calls[0]
1103+
expect(String(url)).toContain('/api/v1/deployments/d1')
1104+
expect(init?.method).toBe('PATCH')
1105+
const sent = JSON.parse(String(init!.body))
1106+
expect(sent).toEqual({ private: true, allowed_ips: ['1.1.1.1'] })
1107+
})
1108+
1109+
it('URI-encodes the deployment id', async () => {
1110+
const m = installFetch()
1111+
m.mockResolvedValueOnce(jsonResponse({
1112+
ok: true,
1113+
item: { id: 'd weird', app_id: 'd', status: 'running', port: 1, tier: 'pro', env: {}, environment: 'production', created_at: 'x', updated_at: 'y' },
1114+
}))
1115+
await updateDeploymentAccess('d weird', false, [])
1116+
expect(String(m.mock.calls[0][0])).toContain('/api/v1/deployments/d%20weird')
1117+
})
1118+
1119+
it('propagates 404 so the page can surface "edits pending backend"', async () => {
1120+
const m = installFetch()
1121+
m.mockResolvedValueOnce(jsonResponse({ error: 'not_found' }, { status: 404 }))
1122+
await expect(updateDeploymentAccess('d1', true, ['8.8.8.8']))
1123+
.rejects.toMatchObject({ status: 404 })
1124+
})
1125+
1126+
it('propagates 402 (tier gate)', async () => {
1127+
const m = installFetch()
1128+
m.mockResolvedValueOnce(jsonResponse(
1129+
{ error: 'upgrade_required', agent_action: 'upgrade_to_pro' },
1130+
{ status: 402 },
1131+
))
1132+
await expect(updateDeploymentAccess('d1', true, ['8.8.8.8']))
1133+
.rejects.toMatchObject({ status: 402 })
1134+
})
9661135
})
9671136

9681137
// ─── fetchStackFamily() — Pro+ env grid loader ───────────────────────────

src/api/index.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,12 @@ type DeploymentRespItem = {
434434
build_duration_s?: number
435435
resource_id?: string
436436
name?: string
437+
// Track B (private deploys): server flags + IP allow-list. Older API
438+
// builds omit these fields entirely — the adapter treats them as
439+
// `private=false` and `allowed_ips=[]` so the dashboard never lies about
440+
// privacy state when the backend hasn't shipped yet.
441+
private?: boolean
442+
allowed_ips?: string[]
437443
}
438444

439445
type DeploymentsListResp = {
@@ -489,6 +495,11 @@ function adaptDeployment(d: DeploymentRespItem): DashboardDeployment {
489495
last_deploy_at: d.last_deploy_at ?? d.updated_at,
490496
build_duration_s: d.build_duration_s,
491497
resource_id: d.resource_id,
498+
// Private-deploy fields (Track B). Older API builds omit them; we
499+
// surface false / [] so the UI never silently inherits "private"
500+
// state from a stale payload.
501+
private: d.private ?? false,
502+
allowed_ips: d.allowed_ips ?? [],
492503
}
493504
}
494505

@@ -521,6 +532,102 @@ export async function getDeployment(
521532
}
522533
}
523534

535+
// ─── createDeploy — POST /deploy/new (Track B private-deploy fields) ─────
536+
//
537+
// The dashboard doesn't usually drive deploys itself (the read-only model
538+
// favours agent-driven mutations via PromptCard), but the createDeploy
539+
// helper exists so:
540+
// 1. The "Configure private access" panel on DeploymentsPage can build a
541+
// precise agent prompt that mirrors the request shape the helper
542+
// would send (single source of truth for the field names).
543+
// 2. Future agentic flows in the dashboard (e.g. one-click redeploy with
544+
// a privacy patch) can call this without re-implementing the contract.
545+
//
546+
// Body shape — accepted by the Track A backend:
547+
// - tarball is uploaded multipart; the dashboard doesn't have file-upload
548+
// UI yet, so this helper takes the metadata and trusts the caller to
549+
// attach a tarball via FormData if needed (omit for prompts-only flow).
550+
// - env_vars is sent as `env` (server's legacy alias) for symmetry with
551+
// the response adapter above.
552+
// - `private` (bool) + `allowed_ips` (string[]) are the Track B fields
553+
// this Track B PR introduces. Backend returns 402 with agent_action on
554+
// hobby/free/anonymous, 400 with `validation_error` on empty
555+
// allowed_ips when private=true, or invalid IPs/CIDRs.
556+
//
557+
// Errors propagate (APIError with status + code); the caller decides
558+
// whether to surface them inline or fall back to the prompt-only path.
559+
export interface CreateDeployInput {
560+
name?: string
561+
port?: number
562+
env?: string // Env scope name (production / staging / dev).
563+
env_vars?: Record<string, string>
564+
resource_id?: string
565+
/** Track B: gate the deploy by an IP allow-list. Requires Pro+. */
566+
private?: boolean
567+
/** Track B: IPv4 addresses or CIDR blocks (max 32) permitted when
568+
* `private` is true. Backend returns 400 on empty list when private=true
569+
* or on invalid IP/CIDR strings. */
570+
allowed_ips?: string[]
571+
}
572+
573+
export async function createDeploy(
574+
input: CreateDeployInput,
575+
): Promise<{ ok: true; deployment: DashboardDeployment }> {
576+
// Wire shape matches the Track A `POST /deploy/new` contract: env_vars
577+
// is sent as `env` (legacy alias) for symmetry with the list-response
578+
// adapter above. The dedicated `environment` field carries the env
579+
// scope. `private` + `allowed_ips` ride alongside as top-level fields.
580+
const body: Record<string, unknown> = {}
581+
if (input.name) body.name = input.name
582+
if (input.port !== undefined) body.port = input.port
583+
if (input.env) body.environment = input.env
584+
if (input.env_vars) body.env = input.env_vars
585+
if (input.resource_id) body.resource_id = input.resource_id
586+
if (input.private !== undefined) body.private = input.private
587+
if (input.allowed_ips !== undefined) body.allowed_ips = input.allowed_ips
588+
const r = await call<DeploymentGetResp>('/deploy/new', {
589+
method: 'POST',
590+
body: JSON.stringify(body),
591+
})
592+
if (!r.item) {
593+
throw new APIError(500, 'invalid_response', 'POST /deploy/new returned no item')
594+
}
595+
return { ok: true, deployment: adaptDeployment(r.item) }
596+
}
597+
598+
// ─── updateDeploymentAccess — PATCH /api/v1/deployments/:id (Track A) ────
599+
//
600+
// Pro+ feature: toggle a deployment's privacy state and edit its
601+
// allowed_ips after creation. The PATCH endpoint is Track A's
602+
// responsibility — until it ships, this helper still issues the request
603+
// and surfaces a 404 to the caller so the DeployDetailPage can render a
604+
// read-only "edits pending backend" hint instead of pretending the change
605+
// landed.
606+
//
607+
// Errors:
608+
// - 402 with agent_action — tier gate (hobby / free / anonymous)
609+
// - 400 with validation_error — empty allowed_ips when private=true,
610+
// invalid IPs/CIDRs, > 32 entries
611+
// - 404 — endpoint not yet shipped (Track A pending)
612+
// - 5xx — server error; bubble up so the page shows a real banner
613+
export async function updateDeploymentAccess(
614+
id: string,
615+
privateFlag: boolean,
616+
allowedIps: string[],
617+
): Promise<{ ok: true; deployment: DashboardDeployment }> {
618+
const r = await call<DeploymentGetResp>(
619+
`/api/v1/deployments/${encodeURIComponent(id)}`,
620+
{
621+
method: 'PATCH',
622+
body: JSON.stringify({ private: privateFlag, allowed_ips: allowedIps }),
623+
},
624+
)
625+
if (!r.item) {
626+
throw new APIError(500, 'invalid_response', 'PATCH /deployments/:id returned no item')
627+
}
628+
return { ok: true, deployment: adaptDeployment(r.item) }
629+
}
630+
524631
// ─── Stack family — env-sibling grid ─────────────────────────────────────
525632
// GET /api/v1/stacks/:slug/family returns root + every direct child as a
526633
// flat list (root first) so the dashboard can render "production · staging

src/api/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ export interface DashboardDeployment {
103103
build_duration_s?: number
104104
/** Optional resource binding (UUID of the primary resource). */
105105
resource_id?: string
106+
/** When true the deploy is gated by an IP allow-list; agents/browsers
107+
* outside `allowed_ips` get a 403 from the edge. Defaults to false on
108+
* older API builds (public deploy). Pro+ feature — anonymous / free
109+
* / hobby get 402 from POST /deploy/new when this is set. */
110+
private?: boolean
111+
/** IPv4 addresses or CIDR blocks (max 32) permitted to reach the
112+
* deployment when `private=true`. Empty / undefined when public. */
113+
allowed_ips?: string[]
106114
}
107115

108116
export interface DashboardTeam {

0 commit comments

Comments
 (0)