@fabrika/provider-zerops 0.8.2 → 0.9.0-cloudflare.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/package.json +16 -14
  2. package/src/api/client.ts +6 -0
  3. package/src/api/types.ts +12 -0
  4. package/src/cli.ts +11 -0
  5. package/src/collaborators.ts +0 -14
  6. package/src/control.ts +33 -57
  7. package/src/execution.ts +113 -102
  8. package/src/installation/admin.ts +1 -1
  9. package/src/installation/deploy-options.ts +1 -2
  10. package/src/installation/deploy.ts +41 -75
  11. package/src/installation/generated/platform-console-schema.ts +3 -0
  12. package/src/installation/generated/platform-proxy-manifest.ts +21 -1
  13. package/src/installation/guide.md +9 -6
  14. package/src/installation/hosts.ts +1 -7
  15. package/src/installation/index.ts +0 -13
  16. package/src/installation/init-options.ts +2 -1
  17. package/src/installation/install-environment.ts +42 -106
  18. package/src/installation/install-options.ts +3 -1
  19. package/src/installation/install-secrets.ts +24 -68
  20. package/src/installation/install.ts +8 -4
  21. package/src/installation/sidecar.ts +5 -26
  22. package/src/installation/topology/setups.ts +5 -0
  23. package/src/installation/upgrade-options.ts +2 -2
  24. package/src/managed-env.ts +1 -174
  25. package/src/manifest.ts +11 -14
  26. package/src/namespace/claims.ts +56 -1
  27. package/src/namespace/errors.ts +1 -1
  28. package/src/namespace/manifest.ts +6 -19
  29. package/src/namespace/provisioning.ts +15 -3
  30. package/src/namespace/proxy-attempt.ts +43 -2
  31. package/src/namespace/proxy-reads.ts +12 -22
  32. package/src/namespace-cli.ts +152 -0
  33. package/src/plan.ts +2 -7
  34. package/src/provider.ts +128 -126
  35. package/src/proxy-manifest-carrier.ts +18 -0
  36. package/src/types.ts +0 -2
  37. package/types/src/api/client.d.ts +5 -0
  38. package/types/src/api/errors.d.ts +24 -0
  39. package/types/src/api/polling.d.ts +41 -0
  40. package/types/src/api/readers.d.ts +1115 -0
  41. package/types/src/api/service-env-limit.d.ts +17 -0
  42. package/types/src/api/service-env-response.d.ts +23 -0
  43. package/types/src/api/types.d.ts +687 -0
  44. package/types/src/api.d.ts +6 -0
  45. package/types/src/authoring.d.ts +9 -0
  46. package/types/src/cli-args.d.ts +19 -0
  47. package/types/src/cli.d.ts +13 -0
  48. package/types/src/collaborators.d.ts +16 -0
  49. package/types/src/compile.d.ts +91 -0
  50. package/types/src/control.d.ts +40 -0
  51. package/types/src/database-budget.d.ts +38 -0
  52. package/types/src/descriptor.d.ts +28 -0
  53. package/types/src/execution.d.ts +125 -0
  54. package/types/src/exposure.d.ts +47 -0
  55. package/types/src/index.d.ts +31 -0
  56. package/types/src/installation/admin-options.d.ts +24 -0
  57. package/types/src/installation/admin.d.ts +35 -0
  58. package/types/src/installation/checker-release.d.ts +12 -0
  59. package/types/src/installation/deploy-options.d.ts +37 -0
  60. package/types/src/installation/deploy.d.ts +136 -0
  61. package/types/src/installation/generated/platform-console-schema.d.ts +2 -0
  62. package/types/src/installation/generated/platform-proxy-manifest.d.ts +2 -0
  63. package/types/src/installation/hosts.d.ts +34 -0
  64. package/types/src/installation/index.d.ts +7 -0
  65. package/types/src/installation/init-options.d.ts +38 -0
  66. package/types/src/installation/init.d.ts +60 -0
  67. package/types/src/installation/install-collaborators.d.ts +24 -0
  68. package/types/src/installation/install-environment.d.ts +63 -0
  69. package/types/src/installation/install-options.d.ts +49 -0
  70. package/types/src/installation/install-project.d.ts +13 -0
  71. package/types/src/installation/install-provisioning.d.ts +58 -0
  72. package/types/src/installation/install-secrets.d.ts +17 -0
  73. package/types/src/installation/install-token.d.ts +10 -0
  74. package/types/src/installation/install.d.ts +23 -0
  75. package/types/src/installation/installation-source.d.ts +8 -0
  76. package/types/src/installation/log.d.ts +44 -0
  77. package/types/src/installation/machine-key.d.ts +19 -0
  78. package/types/src/installation/placement-options.d.ts +11 -0
  79. package/types/src/installation/platform-archive.d.ts +7 -0
  80. package/types/src/installation/platform-source.d.ts +8 -0
  81. package/types/src/installation/platform-upload.d.ts +3 -0
  82. package/types/src/installation/sidecar.d.ts +49 -0
  83. package/types/src/installation/source-options.d.ts +1 -0
  84. package/types/src/installation/topology/artifacts.d.ts +8 -0
  85. package/types/src/installation/topology/index.d.ts +5 -0
  86. package/types/src/installation/topology/installation-schemas.d.ts +2242 -0
  87. package/types/src/installation/topology/invariants.d.ts +23 -0
  88. package/types/src/installation/topology/setups.d.ts +59 -0
  89. package/types/src/installation/topology/topology.d.ts +153 -0
  90. package/types/src/installation/topology/validate.d.ts +11 -0
  91. package/types/src/installation/upgrade-options.d.ts +26 -0
  92. package/types/src/installation/upgrade-output.d.ts +6 -0
  93. package/types/src/installation/upgrade.d.ts +88 -0
  94. package/types/src/managed-env.d.ts +24 -0
  95. package/types/src/manifest.d.ts +204 -0
  96. package/types/src/namespace/claims.d.ts +16 -0
  97. package/types/src/namespace/codec.d.ts +334 -0
  98. package/types/src/namespace/constants.d.ts +25 -0
  99. package/types/src/namespace/errors.d.ts +15 -0
  100. package/types/src/namespace/manifest.d.ts +4 -0
  101. package/types/src/namespace/operator.d.ts +12 -0
  102. package/types/src/namespace/provisioning.d.ts +4 -0
  103. package/types/src/namespace/proxy-attempt.d.ts +39 -0
  104. package/types/src/namespace/proxy-reads.d.ts +22 -0
  105. package/types/src/namespace/proxy-secrets.d.ts +8 -0
  106. package/types/src/namespace/proxy-source.d.ts +74 -0
  107. package/types/src/namespace/topology.d.ts +9 -0
  108. package/types/src/namespace/types.d.ts +101 -0
  109. package/types/src/namespace/update.d.ts +6 -0
  110. package/types/src/namespace/validation.d.ts +12 -0
  111. package/types/src/namespace-cli.d.ts +2 -0
  112. package/types/src/namespace.d.ts +9 -0
  113. package/types/src/plan.d.ts +11 -0
  114. package/types/src/provider.d.ts +63 -0
  115. package/types/src/proxy-manifest-carrier.d.ts +24 -0
  116. package/types/src/schema.generated.d.ts +376 -0
  117. package/types/src/service-names.d.ts +4 -0
  118. package/types/src/service-references.d.ts +13 -0
  119. package/types/src/source/constants.d.ts +6 -0
  120. package/types/src/source/port.d.ts +60 -0
  121. package/types/src/source/types.d.ts +4 -0
  122. package/types/src/source/validation.d.ts +19 -0
  123. package/types/src/source.d.ts +2 -0
  124. package/types/src/types.d.ts +119 -0
  125. package/types/src/version-attempt.d.ts +8 -0
  126. package/src/installation/mcp.ts +0 -102
  127. package/src/installation/proxy-manifest.ts +0 -214
  128. package/src/installation/secrets.ts +0 -78
@@ -1,18 +1,5 @@
1
- import {
2
- FRAMEWORK_VARIABLE_PREFIXES,
3
- type FrameworkVariableName,
4
- frameworkVariableReference,
5
- isConnectKeyVariable,
6
- isConnectUrlVariable,
7
- isEmailTransportVariable,
8
- isFrameworkVariableName,
9
- isPlatformTransportVariable,
10
- isWorkloadKeyVariable,
11
- PLATFORM_RECEIVERS,
12
- platformTransportVariables,
13
- } from '@fabrika/auth-core'
1
+ import { frameworkVariableReference, isFrameworkVariableName } from '@fabrika/auth-core'
14
2
  import type { JsonValue } from '@fabrika/provider-contract'
15
- import { z } from 'zod'
16
3
  import { renderYaml } from './compile'
17
4
  import {
18
5
  assertOwnRunWhenExtending,
@@ -22,166 +9,6 @@ import {
22
9
  selectZeropsDescriptorSetup,
23
10
  } from './descriptor'
24
11
 
25
- /** The framework-only names Control hands every Zerops deploy; the rest of the list is the provider's own. */
26
- export type ManagedKey = Extract<
27
- FrameworkVariableName,
28
- | 'FABRIKA_RELEASE'
29
- | 'FABRIKA_OPERATIONS_DSN'
30
- | 'FABRIKA_IAM_ISSUER'
31
- | 'FABRIKA_IAM_APP_ID'
32
- | 'FABRIKA_APP_ID'
33
- | 'FABRIKA_ENVIRONMENT'
34
- | 'FABRIKA_SERVICE_KEY'
35
- | 'FABRIKA_PROXY_SECRET'
36
- | 'FABRIKA_IAM_RPC_URL'
37
- | 'FABRIKA_IAM_RPC_KEY'
38
- | 'FABRIKA_APP_ORIGIN'
39
- | 'FABRIKA_TOKEN_KEYS'
40
- >
41
-
42
- export const MANAGED_KEYS: readonly ManagedKey[] = [
43
- 'FABRIKA_RELEASE',
44
- 'FABRIKA_OPERATIONS_DSN',
45
- 'FABRIKA_IAM_ISSUER',
46
- 'FABRIKA_IAM_APP_ID',
47
- 'FABRIKA_APP_ID',
48
- 'FABRIKA_ENVIRONMENT',
49
- 'FABRIKA_SERVICE_KEY',
50
- 'FABRIKA_PROXY_SECRET',
51
- 'FABRIKA_IAM_RPC_URL',
52
- 'FABRIKA_IAM_RPC_KEY',
53
- 'FABRIKA_APP_ORIGIN',
54
- 'FABRIKA_TOKEN_KEYS',
55
- ]
56
-
57
- /** The managed keys written as service variables; `FABRIKA_RELEASE` travels only in the execution descriptor (ADR-0044). */
58
- export const SERVICE_MANAGED_KEYS: readonly Exclude<ManagedKey, 'FABRIKA_RELEASE'>[] = [
59
- 'FABRIKA_OPERATIONS_DSN',
60
- 'FABRIKA_IAM_ISSUER',
61
- 'FABRIKA_IAM_APP_ID',
62
- 'FABRIKA_APP_ID',
63
- 'FABRIKA_ENVIRONMENT',
64
- 'FABRIKA_SERVICE_KEY',
65
- 'FABRIKA_PROXY_SECRET',
66
- 'FABRIKA_IAM_RPC_URL',
67
- 'FABRIKA_IAM_RPC_KEY',
68
- 'FABRIKA_APP_ORIGIN',
69
- 'FABRIKA_TOKEN_KEYS',
70
- ]
71
-
72
- /** The managed keys that carry a credential, written sensitive and therefore never compared. */
73
- export const SENSITIVE_MANAGED_KEYS: ReadonlySet<ManagedKey> = new Set<ManagedKey>([
74
- 'FABRIKA_OPERATIONS_DSN',
75
- 'FABRIKA_SERVICE_KEY',
76
- 'FABRIKA_PROXY_SECRET',
77
- 'FABRIKA_IAM_RPC_KEY',
78
- 'FABRIKA_TOKEN_KEYS',
79
- ])
80
-
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)
88
-
89
- export type ManagedValues = Record<ManagedKey, string | null>
90
-
91
- const managedValuesSchema = z.object({
92
- FABRIKA_RELEASE: z.string().nullable(),
93
- FABRIKA_OPERATIONS_DSN: z.string().nullable(),
94
- FABRIKA_IAM_ISSUER: z.string().nullable(),
95
- FABRIKA_IAM_APP_ID: z.string().nullable(),
96
- FABRIKA_APP_ID: z.string().nullable(),
97
- FABRIKA_ENVIRONMENT: z.string().nullable(),
98
- FABRIKA_SERVICE_KEY: z.string().nullable(),
99
- FABRIKA_PROXY_SECRET: z.string().nullable(),
100
- // IAM's membership transport (ADR-0022 rule 6): its public origin and this environment's key, or both null.
101
- FABRIKA_IAM_RPC_URL: z.string().nullable(),
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(),
107
- }).strict()
108
-
109
- export function parseManagedValues(value: unknown): ManagedValues {
110
- const result = managedValuesSchema.safeParse(value)
111
- if (!result.success) throw new Error('zerops: managed environment must name exactly the twelve managed keys')
112
- return result.data
113
- }
114
-
115
- /**
116
- * What Control hands a deploy: the twelve managed keys, the key of each job and webhook of the approved contract, the
117
- * key and owner origin of each connector import, and the installation's mail transport when a job sends through it.
118
- */
119
- export interface ManagedEnvironment {
120
- readonly values: ManagedValues
121
- /**
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`.
124
- */
125
- readonly familyVariables: Readonly<Record<string, string>>
126
- }
127
-
128
- const keySchema = z.string().startsWith('px_').min(4)
129
- const mailValueSchema = z.string().min(1)
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:'])
142
-
143
- export function parseManagedEnvironment(value: Readonly<Record<string, string | null>>): ManagedEnvironment {
144
- const managed: Record<string, string | null> = {}
145
- const familyVariables: Record<string, string> = {}
146
- for (const [name, entry] of Object.entries(value)) {
147
- if (!isFrameworkFamilyVariable(name)) {
148
- managed[name] = entry
149
- continue
150
- }
151
- if (isEmailTransportVariable(name)) {
152
- const parsed = mailValueSchema.safeParse(entry)
153
- if (!parsed.success) throw new Error(`zerops: ${name} must not be empty`)
154
- familyVariables[name] = parsed.data
155
- continue
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
- }
164
- const origin = isConnectUrlVariable(name)
165
- if (!origin && !isWorkloadKeyVariable(name) && !isConnectKeyVariable(name)) {
166
- throw new Error(`zerops: ${name} is not a variable a deploy writes`)
167
- }
168
- const parsed = (origin ? connectOriginSchema : keySchema).safeParse(entry)
169
- if (!parsed.success) {
170
- throw new Error(`zerops: ${name} must be ${origin ? 'an https origin' : 'a px_ key'}`)
171
- }
172
- familyVariables[name] = parsed.data
173
- }
174
- return { values: parseManagedValues(managed), familyVariables }
175
- }
176
-
177
- /**
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.
180
- */
181
- export function isFrameworkFamilyVariable(name: string): boolean {
182
- return FRAMEWORK_VARIABLE_PREFIXES.some((prefix) => name.startsWith(prefix))
183
- }
184
-
185
12
  const refuse = (): never => {
186
13
  throw new Error('zerops: application configuration uses a Fabrika-managed environment name')
187
14
  }
package/src/manifest.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CONTRACT_JOB_NAME_PATTERN, CRON_SCHEDULE_PATTERN } from '@fabrika/app/build'
1
2
  import { appSchemaSchema } from '@fabrika/auth'
2
3
  import { type AppSchema, type ContractArtifact, contractDigest, isDigest } from '@fabrika/auth-core'
3
4
  import type { OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
@@ -12,10 +13,12 @@ import {
12
13
  FABRIKA_CHECK_VERSION,
13
14
  LOCKED_APP_ID_PATTERN,
14
15
  lockedAuthoredBuild,
16
+ READINESS_PATH_PATTERN,
15
17
  ZEROPS_RUNTIME_PACKAGES,
16
18
  zeropsAssetRoots,
17
19
  type ZeropsContractRun,
18
20
  zeropsContractRun,
21
+ type ZeropsDevExecution,
19
22
  type ZeropsLockedExecution,
20
23
  type ZeropsRuntimePackage,
21
24
  } from './execution'
@@ -310,15 +313,10 @@ const importDocumentSchema = z.object({ project: importProjectSchema.optional(),
310
313
  /** The import document a manifest carries: every service already holds the ADR-0004 invariants. */
311
314
  export type FabrikaManifestImportDocument = z.infer<typeof importDocumentSchema>
312
315
 
313
- /** A literal path of unreserved characters: it is written into the generated probes unquoted by any shell. */
314
- const READINESS_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
315
- const JOB_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
316
- const CRON_SCHEDULE = /^[0-9*/,-]+(?: [0-9*/,-]+){4}$/
317
-
318
316
  const contractRunSchema = z.object({
319
317
  digest: z.string().regex(CONTRACT_DIGEST_PATTERN),
320
- readinessPath: z.string().regex(READINESS_PATH),
321
- jobs: z.array(z.object({ name: z.string().regex(JOB_NAME), schedule: z.string().regex(CRON_SCHEDULE) }).strict()),
318
+ readinessPath: z.string().regex(READINESS_PATH_PATTERN),
319
+ jobs: z.array(z.object({ name: z.string().regex(CONTRACT_JOB_NAME_PATTERN), schedule: z.string().regex(CRON_SCHEDULE_PATTERN) }).strict()),
322
320
  files: z.literal(true).optional(),
323
321
  scan: z.literal(true).optional(),
324
322
  }).strict()
@@ -476,8 +474,11 @@ export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecut
476
474
  }
477
475
  }
478
476
 
479
- /** What Control lets a provider publish for one environment (`ProviderEnvironment.access`). */
480
- export type ZeropsAccess = Pick<ProviderEnvironment, 'access'>
477
+ /**
478
+ * What Control lets a provider publish for one environment (`ProviderEnvironment.access`), and, for an environment of
479
+ * kind `dev`, the commit its bootstrap builds.
480
+ */
481
+ export type ZeropsAccess = Pick<ProviderEnvironment, 'access'> & { readonly dev?: ZeropsDevExecution }
481
482
 
482
483
  /**
483
484
  * The execution descriptor's lock-down at deploy (ADR-0050): Control's approved contract decides it, not the artifact,
@@ -506,6 +507,7 @@ export function lockedExecutionFor(
506
507
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
507
508
  runtimePackages: manifest.target.runtimePackages ?? [],
508
509
  sourceMaps: artifactUpload === undefined ? null : sourceMapUpload(artifactUpload, access.contract),
510
+ ...(granted.dev === undefined ? {} : { dev: granted.dev }),
509
511
  }
510
512
  }
511
513
 
@@ -522,11 +524,6 @@ const sourceMapUpload = (upload: NonNullable<ProviderDeployInput['artifactUpload
522
524
  assetRoots: zeropsAssetRoots(contract),
523
525
  })
524
526
 
525
- /** The IAM schema a deploy, cancel or reconcile applies: the approved contract's. `undefined` reconciles nothing. */
526
- export function approvedSchemaOf(granted: ZeropsAccess): AppSchema | undefined {
527
- return granted.access?.schema
528
- }
529
-
530
527
  export const manifestServiceHostnames = (manifest: FabrikaManifest): string[] =>
531
528
  manifest.target.importDocument.services.map((service) => service.hostname)
532
529
 
@@ -1,9 +1,16 @@
1
- import type { ProviderDeploymentNamespace, ProviderRegistration } from '@fabrika/provider-contract'
1
+ import type {
2
+ ProviderClaimedResource,
3
+ ProviderClaimedResourcesInput,
4
+ ProviderDeploymentNamespace,
5
+ ProviderRegistration,
6
+ } from '@fabrika/provider-contract'
2
7
  import { isProxyHost } from '@fabrika/proxy-core/manifest'
8
+ import { type ZeropsApi, ZeropsApiError } from '../api'
3
9
  import { assertFabrikaManifestCoordinates, manifestServiceHostnames, zeropsArtifactCodec } from '../manifest'
4
10
  import { zeropsSharedServicePrefix } from '../service-names'
5
11
  import { decodeEnvelope } from './codec'
6
12
  import { ZEROPS_NAMESPACE_POSTGRES_HOSTNAME, ZEROPS_NAMESPACE_PROXY_HOSTNAME } from './constants'
13
+ import { asNamespaceError } from './errors'
7
14
 
8
15
  export const namespaceResourceClaims = (namespace: ProviderDeploymentNamespace): string[] => {
9
16
  const target = decodeEnvelope(namespace.target)
@@ -75,3 +82,51 @@ export const registrationResourceClaims = (registration: ProviderRegistration):
75
82
  }
76
83
  return hostnames.map((hostname) => `service:${hostname}`).sort()
77
84
  }
85
+
86
+ /**
87
+ * The platform's answer for a project that does not exist, a deleted one included. A project the token holds no grant
88
+ * on answers `403 insufficientPermissions` instead (zerops-platform.md), so this never reads an unreachable one as gone.
89
+ */
90
+ export const ZEROPS_PROJECT_NOT_FOUND = 'projectNotFound'
91
+
92
+ const SERVICE_CLAIM_PREFIX = 'service:'
93
+
94
+ /**
95
+ * Whether each claimed service still exists in the namespace's project. Gone means the project reads `projectNotFound`,
96
+ * or it exists and has no service under the claimed hostname. Any other answer, a key that names no service, or a
97
+ * namespace that never recorded its project throws: Control then releases nothing.
98
+ */
99
+ export const claimedResources = async (
100
+ api: Pick<ZeropsApi, 'getProject' | 'findService'>,
101
+ input: ProviderClaimedResourcesInput,
102
+ ): Promise<ProviderClaimedResource[]> => {
103
+ try {
104
+ const services = input.resourceKeys.map((resourceKey) => {
105
+ const hostname = resourceKey.startsWith(SERVICE_CLAIM_PREFIX) ? resourceKey.slice(SERVICE_CLAIM_PREFIX.length) : ''
106
+ if (hostname === '') throw new Error(`Zerops cannot tell whether claimed resource \`${resourceKey}\` exists`)
107
+ return { resourceKey, hostname }
108
+ })
109
+ const projectId = decodeEnvelope(input.namespace.target).projectId
110
+ // An interrupted first provision can leave a project its marker names but the target does not.
111
+ if (projectId === undefined) {
112
+ throw new Error(`Zerops namespace \`${input.namespace.id}\` records no project, so its services cannot be read back`)
113
+ }
114
+ if (!(await projectExists(api, projectId, input.signal))) return services.map(({ resourceKey }) => ({ resourceKey, exists: false }))
115
+ return await Promise.all(services.map(async ({ resourceKey, hostname }) => ({
116
+ resourceKey,
117
+ exists: (await api.findService({ projectId, hostname, signal: input.signal })) !== null,
118
+ })))
119
+ } catch (cause) {
120
+ throw asNamespaceError(cause, 'claim check')
121
+ }
122
+ }
123
+
124
+ export const projectExists = async (api: Pick<ZeropsApi, 'getProject'>, projectId: string, signal: AbortSignal): Promise<boolean> => {
125
+ try {
126
+ await api.getProject({ projectId, signal })
127
+ return true
128
+ } catch (error) {
129
+ if (error instanceof ZeropsApiError && error.code === ZEROPS_PROJECT_NOT_FOUND) return false
130
+ throw error
131
+ }
132
+ }
@@ -42,7 +42,7 @@ const splitApiMessage = (message: string, code: string): { summary: string; deta
42
42
  }
43
43
 
44
44
  /** Every namespace lifecycle failure leaves this provider as one typed, coded error. */
45
- export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile' | 'update'): ProviderNamespaceError => {
45
+ export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile' | 'update' | 'claim check'): ProviderNamespaceError => {
46
46
  if (cause instanceof ProviderNamespaceError) return cause
47
47
  // The same value is refused again on every retry; only a smaller manifest or a new carrier fixes it.
48
48
  if (cause instanceof ZeropsServiceEnvValueTooLargeError) return namespaceError(cause.code, cause.message, false)
@@ -1,7 +1,6 @@
1
1
  import { appEnvironmentId } from '@fabrika/auth-core'
2
2
  import { decodeEnvelope, type ProviderNamespaceMember } from '@fabrika/provider-contract'
3
- import { isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS } from '@fabrika/proxy-core/headers'
4
- import { contractProxyTable, type ProxyManifest } from '@fabrika/proxy-core/manifest'
3
+ import { contractProxyEntry, contractProxyTable, type ProxyManifest } from '@fabrika/proxy-core/manifest'
5
4
  import { assertFabrikaManifestCoordinates, zeropsArtifactCodec } from '../manifest'
6
5
 
7
6
  /** Compile every public member of one deployment namespace into its strict proxy manifest. */
@@ -28,29 +27,17 @@ export function compileNamespaceProxyManifest(namespaceId: string, members: read
28
27
  if (hosts.has(host)) throw new Error(`duplicate proxy host \`${host}\` in deployment namespace ${namespaceId}`)
29
28
  ids.add(id)
30
29
  hosts.add(host)
31
- const operationsOrigin = member.operationsOrigin
32
- if (operationsOrigin !== undefined && !isPolicyOrigin(operationsOrigin, ['https:', 'http:'])) {
33
- throw new Error(`Zerops proxy target ${member.appId}/${member.env} has an Operations ingest origin that is not an http(s) origin`)
34
- }
35
- const crossOrigins = member.crossOrigins ?? []
36
- if (
37
- crossOrigins.length > MAX_CROSS_ORIGINS
38
- || new Set(crossOrigins).size !== crossOrigins.length
39
- || crossOrigins.some((origin) => !isCrossOrigin(origin) || new URL(origin).hostname === host)
40
- ) {
41
- throw new Error(`Zerops proxy target ${member.appId}/${member.env} has a cross-origin list the proxy manifest refuses`)
42
- }
43
- apps.push({
30
+ apps.push(contractProxyEntry({
44
31
  id,
45
32
  hosts: [host],
46
33
  upstream: proxy.upstream,
47
34
  // A Zerops public domain is always served over the project's TLS-terminating L7 balancer, so the
48
35
  // browser speaks https even though the proxy itself is reached over plain HTTP (ADR-0022).
49
36
  scheme: 'https',
50
- ...contractProxyTable(access.contract, access.contractDigest),
51
- ...(operationsOrigin === undefined ? {} : { operationsOrigin }),
52
- ...(crossOrigins.length === 0 ? {} : { crossOrigins: [...crossOrigins] }),
53
- })
37
+ table: contractProxyTable(access.contract, access.contractDigest),
38
+ ...(member.operationsOrigin === undefined ? {} : { operationsOrigin: member.operationsOrigin }),
39
+ ...(member.crossOrigins === undefined ? {} : { crossOrigins: member.crossOrigins }),
40
+ }))
54
41
  }
55
42
  return { apps }
56
43
  }
@@ -17,7 +17,7 @@ import {
17
17
  type ZeropsService,
18
18
  } from '../api'
19
19
  import { defaultSleep } from '../collaborators'
20
- import { namespaceResourceClaims, registrationResourceClaims } from './claims'
20
+ import { claimedResources, namespaceResourceClaims, projectExists, registrationResourceClaims, ZEROPS_PROJECT_NOT_FOUND } from './claims'
21
21
  import { checkpoint, decodeEnvelope, normalizedNamespace, normalizeTarget } from './codec'
22
22
  import { ZEROPS_NAMESPACE_POSTGRES_HOSTNAME, ZEROPS_NAMESPACE_PROXY_HOSTNAME, ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE } from './constants'
23
23
  import { asNamespaceError, namespaceError, ZEROPS_NAMESPACE_CANCELLED, ZEROPS_SUBDOMAIN_NOT_PUBLISHED } from './errors'
@@ -230,6 +230,7 @@ const deployProxy = async (
230
230
  target: NormalizedZeropsNamespaceTarget,
231
231
  options: ZeropsNamespaceOptions,
232
232
  wait: ProviderNamespaceRepublishInput['wait'],
233
+ reuseActive: boolean,
233
234
  ): Promise<{ namespace: ProviderDeploymentNamespace; target: NormalizedZeropsNamespaceTarget }> => {
234
235
  let currentNamespace = namespace
235
236
  const manifest = compileNamespaceProxyManifest(namespace.id, input.members)
@@ -237,6 +238,7 @@ const deployProxy = async (
237
238
  namespaceId: namespace.id,
238
239
  target,
239
240
  manifest,
241
+ brand: input.brand ?? null,
240
242
  proxySecrets: namespaceProxySecrets(manifest, input.members),
241
243
  ...(options.source === undefined ? {} : { source: options.source }),
242
244
  api: options.api,
@@ -244,6 +246,7 @@ const deployProxy = async (
244
246
  iamKey: options.iamKey,
245
247
  signal: input.signal,
246
248
  wait,
249
+ reuseActive,
247
250
  sleep: options.sleep ?? defaultSleep,
248
251
  boundary: () => input.events.boundary(),
249
252
  checkpoint: async next => {
@@ -263,7 +266,7 @@ const republishNamespace = async (
263
266
  if (target.proxyServiceId === undefined || (target.proxyBuild === undefined && !target.ready)) {
264
267
  throw new Error(`Namespace ${namespace.id} is not ready`)
265
268
  }
266
- const deployed = await deployProxy(input, namespace, target, options, input.wait)
269
+ const deployed = await deployProxy(input, namespace, target, options, input.wait, true)
267
270
  return { namespace: deployed.namespace, settled: deployed.target.proxyBuild === undefined && deployed.target.ready }
268
271
  }
269
272
 
@@ -366,6 +369,14 @@ const runNamespaceMutation = async (
366
369
  } catch {
367
370
  input.signal.throwIfAborted()
368
371
  await checkpoint(input, namespace, { ...target, ready: false })
372
+ // A deleted project never resolves the version: retrying would hold the namespace `provisioning`, unremovable.
373
+ if (target.projectId !== undefined && !(await projectExists(options.api, target.projectId, input.signal))) {
374
+ throw namespaceError(
375
+ ZEROPS_PROJECT_NOT_FOUND,
376
+ `Zerops project ${target.projectId} of namespace \`${namespace.id}\` no longer exists; remove the namespace`,
377
+ false,
378
+ )
379
+ }
369
380
  throw namespaceError('proxyAttemptUnresolved', `Proxy version ${target.proxyDeployment.appVersionId} remains unresolved`, true)
370
381
  }
371
382
  }
@@ -441,7 +452,7 @@ const runNamespaceMutation = async (
441
452
  }
442
453
  namespace = await checkpoint(input, namespace, target)
443
454
 
444
- const deployed = await deployProxy(input, namespace, target, options, 'active')
455
+ const deployed = await deployProxy(input, namespace, target, options, 'active', false)
445
456
  namespace = deployed.namespace
446
457
  target = { ...deployed.target, proxyConfigured: true }
447
458
  // After the deploy, because the platform refuses a service with no HTTP port — and never before `ready`
@@ -478,6 +489,7 @@ export const createZeropsNamespaceCapabilities = (options: ZeropsNamespaceOption
478
489
  provision: (input) => mutateNamespace(input, options, 'provision'),
479
490
  reconcile: (input) => mutateNamespace(input, options, 'reconcile'),
480
491
  republish: (input) => republishNamespace(input, options),
492
+ claimedResources: (input) => claimedResources(options.api, input),
481
493
  updates: createZeropsNamespaceUpdates(options),
482
494
  operator: createZeropsNamespaceOperator({ proxySource: options.proxySource }),
483
495
  }
@@ -1,4 +1,5 @@
1
1
  import { followLeaseLoss, isLeaseLoss, ProviderNamespaceError } from '@fabrika/provider-contract'
2
+ import type { InstallationBrand } from '@fabrika/proxy-core/brand'
2
3
  import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON, type ProxyManifest } from '@fabrika/proxy-core/manifest'
3
4
  import {
4
5
  readableContent,
@@ -11,7 +12,12 @@ import {
11
12
  } from '../api'
12
13
  import type { Sleeper } from '../collaborators'
13
14
  import { prepareZeropsDescriptor } from '../descriptor'
14
- import { ZEROPS_PROXY_MANIFEST_PATH, type ZeropsProxyManifestCarrier, zeropsProxyManifestCarrier } from '../proxy-manifest-carrier'
15
+ import {
16
+ ZEROPS_PROXY_MANIFEST_PATH,
17
+ zeropsProxyAcceptsBrand,
18
+ type ZeropsProxyManifestCarrier,
19
+ zeropsProxyManifestCarrier,
20
+ } from '../proxy-manifest-carrier'
15
21
  import { isZeropsUploadDestination } from '../source'
16
22
  import type { ZeropsSourcePort } from '../source/port'
17
23
  import { deleteVersion } from '../version-attempt'
@@ -35,6 +41,8 @@ export interface ZeropsProxyRepublishInput {
35
41
  readonly namespaceId: string
36
42
  readonly target: ZeropsNamespaceTarget
37
43
  readonly manifest: ProxyManifest
44
+ /** The installation's client logo (ADR-0066); added to the manifest only when the pinned proxy can parse it. */
45
+ readonly brand?: InstallationBrand | null
38
46
  readonly source?: ZeropsSourcePort
39
47
  readonly api: ZeropsProxyApi
40
48
  readonly iamUrl: string
@@ -44,6 +52,11 @@ export interface ZeropsProxyRepublishInput {
44
52
  /** Aborts with `lease_lost` when the namespace lease goes, and with `cancel_requested` when a deploy is cancelled. */
45
53
  readonly signal: AbortSignal
46
54
  readonly wait: 'started' | 'active'
55
+ /**
56
+ * Reuse the proxy's ACTIVE version when it was built from this pin with this manifest identity. A deploy and a
57
+ * registry change pass true; provisioning and reconcile rebuild, so an operator's repair always reaches the platform.
58
+ */
59
+ readonly reuseActive: boolean
47
60
  /** Persist the target; the caller fences the write on the namespace lease. */
48
61
  readonly checkpoint: (target: ZeropsNamespaceTarget) => Promise<void>
49
62
  readonly boundary: () => Promise<void>
@@ -88,6 +101,28 @@ const writeProxyIam = async (input: ZeropsProxyRepublishInput, serviceId: string
88
101
  const proxySecretsDigest = async (secrets: ReadonlyMap<string, string>): Promise<string> =>
89
102
  digest([...secrets].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, value]) => `${key}=${value}`).join('\n'))
90
103
 
104
+ /**
105
+ * Whether the proxy already runs `manifestSha256` on this pin: the recorded deployment names both, and its version is
106
+ * still the service's ACTIVE one. Any doubt, a failed read included, answers false, and the proxy is rebuilt.
107
+ */
108
+ const servesManifest = async (input: ZeropsProxyRepublishInput, target: ZeropsNamespaceTarget, manifestSha256: string): Promise<boolean> => {
109
+ const deployed = target.proxyDeployment
110
+ const pin = target.proxySource
111
+ if (
112
+ target.ready !== true || deployed === undefined || deployed.manifestSha256 !== manifestSha256
113
+ || deployed.commitSha !== pin.commitSha || deployed.descriptorSha256 !== pin.descriptor.sha256
114
+ || deployed.repository.owner !== pin.repository.owner || deployed.repository.name !== pin.repository.name
115
+ ) return false
116
+ await input.boundary()
117
+ try {
118
+ const version = await input.api.getAppVersion({ appVersionId: deployed.appVersionId, signal: input.signal })
119
+ return version.id === deployed.appVersionId && version.status === ZEROPS_ACTIVE && version.serviceStackId === target.proxyServiceId
120
+ } catch {
121
+ input.signal.throwIfAborted()
122
+ return false
123
+ }
124
+ }
125
+
91
126
  /**
92
127
  * Rebuild the namespace proxy with `manifest` (ADR-0044). The attempt is recorded as `proxyBuild` and never
93
128
  * resumed: an inherited attempt not yet `triggered` is deleted, a triggered one is polled to a terminal state.
@@ -100,7 +135,11 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
100
135
  await verifyZeropsNamespaceProxySource(pin)
101
136
  // The pinned descriptor decides: a namespace kept on an older proxy revision still lifts the variable (ADR-0045).
102
137
  const carrier = zeropsProxyManifestCarrier(pin.descriptor.contents)
103
- const manifest = encodeProxyManifestJson(input.manifest)
138
+ // So does it for the logo: an older parser refuses the key, and the namespace keeps the mark until it is re-pinned.
139
+ const brand = input.brand ?? null
140
+ const manifest = encodeProxyManifestJson(
141
+ brand !== null && zeropsProxyAcceptsBrand(pin.descriptor.contents) ? { ...input.manifest, brand } : input.manifest,
142
+ )
104
143
  // The attempt's identity covers the IAM configuration and the proxy secrets too, so rotating any forces a rebuild.
105
144
  const proxySecrets = input.proxySecrets ?? new Map<string, string>()
106
145
  const secrets = proxySecrets.size === 0 ? '' : `\n${await proxySecretsDigest(proxySecrets)}`
@@ -173,6 +212,8 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
173
212
  if (inherited.manifestSha256 === manifestSha256) return target
174
213
  }
175
214
 
215
+ if (input.reuseActive && target.proxyBuild === undefined && await servesManifest(input, target, manifestSha256)) return target
216
+
176
217
  if (input.source === undefined) throw new Error('Zerops namespace source client is required')
177
218
  const runId = crypto.randomUUID()
178
219
  const resolved = await input.source.resolve({
@@ -1,36 +1,26 @@
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, or a CSP directive it does not know, refuses the whole manifest and blocks the namespace's next republish.
3
- // So a deploy that would publish either is refused before publication while the namespace's proxy source is not
4
- // this control plane's own.
1
+ // What a namespace proxy pinned to an older source cannot read or serve. Its manifest parser is strict: one row of a form
2
+ // it does not know, or a CSP directive it does not know, refuses the whole manifest and blocks the namespace's next
3
+ // republish; and it has no terminal sign-out. So a deploy that would publish either, or a terminal, is refused before
4
+ // publication while the namespace's proxy source is not this control plane's own.
5
5
 
6
6
  import type { ProviderApprovedAccess } from '@fabrika/provider-contract'
7
- import type { CspOrigins } from '@fabrika/proxy-core/headers'
8
- import { contractProxyTable, type ProxyEndpointRow } from '@fabrika/proxy-core/manifest'
7
+ import { contractProxyTable, type OlderProxyReaders } from '@fabrika/proxy-core/manifest'
9
8
  import type { ZeropsNamespaceProxySource } from './types'
10
9
 
11
- /**
12
- * What the namespace proxy of one older platform release parses of an application's manifest entry: a row, and the
13
- * entry's CSP directives, each by that release's own parser.
14
- */
15
- export interface OlderProxyReader {
16
- readonly row: (row: ProxyEndpointRow) => boolean
17
- readonly csp: (csp: CspOrigins) => boolean
18
- }
19
-
20
- /**
21
- * The namespace proxies of the older releases a pin may run whose parsers Control holds (ADR-0062). A pin's release is not
22
- * recorded, so what any of them reads is what the pin may read.
23
- */
24
- export type OlderProxyReaders = readonly [OlderProxyReader, ...OlderProxyReader[]]
10
+ export type { OlderProxyReader, OlderProxyReaders } from '@fabrika/proxy-core/manifest'
25
11
 
26
12
  /**
27
13
  * What of the approved contract's manifest entry no older release's proxy parses, as the namespace publishes it: each
28
14
  * such row by its id, and `csp` when no older proxy parses the entry's CSP (`workerSrc`, which only this release knows).
15
+ * A contract that declares a terminal adds `terminal` when no older proxy serves the sign-out its sessions end at
16
+ * (ADR-0067 §5).
29
17
  */
30
18
  export function entriesNeedingCurrentProxy(access: ProviderApprovedAccess, olderProxies: OlderProxyReaders): string[] {
31
19
  const table = contractProxyTable(access.contract, access.contractDigest)
32
20
  const rows = table.endpoints.filter((row) => !olderProxies.some((proxy) => proxy.row(row))).map((row) => row.id)
33
- return olderProxies.some((proxy) => proxy.csp(table.csp)) ? rows : [...rows, 'csp']
21
+ const csp = olderProxies.some((proxy) => proxy.csp(table.csp)) ? [] : ['csp']
22
+ const terminal = access.contract.terminal === undefined || olderProxies.some((proxy) => proxy.signsOut) ? [] : ['terminal']
23
+ return [...rows, ...csp, ...terminal]
34
24
  }
35
25
 
36
26
  /** This control plane's own proxy source, and the parsers of the older releases a pinned proxy may run. */
@@ -58,7 +48,7 @@ export function assertNamespaceProxyReads(
58
48
  return
59
49
  }
60
50
  throw new Error(
61
- `the proxy of deployment namespace \`${namespace}\` is pinned to an older source than this platform's, and no older release's proxy reads ${
51
+ `the proxy of deployment namespace \`${namespace}\` is pinned to an older source than this platform's, and no older release's proxy reads or serves ${
62
52
  rows.map((row) => `\`${row}\``).join(', ')
63
53
  }; re-pin it with \`fabrika control namespaces update --namespace=${namespace} --proxy-source=current\`, then deploy again`,
64
54
  )