@fabrika/provider-zerops 0.5.5 → 0.7.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.5.5",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,14 +43,14 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "0.5.5",
47
- "@fabrika/auth-core": "0.5.5",
48
- "@fabrika/email": "0.5.5",
49
- "@fabrika/iam-contract": "0.5.5",
50
- "@fabrika/installation-init": "0.5.5",
51
- "@fabrika/operations-contract": "0.5.5",
52
- "@fabrika/provider-contract": "0.5.5",
53
- "@fabrika/proxy-core": "0.5.5",
46
+ "@fabrika/auth": "0.7.0",
47
+ "@fabrika/auth-core": "0.7.0",
48
+ "@fabrika/email": "0.7.0",
49
+ "@fabrika/iam-contract": "0.7.0",
50
+ "@fabrika/installation-init": "0.7.0",
51
+ "@fabrika/operations-contract": "0.7.0",
52
+ "@fabrika/provider-contract": "0.7.0",
53
+ "@fabrika/proxy-core": "0.7.0",
54
54
  "yaml": "^2.9.1"
55
55
  }
56
56
  }
package/src/control.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  type ZeropsNamespaceTarget,
49
49
  zeropsNamespaceTargetCodec,
50
50
  } from './namespace'
51
+ import { assertNamespaceProxyReads, type OlderProxyRowReaders } from './namespace/proxy-reads'
51
52
  import { createZeropsDeployer, ZEROPS_VERSION_DEADLINE_MS } from './provider'
52
53
  import { allowedReferenceHosts, assertNoPlatformOnlyReference, foreignReferenceHost, serviceReferenceHosts } from './service-references'
53
54
  import { normalizeZeropsSourceRepository } from './source'
@@ -101,6 +102,8 @@ export interface ZeropsControlProviderOptions {
101
102
  readonly proxySource: ZeropsNamespaceProxySource
102
103
  readonly iamUrl: string
103
104
  readonly iamKey: string
105
+ /** The row parsers of the older releases' proxies, which a namespace pinned to an older source may run. */
106
+ readonly olderProxies: OlderProxyRowReaders
104
107
  }
105
108
  }
106
109
 
@@ -345,6 +348,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
345
348
  const binding = privateBinding(input.sourceBinding)
346
349
  assertEnvironmentOfApp(input.app, input.environment)
347
350
  const placement = resolvedEnvironment(input.environment, { requireReady: true })
351
+ assertNamespaceProxyReads(placement.namespace.id, input.environment.access, placement.namespaceTarget.proxySource, options.namespaces)
348
352
  const artifact = artifactFor(input.environment)
349
353
  await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
350
354
  let source: ZeropsRuntimeTarget['source']
package/src/execution.ts CHANGED
@@ -4,14 +4,13 @@
4
4
 
5
5
  import {
6
6
  type ContractArtifact,
7
- contractDigest,
8
7
  FABRIKA_CONTRACT_DIGEST,
9
8
  FABRIKA_SOURCE_MAP_UPLOAD,
10
9
  FABRIKA_SOURCE_MAP_UPLOAD_KEY,
11
10
  type FrameworkVariableName,
12
11
  } from '@fabrika/auth-core'
13
12
  import type { OperationsAssetRoot, OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
14
- import type { JsonValue } from '@fabrika/provider-contract'
13
+ import { FRAMEWORK_RELEASE_PATTERN, type JsonValue } from '@fabrika/provider-contract'
15
14
  import packageManifest from '../package.json'
16
15
  import { renderYaml } from './compile'
17
16
  import { parseZeropsDescriptor, type PreparedZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
@@ -24,9 +23,9 @@ export const ZEROPS_APPLICATION_PORT = 3000
24
23
  export const ZEROPS_APPLICATION_RUNTIME = 'alpine/bun@1.3'
25
24
 
26
25
  /**
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.
26
+ * This package's own version. Control runs the git checkout of its release tag, so this reads the source version,
27
+ * which `release:validate` holds equal to the tag; the public packages are released together, so it is the checker
28
+ * release of the installation. A build checks with the application's own release instead (`checkRelease`).
30
29
  */
31
30
  export const FABRIKA_CHECK_VERSION: string = packageManifest.version
32
31
 
@@ -91,6 +90,11 @@ export interface ZeropsContractRun {
91
90
  export interface ZeropsLockedExecution {
92
91
  readonly appId: string
93
92
  readonly contract: ZeropsContractRun
93
+ /**
94
+ * The release of the reader that read the application (`ProviderApprovedAccess.checker`), the newest patch of the
95
+ * framework minor it pins: the build runs `fabrika-check` and `fabrika-source-maps` of `@fabrika/check` at it.
96
+ */
97
+ readonly checkRelease: string
94
98
  /** A `${host_…}` reference to the application's PostgreSQL service, or null when it has none. */
95
99
  readonly databaseUrl: string | null
96
100
  /**
@@ -112,20 +116,24 @@ export const CONTRACT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/
112
116
 
113
117
  const byName = <T extends { readonly name: string }>(left: T, right: T): number => left.name < right.name ? -1 : left.name > right.name ? 1 : 0
114
118
 
115
- /** The run facts of a contract. A contract without a health endpoint has nothing a readiness check can request. */
116
- export function zeropsContractRun(artifact: ContractArtifact): ZeropsContractRun {
119
+ /**
120
+ * The run facts of a contract, with the digest the application's own release computes for it. A contract without a
121
+ * health endpoint has nothing a readiness check can request.
122
+ */
123
+ export function zeropsContractRun(artifact: ContractArtifact, digest: string): ZeropsContractRun {
117
124
  const health = artifact.endpoints.filter((row) => row.kind === 'health').sort((left, right) => left.id < right.id ? -1 : 1)[0]
118
125
  if (health === undefined) {
119
126
  throw new Error(
120
127
  "fabrika app build: a contract application on Zerops needs a health endpoint for its readiness check (allowed form: health: c.health('/healthz'))",
121
128
  )
122
129
  }
130
+ const fileKinds = Object.values(artifact.fileKinds ?? {})
123
131
  return {
124
- digest: contractDigest(artifact),
132
+ digest,
125
133
  readinessPath: health.path,
126
134
  jobs: Object.entries(artifact.jobs).map(([name, job]) => ({ name, schedule: job.schedule })).sort(byName),
127
- ...(Object.keys(artifact.files).length > 0 ? { files: true } : {}),
128
- ...(Object.values(artifact.files).some((collection) => collection.scan === true) ? { scan: true } : {}),
135
+ ...(Object.keys(artifact.files).length > 0 || fileKinds.length > 0 ? { files: true } : {}),
136
+ ...([...Object.values(artifact.files), ...fileKinds].some((declared) => declared.scan === true) ? { scan: true } : {}),
129
137
  }
130
138
  }
131
139
 
@@ -295,7 +303,7 @@ const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
295
303
 
296
304
  // After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
297
305
  // provider reads the state from the report line.
298
- const SOURCE_MAP_UPLOAD = `cd /tmp && bun x --bun -p @fabrika/check@${FABRIKA_CHECK_VERSION} fabrika-source-maps ${SOURCE_ROOT} || true`
306
+ const sourceMapUpload = (release: string): string => `cd /tmp && bun x --bun -p @fabrika/check@${release} fabrika-source-maps ${SOURCE_ROOT} || true`
299
307
 
300
308
  /**
301
309
  * The upload's build variables: the destination and asset roots as a literal, and the bearer lifted from the service
@@ -315,18 +323,19 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
315
323
  */
316
324
  export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
317
325
  if (!LOCKED_APP_ID_PATTERN.test(execution.appId)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
326
+ if (!FRAMEWORK_RELEASE_PATTERN.test(execution.checkRelease)) throw new Error('zerops: the checker release must be a release version')
318
327
  const database = execution.databaseUrl !== null
319
328
  return [
320
329
  INSTALL,
321
330
  'bun test --isolate --pass-with-no-tests',
322
331
  `rm -rf node_modules && ${INSTALL}`,
323
332
  `bun -e "await import('@fabrika/app/build').then((build) => build.writeGeneratedEntry('.', 'bun'))"`,
324
- `cd /tmp && bun x --bun -p @fabrika/check@${FABRIKA_CHECK_VERSION} fabrika-check ${SOURCE_ROOT}`,
333
+ `cd /tmp && bun x --bun -p @fabrika/check@${execution.checkRelease} fabrika-check ${SOURCE_ROOT}`,
325
334
  writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
326
335
  `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
327
336
  ...(database ? [writeFileCommand(MIGRATE_SCRIPT, migrateScript(execution.appId))] : []),
328
337
  writeFileCommand(START_SCRIPT, startScript(execution.appId, database)),
329
- ...(execution.sourceMaps === null ? [] : [SOURCE_MAP_UPLOAD]),
338
+ ...(execution.sourceMaps === null ? [] : [sourceMapUpload(execution.checkRelease)]),
330
339
  ]
331
340
  }
332
341
 
package/src/index.ts CHANGED
@@ -101,6 +101,8 @@ export type {
101
101
  ZeropsProxyRepublishInput,
102
102
  } from './namespace'
103
103
  export { postgresSchema as zeropsNamespacePostgresSchema } from './namespace/codec'
104
+ export { rowsNeedingCurrentProxy } from './namespace/proxy-reads'
105
+ export type { OlderProxyRowReader, OlderProxyRowReaders } from './namespace/proxy-reads'
104
106
  export { buildZeropsPlan, resolveDeployHostname } from './plan'
105
107
  export type { ZeropsStep, ZeropsStepKind } from './plan'
106
108
  export { CANCELLED, createZeropsDeployer, interpolateManifest } from './provider'
@@ -27,6 +27,9 @@ export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
27
27
  { action: 'source.workspace.write', description: 'Create and commit to own Git workspaces on an authoring target' },
28
28
  { action: 'source.pull-request.create', description: 'Open a pull request from an own Git workspace' },
29
29
  { action: 'access.approve', description: 'Approve an access change and protect an environment (a person in the console only)' },
30
+ { action: 'mail.read', description: 'List relayed mail and read its metadata' },
31
+ { action: 'mail.content.read', description: 'Read the recipient, subject and bodies of relayed mail' },
32
+ { action: 'mail.manage', description: 'Release or drop held mail and manage the mail rules' },
30
33
  { action: 'operations.read', description: 'Read Operations errors and activity' },
31
34
  { action: 'operations.triage', description: 'Triage Operations errors' },
32
35
  { action: 'operations.manage', description: 'Manage Operations sources, alerts, and retention' },
@@ -248,6 +248,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
248
248
  admission: null,
249
249
  oauth: null,
250
250
  },
251
+ {
252
+ id: 'mail-relay',
253
+ kind: 'protocol',
254
+ method: 'POST',
255
+ path: '/platform/mail/*',
256
+ callers: [],
257
+ requirement: null,
258
+ admission: null,
259
+ oauth: null,
260
+ },
251
261
  {
252
262
  id: 'webhooks-root',
253
263
  kind: 'service',
@@ -433,6 +443,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
433
443
  admission: null,
434
444
  oauth: null,
435
445
  },
446
+ {
447
+ id: 'mail-relay',
448
+ kind: 'protocol',
449
+ method: 'POST',
450
+ path: '/platform/mail/*',
451
+ callers: [],
452
+ requirement: null,
453
+ admission: null,
454
+ oauth: null,
455
+ },
436
456
  {
437
457
  id: 'webhooks-root',
438
458
  kind: 'service',
@@ -216,7 +216,11 @@ const iam: ZeropsYamlSetupSpec = {
216
216
  // The Cloudflare `scheduled` handler's replacement, running the same code path. `allContainers:
217
217
  // false` so a horizontally scaled service still prunes once.
218
218
  crontab: [{ timing: '0 3 * * *', command: 'bun packages/iam/src/node/prune.ts', allContainers: false }],
219
- envVariables: { PORT: '3000' },
219
+ envVariables: {
220
+ PORT: '3000',
221
+ // Control's private origin: with a provider configured, IAM hands its mail to Control's relay.
222
+ FABRIKA_CONTROL_URL: 'http://control:3000',
223
+ },
220
224
  },
221
225
  }
222
226
 
@@ -6,7 +6,10 @@ import {
6
6
  isConnectUrlVariable,
7
7
  isEmailTransportVariable,
8
8
  isFrameworkVariableName,
9
+ isPlatformTransportVariable,
9
10
  isWorkloadKeyVariable,
11
+ PLATFORM_RECEIVERS,
12
+ platformTransportVariables,
10
13
  } from '@fabrika/auth-core'
11
14
  import type { JsonValue } from '@fabrika/provider-contract'
12
15
  import { z } from 'zod'
@@ -32,6 +35,8 @@ export type ManagedKey = Extract<
32
35
  | 'FABRIKA_PROXY_SECRET'
33
36
  | 'FABRIKA_IAM_RPC_URL'
34
37
  | 'FABRIKA_IAM_RPC_KEY'
38
+ | 'FABRIKA_APP_ORIGIN'
39
+ | 'FABRIKA_TOKEN_KEYS'
35
40
  >
36
41
 
37
42
  export const MANAGED_KEYS: readonly ManagedKey[] = [
@@ -45,6 +50,8 @@ export const MANAGED_KEYS: readonly ManagedKey[] = [
45
50
  'FABRIKA_PROXY_SECRET',
46
51
  'FABRIKA_IAM_RPC_URL',
47
52
  'FABRIKA_IAM_RPC_KEY',
53
+ 'FABRIKA_APP_ORIGIN',
54
+ 'FABRIKA_TOKEN_KEYS',
48
55
  ]
49
56
 
50
57
  /** The managed keys written as service variables; `FABRIKA_RELEASE` travels only in the execution descriptor (ADR-0044). */
@@ -58,6 +65,8 @@ export const SERVICE_MANAGED_KEYS: readonly Exclude<ManagedKey, 'FABRIKA_RELEASE
58
65
  'FABRIKA_PROXY_SECRET',
59
66
  'FABRIKA_IAM_RPC_URL',
60
67
  'FABRIKA_IAM_RPC_KEY',
68
+ 'FABRIKA_APP_ORIGIN',
69
+ 'FABRIKA_TOKEN_KEYS',
61
70
  ]
62
71
 
63
72
  /** The managed keys that carry a credential, written sensitive and therefore never compared. */
@@ -66,10 +75,16 @@ export const SENSITIVE_MANAGED_KEYS: ReadonlySet<ManagedKey> = new Set<ManagedKe
66
75
  'FABRIKA_SERVICE_KEY',
67
76
  'FABRIKA_PROXY_SECRET',
68
77
  'FABRIKA_IAM_RPC_KEY',
78
+ 'FABRIKA_TOKEN_KEYS',
69
79
  ])
70
80
 
71
- /** A workload or connect key and every mail transport variable are written sensitive; a connect URL is an origin. */
72
- export const isSensitiveFamilyVariable = (name: string): boolean => !isConnectUrlVariable(name)
81
+ const PLATFORM_URL_VARIABLES: ReadonlySet<string> = new Set(PLATFORM_RECEIVERS.map((receiver) => platformTransportVariables(receiver).url))
82
+
83
+ /**
84
+ * A workload, connect or platform transport key and every mail transport variable are written sensitive; a connect or
85
+ * platform transport URL is an origin.
86
+ */
87
+ export const isSensitiveFamilyVariable = (name: string): boolean => !isConnectUrlVariable(name) && !PLATFORM_URL_VARIABLES.has(name)
73
88
 
74
89
  export type ManagedValues = Record<ManagedKey, string | null>
75
90
 
@@ -85,36 +100,45 @@ const managedValuesSchema = z.object({
85
100
  // IAM's membership transport (ADR-0022 rule 6): its public origin and this environment's key, or both null.
86
101
  FABRIKA_IAM_RPC_URL: z.string().nullable(),
87
102
  FABRIKA_IAM_RPC_KEY: z.string().nullable(),
103
+ // The environment's canonical origin (`ctx.url`), or null when it has no public origin.
104
+ FABRIKA_APP_ORIGIN: z.string().nullable(),
105
+ // The keys of the application's signed tokens, or null when its contract declares no token purpose.
106
+ FABRIKA_TOKEN_KEYS: z.string().nullable(),
88
107
  }).strict()
89
108
 
90
109
  export function parseManagedValues(value: unknown): ManagedValues {
91
110
  const result = managedValuesSchema.safeParse(value)
92
- if (!result.success) throw new Error('zerops: managed environment must name exactly the ten managed keys')
111
+ if (!result.success) throw new Error('zerops: managed environment must name exactly the twelve managed keys')
93
112
  return result.data
94
113
  }
95
114
 
96
115
  /**
97
- * What Control hands a deploy: the ten managed keys, the key of each job and webhook of the approved contract, the
116
+ * What Control hands a deploy: the twelve managed keys, the key of each job and webhook of the approved contract, the
98
117
  * key and owner origin of each connector import, and the installation's mail transport when a job sends through it.
99
118
  */
100
119
  export interface ManagedEnvironment {
101
120
  readonly values: ManagedValues
102
121
  /**
103
- * `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`, and each
104
- * `FABRIKA_EMAIL_*` of the installation's mail transport.
122
+ * `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`, each
123
+ * `FABRIKA_EMAIL_*` of the installation's mail transport, and `FABRIKA_PLATFORM_<RECEIVER>_URL` / `_KEY`.
105
124
  */
106
125
  readonly familyVariables: Readonly<Record<string, string>>
107
126
  }
108
127
 
109
128
  const keySchema = z.string().startsWith('px_').min(4)
110
129
  const mailValueSchema = z.string().min(1)
111
- const originSchema = z.string().refine((value) => {
112
- try {
113
- return new URL(value).origin === value && value.startsWith('https://')
114
- } catch {
115
- return false
116
- }
117
- })
130
+ const originSchema = (schemes: readonly string[]) =>
131
+ z.string().refine((value) => {
132
+ try {
133
+ const url = new URL(value)
134
+ return url.origin === value && schemes.includes(url.protocol)
135
+ } catch {
136
+ return false
137
+ }
138
+ })
139
+ const connectOriginSchema = originSchema(['https:'])
140
+ // Control's public origin is http in the local stack (`http://control.fabrika.localhost:18080`).
141
+ const platformOriginSchema = originSchema(['http:', 'https:'])
118
142
 
119
143
  export function parseManagedEnvironment(value: Readonly<Record<string, string | null>>): ManagedEnvironment {
120
144
  const managed: Record<string, string | null> = {}
@@ -130,11 +154,18 @@ export function parseManagedEnvironment(value: Readonly<Record<string, string |
130
154
  familyVariables[name] = parsed.data
131
155
  continue
132
156
  }
157
+ if (isPlatformTransportVariable(name)) {
158
+ const address = PLATFORM_URL_VARIABLES.has(name)
159
+ const parsed = (address ? platformOriginSchema : keySchema).safeParse(entry)
160
+ if (!parsed.success) throw new Error(`zerops: ${name} must be ${address ? 'an http(s) origin' : 'a px_ key'}`)
161
+ familyVariables[name] = parsed.data
162
+ continue
163
+ }
133
164
  const origin = isConnectUrlVariable(name)
134
165
  if (!origin && !isWorkloadKeyVariable(name) && !isConnectKeyVariable(name)) {
135
166
  throw new Error(`zerops: ${name} is not a variable a deploy writes`)
136
167
  }
137
- const parsed = (origin ? originSchema : keySchema).safeParse(entry)
168
+ const parsed = (origin ? connectOriginSchema : keySchema).safeParse(entry)
138
169
  if (!parsed.success) {
139
170
  throw new Error(`zerops: ${name} must be ${origin ? 'an https origin' : 'a px_ key'}`)
140
171
  }
@@ -144,8 +175,8 @@ export function parseManagedEnvironment(value: Readonly<Record<string, string |
144
175
  }
145
176
 
146
177
  /**
147
- * A service variable only the platform writes per workload, import or the installation's mail transport: a stale one is
148
- * removed when the deploy no longer hands it.
178
+ * A service variable only the platform writes per workload, import, platform receiver or the installation's mail
179
+ * transport: a stale one is removed when the deploy no longer hands it.
149
180
  */
150
181
  export function isFrameworkFamilyVariable(name: string): boolean {
151
182
  return FRAMEWORK_VARIABLE_PREFIXES.some((prefix) => name.startsWith(prefix))
package/src/manifest.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { appSchemaSchema } from '@fabrika/auth'
2
- import { type AppSchema, type ContractArtifact, isDigest } from '@fabrika/auth-core'
2
+ import { type AppSchema, type ContractArtifact, contractDigest, isDigest } from '@fabrika/auth-core'
3
3
  import type { OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
4
4
  import type { JsonValue, ProviderCodec, ProviderDeployInput, ProviderEnvironment } from '@fabrika/provider-contract'
5
5
  import { proxyEndpointRowSchema } from '@fabrika/proxy-core/manifest'
@@ -9,6 +9,7 @@ import { assertOwnRunWhenExtending, executionZeropsDocument, parseZeropsDescript
9
9
  import {
10
10
  assertScanMemory,
11
11
  CONTRACT_DIGEST_PATTERN,
12
+ FABRIKA_CHECK_VERSION,
12
13
  LOCKED_APP_ID_PATTERN,
13
14
  lockedAuthoredBuild,
14
15
  ZEROPS_RUNTIME_PACKAGES,
@@ -456,13 +457,17 @@ const applicationStorageHostname = (services: FabrikaManifestImportDocument['ser
456
457
  return own[0] ?? null
457
458
  }
458
459
 
459
- /** What the platform renders a contract application's execution descriptor from, or null for any other application. */
460
+ /**
461
+ * What the platform renders a contract application's execution descriptor from, or null for any other application.
462
+ * The manifest is the one this release's `fabrika app build` wrote, so its build checks with this release.
463
+ */
460
464
  export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecution | null => {
461
465
  const { contract } = manifest.target
462
466
  if (contract === undefined) return null
463
467
  return {
464
468
  appId: manifest.app.id,
465
469
  contract,
470
+ checkRelease: FABRIKA_CHECK_VERSION,
466
471
  databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
467
472
  databaseConnections: null,
468
473
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
@@ -490,11 +495,12 @@ export function lockedExecutionFor(
490
495
  throw new Error('zerops: Control approved no access for this environment')
491
496
  }
492
497
  if (!LOCKED_APP_ID_PATTERN.test(manifest.app.id)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
493
- const contract = zeropsContractRun(access.contract)
498
+ const contract = zeropsContractRun(access.contract, access.contractDigest)
494
499
  assertScanMemory(manifest.target.importDocument.services, manifest.target.deployService, contract)
495
500
  return {
496
501
  appId: manifest.app.id,
497
502
  contract,
503
+ checkRelease: access.checker,
498
504
  databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
499
505
  databaseConnections: null,
500
506
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
@@ -658,7 +664,9 @@ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string, sou
658
664
  zeropsSetup: config.target.zeropsSetup,
659
665
  proxy: config.target.proxy,
660
666
  namespaceResources: config.target.namespaceResources,
661
- contract: config.target.contract === undefined ? undefined : zeropsContractRun(config.target.contract),
667
+ contract: config.target.contract === undefined
668
+ ? undefined
669
+ : zeropsContractRun(config.target.contract, contractDigest(config.target.contract)),
662
670
  runtimePackages: config.target.runtimePackages,
663
671
  },
664
672
  }
@@ -47,7 +47,7 @@ export function compileNamespaceProxyManifest(namespaceId: string, members: read
47
47
  // A Zerops public domain is always served over the project's TLS-terminating L7 balancer, so the
48
48
  // browser speaks https even though the proxy itself is reached over plain HTTP (ADR-0022).
49
49
  scheme: 'https',
50
- ...contractProxyTable(access.contract),
50
+ ...contractProxyTable(access.contract, access.contractDigest),
51
51
  ...(operationsOrigin === undefined ? {} : { operationsOrigin }),
52
52
  ...(crossOrigins.length === 0 ? {} : { crossOrigins: [...crossOrigins] }),
53
53
  })
@@ -0,0 +1,54 @@
1
+ // What a namespace proxy pinned to an older source cannot read. Its manifest parser is strict: one row of a form it does
2
+ // not know refuses the whole manifest, and every member of the namespace loses its proxy at the next republish. So a
3
+ // deploy that would publish such a row is refused while the namespace's proxy source is not this control plane's own.
4
+
5
+ import type { ProviderApprovedAccess } from '@fabrika/provider-contract'
6
+ import { contractProxyTable, type ProxyEndpointRow } from '@fabrika/proxy-core/manifest'
7
+ import type { ZeropsNamespaceProxySource } from './types'
8
+
9
+ /** Whether the namespace proxy of one older platform release parses a manifest row: that release's own row parser. */
10
+ export type OlderProxyRowReader = (row: ProxyEndpointRow) => boolean
11
+
12
+ /**
13
+ * The namespace proxies of the older releases this platform serves (ADR-0062). Control holds their parsers, as it holds
14
+ * their contract readers; a pin's release is not recorded, so a row any of them reads is one the pin may read.
15
+ */
16
+ export type OlderProxyRowReaders = readonly [OlderProxyRowReader, ...OlderProxyRowReader[]]
17
+
18
+ /** The rows of the approved contract's proxy table that no older release's proxy parses, as the namespace publishes them. */
19
+ export function rowsNeedingCurrentProxy(access: ProviderApprovedAccess, olderProxies: OlderProxyRowReaders): string[] {
20
+ return contractProxyTable(access.contract, access.contractDigest).endpoints
21
+ .filter((row) => !olderProxies.some((reads) => reads(row)))
22
+ .map((row) => row.id)
23
+ }
24
+
25
+ /** This control plane's own proxy source, and the parsers of the older releases a pinned proxy may run. */
26
+ export interface PlatformProxies {
27
+ readonly proxySource: ZeropsNamespaceProxySource
28
+ readonly olderProxies: OlderProxyRowReaders
29
+ }
30
+
31
+ /**
32
+ * Refuses a deploy whose approved contract holds a row the namespace's pinned proxy may not read: its source is not the
33
+ * one this control plane deploys new placements from. `platform` is absent where this control plane manages no
34
+ * namespace proxy, which then has nothing to compare.
35
+ */
36
+ export function assertNamespaceProxyReads(
37
+ namespace: string,
38
+ access: ProviderApprovedAccess | undefined,
39
+ pinned: ZeropsNamespaceProxySource,
40
+ platform: PlatformProxies | undefined,
41
+ ): void {
42
+ if (access === undefined || platform === undefined || pinned.commitSha === platform.proxySource.commitSha) {
43
+ return
44
+ }
45
+ const rows = rowsNeedingCurrentProxy(access, platform.olderProxies)
46
+ if (rows.length === 0) {
47
+ return
48
+ }
49
+ throw new Error(
50
+ `the proxy of deployment namespace \`${namespace}\` is pinned to an older source than this platform's, and no older release's proxy reads ${
51
+ rows.map((row) => `\`${row}\``).join(', ')
52
+ }; re-pin it with \`fabrika control namespaces update --namespace=${namespace} --proxy-source=current\`, then deploy again`,
53
+ )
54
+ }
package/src/provider.ts CHANGED
@@ -48,7 +48,6 @@ import {
48
48
  zeropsProcessConnections,
49
49
  } from './database-budget'
50
50
  import {
51
- FABRIKA_CHECK_VERSION,
52
51
  PLATFORM_TYPESCRIPT_VERSION,
53
52
  renderLockedExecutionDescriptor,
54
53
  zeropsJobProcesses,
@@ -581,7 +580,7 @@ const verifyExposure = async (env: StepEnv): Promise<void> => {
581
580
  )
582
581
  }
583
582
 
584
- /** Write the nine service-level managed keys; `FABRIKA_RELEASE` is never a service variable (ADR-0044). */
583
+ /** Write the eleven service-level managed keys; `FABRIKA_RELEASE` is never a service variable (ADR-0044). */
585
584
  const writeServiceEnv = async (env: StepEnv): Promise<void> => {
586
585
  const { zerops, log, signal, managedValues, familyVariables } = env
587
586
  const serviceId = env.run.target.serviceId
@@ -930,7 +929,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
930
929
  ? overlayRelease(contents, artifact.target.zeropsSetup, release)
931
930
  : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, sizedExecution(locked, attempt))
932
931
  if (locked !== null) {
933
- log(` generated run section; build checks @fabrika/check@${FABRIKA_CHECK_VERSION}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
932
+ log(` generated run section; build checks @fabrika/check@${locked.checkRelease}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
934
933
  if (locked.sourceMaps !== null) log(` the build uploads the source maps of ${locked.sourceMaps.assetRoots.length} asset root(s)`)
935
934
  }
936
935
  assertRunning(signal)