@fabrika/provider-zerops 0.0.26 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.0.26",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,9 +32,9 @@
32
32
  "gen:schema": "bun scripts/generate-zerops-types.ts"
33
33
  },
34
34
  "dependencies": {
35
- "@fabrika/auth": "^0.0.26",
36
- "@fabrika/auth-core": "^0.0.26",
37
- "@fabrika/provider-contract": "^0.0.26",
38
- "@fabrika/proxy-contract": "^0.0.26"
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"
39
39
  }
40
40
  }
package/src/compile.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  // credentials leak to app B. "Never write to project-level env" alone does not prevent that; the isolation
19
19
  // setting is the other half.
20
20
 
21
+ import type { JsonValue } from '@fabrika/provider-contract'
21
22
  import type { ZeropsImportProject, ZeropsImportService } from './schema.generated'
22
23
  import type { ZeropsProjectSpec, ZeropsResourceContext, ZeropsServiceSpec, ZeropsSourceTarget } from './types'
23
24
 
@@ -37,6 +38,16 @@ export interface ZeropsImportDocument {
37
38
  services: ZeropsImportService[]
38
39
  }
39
40
 
41
+ /**
42
+ * The same document once it has been through JSON — a manifest's stored `importDocument`, or one whose
43
+ * values were substituted. Structurally identical, leaves typed as JSON, so `assertZeropsInvariants`
44
+ * checks it too rather than trusting whoever produced it.
45
+ */
46
+ export interface ZeropsJsonImportDocument {
47
+ readonly project?: { readonly [key: string]: JsonValue }
48
+ readonly services: ReadonlyArray<{ readonly [key: string]: JsonValue }>
49
+ }
50
+
40
51
  /** What the compiler needs beyond the app's declaration. */
41
52
  export interface CompileInput {
42
53
  target: ZeropsSourceTarget
@@ -185,14 +196,14 @@ const compileProject = (spec: ZeropsProjectSpec): ZeropsImportProject => {
185
196
  *
186
197
  * Deliberately checks the emitted document rather than the input: it is the document that reaches Zerops.
187
198
  */
188
- export const assertZeropsInvariants = (document: ZeropsImportDocument): void => {
199
+ export const assertZeropsInvariants = (document: ZeropsImportDocument | ZeropsJsonImportDocument): void => {
189
200
  const project = document.project
190
201
  if (project !== undefined) {
191
- if (project.envVariables !== undefined) {
202
+ if (project['envVariables'] !== undefined) {
192
203
  throw new Error('zerops: refusing to write PROJECT-level env variables — they reach every service in the project (ADR-0004)')
193
204
  }
194
- if (project.envIsolation !== ENV_ISOLATION) {
195
- throw new Error(`zerops: project envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(project.envIsolation)}\` (ADR-0004)`)
205
+ if (project['envIsolation'] !== ENV_ISOLATION) {
206
+ throw new Error(`zerops: project envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(project['envIsolation'])}\` (ADR-0004)`)
196
207
  }
197
208
  }
198
209
  if (document.services.length === 0) {
@@ -200,23 +211,24 @@ export const assertZeropsInvariants = (document: ZeropsImportDocument): void =>
200
211
  }
201
212
  const seen = new Set<string>()
202
213
  for (const service of document.services) {
203
- if (service.envIsolation !== ENV_ISOLATION) {
214
+ const hostname = String(service['hostname'])
215
+ if (service['envIsolation'] !== ENV_ISOLATION) {
204
216
  throw new Error(
205
- `zerops: service \`${service.hostname}\` envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(service.envIsolation)}\` (ADR-0004)`,
217
+ `zerops: service \`${hostname}\` envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(service['envIsolation'])}\` (ADR-0004)`,
206
218
  )
207
219
  }
208
220
  // Every service class, managed included. A document missing it anywhere cannot be re-applied at
209
221
  // all: the platform answers `400 serviceStackNameUnavailable` on the first hostname that exists
210
222
  // and rejects the whole import, so one omission breaks every later apply, not just that service.
211
- if (service.override !== OVERRIDE) {
223
+ if (service['override'] !== OVERRIDE) {
212
224
  throw new Error(
213
- `zerops: service \`${service.hostname}\` must carry \`override: true\`; without it a re-apply is rejected as a name collision`,
225
+ `zerops: service \`${hostname}\` must carry \`override: true\`; without it a re-apply is rejected as a name collision`,
214
226
  )
215
227
  }
216
- if (service.envSecrets !== undefined || service.dotEnvSecrets !== undefined) {
228
+ if (service['envSecrets'] !== undefined || service['dotEnvSecrets'] !== undefined) {
217
229
  // Never assert on the VALUE — only that the field is present.
218
230
  throw new Error(
219
- `zerops: service \`${service.hostname}\` carries secrets in the import document; secrets are written through the env API (ADR-0004)`,
231
+ `zerops: service \`${hostname}\` carries secrets in the import document; secrets are written through the env API (ADR-0004)`,
220
232
  )
221
233
  }
222
234
  // `zeropsSetup` is part of the import's pipeline configuration, and the platform rejects the WHOLE
@@ -230,15 +242,15 @@ export const assertZeropsInvariants = (document: ZeropsImportDocument): void =>
230
242
  // Nothing is lost by dropping the setup name here: it is only redundant restatement of Zerops' own
231
243
  // "setup name defaults to the hostname" rule, and a deploy that needs to say otherwise passes
232
244
  // `zeropsSetup` at TRIGGER time, where no repository is required.
233
- if (service.zeropsSetup !== undefined && service.buildFromGit === undefined) {
245
+ if (service['zeropsSetup'] !== undefined && service['buildFromGit'] === undefined) {
234
246
  throw new Error(
235
- `zerops: service \`${service.hostname}\` names \`zeropsSetup\` without \`buildFromGit\`; the platform rejects the import (pass the setup when triggering the pipeline instead)`,
247
+ `zerops: service \`${hostname}\` names \`zeropsSetup\` without \`buildFromGit\`; the platform rejects the import (pass the setup when triggering the pipeline instead)`,
236
248
  )
237
249
  }
238
- if (seen.has(service.hostname)) {
239
- throw new Error(`zerops: duplicate service hostname \`${service.hostname}\` in the import document`)
250
+ if (seen.has(hostname)) {
251
+ throw new Error(`zerops: duplicate service hostname \`${hostname}\` in the import document`)
240
252
  }
241
- seen.add(service.hostname)
253
+ seen.add(hostname)
242
254
  }
243
255
  }
244
256
 
@@ -257,13 +269,55 @@ export const compileImport = (input: CompileInput): ZeropsImportDocument => {
257
269
  /** Keys/scalars that are safe unquoted in YAML. Everything else gets a double-quoted, JSON-escaped form. */
258
270
  const PLAIN = /^[A-Za-z_][A-Za-z0-9_./@+-]*$/
259
271
 
272
+ /**
273
+ * Code points a YAML 1.1 reader (go-yaml, which parses this on Zerops) ends a line on or refuses outright,
274
+ * while Bun's 1.2 reader takes them for ordinary text — so no test using Bun can see the difference. `\n`
275
+ * and `\r` are excluded: the block form owns one and `blockScalarSafe` refuses the other.
276
+ */
277
+ const isYamlHostileCodePoint = (code: number): boolean =>
278
+ (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)
279
+ || (code >= 0x7f && code <= 0x9f)
280
+ || code === 0x2028
281
+ || code === 0x2029
282
+ || code === 0xfeff
283
+ || (code >= 0xd800 && code <= 0xdfff)
284
+
285
+ /** `JSON.stringify` leaves DEL, the C1 block, U+2028/9 and U+FEFF raw; YAML's own `\uXXXX` carries them. */
286
+ const escapeYamlHostile = (quoted: string): string =>
287
+ [...quoted].map((character) => {
288
+ const code = character.codePointAt(0) ?? 0
289
+ return isYamlHostileCodePoint(code) ? `\\u${code.toString(16).padStart(4, '0')}` : character
290
+ }).join('')
291
+
260
292
  const scalar = (value: string): string => {
261
293
  // Reserved plain scalars that YAML would read as booleans/null, plus anything with structure.
262
294
  const reserved = new Set(['true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'])
263
295
  if (value !== '' && PLAIN.test(value) && !reserved.has(value.toLowerCase())) {
264
296
  return value
265
297
  }
266
- return JSON.stringify(value)
298
+ return escapeYamlHostile(JSON.stringify(value))
299
+ }
300
+
301
+ /**
302
+ * A `|-` block reproduces the string only when the text owns its own lines. An indented or blank first
303
+ * line moves (or breaks) the block's own indentation, `\r` and a blank line do not survive it, and `-`
304
+ * eats a trailing newline. A code point a YAML 1.1 reader breaks a line on cannot go in at all — the block
305
+ * is the one form with no escapes. Everything else goes out double-quoted, which is exact YAML.
306
+ */
307
+ const blockScalarSafe = (value: string): boolean => {
308
+ if (!value.includes('\n') || value.includes('\r')) {
309
+ return false
310
+ }
311
+ if ([...value].some((character) => isYamlHostileCodePoint(character.codePointAt(0) ?? 0))) {
312
+ return false
313
+ }
314
+ const lines = value.split('\n')
315
+ const first = lines[0] ?? ''
316
+ if (first === '' || first.startsWith(' ') || first.startsWith('\t')) {
317
+ return false
318
+ }
319
+ // A blank line loses its whitespace, and an empty last line is the trailing newline `-` strips.
320
+ return !lines.some((line, index) => line.trim() === '' && (line !== '' || index === lines.length - 1))
267
321
  }
268
322
 
269
323
  const isArray = (value: unknown): value is unknown[] => Array.isArray(value)
@@ -316,9 +370,8 @@ const emit = (value: unknown, indent: number): string[] => {
316
370
  return lines
317
371
  }
318
372
  if (typeof value === 'string') {
319
- if (value.includes('\n')) {
320
- const body = value.replace(/\n$/, '').split('\n').map((line) => `${pad} ${line}`)
321
- return [`${pad}|-`, ...body]
373
+ if (blockScalarSafe(value)) {
374
+ return [`${pad}|-`, ...value.split('\n').map((line) => `${pad} ${line}`)]
322
375
  }
323
376
  return [`${pad}${scalar(value)}`]
324
377
  }
package/src/control.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  RuntimeProviderRun,
18
18
  SchemaReconciler,
19
19
  } from '@fabrika/provider-contract'
20
+ import { ProviderSecretError } from '@fabrika/provider-contract'
20
21
  import {
21
22
  createZeropsApi,
22
23
  ZEROPS_ACTIVE,
@@ -40,6 +41,7 @@ import {
40
41
  } from './manifest'
41
42
  import { createZeropsNamespaceCapabilities, type ZeropsNamespaceTarget, zeropsNamespaceTargetCodec } from './namespace'
42
43
  import { createZeropsProvider, type ZeropsSourceTransportBinding } from './provider'
44
+ import { allowedReferenceHosts, foreignReferenceHost, serviceReferenceHosts } from './service-references'
43
45
  import { normalizeZeropsSourceRepository, type ZeropsSourceClient, type ZeropsSourceClientV2, type ZeropsSourceResolveResult } from './source'
44
46
  import type { ZeropsRunState, ZeropsRuntimeTarget } from './types'
45
47
 
@@ -316,6 +318,20 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
316
318
  { appId: environment.appId, env: environment.env },
317
319
  )
318
320
 
321
+ /**
322
+ * A variable written through the env API resolves `${host_KEY}` at container start like `run.envVariables`
323
+ * does, across services whatever `envIsolation` says — so an unchecked secret reads the proxy's key.
324
+ */
325
+ const assertOwnReferencesOnly = (environment: ProviderEnvironment, name: string, value: string): void => {
326
+ // A secret naming no service at all must not start failing on an artifact that no longer parses.
327
+ if (serviceReferenceHosts(name).length === 0 && serviceReferenceHosts(value).length === 0) return
328
+ const allowed = allowedReferenceHosts(manifestServiceHostnames(artifactFor(environment)))
329
+ const foreign = foreignReferenceHost(name, allowed) ?? foreignReferenceHost(value, allowed)
330
+ if (foreign !== null) {
331
+ throw new ProviderSecretError(`zerops: secret \`${name}\` references service \`${foreign}\`, which this app does not own`, 'foreignReference')
332
+ }
333
+ }
334
+
319
335
  const cancelSourceBestEffort = async (runId: string, appVersionId: string): Promise<void> => {
320
336
  const sourceController = new AbortController()
321
337
  const timeoutController = new AbortController()
@@ -678,6 +694,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
678
694
  },
679
695
  secrets: {
680
696
  put: async (input) => {
697
+ assertOwnReferencesOnly(input.environment, input.name, input.value)
681
698
  const target = decodeEnvironment(input.environment).target
682
699
  await api.putServiceEnv({
683
700
  serviceId: target.serviceId,
package/src/index.ts CHANGED
@@ -49,7 +49,7 @@ export {
49
49
  renderImportYaml,
50
50
  renderYaml,
51
51
  } from './compile'
52
- export type { CompileInput, ZeropsImportDocument } from './compile'
52
+ export type { CompileInput, ZeropsImportDocument, ZeropsJsonImportDocument } from './compile'
53
53
  export { createZeropsControlProvider, zeropsStoredTargetCodec } from './control'
54
54
  export type { ZeropsBeforeDeploy, ZeropsBeforeDeployInput, ZeropsControlProviderOptions, ZeropsProviderExecutor, ZeropsStoredTarget } from './control'
55
55
  export {
package/src/manifest.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AppActionDef, AppGates, AppSchema, AppScopeDef, GateRule, RoleDef } from '@fabrika/auth-core'
2
2
  import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
3
3
  import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
4
+ import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
4
5
  import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
5
6
 
6
7
  export const FABRIKA_MANIFEST_VERSION = 3
@@ -225,6 +226,36 @@ function parseProxy(value: unknown): ZeropsProxySpec | null {
225
226
  return upstream === undefined || gates === null ? null : { upstream, gates }
226
227
  }
227
228
 
229
+ /** The first foreign host referenced by any string leaf OR key of `value`, however deeply nested. */
230
+ const foreignReferenceInValue = (value: unknown, allowed: ReadonlySet<string>): string | null => {
231
+ if (typeof value === 'string') {
232
+ return foreignReferenceHost(value, allowed)
233
+ }
234
+ if (Array.isArray(value)) {
235
+ for (const entry of value) {
236
+ const host = foreignReferenceInValue(entry, allowed)
237
+ if (host !== null) return host
238
+ }
239
+ return null
240
+ }
241
+ if (typeof value === 'object' && value !== null) {
242
+ for (const key of Object.keys(value)) {
243
+ const host = foreignReferenceHost(key, allowed) ?? foreignReferenceInValue(prop(value, key), allowed)
244
+ if (host !== null) return host
245
+ }
246
+ }
247
+ return null
248
+ }
249
+
250
+ /** Read the descriptor as the platform does. Text scanning misses what YAML folds, escapes or hides in a block scalar. */
251
+ const parseDescriptorDocument = (contents: string): unknown => {
252
+ try {
253
+ return Bun.YAML.parse(contents)
254
+ } catch {
255
+ throw new Error('invalid fabrika manifest: zerops.yaml is not parseable YAML')
256
+ }
257
+ }
258
+
228
259
  const parseImportDocument = (value: unknown): FabrikaImportDocument => {
229
260
  if (value === undefined) {
230
261
  throw new Error('invalid fabrika manifest Zerops import document')
@@ -238,7 +269,7 @@ const parseImportDocument = (value: unknown): FabrikaImportDocument => {
238
269
  if (!Array.isArray(rawServices) || rawServices.length === 0) {
239
270
  throw new Error('invalid fabrika manifest Zerops import services')
240
271
  }
241
- const services: Array<{ readonly [key: string]: JsonValue }> = []
272
+ const parsed: Array<[string, { readonly [key: string]: JsonValue }]> = []
242
273
  const hostnames = new Set<string>()
243
274
  for (const [index, rawService] of rawServices.entries()) {
244
275
  const service = jsonObject(rawService, `Zerops import service ${index}`)
@@ -261,8 +292,19 @@ const parseImportDocument = (value: unknown): FabrikaImportDocument => {
261
292
  throw new Error('invalid fabrika manifest: Zerops import contains a forbidden environment field')
262
293
  }
263
294
  hostnames.add(hostname)
264
- services.push(service)
295
+ parsed.push([hostname, service])
296
+ }
297
+ // A `${host_KEY}` reference crosses `envIsolation: service` by design, so the declared hostnames are the
298
+ // boundary. They are never rewritten at deploy: a shared namespace's app prefix is authored INTO the
299
+ // hostname (`service-names.ts`), which `registrationResourceClaims` then enforces.
300
+ const allowed = allowedReferenceHosts([...hostnames])
301
+ for (const [hostname, service] of parsed) {
302
+ const foreign = foreignReferenceInValue(service, allowed)
303
+ if (foreign !== null) {
304
+ throw new Error(`invalid fabrika manifest: Zerops service \`${hostname}\` references service \`${foreign}\`, which this app does not own`)
305
+ }
265
306
  }
307
+ const services = parsed.map(([, service]) => service)
266
308
  const rawProject = raw['project']
267
309
  let project: { readonly [key: string]: JsonValue } | undefined
268
310
  if (rawProject !== undefined) {
@@ -386,6 +428,10 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
386
428
  if (!serviceHostnames.includes(deployService)) {
387
429
  throw new Error('invalid fabrika manifest Zerops deploy service')
388
430
  }
431
+ const foreignInDescriptor = foreignReferenceInValue(parseDescriptorDocument(sourceDescriptor.contents), allowedReferenceHosts(serviceHostnames))
432
+ if (foreignInDescriptor !== null) {
433
+ throw new Error(`invalid fabrika manifest: zerops.yaml references service \`${foreignInDescriptor}\`, which this app does not own`)
434
+ }
389
435
  const zeropsSetup = optionalString(rawTarget, 'zeropsSetup')
390
436
  if (prop(rawTarget, 'zeropsSetup') !== undefined && zeropsSetup === null) {
391
437
  throw new Error('invalid fabrika manifest zeropsSetup')
package/src/namespace.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  type ProviderNamespacePreset,
15
15
  type ProviderRegistration,
16
16
  } from '@fabrika/provider-contract'
17
- import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-contract'
17
+ import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON, isProxyHost } from '@fabrika/proxy-contract'
18
18
  import {
19
19
  ZEROPS_ACTIVE,
20
20
  ZEROPS_SERVICE_NOT_HTTP,
@@ -377,11 +377,22 @@ const registrationResourceClaims = (registration: ProviderRegistration): string[
377
377
  })
378
378
  // A proxy target is routed by HOST, so a registration with no domain has nothing the proxy can serve:
379
379
  // refuse it here, where nothing has been created yet, instead of at the first deploy's manifest compile.
380
- if (manifest.target.proxy !== undefined && (registration.environment.domain ?? '').trim() === '') {
381
- throw new Error(
382
- `Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` requires a public domain: `
383
- + `pass --domain with a host this namespace serves (\`namespaces get ${namespace.id}\` lists them)`,
384
- )
380
+ if (manifest.target.proxy !== undefined) {
381
+ const domain = registration.environment.domain ?? ''
382
+ if (domain.trim() === '') {
383
+ throw new Error(
384
+ `Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` requires a public domain: `
385
+ + `pass --domain with a host this namespace serves (\`namespaces get ${namespace.id}\` lists them)`,
386
+ )
387
+ }
388
+ // One Caddy fronts the whole namespace and its host matcher reads `*` as a wildcard label, so a
389
+ // pattern here would answer for every sibling app. The manifest parser refuses it either way.
390
+ if (!isProxyHost(domain.toLowerCase())) {
391
+ throw new Error(
392
+ `Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` domain \`${domain}\` is not a literal hostname: `
393
+ + 'use dot-separated labels of letters, digits and `-` (no wildcard, no port, no placeholder)',
394
+ )
395
+ }
385
396
  }
386
397
  const namespaceClaims = new Set(namespaceResourceClaims(namespace))
387
398
  for (const requirement of manifest.target.namespaceResources ?? []) {
package/src/provider.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // the poll loop rechecks it, the sleep between polls is interruptible, and a cancelled run asks Zerops to
10
10
  // cancel the build it started rather than leaving it running.
11
11
 
12
- import { createProvider, type ProviderDeploySession, type ProviderModule, type TypedProviderRun } from '@fabrika/provider-contract'
12
+ import { createProvider, type JsonValue, type ProviderDeploySession, type ProviderModule, type TypedProviderRun } from '@fabrika/provider-contract'
13
13
  import {
14
14
  waitForProcess,
15
15
  ZEROPS_ACTIVE,
@@ -23,14 +23,16 @@ import {
23
23
  } from './api'
24
24
  import { zeropsTargetCodec } from './codec'
25
25
  import { defaultSleep, defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
26
+ import { assertZeropsInvariants, renderYaml } from './compile'
26
27
  import {
28
+ type FabrikaImportDocument,
27
29
  type FabrikaManifest,
28
30
  manifestServiceHostnames,
29
- renderFabrikaImportYaml,
30
31
  verifyZeropsArtifactSourceDescriptor,
31
32
  zeropsArtifactCodec,
32
33
  } from './manifest'
33
34
  import { buildZeropsPlan, resolveDeployHostname, type ZeropsJobSpec, type ZeropsPlan } from './plan'
35
+ import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
34
36
  import type { ZeropsSourceUploadInputV2, ZeropsSourceUploadResult } from './source'
35
37
  import type { ZeropsRunState, ZeropsRuntimeSource, ZeropsRuntimeTarget } from './types'
36
38
 
@@ -70,19 +72,139 @@ const assertRunning = (signal: AbortSignal): void => {
70
72
 
71
73
  const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g
72
74
 
73
- /** Resolve only variables declared by the manifest. Unknown or missing placeholders fail the deploy. */
74
- export function interpolateManifest(source: string, declared: string[], values: Record<string, string>): string {
75
- const allowed = new Set(declared)
76
- return source.replace(PLACEHOLDER, (_placeholder, name: string) => {
77
- if (!allowed.has(name)) {
78
- throw new Error(`zerops: manifest uses undeclared variable \`${name}\``)
75
+ /** Fields that decide WHAT a service is: a placeholder there would let a value redefine the service. */
76
+ const STRUCTURAL_FIELDS: readonly string[] = ['hostname', 'type', 'envIsolation', 'override']
77
+
78
+ /** Rebuild the tree with every string leaf resolved — arrays and nested objects included. */
79
+ const substituteLeaves = (value: JsonValue, resolve: (text: string) => string): JsonValue => {
80
+ if (typeof value === 'string') {
81
+ return resolve(value)
82
+ }
83
+ if (Array.isArray(value)) {
84
+ return value.map((item) => substituteLeaves(item, resolve))
85
+ }
86
+ if (value !== null && typeof value === 'object') {
87
+ return substituteRecord(value, resolve)
88
+ }
89
+ return value
90
+ }
91
+
92
+ const substituteRecord = (
93
+ record: { readonly [key: string]: JsonValue },
94
+ resolve: (text: string) => string,
95
+ ): { [key: string]: JsonValue } => {
96
+ const substituted: { [key: string]: JsonValue } = {}
97
+ for (const [key, entry] of Object.entries(record)) {
98
+ const planted = key.match(PLACEHOLDER)
99
+ if (planted !== null) {
100
+ throw new Error(`zerops: placeholder \`${String(planted[0])}\` stands in an import document key; a variable may only fill a value`)
79
101
  }
80
- const value = values[name]
81
- if (value === undefined) {
82
- throw new Error(`zerops: manifest variable \`${name}\` has no deploy-time value`)
102
+ // Defined rather than assigned: a literal `__proto__` key would otherwise set the prototype.
103
+ Object.defineProperty(substituted, key, {
104
+ value: substituteLeaves(entry, resolve),
105
+ enumerable: true,
106
+ writable: true,
107
+ configurable: true,
108
+ })
109
+ }
110
+ return substituted
111
+ }
112
+
113
+ /** A structural field that moved means a placeholder sat where only the compiler may write. */
114
+ const assertStructureUnchanged = (before: FabrikaImportDocument, after: FabrikaImportDocument): void => {
115
+ if (JSON.stringify(before.project) !== JSON.stringify(after.project)) {
116
+ throw new Error('zerops: variable substitution changed the project entry; a variable may not stand in the project fabrika creates')
117
+ }
118
+ for (const [index, service] of after.services.entries()) {
119
+ const original = before.services[index]
120
+ for (const field of STRUCTURAL_FIELDS) {
121
+ if (original?.[field] !== service[field]) {
122
+ throw new Error(`zerops: variable substitution changed service field \`${field}\`; a variable may not stand in a service's identity`)
123
+ }
83
124
  }
84
- return value
85
- })
125
+ }
126
+ }
127
+
128
+ /** Substitution is a single pass, so a placeholder left in the document came out of a variable's VALUE. */
129
+ const assertNoPlaceholderLeft = (document: FabrikaImportDocument): void => {
130
+ const refuse = (text: string): void => {
131
+ const left = text.match(PLACEHOLDER)
132
+ if (left !== null) {
133
+ throw new Error(`zerops: placeholder \`${String(left[0])}\` survived substitution; a variable value may not carry one`)
134
+ }
135
+ }
136
+ const visitRecord = (record: { readonly [key: string]: JsonValue }): void => {
137
+ for (const [key, entry] of Object.entries(record)) {
138
+ refuse(key)
139
+ visit(entry)
140
+ }
141
+ }
142
+ const visit = (value: JsonValue): void => {
143
+ if (typeof value === 'string') {
144
+ refuse(value)
145
+ return
146
+ }
147
+ if (Array.isArray(value)) {
148
+ for (const item of value) visit(item)
149
+ return
150
+ }
151
+ if (value !== null && typeof value === 'object') {
152
+ visitRecord(value)
153
+ }
154
+ }
155
+ if (document.project !== undefined) {
156
+ visitRecord(document.project)
157
+ }
158
+ for (const service of document.services) {
159
+ visitRecord(service)
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Resolve the manifest's declared variables INTO THE DOCUMENT — never into the rendered YAML, where a
165
+ * value carrying a quote or a newline would be read as structure and could add a service or turn
166
+ * `envIsolation` off. Only variables the manifest declares resolve; every other placeholder fails the
167
+ * deploy, and so does a value that names a service outside the app (Zerops resolves `${host_KEY}`
168
+ * regardless of isolation). The document is re-checked here because this is the last point it is an object.
169
+ */
170
+ export function interpolateManifest(
171
+ document: FabrikaImportDocument,
172
+ declared: readonly string[],
173
+ values: Record<string, string>,
174
+ ownHostnames: readonly string[],
175
+ ): FabrikaImportDocument {
176
+ const allowed = new Set(declared)
177
+ const hosts = allowedReferenceHosts(ownHostnames)
178
+ const resolve = (text: string): string => {
179
+ const substituted = text.replace(PLACEHOLDER, (_placeholder, name: string) => {
180
+ if (!allowed.has(name)) {
181
+ throw new Error(`zerops: manifest uses undeclared variable \`${name}\``)
182
+ }
183
+ const value = values[name]
184
+ if (value === undefined) {
185
+ throw new Error(`zerops: manifest variable \`${name}\` has no deploy-time value`)
186
+ }
187
+ const foreign = foreignReferenceHost(value, hosts)
188
+ if (foreign !== null) {
189
+ throw new Error(`zerops: manifest variable \`${name}\` references service \`${foreign}\`, which this app does not own`)
190
+ }
191
+ return value
192
+ })
193
+ // Adjacent values can spell a reference neither of them carries alone.
194
+ const foreign = substituted === text ? null : foreignReferenceHost(substituted, hosts)
195
+ if (foreign !== null) {
196
+ throw new Error(`zerops: a substituted value references service \`${foreign}\`, which this app does not own`)
197
+ }
198
+ return substituted
199
+ }
200
+ const services = document.services.map((service) => substituteRecord(service, resolve))
201
+ const substituted: FabrikaImportDocument = document.project === undefined
202
+ ? { services }
203
+ : { project: substituteRecord(document.project, resolve), services }
204
+ assertZeropsInvariants(substituted)
205
+ assertStructureUnchanged(document, substituted)
206
+ assertNoPlaceholderLeft(substituted)
207
+ return substituted
86
208
  }
87
209
 
88
210
  /** Human-readable failure for a version that finished in a non-`ACTIVE` state. */
@@ -488,10 +610,8 @@ export const createZeropsProvider = (
488
610
  throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
489
611
  }
490
612
  const hostnames = manifestServiceHostnames(run.artifact)
491
- const compiled = {
492
- yaml: interpolateManifest(renderFabrikaImportYaml(run.artifact), run.artifact.app.pipeline.vars, run.vars),
493
- hostnames,
494
- }
613
+ const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
614
+ const compiled = { yaml: renderYaml(document), hostnames }
495
615
  const deployHostname = resolveDeployHostname(run.artifact)
496
616
  const plan: ZeropsPlan = buildZeropsPlan(run.artifact, run.target, run.env)
497
617
  const env: StepEnv = {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `${<host>_<KEY>}` — Zerops resolves it across services regardless of `envIsolation`, so the host is the boundary.
3
+ * `${RUNTIME_<host>_<KEY>}`, the build-time bridge, counts as a reference to that host until measured otherwise.
4
+ */
5
+ const SERVICE_REFERENCE = /\$\{(?:RUNTIME_)?([a-z0-9]{1,25})_[A-Za-z0-9_]+\}/g
6
+
7
+ /** Namespace services every app in it may reference; the proxy is deliberately not one of them. */
8
+ const SHARED_NAMESPACE_HOSTNAMES: readonly string[] = ['postgres']
9
+
10
+ /** Hosts named by cross-service references in `text`, deduplicated, in order of first appearance. */
11
+ export function serviceReferenceHosts(text: string): string[] {
12
+ const hosts: string[] = []
13
+ for (const match of text.matchAll(SERVICE_REFERENCE)) {
14
+ const host = match[1]
15
+ if (host !== undefined && !hosts.includes(host)) hosts.push(host)
16
+ }
17
+ return hosts
18
+ }
19
+
20
+ /** The hosts a service of this app may reference: its own declared hostnames plus the namespace's shared services. */
21
+ export function allowedReferenceHosts(ownHostnames: readonly string[]): ReadonlySet<string> {
22
+ return new Set([...ownHostnames, ...SHARED_NAMESPACE_HOSTNAMES])
23
+ }
24
+
25
+ /** The first referenced host outside `allowed`, or null when every reference stays inside the app. */
26
+ export function foreignReferenceHost(text: string, allowed: ReadonlySet<string>): string | null {
27
+ return serviceReferenceHosts(text).find((host) => !allowed.has(host)) ?? null
28
+ }