@fabrika/builder 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/BUILD-CONTRACT.md +8 -0
  2. package/README.md +9 -0
  3. package/cli/src/bin.ts +35 -2
  4. package/cli/src/build-status.ts +7 -15
  5. package/cli/src/commands/advance.ts +3 -2
  6. package/cli/src/commands/ensure-daemon.ts +3 -3
  7. package/cli/src/commands/gate.ts +4 -10
  8. package/cli/src/commands/init.ts +44 -7
  9. package/cli/src/commands/next.ts +3 -3
  10. package/cli/src/commands/platform.ts +81 -0
  11. package/cli/src/config.ts +1 -9
  12. package/cli/src/context.ts +4 -1
  13. package/cli/src/daemon-client.ts +36 -185
  14. package/cli/src/daemon-lifecycle.ts +63 -142
  15. package/cli/src/derive.ts +4 -8
  16. package/cli/src/errors.ts +1 -0
  17. package/cli/src/index.ts +2 -3
  18. package/cli/src/inject-skills.ts +8 -31
  19. package/cli/src/layout.ts +8 -18
  20. package/cli/src/packet.ts +18 -17
  21. package/cli/src/platform-client.ts +40 -0
  22. package/daemon/src/admission.ts +19 -0
  23. package/daemon/src/bin.ts +7 -9
  24. package/daemon/src/handlers.ts +82 -79
  25. package/daemon/src/index.ts +14 -5
  26. package/daemon/src/paths.ts +5 -6
  27. package/daemon/src/phases.ts +27 -126
  28. package/daemon/src/platform-compiler.ts +21 -0
  29. package/daemon/src/platform-remote.ts +146 -0
  30. package/daemon/src/platform-routes.ts +46 -0
  31. package/daemon/src/platform-source.ts +147 -0
  32. package/daemon/src/platform-storage.ts +222 -0
  33. package/daemon/src/platform.ts +481 -0
  34. package/decisions/README.md +4 -0
  35. package/decisions/patterns/zerops-control-deploy.md +49 -0
  36. package/decisions/patterns/zerops-local-app.md +57 -0
  37. package/decisions/patterns/zerops-postgres-seeds.md +37 -0
  38. package/decisions/technologies/zerops-fabrika-auth.md +53 -0
  39. package/decisions/technologies/zerops-platform.md +64 -0
  40. package/package.json +13 -5
  41. package/shared/index.ts +365 -0
  42. package/{cli/src → shared}/phases.ts +42 -50
  43. package/shared/urls.ts +41 -0
  44. package/skill/SKILL.md +34 -0
  45. package/stacks/README.md +2 -0
  46. package/stacks/zerops-app.md +66 -0
  47. package/ui/dist/assets/client.2fkhreps.js +214 -0
  48. package/ui/dist/assets/client.css +1 -1
  49. package/ui/dist/assets/components.js +57 -57
  50. package/ui/dist/index.html +1 -1
  51. package/ui/dist/assets/client.fhjppkvr.js +0 -214
@@ -0,0 +1,53 @@
1
+ ---
2
+ id: zerops-fabrika-auth
3
+ kind: technology
4
+ title: Fabrika IAM with proxy-only enforcement on Zerops
5
+ slot: authz
6
+ provides: [authentication, authorization, proxy-enforcement]
7
+ requires: [zerops-platform]
8
+ recommends: []
9
+ conflicts: [propustka, cloudflare-access, auth-password-kv]
10
+ npm: ["@fabrika/auth@^0.0.4", "@fabrika/auth-core@^0.0.4"]
11
+ status: wip
12
+ sources:
13
+ - where: "packages/auth/CLAUDE.md + src/index.ts + src/iam.ts in fabrika-platform"
14
+ read: "Verify-only SDK, real IAM requirements, negative versus unavailable results."
15
+ - where: "examples/zerops-app/fabrika.gates.ts + fabrika.schema.ts + src/authz.ts in fabrika-platform"
16
+ read: "Proxy gate declarations, app vocabulary and the example's direct JWKS verification."
17
+ - where: "docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md + 0023-one-session-per-host.md in fabrika-platform"
18
+ read: "Single gate evaluator, per-host session handoff and application-owned object authorization."
19
+ ---
20
+
21
+ # Fabrika IAM with proxy-only enforcement
22
+
23
+ Declare the application's `AppSchema` vocabulary and ordered `AppGates` in source.
24
+ Attach the gates to `target.proxy` in `fabrika.config.ts`. Control reconciles the
25
+ schema and registers deployment return origins; the application does not assert
26
+ its own trusted return origins through its schema.
27
+
28
+ The proxy alone evaluates gates, handles login and the reserved
29
+ `/__fabrika/auth/callback` route, and injects `X-Fabrika-Token`. App services remain
30
+ private. Never implement another gate evaluator in the app, frontend or Caddy
31
+ route configuration.
32
+
33
+ The application verifies the injected token's signature, issuer, audience and
34
+ expiry against IAM's JWKS. Prefer the `@fabrika/auth` SDK's `createIam` and
35
+ `authenticate` contract where its IAM binding is available; read the installed
36
+ types when composing the HTTP transport. The public notes example demonstrates
37
+ direct JWKS verification with `@fabrika/auth-core` claim parsing. Neither path
38
+ trusts an unverified header or gives the app a session-to-token exchange.
39
+
40
+ Keep per-object authorization in feature handlers using the verified context and
41
+ validated object scope (`can`, `requirePermission`, or the example's `Caller`).
42
+ Public-gated routes use an anonymous context with no permissions. Preserve SDK
43
+ failure semantics: a decided invalid credential differs from IAM unavailability,
44
+ which refuses with 503 rather than pretending the credential was checked.
45
+
46
+ ## Local authentication is the same model
47
+
48
+ Run real IAM and the proxy locally. IAM's own `LOCAL_DEV_LOGIN` mechanism can
49
+ create a real local session; it is not an application dev flag. Restricted-role
50
+ tests need real IAM principals and grants, not the bootstrap admin for every
51
+ scenario. Never synthesize a caller, mint tokens in the app, add `FakeIamClient`,
52
+ or disable gates to make local previews work. Credentials and raw request headers
53
+ must not enter logs or committed fixtures.
@@ -0,0 +1,64 @@
1
+ ---
2
+ id: zerops-platform
3
+ kind: technology
4
+ title: Zerops through the Fabrika provider
5
+ slot: platform
6
+ provides: [platform, private-services, managed-deploy]
7
+ requires: [bun, postgres]
8
+ recommends: []
9
+ conflicts: [cloudflare]
10
+ npm: ["@fabrika/provider-zerops@^0.0.4", "@fabrika/app@^0.0.4"]
11
+ status: wip
12
+ sources:
13
+ - where: "examples/zerops-app/fabrika.config.ts + zerops.yaml in fabrika-platform"
14
+ read: "Current defineApp shape, private runtime, database references and repository-root build descriptor."
15
+ - where: "packages/provider-zerops/src/types.ts + src/index.ts in fabrika-platform"
16
+ read: "Provider-owned authoring contract and exports; verify installed versions before use."
17
+ - where: "docs/reference/zerops-platform.md in fabrika-platform"
18
+ read: "Live-verified routing, PostgreSQL TLS, sizing and probe constraints."
19
+ ---
20
+
21
+ # Zerops through the Fabrika provider
22
+
23
+ Use this for applications deployed by an existing Fabrika installation. Import
24
+ `defineApp`, `ZeropsResourceContext` and `ZeropsServiceSpec` from
25
+ `@fabrika/provider-zerops`. Keep `fabrika.config.ts` as the app's deploy source of
26
+ truth: stable `id`, `schema`, `pipeline` and `target` with `platform: 'zerops'`.
27
+
28
+ ## Application contract
29
+
30
+ - Declare services through `target.services(ctx)`. Select the runtime explicitly
31
+ with `target.deployService` when declaring multiple services. Set
32
+ `target.zeropsSetup` to the matching setup in repository-root `zerops.yaml`.
33
+ - Declare `target.proxy: { upstream, gates }` with the private runtime address.
34
+ Set runtime `enableSubdomainAccess: false`; only the namespace proxy has public
35
+ routing. A custom domain belongs on the proxy, never the app service.
36
+ - Leave project placement to Control's environment/namespace registry. Keep
37
+ provider tokens and concrete project coordinates out of application source.
38
+ - Use the public example's Bun adapter (`@fabrika/app/bun`) for the long-running
39
+ Fetch application. Serve built SPA assets from the private application; preserve
40
+ API errors instead of falling through to `index.html`.
41
+ - Commit `zerops.yaml` and the Bun lockfile at the app repository root. Zerops
42
+ performs its build; the descriptor declares build/deploy files, start command,
43
+ ports and probes. Probe durations are Go duration strings bounded to 10s–1h.
44
+ - Run repeatable, concurrency-safe PostgreSQL migrations in `run.initCommands`,
45
+ which runs on every container start. A readiness check gates deployment;
46
+ `run.healthCheck` is the separate runtime liveness mechanism.
47
+
48
+ ## Data and configuration
49
+
50
+ The default public config declares an app-owned PostgreSQL service. A cheap
51
+ namespace can instead supply the typed shared PostgreSQL binding: consult
52
+ `examples/zerops-app/fabrika.cheap.config.ts` and its matching descriptor before
53
+ selecting that variant. Preserve the administrator's namespace assignment.
54
+
55
+ Use the provider's PostgreSQL TLS reference including the database name and
56
+ `sslmode=require`; do not infer a connection string from a hostname. Service names
57
+ must satisfy `^[a-z0-9]{1,25}$` and references may address only owned services and
58
+ declared namespace resources.
59
+
60
+ The provider owns isolation fields. Secret values live at service level in Zerops,
61
+ not in config, YAML or project-level environment variables. `pipeline.secrets`
62
+ names requirements; it does not add a deploy-time secret synchronization step.
63
+ Control supplies reserved managed variables such as `FABRIKA_IAM_ISSUER`,
64
+ `FABRIKA_OPERATIONS_DSN` and `FABRIKA_RELEASE`; do not redeclare them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/builder",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Local-first guided application design and agent-assisted implementation.",
6
6
  "license": "MIT",
@@ -16,6 +16,9 @@
16
16
  "index.ts",
17
17
  "cli/src",
18
18
  "daemon/src",
19
+ "shared/index.ts",
20
+ "shared/phases.ts",
21
+ "shared/urls.ts",
19
22
  "ui/dist",
20
23
  "skill",
21
24
  "decisions",
@@ -40,12 +43,17 @@
40
43
  "build": "bun ui/build.ts",
41
44
  "release:prepare": "bun run build",
42
45
  "typecheck": "tsc --noEmit",
43
- "test": "bun test --preload ./daemon/test/preload.ts cli/test daemon/test ui/test"
46
+ "test": "bun test --isolate --preload ./daemon/test/preload.ts cli/test daemon/test ui/test shared"
44
47
  },
45
48
  "dependencies": {
46
- "@fabrika/annotations": "^0.2.0",
47
- "@fabrika/canvas-kernel": "^0.2.0",
48
- "@fabrika/daemon-kit": "^0.2.0",
49
+ "zod": "^3.25.76",
50
+ "@fabrika/annotations": "^0.3.1",
51
+ "@fabrika/app": "^0.3.1",
52
+ "@fabrika/auth-core": "^0.3.1",
53
+ "@fabrika/canvas-kernel": "^0.3.1",
54
+ "@fabrika/control-contract": "^0.3.1",
55
+ "@fabrika/daemon-kit": "^0.3.1",
56
+ "@fabrika/provider-zerops": "^0.3.1",
49
57
  "preact": "^10.25.0"
50
58
  }
51
59
  }
@@ -0,0 +1,365 @@
1
+ import { type BuilderConnectionReceiptDto, builderConnectionReceiptSchema } from '@fabrika/control-contract'
2
+ import { z } from 'zod'
3
+ import { PHASE_IDS } from './phases'
4
+ import { parseBuilderOrigin, parseBuilderPreviewUrl } from './urls'
5
+
6
+ export { parseBuilderOrigin, parseBuilderPreviewUrl } from './urls'
7
+
8
+ export interface BuilderProjectConnection {
9
+ controlOrigin: string
10
+ iamIssuer: string
11
+ receipt: BuilderConnectionReceiptDto
12
+ }
13
+
14
+ export interface BuilderProjectConfig {
15
+ profile: string
16
+ sessionId: string
17
+ createdAt: string
18
+ connection?: BuilderProjectConnection
19
+ previewUrl?: string
20
+ }
21
+
22
+ export type BuilderPairingState =
23
+ // Only the browser approval proof belongs in this URL's fragment; redemption credentials stay daemon-local.
24
+ | { status: 'pending'; authorizationUrl: string; expiresAt: number }
25
+ | { status: 'approved' }
26
+ | { status: 'failed'; message: string }
27
+ | { status: 'expired' }
28
+ | { status: 'cancelled' }
29
+ | { status: 'recovery_required'; message: string }
30
+ | { status: 'connected' }
31
+
32
+ export interface BuilderDeploymentState {
33
+ status: 'preparing' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
34
+ runId: string | null
35
+ commitSha: string | null
36
+ message: string | null
37
+ appUrl: string | null
38
+ consoleUrl: string | null
39
+ }
40
+
41
+ export interface BuilderPlatformStatus {
42
+ sessionId: string
43
+ projectRoot: string
44
+ connection: BuilderProjectConnection | null
45
+ credential: { available: boolean; expiresAt: number | null }
46
+ pairing: BuilderPairingState | null
47
+ previewUrl: string | null
48
+ deployment: BuilderDeploymentState | null
49
+ }
50
+
51
+ export interface RegisterProjectInput {
52
+ projectRoot: string
53
+ }
54
+
55
+ export interface ConnectProjectInput {
56
+ controlOrigin: string
57
+ }
58
+
59
+ export interface PreviewProjectInput {
60
+ url: string | null
61
+ }
62
+
63
+ export interface BuilderErrorResponse {
64
+ error: { code: string; message: string }
65
+ }
66
+
67
+ // These legacy daemon projections deliberately recover malformed optional fields.
68
+ const wireNumberSchema = z.union([z.number(), z.nan()])
69
+ const optionalTextSchema = z.string().optional().catch(undefined)
70
+ const phaseIdSchema = z.enum(PHASE_IDS)
71
+ export const phaseStateSchema = z.object({
72
+ current: phaseIdSchema,
73
+ index: wireNumberSchema,
74
+ completed: z.array(phaseIdSchema),
75
+ reads: z.array(z.string()),
76
+ produces: z.array(z.string()),
77
+ gate: z.boolean(),
78
+ satisfied: z.boolean(),
79
+ })
80
+ export const uiPhaseStateSchema = phaseStateSchema.extend({ gateName: z.string().nullable().optional().catch(undefined) })
81
+ .transform(({ gateName, ...state }) => ({ ...state, ...(gateName === undefined ? {} : { gateName }) }))
82
+ export const healthInfoSchema = z.object({
83
+ ok: z.literal(true),
84
+ version: optionalTextSchema,
85
+ pid: wireNumberSchema.optional().catch(undefined),
86
+ dataDir: optionalTextSchema,
87
+ sessionsDir: z.string(),
88
+ sessions: z.union([wireNumberSchema, z.array(z.string())]).optional().catch(undefined),
89
+ }).transform(({ ok, version, pid, dataDir, sessionsDir, sessions }) => ({
90
+ ok,
91
+ ...(version === undefined ? {} : { version }),
92
+ ...(pid === undefined ? {} : { pid }),
93
+ ...(dataDir === undefined ? {} : { dataDir }),
94
+ sessionsDir,
95
+ ...(sessions === undefined ? {} : { sessions }),
96
+ }))
97
+ export const uiHealthSchema = z.object({
98
+ ok: z.literal(true),
99
+ version: optionalTextSchema,
100
+ sessions: z.array(z.string()).optional().catch(undefined),
101
+ })
102
+ .transform(({ ok, version, sessions }) => ({ ok, ...(version === undefined ? {} : { version }), ...(sessions === undefined ? {} : { sessions }) }))
103
+ export const activeDaemonSchema = z.object({ ok: z.literal(true), sessionsDir: z.string() })
104
+ export const builderPlatformErrorSchema = z.object({ error: z.object({ code: z.literal('builder_platform'), message: z.string().max(1000) }) })
105
+ export const planResultSchema = z.object({
106
+ ok: z.boolean(),
107
+ browserUrl: optionalTextSchema,
108
+ revision: wireNumberSchema.optional().catch(undefined),
109
+ isNew: z.boolean().optional().catch(undefined),
110
+ canvasFiles: z.array(z.string()).optional().catch(undefined),
111
+ gate: optionalTextSchema,
112
+ error: optionalTextSchema,
113
+ errors: z.record(z.string()).optional().catch(undefined),
114
+ }).transform(({ ok, browserUrl, revision, isNew, canvasFiles, gate, error, errors }) => ({
115
+ ok,
116
+ ...(browserUrl === undefined ? {} : { browserUrl }),
117
+ ...(revision === undefined ? {} : { revision }),
118
+ ...(isNew === undefined ? {} : { isNew }),
119
+ ...(canvasFiles === undefined ? {} : { canvasFiles }),
120
+ ...(gate === undefined ? {} : { gate }),
121
+ ...(error === undefined ? {} : { error }),
122
+ ...(errors === undefined ? {} : { errors }),
123
+ }))
124
+ export const buildEventSchema = z.object({
125
+ status: z.enum(['queued', 'running', 'passed', 'failed', 'blocked', 'info']),
126
+ issue: optionalTextSchema,
127
+ message: optionalTextSchema,
128
+ at: optionalTextSchema,
129
+ data: z.record(z.unknown()).optional().catch(undefined),
130
+ }).transform(({ status, issue, message, at, data }) => ({
131
+ status,
132
+ ...(issue === undefined ? {} : { issue }),
133
+ ...(message === undefined ? {} : { message }),
134
+ ...(at === undefined ? {} : { at }),
135
+ ...(data === undefined ? {} : { data }),
136
+ }))
137
+ export const planInputSchema = z.object({
138
+ view: optionalTextSchema,
139
+ directory: optionalTextSchema,
140
+ projectRoot: optionalTextSchema,
141
+ gate: optionalTextSchema,
142
+ label: optionalTextSchema,
143
+ response: optionalTextSchema,
144
+ })
145
+ export const feedbackTextSchema = z.object({ feedback: z.string().catch('') })
146
+ export const consumedFeedbackSchema = feedbackTextSchema.extend({ found: z.literal(true) })
147
+ export const submitFeedbackSchema = feedbackTextSchema.extend({ type: z.literal('submit') })
148
+ export const feedbackRevisionSchema = z.object({
149
+ revision: wireNumberSchema,
150
+ hasFeedback: z.unknown().transform(value => value === true),
151
+ feedbackConsumed: z.unknown().transform(value => value === true),
152
+ })
153
+ export const feedbackMetaSchema = z.object({
154
+ revisions: z.array(feedbackRevisionSchema.nullable().catch(null)).transform(items => items.filter(item => item !== null)),
155
+ })
156
+ export const canvasFileSchema = z.union([
157
+ z.string().transform(filename => ({ filename })),
158
+ z.object({
159
+ filename: z.string(),
160
+ label: optionalTextSchema,
161
+ diffStats: z.object({ added: wireNumberSchema, removed: wireNumberSchema }).optional().catch(undefined),
162
+ }).transform(({ filename, label, diffStats }) => ({
163
+ filename,
164
+ ...(label === undefined ? {} : { label }),
165
+ ...(diffStats === undefined ? {} : { diffStats }),
166
+ })),
167
+ ]).refine(value => value.filename.length > 0)
168
+ export const canvasFilesSchema = z.array(canvasFileSchema.nullable().catch(null))
169
+ .transform(items => items.filter(item => item !== null)).catch([])
170
+ const canvasScopeSchema = z.discriminatedUnion('kind', [
171
+ z.object({ kind: z.literal('directory') }),
172
+ z.object({ kind: z.literal('view'), filename: z.string() }),
173
+ ])
174
+ export const revisionMetaSchema = feedbackRevisionSchema.extend({
175
+ label: z.string().nullable().catch(null),
176
+ canvasFiles: canvasFilesSchema,
177
+ createdAt: optionalTextSchema,
178
+ canvasScope: canvasScopeSchema.optional().catch(undefined),
179
+ }).transform(({ createdAt, canvasScope, ...revision }) => ({
180
+ ...revision,
181
+ ...(createdAt === undefined ? {} : { createdAt }),
182
+ ...(canvasScope === undefined ? {} : { canvasScope }),
183
+ })).refine(value => value.revision > 0)
184
+ export const sessionMetaSchema = z.object({
185
+ projectRoot: optionalTextSchema,
186
+ label: optionalTextSchema,
187
+ gate: z.string().nullable().catch(null),
188
+ currentRevision: wireNumberSchema.catch(0),
189
+ canvasFiles: canvasFilesSchema,
190
+ revisions: z.array(revisionMetaSchema.nullable().catch(null)).transform(items => items.filter(item => item !== null)).catch([]),
191
+ }).transform(({ projectRoot, label, ...meta }) => ({
192
+ ...meta,
193
+ ...(projectRoot === undefined ? {} : { projectRoot }),
194
+ ...(label === undefined ? {} : { label }),
195
+ }))
196
+ export const sessionSummarySchema = z.object({ id: z.string(), label: optionalTextSchema })
197
+ .transform(({ id, label }) => ({ id, ...(label === undefined ? {} : { label }) }))
198
+ export const sessionsSchema = z.array(sessionSummarySchema.nullable().catch(null)).transform(items => items.filter(item => item !== null)).catch([])
199
+ export const skeletonSchema = z.object({
200
+ file: z.string(),
201
+ name: z.string(),
202
+ seeds: z.array(z.string()),
203
+ roles: z.array(z.string()),
204
+ steps: wireNumberSchema,
205
+ invariants: wireNumberSchema,
206
+ pending: wireNumberSchema,
207
+ url: optionalTextSchema,
208
+ feature: optionalTextSchema,
209
+ }).transform(({ url, feature, ...skeleton }) => ({
210
+ ...skeleton,
211
+ ...(url === undefined ? {} : { url }),
212
+ ...(feature === undefined ? {} : { feature }),
213
+ }))
214
+ export const skeletonsSchema = z.object({
215
+ skeletons: z.array(skeletonSchema.nullable().catch(null)).transform(items => items.filter(item => item !== null)),
216
+ })
217
+ export const artifactResponseSchema = z.object({ content: z.string() })
218
+ export const responsesEnvelopeSchema = z.object({ responses: z.unknown() })
219
+ export const wsMessageSchema = z.discriminatedUnion('type', [
220
+ z.object({
221
+ type: z.literal('plan-updated'),
222
+ currentRevision: wireNumberSchema,
223
+ canvasFiles: z.array(z.unknown()).transform(value => canvasFilesSchema.parse(value)).optional().catch(undefined),
224
+ gate: z.string().nullable().optional().catch(undefined),
225
+ }),
226
+ z.object({ type: z.literal('revision-updated'), currentRevision: wireNumberSchema }),
227
+ z.object({ type: z.literal('watcher-status'), watching: z.boolean() }),
228
+ z.object({ type: z.literal('phase-updated') }),
229
+ z.object({ type: z.literal('submit-ack'), revision: wireNumberSchema }),
230
+ ]).transform(message => {
231
+ if (message.type !== 'plan-updated') return message
232
+ const { canvasFiles, gate, ...rest } = message
233
+ return { ...rest, ...(canvasFiles === undefined ? {} : { canvasFiles }), ...(gate === undefined ? {} : { gate }) }
234
+ })
235
+
236
+ const nonemptyStringSchema = z.string().refine(value => value.trim().length > 0)
237
+ const timestampSchema = z.number().finite().nonnegative()
238
+ const originSchema = z.string().transform((value, ctx) => {
239
+ try {
240
+ return parseBuilderOrigin(value)
241
+ } catch {
242
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid builder origin' })
243
+ return z.NEVER
244
+ }
245
+ })
246
+ const previewUrlSchema = z.string().transform((value, ctx) => {
247
+ try {
248
+ return parseBuilderPreviewUrl(value)
249
+ } catch {
250
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid preview URL' })
251
+ return z.NEVER
252
+ }
253
+ })
254
+ export const builderProjectConnectionSchema = z.object({
255
+ controlOrigin: originSchema,
256
+ iamIssuer: originSchema,
257
+ receipt: builderConnectionReceiptSchema,
258
+ }).strict()
259
+ export const builderProjectConfigSchema = z.object({
260
+ profile: nonemptyStringSchema,
261
+ sessionId: nonemptyStringSchema,
262
+ createdAt: nonemptyStringSchema.refine(value => Number.isFinite(Date.parse(value))),
263
+ connection: builderProjectConnectionSchema.optional(),
264
+ previewUrl: previewUrlSchema.optional(),
265
+ }).strict().superRefine((value, ctx) => {
266
+ for (const key of ['connection', 'previewUrl']) {
267
+ if (Object.hasOwn(value, key) && Reflect.get(value, key) === undefined) {
268
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: 'Present field must not be undefined' })
269
+ }
270
+ }
271
+ })
272
+ export const builderPairingStateSchema = z.discriminatedUnion('status', [
273
+ z.object({
274
+ status: z.literal('pending'),
275
+ authorizationUrl: previewUrlSchema.refine(value => {
276
+ try {
277
+ parseBuilderOrigin(new URL(value).origin)
278
+ return true
279
+ } catch {
280
+ return false
281
+ }
282
+ }),
283
+ expiresAt: timestampSchema,
284
+ }).strict(),
285
+ z.object({ status: z.literal('approved') }).strict(),
286
+ z.object({ status: z.literal('failed'), message: nonemptyStringSchema }).strict(),
287
+ z.object({ status: z.literal('expired') }).strict(),
288
+ z.object({ status: z.literal('cancelled') }).strict(),
289
+ z.object({ status: z.literal('recovery_required'), message: nonemptyStringSchema }).strict(),
290
+ z.object({ status: z.literal('connected') }).strict(),
291
+ ])
292
+ export const builderDeploymentStateSchema = z.object({
293
+ status: z.enum(['preparing', 'pending', 'running', 'succeeded', 'failed', 'cancelled']),
294
+ runId: nonemptyStringSchema.nullable(),
295
+ commitSha: nonemptyStringSchema.nullable(),
296
+ message: nonemptyStringSchema.nullable(),
297
+ appUrl: previewUrlSchema.nullable(),
298
+ consoleUrl: previewUrlSchema.nullable(),
299
+ }).strict()
300
+ export const builderPlatformStatusSchema = z.object({
301
+ sessionId: nonemptyStringSchema,
302
+ projectRoot: nonemptyStringSchema,
303
+ connection: builderProjectConnectionSchema.nullable(),
304
+ credential: z.object({ available: z.boolean(), expiresAt: timestampSchema.nullable() }).strict(),
305
+ pairing: builderPairingStateSchema.nullable(),
306
+ previewUrl: previewUrlSchema.nullable(),
307
+ deployment: builderDeploymentStateSchema.nullable(),
308
+ }).strict()
309
+ export const registerProjectInputSchema = z.object({ projectRoot: nonemptyStringSchema }).strict()
310
+ export const connectProjectInputSchema = z.object({ controlOrigin: originSchema }).strict()
311
+ export const previewProjectInputSchema = z.object({ url: previewUrlSchema.nullable() }).strict()
312
+
313
+ export function parseBuilderProjectConnection(value: unknown): BuilderProjectConnection {
314
+ const result = builderProjectConnectionSchema.safeParse(value)
315
+ if (!result.success) throw new Error('Invalid project connection')
316
+ return result.data
317
+ }
318
+
319
+ export function parseBuilderProjectConfig(value: unknown): BuilderProjectConfig {
320
+ const result = builderProjectConfigSchema.safeParse(value)
321
+ if (!result.success) throw new Error('Invalid project config')
322
+ return result.data
323
+ }
324
+
325
+ export function parseBuilderPairingState(value: unknown): BuilderPairingState {
326
+ const result = builderPairingStateSchema.safeParse(value)
327
+ if (!result.success) throw new Error('Invalid pairing state')
328
+ return result.data
329
+ }
330
+
331
+ export function parseBuilderDeploymentState(value: unknown): BuilderDeploymentState {
332
+ const result = builderDeploymentStateSchema.safeParse(value)
333
+ if (!result.success) throw new Error('Invalid deployment state')
334
+ return result.data
335
+ }
336
+
337
+ export function parseBuilderPlatformStatus(value: unknown): BuilderPlatformStatus {
338
+ const result = builderPlatformStatusSchema.safeParse(value)
339
+ if (!result.success) throw new Error('Invalid platform status')
340
+ return result.data
341
+ }
342
+
343
+ export function parseRegisterProjectInput(value: unknown): RegisterProjectInput {
344
+ const result = registerProjectInputSchema.safeParse(value)
345
+ if (!result.success) throw new Error('Invalid register input')
346
+ return result.data
347
+ }
348
+
349
+ export function parseConnectProjectInput(value: unknown): ConnectProjectInput {
350
+ const result = connectProjectInputSchema.safeParse(value)
351
+ if (!result.success) throw new Error('Invalid connect input')
352
+ return result.data
353
+ }
354
+
355
+ export function parsePreviewProjectInput(value: unknown): PreviewProjectInput {
356
+ const result = previewProjectInputSchema.safeParse(value)
357
+ if (!result.success) throw new Error('Invalid preview input')
358
+ return result.data
359
+ }
360
+
361
+ export function builderProjectPath(id: string, suffix: 'register' | 'platform' | 'connect' | 'disconnect' | 'deploy' | 'preview'): string {
362
+ const result = nonemptyStringSchema.safeParse(id)
363
+ if (!result.success) throw new Error('project id must be a non-empty string')
364
+ return `/api/project/${encodeURIComponent(result.data)}/${suffix}`
365
+ }