@fabrika/provider-zerops 0.4.2 → 0.4.3

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 (45) hide show
  1. package/package.json +8 -7
  2. package/src/api/client.ts +11 -14
  3. package/src/api/polling.ts +42 -0
  4. package/src/api/readers.ts +9 -2
  5. package/src/api/service-env-limit.ts +29 -0
  6. package/src/api/service-env-response.ts +34 -2
  7. package/src/api/types.ts +39 -9
  8. package/src/api.ts +10 -1
  9. package/src/control.ts +23 -8
  10. package/src/execution.ts +41 -3
  11. package/src/index.ts +5 -0
  12. package/src/installation/admin.ts +4 -2
  13. package/src/installation/checker-release.ts +44 -0
  14. package/src/installation/deploy.ts +66 -22
  15. package/src/installation/generated/platform-console-schema.ts +18 -2
  16. package/src/installation/generated/platform-proxy-manifest.ts +20 -0
  17. package/src/installation/guide.md +164 -52
  18. package/src/installation/index.ts +22 -15
  19. package/src/installation/init-options.ts +2 -2
  20. package/src/installation/init.ts +8 -2
  21. package/src/installation/install-collaborators.ts +4 -0
  22. package/src/installation/install-environment.ts +52 -30
  23. package/src/installation/install-options.ts +20 -19
  24. package/src/installation/install-project.ts +23 -15
  25. package/src/installation/install-provisioning.ts +12 -12
  26. package/src/installation/install-secrets.ts +89 -0
  27. package/src/installation/install-token.ts +37 -0
  28. package/src/installation/install.ts +34 -21
  29. package/src/installation/machine-key.ts +3 -2
  30. package/src/installation/manifest.ts +29 -12
  31. package/src/installation/platform-archive.ts +87 -0
  32. package/src/installation/platform-upload.ts +2 -2
  33. package/src/installation/secrets.ts +3 -4
  34. package/src/installation/topology/setups.ts +15 -27
  35. package/src/installation/topology/topology.ts +105 -104
  36. package/src/managed-env.ts +11 -0
  37. package/src/manifest.ts +25 -3
  38. package/src/namespace/codec.ts +6 -1
  39. package/src/namespace/errors.ts +3 -1
  40. package/src/namespace/provisioning.ts +18 -5
  41. package/src/namespace/proxy-attempt.ts +56 -14
  42. package/src/namespace/update.ts +50 -8
  43. package/src/provider.ts +57 -13
  44. package/src/proxy-manifest-carrier.ts +32 -0
  45. package/src/source/port.ts +9 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,12 +43,13 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "^0.4.2",
47
- "@fabrika/auth-core": "^0.4.2",
48
- "@fabrika/iam-contract": "^0.4.2",
49
- "@fabrika/installation-init": "^0.4.2",
50
- "@fabrika/provider-contract": "^0.4.2",
51
- "@fabrika/proxy-core": "^0.4.2",
46
+ "@fabrika/auth": "^0.4.3",
47
+ "@fabrika/auth-core": "^0.4.3",
48
+ "@fabrika/iam-contract": "^0.4.3",
49
+ "@fabrika/installation-init": "^0.4.3",
50
+ "@fabrika/operations-contract": "^0.4.3",
51
+ "@fabrika/provider-contract": "^0.4.3",
52
+ "@fabrika/proxy-core": "^0.4.3",
52
53
  "yaml": "^2.9.1"
53
54
  }
54
55
  }
package/src/api/client.ts CHANGED
@@ -14,19 +14,12 @@ import {
14
14
  readResponse,
15
15
  serviceEnvSchema,
16
16
  serviceSchema,
17
+ userInfoSchema,
17
18
  } from './readers'
19
+ import { assertServiceEnvValueFits } from './service-env-limit'
18
20
  import { parseServiceEnvResponse } from './service-env-response'
19
21
  import type { ZeropsApi, ZeropsApiOptions, ZeropsAppVersion, ZeropsLogLine } from './types'
20
22
 
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
23
  /** The RFC3339 stamp the plaintext log window puts first on every line. Anchored so a bare year cannot pass. */
31
24
  const LOG_LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
32
25
 
@@ -403,11 +396,12 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
403
396
 
404
397
  listServiceEnv,
405
398
 
406
- createServiceEnv: async ({ serviceId, key, value, signal }) => {
399
+ createServiceEnv: async ({ serviceId, key, value, sensitive, signal }) => {
400
+ assertServiceEnvValueFits(key, value)
407
401
  let started: unknown
408
402
  try {
409
403
  started = await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
410
- body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
404
+ body: { key, content: value, sensitive },
411
405
  signal,
412
406
  omitErrorResponseDetails: true,
413
407
  })
@@ -418,12 +412,13 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
418
412
  await awaitUserDataSync(started, `the user-data sync on service ${serviceId}`, signal)
419
413
  },
420
414
 
421
- putServiceEnv: async ({ serviceId, key, value, signal }) => {
415
+ putServiceEnv: async ({ serviceId, key, value, sensitive, signal }) => {
416
+ assertServiceEnvValueFits(key, value)
422
417
  const label = `the user-data sync on service ${serviceId}`
423
418
  // `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
424
419
  try {
425
420
  const created = await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
426
- body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
421
+ body: { key, content: value, sensitive },
427
422
  signal,
428
423
  redactDetail: true,
429
424
  })
@@ -445,7 +440,7 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
445
440
  )
446
441
  }
447
442
  const updated = await request('update service env', 'PUT', `/user-data/${existing.id}`, {
448
- body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
443
+ body: { key, content: value, sensitive },
449
444
  signal,
450
445
  redactDetail: true,
451
446
  })
@@ -460,6 +455,8 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
460
455
  getProjectEnv: async ({ projectEnvId, signal }) =>
461
456
  readResponse('get project env', serviceEnvSchema, await request('get project env', 'GET', `/project-env/${projectEnvId}`, { signal })),
462
457
 
458
+ getUserInfo: async ({ signal }) => readResponse('get user info', userInfoSchema, await request('get user info', 'GET', '/user/info', { signal })),
459
+
463
460
  createIntegrationToken: async ({ clientId, name, projects, roleCode, canCreateProjects, signal }) =>
464
461
  // `redactDetail`: unlike every other write here the SECRET is in the RESPONSE, not the request, so
465
462
  // this is the one error envelope that could quote a minted token back at us.
@@ -1,4 +1,5 @@
1
1
  import type { Sleeper } from '../collaborators'
2
+ import { ZEROPS_SERVICE_NOT_HTTP, ZeropsApiError } from './errors'
2
3
  import { ZEROPS_PROCESS_FINISHED, ZEROPS_PROCESS_TERMINAL, type ZeropsApi, type ZeropsProcess } from './types'
3
4
 
4
5
  // ── waiting on a process ────────────────────────────────────────────────────────
@@ -57,3 +58,44 @@ export const waitForProcess = async (input: {
57
58
  await input.sleep(intervalMs, input.signal)
58
59
  }
59
60
  }
61
+
62
+ // ── waiting for a service's HTTP ports ──────────────────────────────────────────
63
+
64
+ /** How often `enableSubdomainAccessWhenHttp` asks again while the platform still answers `serviceStackIsNotHttp`. */
65
+ const HTTP_PORT_POLL_INTERVAL_MS = 5_000
66
+
67
+ /** ~3 minutes. The live lag measured between ACTIVE and the HTTP ports was under a minute. */
68
+ const HTTP_PORT_POLL_ATTEMPTS = 36
69
+
70
+ /**
71
+ * `enableSubdomainAccess`, repeated while the service does not yet publish an HTTP port.
72
+ *
73
+ * Live-verified: right after a service's first app version reads ACTIVE the platform still answers
74
+ * `serviceStackIsNotHttp`, and a minute later the same call is accepted. The ports lag ACTIVE, so that
75
+ * answer means "not yet" for a bounded window. Past it the last refusal is rethrown unchanged, so a caller
76
+ * still names the missing port. Any other failure is rethrown at once. The 2xx still proves nothing: the
77
+ * caller decides by reading `subdomainAccess` back.
78
+ */
79
+ export const enableSubdomainAccessWhenHttp = async (input: {
80
+ api: Pick<ZeropsApi, 'enableSubdomainAccess'>
81
+ serviceId: string
82
+ sleep: Sleeper
83
+ signal: AbortSignal
84
+ intervalMs?: number
85
+ attempts?: number
86
+ }): Promise<void> => {
87
+ const intervalMs = input.intervalMs ?? HTTP_PORT_POLL_INTERVAL_MS
88
+ const attempts = input.attempts ?? HTTP_PORT_POLL_ATTEMPTS
89
+ for (let attempt = 0;; attempt += 1) {
90
+ input.signal.throwIfAborted()
91
+ try {
92
+ await input.api.enableSubdomainAccess({ serviceId: input.serviceId, signal: input.signal })
93
+ return
94
+ } catch (error) {
95
+ if (!(error instanceof ZeropsApiError) || error.code !== ZEROPS_SERVICE_NOT_HTTP || attempt >= attempts) {
96
+ throw error
97
+ }
98
+ }
99
+ await input.sleep(intervalMs, input.signal)
100
+ }
101
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { withoutSensitiveContent } from './service-env-response'
2
3
  import type {
3
4
  ZeropsAppVersion,
4
5
  ZeropsAppVersionStatus,
@@ -13,6 +14,7 @@ import type {
13
14
  ZeropsService,
14
15
  ZeropsServiceEnv,
15
16
  ZeropsServiceStatus,
17
+ ZeropsUserInfo,
16
18
  } from './types'
17
19
 
18
20
  /** Zerops sends `null` for an absent optional field; nothing reads one of these as required, so a malformed one reads as absent. */
@@ -148,14 +150,19 @@ export const integrationTokenSchema = z.object({
148
150
  roleCode: optional(roleCodeSchema),
149
151
  }) satisfies z.ZodType<ZeropsIntegrationToken, z.ZodTypeDef, unknown>
150
152
 
153
+ export const userInfoSchema = z.object({
154
+ id: idSchema,
155
+ email: z.string(),
156
+ }) satisfies z.ZodType<ZeropsUserInfo, z.ZodTypeDef, unknown>
157
+
151
158
  export const serviceEnvSchema = z.object({
152
159
  id: idSchema,
153
160
  key: nonBlankSchema,
154
161
  content: z.string(),
155
162
  serviceStackId: optional(idSchema),
156
163
  type: optional(z.string()),
157
- sensitive: optional(z.boolean()),
158
- }) satisfies z.ZodType<ZeropsServiceEnv, z.ZodTypeDef, unknown>
164
+ sensitive: z.boolean(),
165
+ }).transform(withoutSensitiveContent) satisfies z.ZodType<ZeropsServiceEnv, z.ZodTypeDef, unknown>
159
166
 
160
167
  export const importResultSchema = z.object({
161
168
  projectId: idSchema,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The largest service variable value fabrika writes, in UTF-8 bytes. Measured live on 2026-10-15 (issue #3): a value
3
+ * of 32,000 bytes is created, one of 32,768 bytes or more answers a bare `500` with no error code. The bytes between
4
+ * were not probed, so the bound is the largest size that was seen to work.
5
+ */
6
+ export const ZEROPS_SERVICE_ENV_VALUE_MAX_BYTES = 32_000
7
+
8
+ /** The provider's code for a value over the bound; the platform has none of its own, only the 500. */
9
+ export const ZEROPS_SERVICE_ENV_VALUE_TOO_LARGE = 'serviceEnvValueTooLarge'
10
+
11
+ /** A write refused before it was sent. The message names the variable and the sizes, never the value. */
12
+ export class ZeropsServiceEnvValueTooLargeError extends Error {
13
+ readonly code = ZEROPS_SERVICE_ENV_VALUE_TOO_LARGE
14
+
15
+ constructor(readonly key: string, readonly bytes: number) {
16
+ super(
17
+ `zerops: service variable \`${key}\` is ${bytes} bytes (UTF-8), over the ${ZEROPS_SERVICE_ENV_VALUE_MAX_BYTES}-byte limit of a Zerops service variable`,
18
+ )
19
+ this.name = 'ZeropsServiceEnvValueTooLargeError'
20
+ }
21
+ }
22
+
23
+ /** Refuse a value Zerops would answer with a bare 500, which reads as an outage and is retried for ever. */
24
+ export const assertServiceEnvValueFits = (key: string, value: string): void => {
25
+ const bytes = new TextEncoder().encode(value).byteLength
26
+ if (bytes > ZEROPS_SERVICE_ENV_VALUE_MAX_BYTES) {
27
+ throw new ZeropsServiceEnvValueTooLargeError(key, bytes)
28
+ }
29
+ }
@@ -5,13 +5,45 @@ const invalid = (): never => {
5
5
  throw new Error('zerops: invalid service environment response')
6
6
  }
7
7
  const nonemptySchema = z.string().refine((value) => value.trim() !== '')
8
+
9
+ /** One user-data record as the platform sends it, before `withoutSensitiveContent`. */
10
+ interface RawServiceEnv {
11
+ readonly id: string
12
+ readonly key: string
13
+ readonly content: string
14
+ readonly serviceStackId?: string | undefined
15
+ readonly type?: string | undefined
16
+ readonly sensitive: boolean
17
+ }
18
+
19
+ /**
20
+ * Drop a sensitive record's `content`, whatever it says.
21
+ *
22
+ * Zerops answers `REDACTED` outside sudo mode and the real value inside it (measured 2026-10-15), so the
23
+ * same call reads differently depending on the token. Dropping it here makes every caller behave the
24
+ * same for both, and no caller can compare, merge or leak a secret it read back.
25
+ */
26
+ export const withoutSensitiveContent = (raw: RawServiceEnv): ZeropsServiceEnv => {
27
+ const record = {
28
+ id: raw.id,
29
+ key: raw.key,
30
+ ...(raw.serviceStackId === undefined ? {} : { serviceStackId: raw.serviceStackId }),
31
+ ...(raw.type === undefined ? {} : { type: raw.type }),
32
+ }
33
+ return raw.sensitive ? { ...record, sensitive: true } : { ...record, sensitive: false, content: raw.content }
34
+ }
35
+
36
+ /** The value of a record fabrika can read: absent when there is no record or it is sensitive. */
37
+ export const readableContent = (record: ZeropsServiceEnv | undefined): string | undefined =>
38
+ record === undefined || record.sensitive ? undefined : record.content
39
+
8
40
  const serviceEnvEntrySchema = z.object({
9
41
  id: nonemptySchema,
10
42
  key: nonemptySchema,
11
43
  content: z.string(),
12
44
  serviceStackId: nonemptySchema.optional(),
13
45
  type: nonemptySchema.optional(),
14
- sensitive: z.boolean().optional(),
46
+ sensitive: z.boolean(),
15
47
  }).superRefine((entry, ctx) => {
16
48
  if (Object.values(entry).some((value) => value === undefined)) ctx.addIssue({ code: 'custom', message: 'present fields must have values' })
17
49
  })
@@ -29,6 +61,6 @@ export function parseServiceEnvResponse(value: unknown, serviceId: string): Zero
29
61
  ids.add(id)
30
62
  keys.add(key)
31
63
  if (serviceStackId !== undefined && serviceStackId !== serviceId) return invalid()
32
- return entry
64
+ return withoutSensitiveContent(entry)
33
65
  })
34
66
  }
package/src/api/types.ts CHANGED
@@ -231,22 +231,46 @@ export interface ZeropsIntegrationToken {
231
231
  roleCode?: ZeropsRoleCode
232
232
  }
233
233
 
234
+ /**
235
+ * The identity a token authenticates as, narrowed to what fabrika reads. VERIFIED: `GET /user/info`,
236
+ * `ResponseUserAuthorize` — `id` and `email` are in its `required` list.
237
+ *
238
+ * Read with an integration token, `id` is the token's own id (measured live, 2026-08-21) and `email` is
239
+ * `token-<id>@zerops.io` (reported by an operator, not measured here).
240
+ */
241
+ export interface ZeropsUserInfo {
242
+ id: string
243
+ email: string
244
+ }
245
+
234
246
  /**
235
247
  * ONE service-level environment variable. VERIFIED: `OutDtoUserData`.
236
248
  *
237
- * UNVERIFIED BEHAVIOUR: whether `content` carries the real value for a variable Zerops stores as a
238
- * SECRET, or a blurred placeholder. `docs/backlog/06-can-zerops-secrets-be-read-back.md` is open on
239
- * exactly this; the dashboard UX depends on the answer. Never log `content`.
249
+ * A SENSITIVE record carries no `content`. Measured 2026-10-15: Zerops answers `REDACTED` to a token
250
+ * outside sudo mode and the value itself to one inside it, so nothing may depend on reading a secret
251
+ * back, and the reader drops it. Never log `content`.
240
252
  */
241
- export interface ZeropsServiceEnv {
253
+ export type ZeropsServiceEnv = ZeropsServiceEnvRecord & ({ sensitive: false; content: string } | { sensitive: true })
254
+
255
+ export interface ZeropsServiceEnvRecord {
242
256
  id: string
243
257
  key: string
244
- content: string
245
258
  serviceStackId?: string
246
259
  /** Record classification, not a sensitivity or ownership guarantee; sensitive records can be USER. */
247
260
  type?: string
248
- /** Live service-env responses identify sensitive USER records independently of their type. */
249
- sensitive?: boolean
261
+ }
262
+
263
+ /** One service-level variable to write. */
264
+ export interface ZeropsServiceEnvWrite {
265
+ serviceId: string
266
+ key: string
267
+ value: string
268
+ /**
269
+ * `true` for a credential, `false` for everything else. A sensitive value can never be read back
270
+ * (see `ZeropsServiceEnv`), so a value fabrika compares or merges later must be written `false`.
271
+ */
272
+ sensitive: boolean
273
+ signal: AbortSignal
250
274
  }
251
275
 
252
276
  /**
@@ -481,7 +505,7 @@ export interface ZeropsApi {
481
505
  * details are intentionally unavailable because both the request and an upstream response can contain
482
506
  * the secret value.
483
507
  */
484
- createServiceEnv(input: { serviceId: string; key: string; value: string; signal: AbortSignal }): Promise<void>
508
+ createServiceEnv(input: ZeropsServiceEnvWrite): Promise<void>
485
509
 
486
510
  /**
487
511
  * Create-or-update ONE service-level variable — the only way fabrika ever writes a secret, and it is
@@ -491,8 +515,9 @@ export interface ZeropsApi {
491
515
  *
492
516
  * WRITE FIRST, READ ONLY ON CONFLICT. A read before the write cannot be part of this: the create
493
517
  * must work on a service that has never been deployed, which is exactly ADR-0004's bring-up order.
518
+ * An update also sets `sensitive`, so a record written sensitive by an older release becomes readable.
494
519
  */
495
- putServiceEnv(input: { serviceId: string; key: string; value: string; signal: AbortSignal }): Promise<void>
520
+ putServiceEnv(input: ZeropsServiceEnvWrite): Promise<void>
496
521
 
497
522
  /** Delete one service-level variable by its record id. VERIFIED: `DELETE /user-data/{id}`. */
498
523
  deleteServiceEnv(input: { envId: string; signal: AbortSignal }): Promise<void>
@@ -503,6 +528,11 @@ export interface ZeropsApi {
503
528
  */
504
529
  getProjectEnv(input: { projectEnvId: string; signal: AbortSignal }): Promise<ZeropsServiceEnv>
505
530
 
531
+ // ── user: who the token is ────────────────────────────────────────────────────
532
+
533
+ /** The identity this client's token authenticates as. VERIFIED: `GET /user/info` (`GetUserInfo`). */
534
+ getUserInfo(input: { signal: AbortSignal }): Promise<ZeropsUserInfo>
535
+
506
536
  // ── client: minting a credential ──────────────────────────────────────────────
507
537
 
508
538
  /**
package/src/api.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  export { createZeropsApi, ZEROPS_API_BASE } from './api/client'
2
2
  export { ZEROPS_SERVICE_NOT_HTTP, ZeropsApiError } from './api/errors'
3
- export { waitForProcess } from './api/polling'
3
+ export { enableSubdomainAccessWhenHttp, waitForProcess } from './api/polling'
4
+ export {
5
+ assertServiceEnvValueFits,
6
+ ZEROPS_SERVICE_ENV_VALUE_MAX_BYTES,
7
+ ZEROPS_SERVICE_ENV_VALUE_TOO_LARGE,
8
+ ZeropsServiceEnvValueTooLargeError,
9
+ } from './api/service-env-limit'
10
+ export { readableContent } from './api/service-env-response'
4
11
  export {
5
12
  type FetchLike,
6
13
  ZEROPS_ACTIVE,
@@ -24,6 +31,8 @@ export {
24
31
  type ZeropsRoleCode,
25
32
  type ZeropsService,
26
33
  type ZeropsServiceEnv,
34
+ type ZeropsServiceEnvWrite,
27
35
  type ZeropsServiceStatus,
28
36
  type ZeropsTokenProjectGrant,
37
+ type ZeropsUserInfo,
29
38
  } from './api/types'
package/src/control.ts CHANGED
@@ -20,7 +20,15 @@ import type {
20
20
  } from '@fabrika/provider-contract'
21
21
  import { decodeEnvelope, ProviderSecretError } from '@fabrika/provider-contract'
22
22
  import { z } from 'zod'
23
- import { createZeropsApi, waitForProcess, ZEROPS_ACTIVE, ZEROPS_TERMINAL, type ZeropsApi, type ZeropsAppVersion } from './api'
23
+ import {
24
+ createZeropsApi,
25
+ waitForProcess,
26
+ ZEROPS_ACTIVE,
27
+ ZEROPS_TERMINAL,
28
+ type ZeropsApi,
29
+ type ZeropsAppVersion,
30
+ ZeropsServiceEnvValueTooLargeError,
31
+ } from './api'
24
32
  import { defaultSleep, defaultZeropsCollaborators, type Sleeper } from './collaborators'
25
33
  import { createdServices, type ExposureAlerter, isObjectStorageType } from './exposure'
26
34
  import { assertAuthoredManagedInputs, assertAuthoredManagedName, assertNoFrameworkReference, parseManagedEnvironment } from './managed-env'
@@ -371,12 +379,13 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
371
379
  target: runtimeTarget,
372
380
  artifact,
373
381
  ...(input.environment.access === undefined ? {} : { access: input.environment.access }),
382
+ ...(input.artifactUpload === undefined ? {} : { artifactUpload: input.artifactUpload }),
374
383
  dryRun: input.dryRun,
375
384
  signal: input.signal,
376
385
  events: input.events,
377
386
  })
378
387
  input.signal.throwIfAborted()
379
- return { state: result.status }
388
+ return { state: result.status, ...(result.artifactState === undefined ? {} : { artifactState: result.artifactState }) }
380
389
  },
381
390
  cancel: async (input): Promise<ProviderCancelOutcome> => {
382
391
  const signal = input.signal
@@ -459,12 +468,18 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
459
468
  assertNoFrameworkReference(input.value)
460
469
  assertOwnReferencesOnly(input.environment, input.name, input.value)
461
470
  const target = decodeEnvironment(input.environment).target
462
- await api.putServiceEnv({
463
- serviceId: target.serviceId,
464
- key: input.name,
465
- value: input.value,
466
- signal: abortSignal(),
467
- })
471
+ try {
472
+ await api.putServiceEnv({
473
+ serviceId: target.serviceId,
474
+ key: input.name,
475
+ value: input.value,
476
+ sensitive: true,
477
+ signal: abortSignal(),
478
+ })
479
+ } catch (cause) {
480
+ if (cause instanceof ZeropsServiceEnvValueTooLargeError) throw new ProviderSecretError(cause.message, cause.code)
481
+ throw cause
482
+ }
468
483
  return { valueRef: `zerops:${target.serviceId}/${encodeURIComponent(input.name)}` }
469
484
  },
470
485
  delete: async (input) => {
package/src/execution.ts CHANGED
@@ -2,7 +2,15 @@
2
2
  // runs. Of the authored setup only `build.base`, `build.os` and the SPA's `build.buildCommands` survive; the rest of
3
3
  // the build, the deploy gate and the whole `run` section are generated here. PURE — no network, no filesystem.
4
4
 
5
- import { type ContractArtifact, contractDigest, FABRIKA_CONTRACT_DIGEST, type FrameworkVariableName } from '@fabrika/auth-core'
5
+ import {
6
+ type ContractArtifact,
7
+ contractDigest,
8
+ FABRIKA_CONTRACT_DIGEST,
9
+ FABRIKA_SOURCE_MAP_UPLOAD,
10
+ FABRIKA_SOURCE_MAP_UPLOAD_KEY,
11
+ type FrameworkVariableName,
12
+ } from '@fabrika/auth-core'
13
+ import type { OperationsAssetRoot, OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
6
14
  import type { JsonValue } from '@fabrika/provider-contract'
7
15
  import packageManifest from '../package.json'
8
16
  import { renderYaml } from './compile'
@@ -16,8 +24,9 @@ export const ZEROPS_APPLICATION_PORT = 3000
16
24
  export const ZEROPS_APPLICATION_RUNTIME = 'alpine/bun@1.3'
17
25
 
18
26
  /**
19
- * The checker every build runs, pinned to this package's own version: the public packages are released together
20
- * (`release:validate`), so this is the checker of the installation that renders the build.
27
+ * The checker every build runs, pinned to this package's own version. Control runs the git checkout of its release
28
+ * tag, so this reads the source version, which `release:validate` holds equal to the tag; the public packages are
29
+ * released together, so it is the checker of the installation that renders the build.
21
30
  */
22
31
  export const FABRIKA_CHECK_VERSION: string = packageManifest.version
23
32
 
@@ -73,6 +82,8 @@ export interface ZeropsLockedExecution {
73
82
  /** The hostname of the application's one object-storage service, or null when it has none. */
74
83
  readonly storageHostname: string | null
75
84
  readonly runtimePackages: readonly ZeropsRuntimePackage[]
85
+ /** Where the build uploads its source maps, without the bearer; null when Control minted no upload for the run. */
86
+ readonly sourceMaps: OperationsSourceMapBuildUpload | null
76
87
  }
77
88
 
78
89
  /** The application ids the generated scripts may name: the contract's own id rule, so a script needs no quoting. */
@@ -148,6 +159,16 @@ const refuse = (setup: string, key: string, reason: string): never => {
148
159
  throw new Error(`invalid fabrika manifest: zerops.yaml setup \`${setup}\` sets \`${key}\`; ${reason} (allowed form: ${LOCKED_BUILD_FORM})`)
149
160
  }
150
161
 
162
+ /** The directories a contract serves its pages from: each SPA mount's and each share link's SPA, by URL prefix. */
163
+ export function zeropsAssetRoots(artifact: ContractArtifact): OperationsAssetRoot[] {
164
+ const roots: OperationsAssetRoot[] = []
165
+ for (const mount of Object.values(artifact.mounts)) {
166
+ if (mount.kind === 'spa') roots.push({ prefix: mount.prefix, dir: mount.dir })
167
+ if (mount.kind === 'link') roots.push({ prefix: mount.prefix, dir: mount.spa })
168
+ }
169
+ return roots.sort((left, right) => left.prefix < right.prefix ? -1 : left.prefix > right.prefix ? 1 : 0)
170
+ }
171
+
151
172
  interface AuthoredBuild {
152
173
  readonly base: string | string[] | null
153
174
  readonly os: string | null
@@ -253,6 +274,21 @@ const startScript = (appId: string, database: boolean): readonly string[] => [
253
274
 
254
275
  const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
255
276
 
277
+ // After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
278
+ // provider reads the state from the report line.
279
+ const SOURCE_MAP_UPLOAD = `cd /tmp && bun x --bun -p @fabrika/check@${FABRIKA_CHECK_VERSION} fabrika-source-maps ${SOURCE_ROOT} || true`
280
+
281
+ /**
282
+ * The upload's build variables: the destination and asset roots as a literal, and the bearer lifted from the service
283
+ * variable the deploy wrote sensitive, so the descriptor never holds it.
284
+ */
285
+ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record<string, string> => {
286
+ const value = JSON.stringify(upload)
287
+ // Zerops resolves `${…}` in a build variable, so an SPA directory carrying one would read another variable.
288
+ if (value.includes('${')) throw new Error('zerops: an SPA directory or prefix must not contain `${`')
289
+ return { [FABRIKA_SOURCE_MAP_UPLOAD]: value, [FABRIKA_SOURCE_MAP_UPLOAD_KEY]: `\${RUNTIME_${FABRIKA_SOURCE_MAP_UPLOAD_KEY}}` }
290
+ }
291
+
256
292
  /**
257
293
  * The generated build commands, in order; the authored SPA commands run before all of them. The application's tests
258
294
  * run first: they are authored code, so everything after them is reinstalled, regenerated or checked again.
@@ -270,6 +306,7 @@ export function generatedBuildCommands(execution: ZeropsLockedExecution): readon
270
306
  `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
271
307
  ...(database ? [writeFileCommand(MIGRATE_SCRIPT, migrateScript(execution.appId))] : []),
272
308
  writeFileCommand(START_SCRIPT, startScript(execution.appId, database)),
309
+ ...(execution.sourceMaps === null ? [] : [SOURCE_MAP_UPLOAD]),
273
310
  ]
274
311
  }
275
312
 
@@ -330,6 +367,7 @@ export function renderLockedSetup(
330
367
  base: authored.base ?? [ZEROPS_APPLICATION_RUNTIME],
331
368
  ...(authored.os === null ? {} : { os: authored.os }),
332
369
  buildCommands: [...authored.commands, ...generatedBuildCommands(execution)],
370
+ ...(execution.sourceMaps === null ? {} : { envVariables: sourceMapBuildVariables(execution.sourceMaps) }),
333
371
  deployFiles: ['./'],
334
372
  cache: ['node_modules'],
335
373
  },
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export {
2
2
  createZeropsApi,
3
+ enableSubdomainAccessWhenHttp,
4
+ readableContent,
3
5
  waitForProcess,
4
6
  ZEROPS_ACTIVE,
5
7
  ZEROPS_API_BASE,
@@ -28,8 +30,10 @@ export type {
28
30
  ZeropsRoleCode,
29
31
  ZeropsService,
30
32
  ZeropsServiceEnv,
33
+ ZeropsServiceEnvWrite,
31
34
  ZeropsServiceStatus,
32
35
  ZeropsTokenProjectGrant,
36
+ ZeropsUserInfo,
33
37
  } from './api'
34
38
  export { defineApp, isZeropsAppConfig, useSharedPostgres, zeropsSharedServiceHostname, zeropsSharedServicePrefix } from './authoring'
35
39
  export type { ZeropsAuthoredAppConfig } from './authoring'
@@ -100,6 +104,7 @@ export { buildZeropsPlan, resolveDeployHostname } from './plan'
100
104
  export type { ZeropsStep, ZeropsStepKind } from './plan'
101
105
  export { CANCELLED, createZeropsDeployer, interpolateManifest } from './provider'
102
106
  export type { ZeropsDeployResult, ZeropsDeployRun } from './provider'
107
+ export { ZEROPS_PROXY_MANIFEST_PATH, type ZeropsProxyManifestCarrier, zeropsProxyManifestCarrier } from './proxy-manifest-carrier'
103
108
  export type * from './schema.generated'
104
109
  export { isZeropsUploadDestination, normalizeZeropsSourceRepository } from './source'
105
110
  export type { ZeropsSourceRepository } from './source'
@@ -84,7 +84,7 @@ export const runPlatformAdmin = async (input: PlatformAdminInput, collaborators:
84
84
  const report = (result: FirstAdministratorResult, log: AdminLog): void => {
85
85
  log.ok(
86
86
  result.principal === 'invited'
87
- ? `invited ${result.email} as principal ${result.principalId}`
87
+ ? `created principal ${result.principalId} for ${result.email} — no e-mail was sent`
88
88
  : `${result.email} already exists as principal ${result.principalId}`,
89
89
  )
90
90
  // `app: null` is the whole point: an `admin` grant scoped to the console's own app id leaves
@@ -113,7 +113,9 @@ const announceEnrollment = (result: FirstAdministratorResult, log: AdminLog, pri
113
113
  return
114
114
  }
115
115
  log.ok(`enrollment issued, valid until ${expiry(result.enrollment.expiresAt)}`)
116
- log.info('The URL below sets the first password. It is a credential: it is printed once, stored nowhere, and it expires.')
116
+ // IAM answers with the URL only when it has no mail service, so nobody else will deliver it.
117
+ log.info('Nothing was e-mailed: this installation has no mail service. Give the URL below to the administrator yourself.')
118
+ log.info('It sets the first password. It is a credential: it is printed once, stored nowhere, and it expires.')
117
119
  print(result.enrollment.url)
118
120
  }
119
121
 
@@ -0,0 +1,44 @@
1
+ import { FABRIKA_CHECK_VERSION } from '../execution'
2
+
3
+ /** The package every contract application build fetches by this checkout's version. */
4
+ export const FABRIKA_CHECK_PACKAGE = '@fabrika/check'
5
+
6
+ /** Whether npm has `@fabrika/check` at `version`. Throws when it cannot tell. */
7
+ export type CheckerPublication = (version: string, signal: AbortSignal) => Promise<boolean>
8
+
9
+ const NPM_REGISTRY = 'https://registry.npmjs.org'
10
+
11
+ export type RegistryFetch = (url: string, init: RequestInit) => Promise<Response>
12
+
13
+ export const npmCheckerPublication = (fetchImpl: RegistryFetch = (url, init) => fetch(url, init)): CheckerPublication => async (version, signal) => {
14
+ const url = `${NPM_REGISTRY}/${FABRIKA_CHECK_PACKAGE.replace('/', '%2F')}/${encodeURIComponent(version)}`
15
+ let response: Response
16
+ try {
17
+ response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal })
18
+ } catch {
19
+ signal.throwIfAborted()
20
+ throw new Error(`could not reach npm to check that ${FABRIKA_CHECK_PACKAGE}@${version} is published; retry when npm answers`)
21
+ }
22
+ await response.body?.cancel()
23
+ if (response.ok) return true
24
+ if (response.status === 404) return false
25
+ throw new Error(`npm answered ${response.status} for ${FABRIKA_CHECK_PACKAGE}@${version}; retry when npm answers`)
26
+ }
27
+
28
+ /**
29
+ * Refuse a checkout whose checker npm does not have. Every contract application build of the installation fetches
30
+ * `@fabrika/check` at this checkout's version, so without it no application could deploy. Between a release's bump
31
+ * commit and its publish, `main` is such a checkout.
32
+ */
33
+ export const assertCheckerPublished = async (
34
+ published: CheckerPublication,
35
+ signal: AbortSignal,
36
+ version: string = FABRIKA_CHECK_VERSION,
37
+ ): Promise<void> => {
38
+ if (await published(version, signal)) return
39
+ throw new Error(
40
+ `this checkout is version ${version}, which is not published yet: npm has no ${FABRIKA_CHECK_PACKAGE}@${version}, and every `
41
+ + 'application build of this installation fetches it. Deploy a released tag of contember/fabrika-platform, '
42
+ + `or wait until v${version} is published.`,
43
+ )
44
+ }