@fabrika/builder 0.2.0 → 0.3.2

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 +4 -11
  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 +21 -0
  23. package/daemon/src/bin.ts +12 -14
  24. package/daemon/src/handlers.ts +84 -81
  25. package/daemon/src/index.ts +16 -6
  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 +48 -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.css +1 -1
  48. package/ui/dist/assets/client.krrpk10y.js +214 -0
  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,481 @@
1
+ import type { RpcFetch } from '@fabrika/app'
2
+ import {
3
+ type DeviceAuthorizationConsent,
4
+ parseExchangeDeviceAuthorizationResult,
5
+ parsePollDeviceAuthorizationResult,
6
+ parseStartDeviceAuthorizationResult,
7
+ type StartDeviceAuthorizationInput,
8
+ } from '@fabrika/auth-core'
9
+ import { parseBuilderConnectionReceipt } from '@fabrika/control-contract'
10
+ import { zeropsArtifactCodec, zeropsStoredTargetCodec } from '@fabrika/provider-zerops'
11
+ import { createHash, randomBytes } from 'node:crypto'
12
+ import { mkdirSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { join, resolve } from 'node:path'
15
+ import { setTimeout } from 'node:timers/promises'
16
+ import { type BuilderPairingState, type BuilderPlatformStatus, parseBuilderOrigin, parseBuilderPreviewUrl } from '../../shared'
17
+ import { DATA_DIR } from './paths'
18
+ import { parseEnvironments, parseRun, PlatformRemote } from './platform-remote'
19
+ import { compileProject, repositoryIdentity, verifySource } from './platform-source'
20
+ import { PlatformError, PlatformStorage, type ProjectRecord, type StoredCredential } from './platform-storage'
21
+
22
+ const PERMISSIONS = ['app.manage', 'deploy.trigger', 'deploy.read']
23
+ const KEY_LIFETIME = 30 * 24 * 60 * 60
24
+ const RECOVERY = 'The credential may already exist. Revoke this connection in the platform console, disconnect locally, then pair again.'
25
+
26
+ class ObservationError extends PlatformError {}
27
+
28
+ function activeDeployment(project: ProjectRecord): boolean {
29
+ return project.deployment !== null && ['preparing', 'pending', 'running'].includes(project.deployment.status)
30
+ }
31
+
32
+ export interface BuilderPlatformOptions {
33
+ stateDir?: string
34
+ credentialDir?: string
35
+ dataDir?: string
36
+ fetch?: RpcFetch
37
+ now?: () => number
38
+ sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>
39
+ }
40
+
41
+ export class BuilderPlatform {
42
+ readonly storage: PlatformStorage
43
+ private readonly stopped = new AbortController()
44
+ private readonly operations = new Map<string, AbortController>()
45
+ private readonly pairings = new Map<string, BuilderPairingState>()
46
+ private readonly targets = new Map<string, string>()
47
+ private readonly now: () => number
48
+ private readonly sleep: (milliseconds: number, signal: AbortSignal) => Promise<void>
49
+ private readonly fetcher: RpcFetch | undefined
50
+
51
+ constructor(private readonly sessionRoot: (id: string) => string | undefined, options: BuilderPlatformOptions = {}) {
52
+ const dataDir = resolve(options.dataDir ?? DATA_DIR)
53
+ this.storage = new PlatformStorage(
54
+ resolve(options.stateDir ?? join(dataDir, 'builder-projects')),
55
+ resolve(options.credentialDir ?? join(homedir(), '.fabrika-builder', 'credentials')),
56
+ dataDir,
57
+ )
58
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000))
59
+ this.sleep = options.sleep ?? (async (ms, signal) => {
60
+ await setTimeout(ms, undefined, { signal })
61
+ })
62
+ this.fetcher = options.fetch
63
+ }
64
+
65
+ stop(): void {
66
+ this.stopped.abort()
67
+ }
68
+
69
+ projectRoot(id: string): string | undefined {
70
+ return this.storage.project(id)?.projectRoot ?? this.sessionRoot(id)
71
+ }
72
+
73
+ register(id: string, root: string): BuilderPlatformStatus {
74
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) throw new PlatformError('Invalid project session ID')
75
+ const projectRoot = this.storage.assertProjectRoot(root)
76
+ const existing = this.storage.project(id)
77
+ const session = this.sessionRoot(id)
78
+ if ((existing && existing.projectRoot !== projectRoot) || (session && this.storage.assertProjectRoot(session) !== projectRoot)) {
79
+ throw new PlatformError('Session is already bound to another project root')
80
+ }
81
+ const project = existing ?? { sessionId: id, projectRoot, deployment: null, recoveryRequired: false }
82
+ this.storage.config(project)
83
+ this.storage.saveProject(project)
84
+ return this.status(id)
85
+ }
86
+
87
+ private project(id: string): ProjectRecord {
88
+ const project = this.storage.project(id)
89
+ if (!project) throw new PlatformError('Register the project before using platform actions')
90
+ this.storage.config(project)
91
+ return project
92
+ }
93
+
94
+ status(id: string): BuilderPlatformStatus {
95
+ const project = this.project(id)
96
+ const config = this.storage.config(project)
97
+ let credential: StoredCredential | null = null
98
+ try {
99
+ credential = this.storage.credential(id)
100
+ } catch (error) {
101
+ if (!project.recoveryRequired) throw error
102
+ }
103
+ if (
104
+ project.deployment?.runId && (project.deployment.status === 'pending' || project.deployment.status === 'running') && !this.operations.has(id)
105
+ && credential
106
+ ) {
107
+ this.resume(project, credential)
108
+ } else if (project.deployment?.status === 'preparing' && !project.deployment.commitSha && !this.operations.has(id)) {
109
+ project.deployment = {
110
+ ...project.deployment,
111
+ status: 'failed',
112
+ message: project.deployment.commitSha
113
+ ? 'Deployment submission was interrupted. Inspect Control for a run before retrying explicitly.'
114
+ : 'Preparation was interrupted. Start deployment again explicitly.',
115
+ }
116
+ this.storage.saveProject(project)
117
+ }
118
+ return {
119
+ sessionId: id,
120
+ projectRoot: project.projectRoot,
121
+ connection: credential?.connection ?? config.connection ?? null,
122
+ credential: { available: !!credential && credential.expiresAt > this.now(), expiresAt: credential?.expiresAt ?? null },
123
+ pairing: this.pairings.get(id)
124
+ ?? (credential ? { status: 'connected' } : project.recoveryRequired ? { status: 'recovery_required', message: RECOVERY } : null),
125
+ previewUrl: config.previewUrl ?? null,
126
+ deployment: project.deployment,
127
+ }
128
+ }
129
+
130
+ preview(id: string, url: string | null): BuilderPlatformStatus {
131
+ const project = this.project(id)
132
+ const { previewUrl: _old, ...config } = this.storage.config(project)
133
+ this.storage.saveConfig(project, { ...config, ...(url === null ? {} : { previewUrl: parseBuilderPreviewUrl(url) }) })
134
+ return this.status(id)
135
+ }
136
+
137
+ private operation(id: string): AbortController {
138
+ if (this.stopped.signal.aborted) throw new PlatformError('Daemon is stopping')
139
+ if (this.operations.has(id)) throw new PlatformError('A project operation is already active')
140
+ const controller = new AbortController()
141
+ this.operations.set(id, controller)
142
+ return controller
143
+ }
144
+
145
+ private signal(controller: AbortController): AbortSignal {
146
+ return AbortSignal.any([controller.signal, this.stopped.signal])
147
+ }
148
+ private remote(signal: AbortSignal): PlatformRemote {
149
+ return new PlatformRemote(this.fetcher, signal)
150
+ }
151
+
152
+ async connect(id: string, requestedOrigin: string): Promise<BuilderPlatformStatus> {
153
+ const project = this.project(id)
154
+ if (this.storage.credential(id) || this.storage.config(project).connection || project.recoveryRequired) {
155
+ throw new PlatformError('An existing connection requires explicit disconnect before pairing again')
156
+ }
157
+ const controller = this.operation(id)
158
+ const signal = this.signal(controller)
159
+ try {
160
+ const remote = this.remote(signal)
161
+ const pins = await remote.discover(parseBuilderOrigin(requestedOrigin, 'Control origin'))
162
+ const approval = randomBytes(32).toString('base64url')
163
+ const redemptionVerifier = randomBytes(32).toString('base64url')
164
+ const challenge = (verifier: string) => createHash('sha256').update(verifier).digest('base64url')
165
+ const label = `Local builder ${id}`.slice(0, 200)
166
+ const input: StartDeviceAuthorizationInput = {
167
+ app: 'vozka',
168
+ label,
169
+ permissions: [...PERMISSIONS],
170
+ keyLifetimeSeconds: KEY_LIFETIME,
171
+ approvalChallenge: challenge(approval),
172
+ redemptionChallenge: challenge(redemptionVerifier),
173
+ }
174
+ const started = parseStartDeviceAuthorizationResult(await remote.json(`${pins.iamIssuer}/auth/device/start`, input))
175
+ if (!started?.ok || started.expiresAt <= this.now() || started.expiresAt > this.now() + 600) {
176
+ throw new PlatformError('IAM refused or returned an invalid pairing attempt')
177
+ }
178
+ const fragment = new URLSearchParams({ attempt: started.attemptId, approval, expires: String(started.expiresAt) })
179
+ this.pairings.set(id, { status: 'pending', authorizationUrl: `${pins.controlOrigin}/builder/connect#${fragment}`, expiresAt: started.expiresAt })
180
+ void this.pair(project, pins, { attemptId: started.attemptId, redemptionVerifier }, started.expiresAt, label, remote, signal)
181
+ .catch(() => {
182
+ if (!signal.aborted) {
183
+ this.pairings.set(id, {
184
+ status: project.recoveryRequired ? 'recovery_required' : 'failed',
185
+ message: project.recoveryRequired ? RECOVERY : 'Pairing failed. Check the installation and start a new attempt.',
186
+ })
187
+ }
188
+ }).finally(() => {
189
+ if (this.operations.get(id) === controller) this.operations.delete(id)
190
+ })
191
+ return this.status(id)
192
+ } catch (error) {
193
+ this.operations.delete(id)
194
+ throw error instanceof PlatformError ? error : new PlatformError('Unable to start pairing')
195
+ }
196
+ }
197
+
198
+ private validateConsent(consent: DeviceAuthorizationConsent, label: string): void {
199
+ if (
200
+ consent.app !== 'vozka' || consent.label !== label || consent.scope.type !== 'app'
201
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(consent.scope.value)
202
+ || [...consent.permissions].sort().join(',') !== [...PERMISSIONS].sort().join(',') || consent.keyExpiresAt <= this.now()
203
+ || consent.keyExpiresAt > this.now() + KEY_LIFETIME
204
+ ) {
205
+ throw new PlatformError('IAM consent does not match the requested app-scoped authorization')
206
+ }
207
+ }
208
+
209
+ private async pair(
210
+ project: ProjectRecord,
211
+ pins: { controlOrigin: string; iamIssuer: string },
212
+ proof: { attemptId: string; redemptionVerifier: string },
213
+ expiresAt: number,
214
+ label: string,
215
+ remote: PlatformRemote,
216
+ signal: AbortSignal,
217
+ ): Promise<void> {
218
+ const id = project.sessionId
219
+ for (let poll = 0; poll < 200 && this.now() < expiresAt; poll++) {
220
+ await this.sleep(3000, signal)
221
+ signal.throwIfAborted()
222
+ const result = parsePollDeviceAuthorizationResult(await remote.json(`${pins.iamIssuer}/auth/device/poll`, proof))
223
+ signal.throwIfAborted()
224
+ if (!result) throw new PlatformError('Invalid IAM pairing response')
225
+ if (!result.ok) {
226
+ if (result.reason === 'slow_down') continue
227
+ throw new PlatformError('Pairing attempt was not found')
228
+ }
229
+ if (result.status === 'pending') continue
230
+ if (result.status === 'issued') {
231
+ project.recoveryRequired = true
232
+ this.storage.saveProject(project)
233
+ this.pairings.set(id, { status: 'recovery_required', message: RECOVERY })
234
+ return
235
+ }
236
+ if (result.status !== 'approved') {
237
+ this.pairings.set(
238
+ id,
239
+ result.status === 'expired' || result.status === 'cancelled'
240
+ ? { status: result.status }
241
+ : { status: 'failed', message: 'Authorization was denied or the approving account lost access.' },
242
+ )
243
+ return
244
+ }
245
+ const consent = result.consent
246
+ this.validateConsent(consent, label)
247
+ this.pairings.set(id, { status: 'approved' })
248
+ // Persist ambiguity before the one-time exchange; a lost response must never cause a remint.
249
+ project.recoveryRequired = true
250
+ this.storage.saveProject(project)
251
+ const exchanged = parseExchangeDeviceAuthorizationResult(
252
+ await remote.json(`${pins.iamIssuer}/auth/device/exchange`, { ...proof, consentId: consent.consentId }),
253
+ )
254
+ if (!exchanged?.ok) throw new PlatformError(RECOVERY)
255
+ if (
256
+ exchanged.receipt.attemptId !== proof.attemptId || exchanged.receipt.revokedAt !== null
257
+ || JSON.stringify(exchanged.receipt.consent) !== JSON.stringify(consent)
258
+ ) throw new PlatformError(RECOVERY)
259
+ const client = remote.controlForReceipt(pins.controlOrigin, exchanged.token)
260
+ const receipt = parseBuilderConnectionReceipt(await client.builderConnections.getReceipt({ attemptId: proof.attemptId }))
261
+ if (receipt.attemptId !== proof.attemptId || receipt.provider !== 'zerops' || receipt.app.id !== consent.scope.value) {
262
+ throw new PlatformError(RECOVERY)
263
+ }
264
+ signal.throwIfAborted()
265
+ const connection = { ...pins, receipt }
266
+ this.storage.saveCredential(id, { connection, token: exchanged.token, expiresAt: consent.keyExpiresAt })
267
+ this.storage.saveConfig(project, { ...this.storage.config(project), connection })
268
+ project.recoveryRequired = false
269
+ this.storage.saveProject(project)
270
+ this.pairings.set(id, { status: 'connected' })
271
+ return
272
+ }
273
+ this.pairings.set(id, { status: 'expired' })
274
+ }
275
+
276
+ disconnect(id: string): BuilderPlatformStatus {
277
+ const project = this.project(id)
278
+ if (activeDeployment(project)) {
279
+ throw new PlatformError('Deployment is still active or awaiting confirmation. Keep the connection until its outcome is known.')
280
+ }
281
+ this.operations.get(id)?.abort()
282
+ this.operations.delete(id)
283
+ this.storage.removeCredential(id)
284
+ const { connection: _old, ...config } = this.storage.config(project)
285
+ this.storage.saveConfig(project, config)
286
+ project.recoveryRequired = false
287
+ this.storage.saveProject(project)
288
+ this.pairings.set(id, { status: 'cancelled' })
289
+ return this.status(id)
290
+ }
291
+
292
+ deploy(id: string): BuilderPlatformStatus {
293
+ const project = this.project(id)
294
+ const credential = this.storage.credential(id)
295
+ if (!credential || credential.expiresAt <= this.now()) throw new PlatformError('Connect the project with a valid credential before deploying')
296
+ if (activeDeployment(project)) {
297
+ throw new PlatformError('A deploy run is already active')
298
+ }
299
+ const key = this.targetKey(credential)
300
+ this.restoreReservations()
301
+ if (this.targets.has(key)) throw new PlatformError('This application environment already has a local operation')
302
+ const controller = this.operation(id)
303
+ this.targets.set(key, id)
304
+ project.deployment = {
305
+ status: 'preparing',
306
+ runId: null,
307
+ commitSha: null,
308
+ appUrl: null,
309
+ consoleUrl: credential.connection.controlOrigin,
310
+ message: 'Verifying committed source and compiling the application.',
311
+ }
312
+ this.storage.saveProject(project)
313
+ this.backgroundDeploy(project, credential, controller, key, false)
314
+ return this.status(id)
315
+ }
316
+
317
+ private targetKey(credential: StoredCredential): string {
318
+ return JSON.stringify([credential.connection.controlOrigin, credential.connection.receipt.app.id, credential.connection.receipt.environment.env])
319
+ }
320
+
321
+ private restoreReservations(): void {
322
+ const reservations = new Map<string, string>()
323
+ for (const project of this.storage.projects()) {
324
+ if (!activeDeployment(project)) continue
325
+ const credential = this.storage.credential(project.sessionId)
326
+ if (!credential) throw new PlatformError('An active deployment has missing custody. Restore its connection before starting another deployment.')
327
+ const key = this.targetKey(credential)
328
+ const owner = reservations.get(key)
329
+ if (owner && owner !== project.sessionId) {
330
+ throw new PlatformError('Multiple saved runs reserve this application environment. Resolve them in the console.')
331
+ }
332
+ reservations.set(key, project.sessionId)
333
+ }
334
+ this.targets.clear()
335
+ for (const [key, owner] of reservations) this.targets.set(key, owner)
336
+ }
337
+
338
+ private resume(project: ProjectRecord, credential: StoredCredential): void {
339
+ const key = this.targetKey(credential)
340
+ const owner = this.targets.get(key)
341
+ if ((owner !== undefined && owner !== project.sessionId) || this.stopped.signal.aborted) return
342
+ const controller = this.operation(project.sessionId)
343
+ this.targets.set(key, project.sessionId)
344
+ this.backgroundDeploy(project, credential, controller, key, true)
345
+ }
346
+
347
+ private backgroundDeploy(project: ProjectRecord, credential: StoredCredential, controller: AbortController, key: string, resume: boolean): void {
348
+ const signal = this.signal(controller)
349
+ void (resume ? this.monitor(project, credential, signal) : this.prepare(project, credential, signal))
350
+ .catch((error: unknown) => {
351
+ if (signal.aborted) return
352
+ if (project.deployment) {
353
+ const unobserved = error instanceof ObservationError || (!project.deployment.runId && project.deployment.commitSha !== null)
354
+ project.deployment = {
355
+ ...project.deployment,
356
+ status: unobserved ? project.deployment.status : 'failed',
357
+ appUrl: null,
358
+ message: unobserved
359
+ ? project.deployment.runId
360
+ ? 'Run observation is unavailable. The remote outcome is unknown; status will retry monitoring.'
361
+ : 'Deployment submission outcome is unknown. Inspect Control; this target remains reserved and cannot be retriggered.'
362
+ : error instanceof PlatformError
363
+ ? error.message
364
+ : 'Deployment failed. Check the platform console and local source.',
365
+ }
366
+ this.storage.saveProject(project)
367
+ }
368
+ }).catch(() => {}).finally(() => {
369
+ if (!activeDeployment(project)) this.targets.delete(key)
370
+ if (this.operations.get(project.sessionId) === controller) this.operations.delete(project.sessionId)
371
+ })
372
+ }
373
+
374
+ private async prepare(project: ProjectRecord, credential: StoredCredential, signal: AbortSignal): Promise<void> {
375
+ const { receipt } = credential.connection
376
+ const app = receipt.app
377
+ const env = receipt.environment.env
378
+ if (receipt.provider !== 'zerops') throw new PlatformError('Unsupported connection provider')
379
+ const sha = await verifySource(project.projectRoot, app, signal)
380
+ mkdirSync(this.storage.stateDir, { recursive: true, mode: 0o700 })
381
+ const manifest = await compileProject(project.projectRoot, env, app.id, this.storage.stateDir, signal)
382
+ if (await verifySource(project.projectRoot, app, signal) !== sha) throw new PlatformError('Source changed during compilation')
383
+ const client = this.remote(signal).control(credential)
384
+ const currentApp = parseBuilderConnectionReceipt({ ...receipt, app: await client.apps.get({ appId: app.id }) }).app
385
+ if (
386
+ currentApp.id !== app.id || repositoryIdentity(currentApp.repoUrl) !== repositoryIdentity(app.repoUrl) || currentApp.workerDir !== app.workerDir
387
+ || currentApp.configPath !== app.configPath || currentApp.buildCmd !== app.buildCmd || currentApp.defaultBranch !== app.defaultBranch
388
+ || currentApp.githubConnectionId !== app.githubConnectionId
389
+ || currentApp.githubInstallationId !== app.githubInstallationId
390
+ ) throw new PlatformError('The approved application source changed in Control; reconnect explicitly')
391
+ const current = parseEnvironments(await client.apps.environments.list({ appId: app.id })).find((item) => item.env === env)
392
+ if (
393
+ current
394
+ && (current.appId !== app.id || current.provider !== 'zerops' || current.target.provider !== 'zerops' || current.artifact.provider !== 'zerops')
395
+ ) throw new PlatformError('Environment provider or application does not match the connection')
396
+ const placement = current ?? receipt.environment
397
+ if (!placement.namespaceId) throw new PlatformError('Select a deployment namespace in the platform console before deploying')
398
+ signal.throwIfAborted()
399
+ const updated = parseEnvironments({
400
+ items: [
401
+ await client.apps.environments.put({
402
+ appId: app.id,
403
+ env,
404
+ environment: {
405
+ namespaceId: placement.namespaceId,
406
+ domain: placement.domain,
407
+ publicOrigin: placement.publicOrigin,
408
+ triggerRef: placement.triggerRef,
409
+ target: current?.target ?? { provider: 'zerops', version: zeropsStoredTargetCodec.version, payload: {} },
410
+ artifact: { provider: 'zerops', version: zeropsArtifactCodec.version, payload: zeropsArtifactCodec.encode(manifest) },
411
+ },
412
+ }),
413
+ ],
414
+ })[0]
415
+ if (!updated || updated.appId !== app.id || updated.env !== env || updated.provider !== 'zerops') {
416
+ throw new PlatformError('Control returned a mismatched environment')
417
+ }
418
+ signal.throwIfAborted()
419
+ // Once triggering starts the response may be lost. Never retry this write automatically.
420
+ project.deployment = {
421
+ status: 'preparing',
422
+ runId: null,
423
+ commitSha: sha,
424
+ message: 'Submitting exact commit. If interrupted, inspect Control before retrying.',
425
+ appUrl: null,
426
+ consoleUrl: credential.connection.controlOrigin,
427
+ }
428
+ this.storage.saveProject(project)
429
+ const run = parseRun(await client.deploy({ appId: app.id, env, ref: sha }))
430
+ if (run.appId !== app.id || run.env !== env || run.ref !== sha) throw new PlatformError('Control returned a different deployment target')
431
+ project.deployment = { ...project.deployment, status: 'pending', runId: run.id, message: 'Deployment submitted; waiting for the platform run.' }
432
+ this.storage.saveProject(project)
433
+ await this.monitor(project, credential, signal)
434
+ }
435
+
436
+ private async monitor(project: ProjectRecord, credential: StoredCredential, signal: AbortSignal): Promise<void> {
437
+ const client = this.remote(signal).control(credential)
438
+ const receipt = credential.connection.receipt
439
+ const deadline = this.now() + 3600
440
+ for (let poll = 0; poll < 1200 && this.now() < deadline; poll++) {
441
+ const deployment = project.deployment
442
+ if (!deployment?.runId || !deployment.commitSha) throw new PlatformError('Saved deployment is missing its run or commit')
443
+ signal.throwIfAborted()
444
+ const run = await client.runs.get({ runId: deployment.runId }).then(parseRun).catch(() => {
445
+ throw new ObservationError('Run observation unavailable')
446
+ })
447
+ if (run.id !== deployment.runId || run.appId !== receipt.app.id || run.env !== receipt.environment.env || run.ref !== deployment.commitSha) {
448
+ throw new ObservationError('Platform returned a different run')
449
+ }
450
+ const terminal = run.status === 'succeeded' || run.status === 'failed'
451
+ if (terminal && run.commitSha !== deployment.commitSha) throw new PlatformError('Completed run commit does not match the requested SHA')
452
+ let appUrl: string | null = null
453
+ if (run.status === 'succeeded') {
454
+ try {
455
+ const environment = parseEnvironments(await client.apps.environments.list({ appId: receipt.app.id })).find((item) =>
456
+ item.appId === receipt.app.id && item.env === receipt.environment.env && item.provider === 'zerops'
457
+ )
458
+ if (environment?.publicOrigin) appUrl = parseBuilderPreviewUrl(environment.publicOrigin)
459
+ else if (environment?.domain) appUrl = parseBuilderPreviewUrl(`https://${environment.domain}`)
460
+ } catch {
461
+ // URL metadata is optional; it cannot change an already verified terminal run result.
462
+ }
463
+ }
464
+ signal.throwIfAborted()
465
+ project.deployment = {
466
+ ...deployment,
467
+ status: run.status,
468
+ appUrl,
469
+ message: run.status === 'succeeded'
470
+ ? 'Deployment succeeded. The configured URL has not been checked for reachability.'
471
+ : run.status === 'failed'
472
+ ? 'Platform deployment failed. See the console for details.'
473
+ : 'Platform deployment is in progress.',
474
+ }
475
+ this.storage.saveProject(project)
476
+ if (terminal) return
477
+ await this.sleep(3000, signal)
478
+ }
479
+ throw new ObservationError('Deployment polling timed out. Inspect the saved run in the platform console.')
480
+ }
481
+ }
@@ -115,6 +115,8 @@ See `../stacks/README.md`.
115
115
  | error tracking (`error-tracking`) | `sentry` |
116
116
  | testing | `opice` |
117
117
 
118
+ The platform-managed Zerops path adds [`zerops-platform`](technologies/zerops-platform.md) and [`zerops-fabrika-auth`](technologies/zerops-fabrika-auth.md).
119
+
118
120
  ### Patterns (`patterns/`)
119
121
 
120
122
  Applied _over_ the technologies they `require` (no package; `kind: pattern`).
@@ -128,6 +130,8 @@ Applied _over_ the technologies they `require` (no package; `kind: pattern`).
128
130
  | CI / CD | `app-deploy` (direct), `vozka-app-deploy` (control plane), `lib-publish` |
129
131
  | observability | `structured-logging`, `observability` |
130
132
 
133
+ Zerops composition uses [`zerops-local-app`](patterns/zerops-local-app.md), [`zerops-control-deploy`](patterns/zerops-control-deploy.md), and [`zerops-postgres-seeds`](patterns/zerops-postgres-seeds.md).
134
+
131
135
  ### Tooling (`tooling/`)
132
136
 
133
137
  `deptective`, `edvabe` — additive dev tools (`slot: null`), not part of preset closure.
@@ -0,0 +1,49 @@
1
+ ---
2
+ id: zerops-control-deploy
3
+ kind: pattern
4
+ title: Deploy Zerops applications through installed Fabrika Control
5
+ slot: deploy-control-plane
6
+ provides: [managed-deploy, release-tracking]
7
+ requires: [zerops-platform, zerops-fabrika-auth]
8
+ recommends: []
9
+ conflicts: [app-deploy, vozka-app-deploy]
10
+ status: wip
11
+ sources:
12
+ - where: "packages/control-contract/src/rpc.ts + packages/provider-zerops/src/provider.ts in fabrika-platform"
13
+ read: "Current Control deployment contract and provider lifecycle; do not infer builder command syntax."
14
+ - where: "examples/zerops-app/README.md + fabrika.config.ts + zerops.yaml in fabrika-platform"
15
+ read: "Application-owned source artifacts versus platform-owned deployment and secrets."
16
+ ---
17
+
18
+ # Deploy through installed Control
19
+
20
+ Apply this to an application bound to an existing Fabrika installation. The human
21
+ creates or selects the app in the installed console and reviews its environment
22
+ and placement. Repository creation, the GitHub connection, namespace provisioning
23
+ and custom-domain binding remain administrator-owned prerequisites. Missing
24
+ prerequisites are a handoff to that administrator.
25
+
26
+ ## Responsibilities
27
+
28
+ - The local agent implements, tests and prepares the application config and build
29
+ descriptor. It keeps the selected application identity stable on later changes.
30
+ - The human controls commits and pushes. Deployment consumes an immutable commit
31
+ already available in the configured remote repository; do not substitute an
32
+ uncommitted tree or automatically create another application on retry.
33
+ - Installed Control owns registry updates and deployment orchestration. Preserve
34
+ the reviewed namespace, source and public origin when updating an environment.
35
+ - Control and the Zerops provider supply credentials server-side, deliver source
36
+ and trigger Zerops' build/deploy pipeline. The local coding agent does not need
37
+ a Zerops account token or an installation provisioning credential.
38
+ - Follow the Control run to its terminal result and verify its requested revision
39
+ and public proxy URL. A successful build, a trigger response or a local emulator
40
+ result alone is not proof of a successful production release.
41
+
42
+ Do not deploy through `zops push`, direct provider API calls or a parallel
43
+ application-owned deployment workflow. Do not route a public domain directly to
44
+ the runtime service. App secrets remain platform-held service variables; keep
45
+ credentials out of source, manifests, browser state and compiler inputs.
46
+
47
+ This decision defines the deployment boundary. Consult the implemented CLI help
48
+ and workflow instructions for invocation syntax; it defines no builder connection
49
+ or deployment commands.
@@ -0,0 +1,57 @@
1
+ ---
2
+ id: zerops-local-app
3
+ kind: pattern
4
+ title: Local Bun SPA and API behind real Fabrika IAM and proxy
5
+ slot: app-delivery-shape
6
+ provides: [spa, local-development, proxy-preview]
7
+ requires: [zerops-platform, zerops-fabrika-auth, bun, postgres, react, buzola]
8
+ recommends: [tailwind, opice]
9
+ conflicts: [single-worker-spa]
10
+ status: wip
11
+ sources:
12
+ - where: "examples/zerops-app/src/server.ts + src/app.ts + README.md in fabrika-platform"
13
+ read: "Bun backend composition and the limits of the example's local run instructions."
14
+ - where: "docs/reference/local-development.md + packages/local-stack/CLAUDE.md in fabrika-platform"
15
+ read: "Real IAM/proxy/Postgres topology, local endpoints and IAM-owned login bypass."
16
+ - where: "packages/local-stack/src/app-registration.ts in fabrika-platform"
17
+ read: "Local schema and return-origin registration using the same IAM contract as deployment."
18
+ ---
19
+
20
+ # Local Bun SPA and API
21
+
22
+ Apply this to a client-rendered React/Buzola app served with its API by a Bun
23
+ process. Use Bun-native asset bundling and Buzola codegen. API namespaces must
24
+ return their own errors and 404s; only UI routes receive SPA fallback.
25
+
26
+ ## Local composition to scaffold
27
+
28
+ The development loop needs PostgreSQL, real IAM, a proxy with this application's
29
+ gates and a private Bun upstream. Register the application's schema and exact
30
+ local proxy return origin with local IAM. The app's configured issuer and audience
31
+ must match the tokens IAM mints. Keep the local database and credentials separate
32
+ from the installed production platform.
33
+
34
+ Use the existing Fabrika local-stack composition as the reference. From the
35
+ **fabrika-platform repository root**, `bun run local:up` starts its documented
36
+ services, and `bun run local:status` reports them. It pre-registers the fixture
37
+ apps, not every new builder project. The public notes fixture is available at
38
+ `http://notes.fabrika.localhost:18081` through its proxy.
39
+
40
+ A new app needs its own documented local service wiring and registration; the
41
+ example's bare server command does not provide those. Establish that wiring as
42
+ the app's foundation work and verify it before reporting a working preview.
43
+ If the local runtime cannot reach IAM, or a required registration cannot be made,
44
+ report the missing prerequisite rather than adding fake auth or bypassing the
45
+ proxy.
46
+
47
+ ## Preview and acceptance
48
+
49
+ Use the **proxy origin** as the browser preview and opice target, including its
50
+ actual port. Do not assume `localhost:5173` or use the private upstream as the
51
+ authenticated preview. Run the real login/callback flow and an authorization
52
+ denial, in addition to the product's success scenario. The local IAM login bypass
53
+ is a bootstrap-admin convenience; role-specific tests use real restricted grants.
54
+
55
+ Keep the coding agent, source, dev runtime and browser test loop local. The local
56
+ Zerops emulator can witness Control orchestration; it cannot prove real Zerops
57
+ builds, routing or infrastructure behavior.
@@ -0,0 +1,37 @@
1
+ ---
2
+ id: zerops-postgres-seeds
3
+ kind: pattern
4
+ title: Named PostgreSQL fixtures for local Zerops application tests
5
+ slot: seed-strategy
6
+ provides: [seed-strategy, foundation-manifest]
7
+ requires: [postgres]
8
+ recommends: [opice]
9
+ conflicts: [named-seeds]
10
+ status: stable
11
+ sources:
12
+ - where: "packages/builder/decisions/technologies/opice.md in fabrika-platform"
13
+ read: "Scenario seeds metadata and its filename contract."
14
+ - where: "examples/zerops-app/src/migrate.ts + src/notes.ts in fabrika-platform"
15
+ read: "PostgreSQL access through Bun SQL and repeatable migration behavior."
16
+ ---
17
+
18
+ # Named PostgreSQL fixtures
19
+
20
+ Apply this to the preset's local PostgreSQL database. An opice scenario naming
21
+ `seeds: ['example']` requires `seeds/example.sql`. Run migrations first, then apply
22
+ the named SQL files through the application's PostgreSQL driver or `psql` before
23
+ the scenario. Document that local seed command in the scaffold.
24
+
25
+ Use stable fixture IDs and PostgreSQL `INSERT ... ON CONFLICT` semantics so
26
+ repeated application is safe. Tests that mutate fixtures must restore their
27
+ required initial state or use isolated data. Keep fixture writes explicitly
28
+ targeted at the local test database; deployment migrations must not load them.
29
+
30
+ This preserves the catalog's named-seed metadata contract without selecting
31
+ `named-seeds`, whose hard dependency and commands are D1-specific. Do not use
32
+ `INSERT OR IGNORE`, lopata D1 commands or Wrangler database commands here.
33
+
34
+ Application data fixtures do not grant identity. Provision scenario principals
35
+ and permissions through the real local IAM setup; never manufacture an
36
+ application auth context from a seed name or role label. A genuinely
37
+ self-provisioning scenario can declare `seeds: []`.