@fabrika/provider-zerops 0.0.25 → 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.25",
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.25",
36
- "@fabrika/auth-core": "^0.0.25",
37
- "@fabrika/provider-contract": "^0.0.25",
38
- "@fabrika/proxy-contract": "^0.0.25"
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/api.ts CHANGED
@@ -258,7 +258,7 @@ export interface ZeropsServiceEnv {
258
258
 
259
259
  /**
260
260
  * A short-lived grant for the project's LOG service. VERIFIED: `ResponseProjectLog` — the field names and
261
- * types are exactly these. What is NOT verified is what the URLs speak; see `readBuildLog`.
261
+ * types are exactly these. What the URLs speak was measured separately; see `readBuildLog`.
262
262
  */
263
263
  export interface ZeropsLogAccess {
264
264
  url: string
@@ -271,10 +271,14 @@ export interface ZeropsLogAccess {
271
271
  expiration: string
272
272
  }
273
273
 
274
+ /** The RFC3339 stamp the plaintext log window puts first on every line. Anchored so a bare year cannot pass. */
275
+ const LOG_LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
276
+
274
277
  /** One relayed build/runtime log line. Shape is fabrika's, not the platform's. */
275
278
  export interface ZeropsLogLine {
276
- /** ISO timestamp when Zerops recorded the line, when it supplied one. */
279
+ /** RFC3339 stamp Zerops put at the head of the line, when the line carried one. */
277
280
  timestamp?: string
281
+ /** The line as the log service wrote it, timestamp and tag included. */
278
282
  message: string
279
283
  }
280
284
 
@@ -457,8 +461,9 @@ export interface ZeropsApi {
457
461
  * Every environment variable of ONE service, each with the record id `deleteServiceEnv` takes.
458
462
  * VERIFIED: `GET /service-stack/{id}/env` (`{ items }`).
459
463
  *
460
- * NOT `GET /service-stack/{id}/user-data`, which answers `400 serviceStackNotFound` on every
461
- * service, deployed or not — see `docs/reference/zerops-platform.md`. The `/env` reading omits
464
+ * NOT `GET /service-stack/{id}/user-data`: it answers a list now (2026-08-21), but `/env` is the
465
+ * endpoint whose record ids `PUT`/`DELETE /user-data/{id}` take, and the list's paging terms were
466
+ * never verified — see `docs/reference/zerops-platform.md`. The `/env` reading omits
462
467
  * variables the service's own `zerops.yaml` declares (`type: ENV`); fabrika never writes those.
463
468
  * The OpenAPI publishes no lookup/pagination terms and live probes found query terms ignored, so the
464
469
  * implementation reads one full response under a byte bound rather than imposing an entry-count cap.
@@ -548,17 +553,42 @@ export interface ZeropsApi {
548
553
  getLogAccess(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsLogAccess>
549
554
 
550
555
  /**
551
- * UNVERIFIED — THE ONE SHAPE NOBODY COULD CHECK. `GET /project/{id}/log` hands back URLs to a
552
- * SEPARATE log service, and that service's request/response contract appears in no published document
553
- * (it is not in the OpenAPI file, which stops at the grant). The default implementation's reading is:
554
- * GET `urlPlain` with the grant as a bearer, one plain-text line per log record, `limit`/`from` as
555
- * query params. Treat that as a guess.
556
+ * One window of the project's log, narrowed to ONE app version.
557
+ *
558
+ * VERIFIED 2026-08-21 by measurement, not by document: the OpenAPI file stops at the grant, so the
559
+ * separate log service was read against a live project. `urlPlain` answers one
560
+ * `<RFC3339 timestamp> <tag> <message>` line per record and accepts `serviceStackId`, `limit`, `tags`
561
+ * and `desc`; `from` and unknown terms are accepted and silently ignored, so nothing may depend on them.
562
+ *
563
+ * ORDERING, also measured: `limit=N` without `desc` returns the NEWEST N records in ASCENDING timestamp
564
+ * order (`limit=3` against a log starting 15:50 answered 17:00:00, 17:00:00, 17:15:00), and `desc=1`
565
+ * returns the same newest N, newest first. So `desc` is needed neither for the `since` cut nor for a
566
+ * build longer than the limit: each poll re-reads the newest window and the caller's dedupe streams it.
556
567
  *
557
- * BECAUSE it is a guess, the driver treats a failure here as non-fatal: log relay degrades to "no
558
- * lines" and the deploy still succeeds or fails on `/app-version` status alone. An unverified endpoint
559
- * must never be able to fail a deploy.
568
+ * A version's BUILD lines carry the tag `zbuilder@<appVersionId>` and are produced on a separate
569
+ * builder stack, so they are selected by `tags` ALONE — combining that tag with the runtime
570
+ * `serviceStackId` matches nothing. RUNTIME lines carry no version marker at all and the runtime
571
+ * service's window mixes every version that ever ran, so they are selected by `serviceStackId` and cut
572
+ * at `since` (the version's `build.pipelineStart`). Without `since` no runtime line is read: a line
573
+ * from an earlier version is worse in a run log than a missing one. The cut is by TIME, not identity —
574
+ * the outgoing container keeps logging until it is replaced, so its lines stamped after `since` still
575
+ * pass, and only the earlier version's older history is excluded.
576
+ *
577
+ * The two windows are read INDEPENDENTLY: one failing must not discard what the other returned, and only
578
+ * a failure of every window attempted is raised. The driver then treats that as non-fatal anyway — log
579
+ * relay degrades to "no lines" and the deploy succeeds or fails on `/app-version` status alone. A log
580
+ * service must never be able to fail a deploy.
560
581
  */
561
- readBuildLog(input: { access: ZeropsLogAccess; serviceId: string; limit?: number; signal: AbortSignal }): Promise<ZeropsLogLine[]>
582
+ readBuildLog(input: {
583
+ access: ZeropsLogAccess
584
+ serviceId: string
585
+ /** Select this version's build lines by their `zbuilder@` tag; without it no build line is read. */
586
+ appVersionId?: string
587
+ /** RFC3339 lower bound for runtime lines, normally `build.pipelineStart`; without it none are read. */
588
+ since?: string
589
+ limit?: number
590
+ signal: AbortSignal
591
+ }): Promise<ZeropsLogLine[]>
562
592
  }
563
593
 
564
594
  // ── default (real) implementation ───────────────────────────────────────────────
@@ -1245,20 +1275,47 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1245
1275
  }
1246
1276
  },
1247
1277
 
1248
- readBuildLog: async ({ access, serviceId, limit, signal }) => {
1249
- // UNVERIFIED (see the interface): this is a best reading of an undocumented log service, not a
1250
- // contract. It is written so a wrong guess degrades to "no lines" instead of failing a deploy.
1278
+ readBuildLog: async ({ access, serviceId, appVersionId, since, limit, signal }) => {
1251
1279
  if (access.urlPlain === '') {
1252
1280
  return []
1253
1281
  }
1254
1282
  const separator = access.urlPlain.includes('?') ? '&' : '?'
1255
- const url = `${access.urlPlain}${separator}serviceStackId=${encodeURIComponent(serviceId)}&limit=${limit ?? 200}`
1256
- const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
1257
- if (!response.ok) {
1258
- throw new Error(`zerops: read build log failed (${response.status})`)
1283
+ const readWindow = async (terms: string): Promise<{ lines: ZeropsLogLine[]; error: unknown }> => {
1284
+ try {
1285
+ const url = `${access.urlPlain}${separator}${terms}&limit=${limit ?? 200}`
1286
+ const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
1287
+ if (!response.ok) {
1288
+ throw new Error(`zerops: read build log failed (${response.status})`)
1289
+ }
1290
+ const text = await response.text()
1291
+ const lines = text.split('\n').filter((line) => line.trim() !== '').map((line) => {
1292
+ const head = line.slice(0, Math.max(line.indexOf(' '), 0))
1293
+ return LOG_LINE_TIMESTAMP.test(head) ? { timestamp: head, message: line } : { message: line }
1294
+ })
1295
+ return { lines, error: undefined }
1296
+ } catch (error) {
1297
+ return { lines: [], error }
1298
+ }
1299
+ }
1300
+ // Selected by tag alone: the build runs on a builder stack, so pairing the tag with the runtime
1301
+ // service id matches nothing (see the interface).
1302
+ const build = appVersionId === undefined ? undefined : await readWindow(`tags=${encodeURIComponent(`zbuilder@${appVersionId}`)}`)
1303
+ const runtime = since === undefined ? undefined : await readWindow(`serviceStackId=${encodeURIComponent(serviceId)}`)
1304
+ const attempted = [build, runtime].filter((result) => result !== undefined)
1305
+ if (attempted.length === 0) {
1306
+ return []
1307
+ }
1308
+ // Independent windows: only a failure of every one attempted is worth raising.
1309
+ const failure = attempted.find((result) => result.error !== undefined)
1310
+ if (failure !== undefined && attempted.every((result) => result.error !== undefined)) {
1311
+ throw failure.error
1259
1312
  }
1260
- const text = await response.text()
1261
- return text.split('\n').filter((line) => line.trim() !== '').map((line) => ({ message: line }))
1313
+ const boundary = since === undefined ? Number.NaN : Date.parse(since)
1314
+ // The runtime window carries every version that ever ran, so a line the driver cannot date is dropped.
1315
+ const runtimeLines = runtime === undefined || Number.isNaN(boundary)
1316
+ ? runtime?.lines ?? []
1317
+ : runtime.lines.filter((line) => line.timestamp !== undefined && Date.parse(line.timestamp) >= boundary)
1318
+ return [...build?.lines ?? [], ...runtimeLines]
1262
1319
  },
1263
1320
  }
1264
1321
  }
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
@@ -6,6 +6,7 @@ import {
6
6
  type ProviderNamespaceCapabilities,
7
7
  ProviderNamespaceError,
8
8
  type ProviderNamespaceFact,
9
+ type ProviderNamespaceHost,
9
10
  type ProviderNamespaceMutationInput,
10
11
  type ProviderNamespaceOperator,
11
12
  type ProviderNamespacePlanInput,
@@ -13,7 +14,7 @@ import {
13
14
  type ProviderNamespacePreset,
14
15
  type ProviderRegistration,
15
16
  } from '@fabrika/provider-contract'
16
- import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-contract'
17
+ import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON, isProxyHost } from '@fabrika/proxy-contract'
17
18
  import {
18
19
  ZEROPS_ACTIVE,
19
20
  ZEROPS_SERVICE_NOT_HTTP,
@@ -35,6 +36,8 @@ export const ZEROPS_NAMESPACE_PROXY_HOSTNAME = 'proxy'
35
36
  export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
36
37
  export const ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE = 'FABRIKA_IAM_ISSUER'
37
38
  export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
39
+ /** The platform-owned variable naming one URL per published HTTP port. READ_ONLY, written by Zerops. */
40
+ export const ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE = 'zeropsSubdomain'
38
41
  /**
39
42
  * How an app reaches the namespace-owned PostgreSQL service — port, database and TLS mode NAMED, not
40
43
  * defaulted. Every part settled against a live account, none of it safe to infer:
@@ -91,6 +94,8 @@ export interface ZeropsNamespaceTarget {
91
94
  readonly proxyConfigured?: boolean
92
95
  readonly proxyBaselineSequence?: number
93
96
  readonly proxyAppVersionId?: string
97
+ /** The hosts the published proxy answers on, recorded once so a namespace read needs no platform call. */
98
+ readonly proxyHosts?: readonly ProviderNamespaceHost[]
94
99
  readonly ready?: boolean
95
100
  }
96
101
 
@@ -221,6 +226,54 @@ const parsePostgres = (payload: JsonValue): ZeropsNamespacePostgres | undefined
221
226
  return profile === undefined ? { type } : { type, profile }
222
227
  }
223
228
 
229
+ const parseProxyHosts = (payload: JsonValue): readonly ProviderNamespaceHost[] | undefined => {
230
+ const value = property(payload, 'proxyHosts')
231
+ if (value === undefined) return undefined
232
+ if (!Array.isArray(value)) {
233
+ throw new Error('Zerops namespace target proxyHosts must be an array')
234
+ }
235
+ return value.map((entry) => {
236
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
237
+ throw new Error('Zerops namespace target proxyHosts entry must be an object')
238
+ }
239
+ const host = property(entry, 'host')
240
+ const port = property(entry, 'port')
241
+ if (typeof host !== 'string' || host === '') {
242
+ throw new Error('Zerops namespace target proxyHosts host must be a non-empty string')
243
+ }
244
+ if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0) {
245
+ throw new Error('Zerops namespace target proxyHosts port must be a positive integer')
246
+ }
247
+ return { host, port }
248
+ })
249
+ }
250
+
251
+ /**
252
+ * One host per published HTTP port, out of the newline-separated URLs the platform writes.
253
+ *
254
+ * The form is `<hostname>-<hash>-<port>.<region>.zerops.app` (e.g. `proxy-2ec8-8080`). Read as URLs
255
+ * rather than split on `-`, so a hostname whose own first label ends in digits cannot be misread; a line
256
+ * that is not a URL, or whose first label carries no trailing port — the single portless line a proxy
257
+ * that has never deployed publishes — is skipped rather than guessed at.
258
+ */
259
+ const parseZeropsProxyHosts = (value: string): ProviderNamespaceHost[] => {
260
+ const hosts: ProviderNamespaceHost[] = []
261
+ for (const line of value.split('\n')) {
262
+ const trimmed = line.trim()
263
+ if (trimmed === '') continue
264
+ let hostname: string
265
+ try {
266
+ hostname = new URL(trimmed).hostname
267
+ } catch {
268
+ continue
269
+ }
270
+ const port = /-(\d+)$/.exec(hostname.split('.')[0] ?? '')?.[1]
271
+ if (port === undefined) continue
272
+ hosts.push({ host: hostname, port: Number.parseInt(port, 10) })
273
+ }
274
+ return hosts.sort((left, right) => left.port - right.port)
275
+ }
276
+
224
277
  export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
225
278
  version: 1,
226
279
  encode: (target) => ({
@@ -238,6 +291,7 @@ export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> =
238
291
  ...(target.proxyConfigured !== undefined ? { proxyConfigured: target.proxyConfigured } : {}),
239
292
  ...(target.proxyBaselineSequence !== undefined ? { proxyBaselineSequence: target.proxyBaselineSequence } : {}),
240
293
  ...(target.proxyAppVersionId !== undefined ? { proxyAppVersionId: target.proxyAppVersionId } : {}),
294
+ ...(target.proxyHosts === undefined ? {} : { proxyHosts: target.proxyHosts.map((entry) => ({ host: entry.host, port: entry.port })) }),
241
295
  ...(target.ready !== undefined ? { ready: target.ready } : {}),
242
296
  }),
243
297
  decode: (payload) => {
@@ -272,6 +326,7 @@ export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> =
272
326
  ...(optionalString(payload, 'proxyAppVersionId') !== undefined
273
327
  ? { proxyAppVersionId: optionalString(payload, 'proxyAppVersionId') }
274
328
  : {}),
329
+ ...(parseProxyHosts(payload) !== undefined ? { proxyHosts: parseProxyHosts(payload) } : {}),
275
330
  ...(optionalBoolean(payload, 'ready') !== undefined ? { ready: optionalBoolean(payload, 'ready') } : {}),
276
331
  }
277
332
  },
@@ -320,6 +375,25 @@ const registrationResourceClaims = (registration: ProviderRegistration): string[
320
375
  appId: registration.app.id,
321
376
  env: registration.environment.env,
322
377
  })
378
+ // A proxy target is routed by HOST, so a registration with no domain has nothing the proxy can serve:
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) {
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
+ }
396
+ }
323
397
  const namespaceClaims = new Set(namespaceResourceClaims(namespace))
324
398
  for (const requirement of manifest.target.namespaceResources ?? []) {
325
399
  if (!namespaceClaims.has(requirement.resourceKey)) {
@@ -503,10 +577,14 @@ const namespacePresentation = (
503
577
  const postgres = target.postgres === undefined
504
578
  ? 'App-owned database services'
505
579
  : `Shared ${target.postgres.type}${target.postgres.profile === undefined ? '' : ` (${target.postgres.profile})`}`
580
+ // Only a published subdomain namespace knows its own hosts; custom domains are bound out of band, so
581
+ // the provider names none and an operator reads them where they were bound.
582
+ const hosts = publicAccess === 'zerops-subdomain' && target.ready === true ? target.proxyHosts ?? [] : []
506
583
  const instructions = [
507
584
  ...(publicAccess === 'custom-domain'
508
585
  ? [`Bind each application domain to the \`proxy\` service in Zerops project \`${projectName}\`; keep every app service private.`]
509
586
  : ['Use the proxy Zerops subdomain only for non-production access; app services must remain private.']),
587
+ ...(hosts.length === 0 ? [] : ['Register a proxy-target environment with one of the hosts below as its domain; each host serves one app.']),
510
588
  ...(target.postgres === undefined
511
589
  ? []
512
590
  : ['Every app consuming the shared PostgreSQL binding shares its physical service and provider-issued credential.']),
@@ -528,6 +606,7 @@ const namespacePresentation = (
528
606
  { label: 'Public access', value: publicAccess },
529
607
  ],
530
608
  instructions,
609
+ ...(hosts.length === 0 ? {} : { hosts }),
531
610
  }
532
611
  }
533
612
 
@@ -832,6 +911,38 @@ const ensureProxyConfiguration = async (
832
911
  return deployRequired
833
912
  }
834
913
 
914
+ /**
915
+ * The hosts the proxy publishes, off the platform-owned `zeropsSubdomain` variable.
916
+ *
917
+ * Read through the same `/env` listing every other namespace variable uses, and read AFTER the proxy is
918
+ * published: the variable names a host per DEPLOYED HTTP port, so a proxy that never deployed names none.
919
+ * Recording the answer in the target is what keeps `namespaces get` a database read.
920
+ */
921
+ const readProxyHosts = async (
922
+ namespace: ProviderDeploymentNamespace,
923
+ target: NormalizedZeropsNamespaceTarget,
924
+ options: ZeropsNamespaceOptions,
925
+ signal: AbortSignal,
926
+ ): Promise<readonly ProviderNamespaceHost[]> => {
927
+ const proxyServiceId = target.proxyServiceId
928
+ if (proxyServiceId === undefined) {
929
+ throw new Error('Zerops namespace has no proxy service id')
930
+ }
931
+ try {
932
+ const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
933
+ const subdomains = variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE)
934
+ return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains.content)
935
+ } catch {
936
+ // A host listing is a convenience; the proxy is already built, deployed and published. Failing the
937
+ // namespace over it would report a placement that WORKS as broken. Never the cause: it can carry
938
+ // upstream text this provider has not redacted.
939
+ console.warn(
940
+ `Zerops namespace ${namespace.id}: reading the published proxy hosts failed; the placement is ready and lists none until its next reconcile`,
941
+ )
942
+ return []
943
+ }
944
+ }
945
+
835
946
  const latestAfterBaseline = async (
836
947
  target: NormalizedZeropsNamespaceTarget,
837
948
  options: ZeropsNamespaceOptions,
@@ -1125,10 +1236,14 @@ const runNamespaceMutation = async (
1125
1236
  if (target.publicAccess === 'zerops-subdomain') {
1126
1237
  await ensureSubdomainAccess(target, options, input.signal)
1127
1238
  }
1239
+ const proxyHosts = target.publicAccess === 'zerops-subdomain' ? await readProxyHosts(namespace, target, options, input.signal) : []
1128
1240
  target = {
1129
1241
  ...target,
1130
1242
  proxyBaselineSequence: undefined,
1131
1243
  proxyAppVersionId: undefined,
1244
+ // Cleared when the proxy publishes none and on a custom-domain namespace, so the record never
1245
+ // outlives what it describes.
1246
+ proxyHosts: proxyHosts.length === 0 ? undefined : proxyHosts,
1132
1247
  ready: true,
1133
1248
  }
1134
1249
  return checkpoint(input, namespace, target)
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. */
@@ -170,23 +292,41 @@ const cleanupPreTriggerVersion = async (env: StepEnv, source: ZeropsRuntimeSourc
170
292
  }
171
293
 
172
294
  /**
173
- * Relay whatever build log Zerops will give us into the run's progress sink.
295
+ * Relay ONE app version's build log into the run's progress sink.
296
+ *
297
+ * Scoped to the version being deployed: the runtime service's log window mixes every version that ever ran
298
+ * on it, so an unscoped read stamps a run's log with lines from earlier releases. `appVersionId` selects
299
+ * the build lines and `since` — the version's pipeline start — cuts the runtime ones
300
+ * (see `ZeropsApi.readBuildLog`). Before the platform reports a pipeline start there is nothing to cut
301
+ * against, so no runtime line is relayed yet. The cut is by time, so the OUTGOING container's own lines
302
+ * still arrive until it is replaced; what it removes is every earlier version's history.
174
303
  *
175
- * Deliberately FAILURE-TOLERANT: the log service's protocol is the one shape in this driver nobody could
176
- * verify against a document (see `ZeropsApi.readBuildLog`). An unverified endpoint must never be able to
177
- * fail a deploy, so a broken guess degrades to "no lines relayed" and the run still succeeds or fails on
178
- * the platform's process and app-version statuses.
304
+ * Deliberately FAILURE-TOLERANT: a log service must never be able to fail a deploy, so an error degrades
305
+ * to "no lines relayed" and the run still succeeds or fails on the platform's process and app-version
306
+ * statuses.
179
307
  *
180
308
  * Relay is pull-based (ADR-0003), so each poll re-reads a window that overlaps the last one; `seen` keeps
181
309
  * the run log from repeating itself. Two genuinely identical lines with no timestamp collapse into one —
182
310
  * an acceptable trade for not printing the same window every three seconds.
183
311
  */
184
- const relayLog = async (env: StepEnv, access: ZeropsLogAccess | null, seen: Set<string>): Promise<void> => {
312
+ const relayLog = async (
313
+ env: StepEnv,
314
+ access: ZeropsLogAccess | null,
315
+ seen: Set<string>,
316
+ appVersionId: string,
317
+ since: string | undefined,
318
+ ): Promise<void> => {
185
319
  if (access === null) {
186
320
  return
187
321
  }
188
322
  try {
189
- const lines = await env.zerops.api.readBuildLog({ access, serviceId: env.run.target.serviceId, signal: env.signal })
323
+ const lines = await env.zerops.api.readBuildLog({
324
+ access,
325
+ serviceId: env.run.target.serviceId,
326
+ appVersionId,
327
+ ...(since === undefined ? {} : { since }),
328
+ signal: env.signal,
329
+ })
190
330
  for (const line of lines) {
191
331
  const key = `${line.timestamp ?? ''}\u0000${line.message}`
192
332
  if (seen.has(key)) {
@@ -248,7 +388,7 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
248
388
  log(` ${appVersionId}: ${version.status ?? 'unknown'}`)
249
389
  previous = version.status
250
390
  }
251
- await relayLog(env, access, seen)
391
+ await relayLog(env, access, seen, appVersionId, version.build?.pipelineStart)
252
392
 
253
393
  if (process?.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status) && process.status !== ZEROPS_PROCESS_FINISHED) {
254
394
  throw new Error(
@@ -470,10 +610,8 @@ export const createZeropsProvider = (
470
610
  throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
471
611
  }
472
612
  const hostnames = manifestServiceHostnames(run.artifact)
473
- const compiled = {
474
- yaml: interpolateManifest(renderFabrikaImportYaml(run.artifact), run.artifact.app.pipeline.vars, run.vars),
475
- hostnames,
476
- }
613
+ const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
614
+ const compiled = { yaml: renderYaml(document), hostnames }
477
615
  const deployHostname = resolveDeployHostname(run.artifact)
478
616
  const plan: ZeropsPlan = buildZeropsPlan(run.artifact, run.target, run.env)
479
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
+ }