Skip to content

Commit 7d23c83

Browse files
dashboard: private deploys + IP allow-list UI (Track B / Pro+)
Track B for the private-deploy feature. Surfaces an opt-in "Private deploy" toggle on /app/deployments and renders the privacy state + Pro+ editor on /app/deployments/:id. Pro/Team/Growth get the live configurator; hobby / free / anonymous get the feature-specific UpgradePromptCard via the new `private_deploy` key in upgradeCopy.ts. - api: createDeploy() + updateDeploymentAccess(); private/allowed_ips on DashboardDeployment + adapter. private defaults to false on older API builds so the UI never silently inherits stale state. PATCH endpoint is Track A's responsibility — a 404 surfaces a friendly "edits pending backend" hint instead of a raw error. - IpAllowList: reusable tag input. Enter / comma / space commits; Backspace on empty pops; chips with × removal; loose IPv4 + IPv6 + CIDR regex with red-border feedback (server has the real validator); max 32 entries matching backend cap. - DeploymentsPage: PrivateDeployConfigurator (Pro+) builds a precise agent prompt mirroring the createDeploy() wire shape — consistent with the read-only / agent-driven dashboard pattern. - DeployDetailPage: header gains a `private` badge; PrivacyPanel section on the Overview tab shows public-hint or allowed_ips list; Pro+ edit affordance with save / cancel + tier + 404 error handling. - Tests: 45 new tests across IpAllowList (19), DeploymentsPage tier-gate + toggle behaviour (5), DeployDetailPage privacy panel (6), and api contract coverage for createDeploy + updateDeploymentAccess (10). 329/329 pass; 3 pre-existing skips. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ca7f979 commit 7d23c83

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

9581127
// ─── 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)