@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.
- package/BUILD-CONTRACT.md +8 -0
- package/README.md +9 -0
- package/cli/src/bin.ts +35 -2
- package/cli/src/build-status.ts +7 -15
- package/cli/src/commands/advance.ts +3 -2
- package/cli/src/commands/ensure-daemon.ts +3 -3
- package/cli/src/commands/gate.ts +4 -10
- package/cli/src/commands/init.ts +44 -7
- package/cli/src/commands/next.ts +3 -3
- package/cli/src/commands/platform.ts +81 -0
- package/cli/src/config.ts +1 -9
- package/cli/src/context.ts +4 -1
- package/cli/src/daemon-client.ts +36 -185
- package/cli/src/daemon-lifecycle.ts +63 -142
- package/cli/src/derive.ts +4 -8
- package/cli/src/errors.ts +1 -0
- package/cli/src/index.ts +2 -3
- package/cli/src/inject-skills.ts +8 -31
- package/cli/src/layout.ts +8 -18
- package/cli/src/packet.ts +18 -17
- package/cli/src/platform-client.ts +40 -0
- package/daemon/src/admission.ts +19 -0
- package/daemon/src/bin.ts +7 -9
- package/daemon/src/handlers.ts +82 -79
- package/daemon/src/index.ts +14 -5
- package/daemon/src/paths.ts +5 -6
- package/daemon/src/phases.ts +27 -126
- package/daemon/src/platform-compiler.ts +21 -0
- package/daemon/src/platform-remote.ts +146 -0
- package/daemon/src/platform-routes.ts +46 -0
- package/daemon/src/platform-source.ts +147 -0
- package/daemon/src/platform-storage.ts +222 -0
- package/daemon/src/platform.ts +481 -0
- package/decisions/README.md +4 -0
- package/decisions/patterns/zerops-control-deploy.md +49 -0
- package/decisions/patterns/zerops-local-app.md +57 -0
- package/decisions/patterns/zerops-postgres-seeds.md +37 -0
- package/decisions/technologies/zerops-fabrika-auth.md +53 -0
- package/decisions/technologies/zerops-platform.md +64 -0
- package/package.json +13 -5
- package/shared/index.ts +365 -0
- package/{cli/src → shared}/phases.ts +42 -50
- package/shared/urls.ts +41 -0
- package/skill/SKILL.md +34 -0
- package/stacks/README.md +2 -0
- package/stacks/zerops-app.md +66 -0
- package/ui/dist/assets/client.2fkhreps.js +214 -0
- package/ui/dist/assets/client.css +1 -1
- package/ui/dist/assets/components.js +57 -57
- package/ui/dist/index.html +1 -1
- 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.
|
|
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
|
-
"
|
|
47
|
-
"@fabrika/
|
|
48
|
-
"@fabrika/
|
|
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
|
}
|
package/shared/index.ts
ADDED
|
@@ -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
|
+
}
|