@fabrika/provider-zerops 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 (82) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +191 -344
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1271
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +266 -241
  73. package/src/source/constants.ts +11 -0
  74. package/src/source/port.ts +49 -0
  75. package/src/source/types.ts +4 -0
  76. package/src/source/validation.ts +72 -0
  77. package/src/source.ts +2 -1220
  78. package/src/types.ts +8 -7
  79. package/src/version-attempt.ts +18 -0
  80. package/src/codec.ts +0 -108
  81. package/src/namespace-command.ts +0 -209
  82. package/src/source-connection.ts +0 -344
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,16 @@
24
24
  "bun": "./src/cli.ts",
25
25
  "types": "./src/cli.ts",
26
26
  "default": "./src/cli.ts"
27
+ },
28
+ "./installation": {
29
+ "bun": "./src/installation/index.ts",
30
+ "types": "./src/installation/index.ts",
31
+ "default": "./src/installation/index.ts"
32
+ },
33
+ "./installation/topology": {
34
+ "bun": "./src/installation/topology/index.ts",
35
+ "types": "./src/installation/topology/index.ts",
36
+ "default": "./src/installation/topology/index.ts"
27
37
  }
28
38
  },
29
39
  "scripts": {
@@ -32,9 +42,12 @@
32
42
  "gen:schema": "bun scripts/generate-zerops-types.ts"
33
43
  },
34
44
  "dependencies": {
35
- "@fabrika/auth": "^0.2.0",
36
- "@fabrika/auth-core": "^0.2.0",
37
- "@fabrika/provider-contract": "^0.2.0",
38
- "@fabrika/proxy-contract": "^0.2.0"
45
+ "zod": "^3.25.76",
46
+ "@fabrika/auth": "^0.3.1",
47
+ "@fabrika/auth-core": "^0.3.1",
48
+ "@fabrika/installation-init": "^0.3.1",
49
+ "@fabrika/provider-contract": "^0.3.1",
50
+ "@fabrika/proxy-core": "^0.3.1",
51
+ "yaml": "^2.9.1"
39
52
  }
40
53
  }
@@ -0,0 +1,522 @@
1
+ import { z } from 'zod'
2
+ import type { Sleeper } from '../collaborators'
3
+ import { apiError, isAppVersionNotFoundResponse, ZeropsApiError } from './errors'
4
+ import { waitForProcess } from './polling'
5
+ import {
6
+ appVersionListSchema,
7
+ appVersionSchema,
8
+ importResultSchema,
9
+ integrationTokenSchema,
10
+ logAccessSchema,
11
+ processSchema,
12
+ projectMatchesSchema,
13
+ projectSchema,
14
+ readResponse,
15
+ serviceEnvSchema,
16
+ serviceSchema,
17
+ } from './readers'
18
+ import { parseServiceEnvResponse } from './service-env-response'
19
+ import type { ZeropsApi, ZeropsApiOptions, ZeropsAppVersion, ZeropsLogLine } from './types'
20
+
21
+ /**
22
+ * `sensitive` on every user-data WRITE, create and update alike. Required by the platform — omitting it answers
23
+ * `400 invalidUserInput` with `{"sensitive":["field is required"]}` (measured live, 2026-08-18) — and
24
+ * `true` because most of what fabrika writes is a credential. It costs nothing to read: a sensitive
25
+ * variable's `content` still comes back verbatim from `GET /service-stack/{id}/env`, also measured, so the
26
+ * compare-before-write both `platform deploy` and the create-only credential slots depend on still works.
27
+ */
28
+ const SENSITIVE_USER_DATA = true
29
+
30
+ /** The RFC3339 stamp the plaintext log window puts first on every line. Anchored so a bare year cannot pass. */
31
+ const LOG_LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
32
+
33
+ const appVersionReservationSchema = z.object({
34
+ id: z.string().refine((value) => value.trim() !== ''),
35
+ uploadUrl: z.string().refine((value) => value.trim() !== ''),
36
+ serviceStackId: z.string().refine((value) => value.trim() !== ''),
37
+ })
38
+ const triggerPipelineSchema = z.object({ process: processSchema.nullish() })
39
+ const deleteAppVersionSchema = z.object({ success: z.literal(true) })
40
+
41
+ // ── default (real) implementation ───────────────────────────────────────────────
42
+
43
+ /** Base URL of the Zerops public REST API. */
44
+ export const ZEROPS_API_BASE = 'https://api.app-prg1.zerops.io/api/rest/public'
45
+
46
+ /** The platform's code for "this service stack already has a variable under that key". */
47
+ const USER_DATA_DUPLICATE_KEY = 'userDataDuplicateKey'
48
+
49
+ /** The platform's code for a service stack it cannot find — sent with a 400, not a 404. */
50
+ const ZEROPS_SERVICE_STACK_NOT_FOUND = 'serviceStackNotFound'
51
+
52
+ const timerSleep: Sleeper = (ms, signal) =>
53
+ new Promise<void>((resolve) => {
54
+ if (signal.aborted) {
55
+ resolve()
56
+ return
57
+ }
58
+ const timer = setTimeout(() => {
59
+ signal.removeEventListener('abort', onAbort)
60
+ resolve()
61
+ }, ms)
62
+ const onAbort = (): void => {
63
+ clearTimeout(timer)
64
+ resolve()
65
+ }
66
+ signal.addEventListener('abort', onAbort, { once: true })
67
+ })
68
+
69
+ /** Build the real client. Every request carries the bearer and the run's signal. */
70
+ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
71
+ const base = (options.baseUrl ?? ZEROPS_API_BASE).replace(/\/+$/, '')
72
+ const doFetch = options.fetchImpl ?? fetch
73
+ // Its own, rather than `collaborators`' — that module imports this one, so taking the value back would close a cycle.
74
+ const sleep = options.sleep ?? timerSleep
75
+ const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError')
76
+ const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'
77
+ const SERVICE_ENV_RESPONSE_MAX_BYTES = 8 * 1024 * 1024
78
+ class ResponseByteBoundError extends Error {}
79
+
80
+ const boundedJson = async (response: Response, label: string, maxBytes: number, signal: AbortSignal): Promise<unknown> => {
81
+ if (signal.aborted) {
82
+ void response.body?.cancel().catch(() => {})
83
+ throw abortError()
84
+ }
85
+ const contentLength = response.headers.get('content-length')
86
+ if (contentLength !== null) {
87
+ const declaredBytes = Number(contentLength)
88
+ if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
89
+ void response.body?.cancel().catch(() => {})
90
+ throw new ResponseByteBoundError(`zerops: ${label} response exceeded its byte bound`)
91
+ }
92
+ }
93
+ if (response.body === null) return null
94
+ const reader = response.body.getReader()
95
+ let abortRead = (): void => {}
96
+ const aborted = new Promise<never>((_resolve, reject) => {
97
+ abortRead = (): void => {
98
+ void reader.cancel().catch(() => {})
99
+ reject(abortError())
100
+ }
101
+ signal.addEventListener('abort', abortRead, { once: true })
102
+ if (signal.aborted) abortRead()
103
+ })
104
+ const chunks: Uint8Array[] = []
105
+ let receivedBytes = 0
106
+ try {
107
+ while (true) {
108
+ const next = await Promise.race([reader.read(), aborted])
109
+ if (signal.aborted) throw abortError()
110
+ if (next.done) break
111
+ receivedBytes += next.value.byteLength
112
+ if (receivedBytes > maxBytes) {
113
+ void reader.cancel().catch(() => {})
114
+ throw new ResponseByteBoundError(`zerops: ${label} response exceeded its byte bound`)
115
+ }
116
+ chunks.push(next.value)
117
+ }
118
+ } finally {
119
+ signal.removeEventListener('abort', abortRead)
120
+ }
121
+ const bytes = new Uint8Array(receivedBytes)
122
+ let offset = 0
123
+ for (const chunk of chunks) {
124
+ bytes.set(chunk, offset)
125
+ offset += chunk.byteLength
126
+ }
127
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
128
+ }
129
+
130
+ const request = async (
131
+ label: string,
132
+ method: string,
133
+ path: string,
134
+ /**
135
+ * `redactDetail` drops the server's message from the thrown error, keeping only its code. Set it on
136
+ * any call whose BODY carries a secret value: a validation error can quote what it rejected, and an
137
+ * error that echoes a secret into a deploy log is a leak whichever end wrote it.
138
+ * `omitErrorResponseDetails` also drops the code for upload flows whose response may carry a
139
+ * credential; nothing from that response body may reach an error.
140
+ */
141
+ init: {
142
+ body?: unknown
143
+ signal: AbortSignal
144
+ redactDetail?: boolean
145
+ omitErrorResponseDetails?: boolean
146
+ /** Pretrigger reservation cleanup needs verified absence, never arbitrary response text or codes. */
147
+ preserveAppVersionNotFound?: boolean
148
+ responseMaxBytes?: number
149
+ },
150
+ ): Promise<unknown> => {
151
+ if (init.signal.aborted) {
152
+ throw abortError()
153
+ }
154
+ const headers: Record<string, string> = { authorization: `Bearer ${options.token}`, accept: 'application/json' }
155
+ if (init.body !== undefined) {
156
+ headers['content-type'] = 'application/json'
157
+ }
158
+ let response: Response
159
+ try {
160
+ response = await doFetch(`${base}${path}`, {
161
+ method,
162
+ headers,
163
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
164
+ signal: init.signal,
165
+ })
166
+ } catch (error) {
167
+ if (init.signal.aborted || isAbortError(error)) throw abortError()
168
+ throw error
169
+ }
170
+ let payload: unknown
171
+ try {
172
+ payload = init.responseMaxBytes === undefined
173
+ ? await response.json()
174
+ : await boundedJson(response, label, init.responseMaxBytes, init.signal)
175
+ } catch (error) {
176
+ if (init.signal.aborted || isAbortError(error)) throw abortError()
177
+ if (error instanceof ResponseByteBoundError) throw error
178
+ payload = null
179
+ }
180
+ if (!response.ok) {
181
+ if (init.omitErrorResponseDetails === true) {
182
+ if (init.preserveAppVersionNotFound === true && response.status === 400 && isAppVersionNotFoundResponse(payload)) {
183
+ throw new ZeropsApiError(`zerops: ${label} failed (400) — appVersionNotFound`, 400, 'appVersionNotFound')
184
+ }
185
+ throw new ZeropsApiError(`zerops: ${label} failed (${response.status})`, response.status, '')
186
+ }
187
+ throw apiError(label, response.status, payload, init.redactDetail === true)
188
+ }
189
+ return payload
190
+ }
191
+
192
+ const invalidResponse = (label: string): Error => new Error(`zerops: ${label} returned an invalid response`)
193
+
194
+ const PAGE_SIZE = 100
195
+ const readAllPages = async <Item>(
196
+ label: string,
197
+ path: string,
198
+ signal: AbortSignal,
199
+ itemSchema: z.ZodType<Item, z.ZodTypeDef, unknown>,
200
+ ): Promise<Item[]> => {
201
+ const pageSchema = z.object({ list: z.array(itemSchema), total: z.number().int().nonnegative() })
202
+ const items: Item[] = []
203
+ let offset = 0
204
+ while (true) {
205
+ const separator = path.includes('?') ? '&' : '?'
206
+ const payload = await request(label, 'GET', `${path}${separator}limit=${PAGE_SIZE}&offset=${offset}`, { signal })
207
+ const { list: page, total } = readResponse(label, pageSchema, payload)
208
+ items.push(...page)
209
+ if (items.length >= total) {
210
+ return items
211
+ }
212
+ if (page.length === 0) {
213
+ throw new Error(`zerops: ${label} ended before total`)
214
+ }
215
+ offset += page.length
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Named so `putServiceEnv` can reach it: the conflict path needs the record id, and the ONE endpoint
221
+ * that hands it over on every service is `/env` (`/user-data` answers 400 unconditionally).
222
+ */
223
+ const listServiceEnv: ZeropsApi['listServiceEnv'] = async ({ serviceId, signal }) => {
224
+ const payload = await request('list service env', 'GET', `/service-stack/${serviceId}/env`, {
225
+ signal,
226
+ responseMaxBytes: SERVICE_ENV_RESPONSE_MAX_BYTES,
227
+ })
228
+ try {
229
+ return parseServiceEnvResponse(payload, serviceId)
230
+ } catch {
231
+ throw invalidResponse('list service env')
232
+ }
233
+ }
234
+
235
+ const getProcess: ZeropsApi['getProcess'] = async ({ processId, signal }) =>
236
+ readResponse('get process', processSchema, await request('get process', 'GET', `/process/${processId}`, { signal }))
237
+
238
+ /**
239
+ * Wait out the `stack.updateUserData` process every user-data write answers with.
240
+ *
241
+ * While one runs the platform refuses the NEXT operation on that service with `400
242
+ * userDataSyncRunning` — which is how a deploy that wrote an app's environment and then asked for a
243
+ * build failed, one second before the sync it had started finished. Measured live: PENDING → RUNNING →
244
+ * FINISHED in about three seconds, on a deployed service and on one that has never had code alike.
245
+ *
246
+ * The response is recognised by `actionName`, not by having an `id`: a user-data RECORD carries an id
247
+ * too, and polling `/process/{recordId}` would hang instead of failing. An unrecognised response is
248
+ * left alone — nothing then says the write is still settling.
249
+ */
250
+ const awaitUserDataSync = async (payload: unknown, label: string, signal: AbortSignal): Promise<void> => {
251
+ const started = processSchema.safeParse(payload)
252
+ if (!started.success || started.data.actionName === undefined || started.data.actionName.trim() === '') {
253
+ return
254
+ }
255
+ await waitForProcess({ api: { getProcess }, processId: started.data.id, sleep, signal, label })
256
+ }
257
+
258
+ return {
259
+ importServices: async ({ projectId, yaml, signal }) =>
260
+ readResponse(
261
+ 'service-stack import',
262
+ importResultSchema,
263
+ await request('service-stack import', 'POST', `/project/${projectId}/service-stack/import`, { body: { yaml }, signal }),
264
+ ),
265
+
266
+ importProject: async ({ clientId, yaml, signal }) =>
267
+ readResponse(
268
+ 'project import',
269
+ importResultSchema,
270
+ await request('project import', 'POST', `/client/${clientId}/project/import`, { body: { yaml }, signal }),
271
+ ),
272
+
273
+ triggerPipeline: async ({ serviceId, buildFromGit, zeropsYaml, zeropsSetup, signal }) => {
274
+ const body: Record<string, unknown> = {}
275
+ if (buildFromGit !== undefined) {
276
+ body['buildFromGit'] = buildFromGit
277
+ }
278
+ if (zeropsYaml !== undefined) {
279
+ body['zeropsYaml'] = zeropsYaml
280
+ }
281
+ if (zeropsSetup !== undefined) {
282
+ body['zeropsSetup'] = zeropsSetup
283
+ }
284
+ const payload = await request('trigger-pipeline', 'PUT', `/service-stack/${serviceId}/trigger-pipeline`, { body, signal })
285
+ return readResponse('trigger-pipeline', triggerPipelineSchema, payload).process ?? null
286
+ },
287
+
288
+ createAppVersion: async ({ serviceId, name, signal }) => {
289
+ const body = name === undefined ? {} : { name }
290
+ const payload = await request('create app-version', 'POST', `/service-stack/${serviceId}/app-version`, {
291
+ body,
292
+ signal,
293
+ omitErrorResponseDetails: true,
294
+ })
295
+ const result = appVersionReservationSchema.safeParse(payload)
296
+ if (!result.success || result.data.serviceStackId !== serviceId) {
297
+ throw invalidResponse('create app-version')
298
+ }
299
+ return {
300
+ id: result.data.id,
301
+ uploadUrl: result.data.uploadUrl,
302
+ }
303
+ },
304
+
305
+ buildAndDeployAppVersion: async ({ appVersionId, zeropsYaml, zeropsYamlSetup, signal }) => {
306
+ const body = zeropsYamlSetup === undefined ? { zeropsYaml } : { zeropsYaml, zeropsYamlSetup }
307
+ // `redactDetail`, not `omitErrorResponseDetails`: this request body is the app's COMMITTED
308
+ // descriptor and this response carries no upload URL, so there is nothing here to protect by
309
+ // dropping the code as well — and a bare `(400)` is what made `userDataSyncRunning` cost a day.
310
+ const payload = await request('build and deploy app-version', 'PUT', `/app-version/${appVersionId}/build-and-deploy`, {
311
+ body,
312
+ signal,
313
+ redactDetail: true,
314
+ })
315
+ const process = readResponse('build and deploy app-version', processSchema, payload)
316
+ if (process.appVersionId !== undefined && process.appVersionId !== appVersionId) {
317
+ throw invalidResponse('build and deploy app-version')
318
+ }
319
+ return { ...process, appVersionId }
320
+ },
321
+
322
+ deleteAppVersion: async ({ appVersionId, signal }) => {
323
+ const payload = await request('delete app-version', 'DELETE', `/app-version/${appVersionId}`, {
324
+ signal,
325
+ omitErrorResponseDetails: true,
326
+ preserveAppVersionNotFound: true,
327
+ })
328
+ readResponse('delete app-version', deleteAppVersionSchema, payload)
329
+ },
330
+
331
+ getAppVersion: async ({ appVersionId, signal }) =>
332
+ readResponse('get app-version', appVersionSchema, await request('get app-version', 'GET', `/app-version/${appVersionId}`, { signal })),
333
+
334
+ latestAppVersion: async ({ serviceId, signal }) => {
335
+ const payload = await request('list app-versions', 'GET', `/service-stack/${serviceId}/app-version?limit=25`, { signal })
336
+ const versions = readResponse('list app-versions', appVersionListSchema, payload).list
337
+ // Highest `sequence` wins — the list's order is not documented, the sequence's meaning is.
338
+ return versions.reduce<ZeropsAppVersion | null>(
339
+ (best, version) => (best === null || (version.sequence ?? -1) > (best.sequence ?? -1) ? version : best),
340
+ null,
341
+ )
342
+ },
343
+
344
+ cancelBuild: async ({ appVersionId, signal }) => {
345
+ await request('cancel-build', 'PUT', `/app-version/${appVersionId}/cancel-build`, { signal })
346
+ },
347
+
348
+ getProcess,
349
+
350
+ enableSubdomainAccess: async ({ serviceId, signal }) => {
351
+ // No body: the operation takes none, and sending one would add a content-type the platform did not ask for.
352
+ await request('enable subdomain access', 'PUT', `/service-stack/${serviceId}/enable-subdomain-access`, { signal })
353
+ },
354
+
355
+ getService: async ({ serviceId, signal }) =>
356
+ readResponse('get service', serviceSchema, await request('get service', 'GET', `/service-stack/${serviceId}`, { signal })),
357
+
358
+ findService: async ({ projectId, hostname, signal }) => {
359
+ try {
360
+ return readResponse(
361
+ 'find service',
362
+ serviceSchema,
363
+ await request('find service', 'GET', `/service-stack-by-name/${projectId}/${encodeURIComponent(hostname)}`, { signal }),
364
+ )
365
+ } catch (error) {
366
+ // A missing service is an answer, not a failure; anything else propagates. The live platform
367
+ // says it as 400 `serviceStackNotFound` (docs/reference/zerops-platform.md), not as a 404.
368
+ if (error instanceof ZeropsApiError && (error.status === 404 || error.code === ZEROPS_SERVICE_STACK_NOT_FOUND)) {
369
+ return null
370
+ }
371
+ throw error
372
+ }
373
+ },
374
+
375
+ getProject: async ({ projectId, signal }) => {
376
+ const payload = await request('get project', 'GET', `/project/${projectId}`, { signal })
377
+ return readResponse('get project', projectSchema, payload)
378
+ },
379
+
380
+ listProjects: async ({ clientId, signal }) => readAllPages('list projects', `/client/${clientId}/project`, signal, projectSchema),
381
+
382
+ findProjects: async ({ clientId, name, signal }) => {
383
+ const payload = await request(
384
+ 'find projects',
385
+ 'GET',
386
+ `/client/${clientId}/projects-by-name/${encodeURIComponent(name)}`,
387
+ { signal },
388
+ )
389
+ return readResponse('find projects', projectMatchesSchema, payload).projects
390
+ },
391
+
392
+ listProjectServices: async ({ projectId, signal }) =>
393
+ readAllPages('list project services', `/project/${projectId}/service-stack`, signal, serviceSchema),
394
+
395
+ listServiceEnv,
396
+
397
+ createServiceEnv: async ({ serviceId, key, value, signal }) => {
398
+ let started: unknown
399
+ try {
400
+ started = await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
401
+ body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
402
+ signal,
403
+ omitErrorResponseDetails: true,
404
+ })
405
+ } catch (error) {
406
+ if (error instanceof Error && error.name === 'AbortError') throw error
407
+ throw new Error('zerops: create-only service env failed')
408
+ }
409
+ await awaitUserDataSync(started, `the user-data sync on service ${serviceId}`, signal)
410
+ },
411
+
412
+ putServiceEnv: async ({ serviceId, key, value, signal }) => {
413
+ const label = `the user-data sync on service ${serviceId}`
414
+ // `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
415
+ try {
416
+ const created = await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
417
+ body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
418
+ signal,
419
+ redactDetail: true,
420
+ })
421
+ await awaitUserDataSync(created, label, signal)
422
+ return
423
+ } catch (error) {
424
+ if (!(error instanceof ZeropsApiError) || error.code !== USER_DATA_DUPLICATE_KEY) {
425
+ throw error
426
+ }
427
+ }
428
+ // Only the conflict reads, and only then: the create must work on a service that has never been
429
+ // deployed, which is the whole of ADR-0004's bring-up order.
430
+ const existing = (await listServiceEnv({ serviceId, signal })).find((entry) => entry.key === key)
431
+ if (existing === undefined || existing.id === '') {
432
+ // The service declares the key in its own `zerops.yaml` (`type: ENV`), which conflicts on create
433
+ // yet never appears in the env list. Replacing it here would be undone by the next deploy.
434
+ throw new Error(
435
+ `zerops: service ${serviceId} already defines \`${key}\` outside the environment API — it cannot be written`,
436
+ )
437
+ }
438
+ const updated = await request('update service env', 'PUT', `/user-data/${existing.id}`, {
439
+ body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
440
+ signal,
441
+ redactDetail: true,
442
+ })
443
+ await awaitUserDataSync(updated, label, signal)
444
+ },
445
+
446
+ deleteServiceEnv: async ({ envId, signal }) => {
447
+ const deleted = await request('delete service env', 'DELETE', `/user-data/${envId}`, { signal })
448
+ await awaitUserDataSync(deleted, `the user-data sync for record ${envId}`, signal)
449
+ },
450
+
451
+ getProjectEnv: async ({ projectEnvId, signal }) =>
452
+ readResponse('get project env', serviceEnvSchema, await request('get project env', 'GET', `/project-env/${projectEnvId}`, { signal })),
453
+
454
+ createIntegrationToken: async ({ clientId, name, projects, roleCode, canCreateProjects, signal }) =>
455
+ // `redactDetail`: unlike every other write here the SECRET is in the RESPONSE, not the request, so
456
+ // this is the one error envelope that could quote a minted token back at us.
457
+ readResponse(
458
+ 'create integration token',
459
+ integrationTokenSchema,
460
+ await request('create integration token', 'POST', `/client/${clientId}/integration-token`, {
461
+ body: {
462
+ name,
463
+ roleCode: roleCode ?? 'NO_ACCESS',
464
+ // Always sent, like `roleCode`: a security-relevant field that never reaches the wire is a
465
+ // field nobody can review.
466
+ canCreateProjects: canCreateProjects ?? false,
467
+ projects: projects.map((grant) => ({ projectId: grant.projectId, roleCode: grant.roleCode })),
468
+ },
469
+ signal,
470
+ redactDetail: true,
471
+ }),
472
+ ),
473
+
474
+ getLogAccess: async ({ projectId, signal }) => {
475
+ const payload = await request('get log access', 'GET', `/project/${projectId}/log`, { signal })
476
+ return readResponse('get log access', logAccessSchema, payload)
477
+ },
478
+
479
+ readBuildLog: async ({ access, serviceId, appVersionId, since, limit, signal }) => {
480
+ if (access.urlPlain === '') {
481
+ return []
482
+ }
483
+ const separator = access.urlPlain.includes('?') ? '&' : '?'
484
+ const readWindow = async (terms: string): Promise<{ lines: ZeropsLogLine[]; error: unknown }> => {
485
+ try {
486
+ const url = `${access.urlPlain}${separator}${terms}&limit=${limit ?? 200}`
487
+ const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
488
+ if (!response.ok) {
489
+ throw new Error(`zerops: read build log failed (${response.status})`)
490
+ }
491
+ const text = await response.text()
492
+ const lines = text.split('\n').filter((line) => line.trim() !== '').map((line) => {
493
+ const head = line.slice(0, Math.max(line.indexOf(' '), 0))
494
+ return LOG_LINE_TIMESTAMP.test(head) ? { timestamp: head, message: line } : { message: line }
495
+ })
496
+ return { lines, error: undefined }
497
+ } catch (error) {
498
+ return { lines: [], error }
499
+ }
500
+ }
501
+ // Selected by tag alone: the build runs on a builder stack, so pairing the tag with the runtime
502
+ // service id matches nothing (see the interface).
503
+ const build = appVersionId === undefined ? undefined : await readWindow(`tags=${encodeURIComponent(`zbuilder@${appVersionId}`)}`)
504
+ const runtime = since === undefined ? undefined : await readWindow(`serviceStackId=${encodeURIComponent(serviceId)}`)
505
+ const attempted = [build, runtime].filter((result) => result !== undefined)
506
+ if (attempted.length === 0) {
507
+ return []
508
+ }
509
+ // Independent windows: only a failure of every one attempted is worth raising.
510
+ const failure = attempted.find((result) => result.error !== undefined)
511
+ if (failure !== undefined && attempted.every((result) => result.error !== undefined)) {
512
+ throw failure.error
513
+ }
514
+ const boundary = since === undefined ? Number.NaN : Date.parse(since)
515
+ // The runtime window carries every version that ever ran, so a line the driver cannot date is dropped.
516
+ const runtimeLines = runtime === undefined || Number.isNaN(boundary)
517
+ ? runtime?.lines ?? []
518
+ : runtime.lines.filter((line) => line.timestamp !== undefined && Date.parse(line.timestamp) >= boundary)
519
+ return [...build?.lines ?? [], ...runtimeLines]
520
+ },
521
+ }
522
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod'
2
+
3
+ const errorDetailShape = {
4
+ code: z.string().optional().catch(undefined),
5
+ message: z.string().optional().catch(undefined),
6
+ // An element that is not an object still counts: `isAppVersionNotFoundResponse` requires exactly one.
7
+ meta: z.array(z.object({ code: z.string().optional().catch(undefined) }).catch({})).catch([]),
8
+ }
9
+ const errorDetailSchema = z.object(errorDetailShape).catch({ meta: [] })
10
+ const errorEnvelopeSchema = z.object({ ...errorDetailShape, error: errorDetailSchema }).catch({ meta: [], error: { meta: [] } })
11
+
12
+ export const isAppVersionNotFoundResponse = (value: unknown): boolean => {
13
+ const { error: inner, ...outer } = errorEnvelopeSchema.parse(value)
14
+ const metadata = [...outer.meta, ...inner.meta]
15
+ return inner.code === 'appVersionNotFound' || outer.code === 'appVersionNotFound'
16
+ || (metadata.length === 1 && metadata[0]?.code === 'appVersionNotFound')
17
+ }
18
+
19
+ /**
20
+ * A non-2xx answer from the Zerops API. `code` is the platform's own error identifier, so a caller can
21
+ * branch on `userDataDuplicateKey` rather than matching a prose message.
22
+ */
23
+ export class ZeropsApiError extends Error {
24
+ constructor(message: string, readonly status: number, readonly code: string) {
25
+ super(message)
26
+ this.name = 'ZeropsApiError'
27
+ }
28
+ }
29
+
30
+ /**
31
+ * The platform's code for "this service publishes no HTTP port", the answer `enableSubdomainAccess` gives
32
+ * before a service's first deploy. Exported so a caller can say what is missing instead of relaying prose.
33
+ */
34
+ export const ZEROPS_SERVICE_NOT_HTTP = 'serviceStackIsNotHttp'
35
+
36
+ /**
37
+ * Turn a non-2xx response into a `ZeropsApiError`. Zerops answers with `{ error: { code, message, meta } }`
38
+ * (the envelope observed live; the OpenAPI `Error` schema shows the inner `{ code, message }`).
39
+ *
40
+ * `redactDetail` drops the server's MESSAGE, which a validation error can fill with the value it
41
+ * rejected. The CODE is a fixed identifier and is kept either way — without it nothing downstream can
42
+ * tell a duplicate key from a missing service.
43
+ */
44
+ export const apiError = (label: string, status: number, body: unknown, redactDetail: boolean): ZeropsApiError => {
45
+ const { error: inner, ...outer } = errorEnvelopeSchema.parse(body)
46
+ const code = inner.code ?? outer.code ?? ''
47
+ const message = redactDetail ? '' : (inner.message ?? outer.message ?? '')
48
+ const detail = [code, message].filter((part) => part !== '').join(': ')
49
+ return new ZeropsApiError(`zerops: ${label} failed (${status})${detail === '' ? '' : ` — ${detail.slice(0, 300)}`}`, status, code)
50
+ }
@@ -0,0 +1,59 @@
1
+ import type { Sleeper } from '../collaborators'
2
+ import { ZEROPS_PROCESS_FINISHED, ZEROPS_PROCESS_TERMINAL, type ZeropsApi, type ZeropsProcess } from './types'
3
+
4
+ // ── waiting on a process ────────────────────────────────────────────────────────
5
+
6
+ /**
7
+ * How often `waitForProcess` polls. The two process durations anyone has measured are 0.7 s
8
+ * (`stack.enableSubdomainAccess`) and 2.6 s (`stack.updateUserData`), so 5 s — the platform DEPLOY's
9
+ * interval — would spend most of its time asleep on a call that already finished.
10
+ */
11
+ const PROCESS_POLL_INTERVAL_MS = 2_000
12
+
13
+ /** ~5 minutes. The slow case is an import that creates managed services, not a build; a build is polled through `/app-version`. */
14
+ const PROCESS_POLL_ATTEMPTS = 150
15
+
16
+ /**
17
+ * Poll ONE process to completion.
18
+ *
19
+ * The same loop `deployPlatformService` (`installation/deploy.ts`) runs over `/app-version`, run over `/process`
20
+ * instead: read, decide ONLY on a status this build knows to be terminal, sleep, and give up on a deadline
21
+ * of our own rather than on a coincidence. Returns the finished process so a caller can read
22
+ * `actionName`/`appVersionId` off it.
23
+ *
24
+ * Anything outside `ZEROPS_PROCESS_TERMINAL` means "keep waiting", including a status this build has never
25
+ * seen — which is why an unpublished vocabulary (see that constant) can cost a timeout and never a false
26
+ * success. A non-`FINISHED` terminal status throws, naming the status and nothing else.
27
+ */
28
+ export const waitForProcess = async (input: {
29
+ api: Pick<ZeropsApi, 'getProcess'>
30
+ processId: string
31
+ sleep: Sleeper
32
+ signal: AbortSignal
33
+ /** What to call this process in a failure or timeout message. Defaults to its id. */
34
+ label?: string
35
+ intervalMs?: number
36
+ attempts?: number
37
+ }): Promise<ZeropsProcess> => {
38
+ const label = input.label ?? input.processId
39
+ const intervalMs = input.intervalMs ?? PROCESS_POLL_INTERVAL_MS
40
+ const attempts = input.attempts ?? PROCESS_POLL_ATTEMPTS
41
+ for (let attempt = 0;; attempt += 1) {
42
+ // A `Sleeper` resolves early on abort, so without this the loop would spin out its whole budget
43
+ // against a cancelled run.
44
+ if (input.signal.aborted) {
45
+ throw new Error(`zerops: waiting for ${label} was cancelled`)
46
+ }
47
+ const process = await input.api.getProcess({ processId: input.processId, signal: input.signal })
48
+ if (process.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status)) {
49
+ if (process.status !== ZEROPS_PROCESS_FINISHED) {
50
+ throw new Error(`zerops: ${label} finished as ${process.status}`)
51
+ }
52
+ return process
53
+ }
54
+ if (attempt >= attempts) {
55
+ throw new Error(`zerops: ${label} did not finish in time`)
56
+ }
57
+ await input.sleep(intervalMs, input.signal)
58
+ }
59
+ }