@fabrika/provider-zerops 0.2.0 → 0.3.1

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 (82) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +191 -344
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1271
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +266 -241
  73. package/src/source/constants.ts +11 -0
  74. package/src/source/port.ts +49 -0
  75. package/src/source/types.ts +4 -0
  76. package/src/source/validation.ts +72 -0
  77. package/src/source.ts +2 -1220
  78. package/src/types.ts +8 -7
  79. package/src/version-attempt.ts +18 -0
  80. package/src/codec.ts +0 -108
  81. package/src/namespace-command.ts +0 -209
  82. package/src/source-connection.ts +0 -344
@@ -0,0 +1,86 @@
1
+ // Invariants asserted on a COMPILED Zerops import document, on top of the provider compiler
2
+ // already enforces (`assertZeropsInvariants` — no project-level env, `envIsolation: service`, no secret
3
+ // values, no duplicate hostnames; ADR-0004).
4
+ //
5
+ // These three are here rather than in the compiler (`src/compile.ts`) because each needs a fact the compiler does not
6
+ // have:
7
+ //
8
+ // • WHICH service is allowed to be public is a property of the topology (ADR-0022 says "the proxy"),
9
+ // not of any single document. The compiler cannot know the proxy's hostname.
10
+ // • The hostname RULE is in the schema's prose (`description`), not in its `pattern` — there is no
11
+ // `pattern` keyword anywhere in the published document, so schema validation cannot catch it.
12
+ // • `corePackage` cannot be DOWNGRADED once a project exists, so "did you mean to say SERIOUS?" is a
13
+ // question worth failing on rather than a comment (ADR-0006).
14
+ //
15
+ // Everything here throws rather than returning a report: these are all cases where emitting the
16
+ // document would be worse than emitting nothing.
17
+
18
+ import type { ZeropsImportDocument } from '../../index'
19
+
20
+ /**
21
+ * The hostname rule, quoted from the published schema's own description: "duplicates in the same
22
+ * project forbidden; maximum 25 characters, lowercase ASCII letters (a-z) or numbers (0-9) only".
23
+ *
24
+ * NOTE THE ABSENT HYPHEN. It is the one that bites — `run-logs` and `apps-prod` are natural names and
25
+ * both are illegal. The schema does not encode this as a `pattern`, so nothing but this check catches it.
26
+ */
27
+ const HOSTNAME = /^[a-z0-9]{1,25}$/
28
+
29
+ export const assertZeropsHostnames = (document: ZeropsImportDocument): void => {
30
+ for (const service of document.services) {
31
+ if (!HOSTNAME.test(service.hostname)) {
32
+ throw new Error(
33
+ `zerops: hostname \`${service.hostname}\` is illegal — max 25 chars, lowercase a-z and 0-9 only (no hyphens, no underscores, no dots)`,
34
+ )
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * ADR-0022: **only the proxy is publicly routed.** App services, the IAM service, the control plane and
41
+ * the databases stay internal and are reached over the project's private network.
42
+ *
43
+ * Zerops makes this the default — a service is not publicly accessible until you opt in — so the failure
44
+ * this guards against is not "we forgot to lock something down" but "someone added
45
+ * `enableSubdomainAccess: true` to debug a service and it shipped". That is a one-word diff and it is
46
+ * exactly the kind of thing review misses.
47
+ *
48
+ * `publicService` may be absent, which asserts that NOTHING in the document is publicly routed — the
49
+ * right assertion for a document whose public entry point lives in another import.
50
+ */
51
+ export const assertOnlyPublicService = (document: ZeropsImportDocument, publicService?: string): void => {
52
+ for (const service of document.services) {
53
+ if (service.enableSubdomainAccess === true && service.hostname !== publicService) {
54
+ throw new Error(
55
+ `zerops: service \`${service.hostname}\` enables public subdomain access; only \`${
56
+ publicService ?? '(none)'
57
+ }\` may be publicly routed (ADR-0022)`,
58
+ )
59
+ }
60
+ }
61
+ if (publicService !== undefined && !document.services.some((service) => service.hostname === publicService)) {
62
+ throw new Error(`zerops: \`${publicService}\` is named as the public service but the document declares no such service`)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * `corePackage` is per-project, upgrade-only, and its upgrade is partially destructive (logs and
68
+ * statistics are lost) — so it is the one field in this document that a later `PUT` cannot fix
69
+ * (ADR-0006). Require it to be stated rather than inherited from the platform default (`LIGHT`).
70
+ */
71
+ export const assertCorePackageIsExplicit = (document: ZeropsImportDocument): void => {
72
+ const project = document.project
73
+ if (project === undefined) {
74
+ return
75
+ }
76
+ if (project.corePackage === undefined) {
77
+ throw new Error(`zerops: project \`${project.name}\` must state its corePackage — it defaults to LIGHT and cannot be downgraded later (ADR-0006)`)
78
+ }
79
+ }
80
+
81
+ /** All three, for a document that creates its own project. */
82
+ export const assertTopologyInvariants = (document: ZeropsImportDocument, publicService?: string): void => {
83
+ assertZeropsHostnames(document)
84
+ assertOnlyPublicService(document, publicService)
85
+ assertCorePackageIsExplicit(document)
86
+ }
@@ -0,0 +1,442 @@
1
+ // The repository-root `zerops.yaml`, as typed values.
2
+ //
3
+ // ── The decision this file IS ──────────────────────────────────────────────────────────────────────
4
+ //
5
+ // Zerops reads `zerops.yaml` from the REPOSITORY ROOT, and fabrika is a monorepo with three deployable
6
+ // services in it. There were two ways to make that coherent:
7
+ //
8
+ // A. ONE root `zerops.yaml` with several named setups; each service's build selects its own by name
9
+ // (`zeropsSetup`, which defaults to the service hostname).
10
+ // B. Per-service `zeropsYaml` inlined into the import document fabrika generates.
11
+ //
12
+ // **A, and the per-package `packages/*/zerops.yaml` files are superseded by this one.** Four reasons,
13
+ // in the order they matter:
14
+ //
15
+ // 1. **The build spec must be the same however the build was triggered.** An inline `zeropsYaml` is
16
+ // present only on builds fabrika itself triggers. A `zcli service push`, a git-push trigger, a tag,
17
+ // or the GUI's "rebuild" button would all build with whatever the repo says instead — i.e. with
18
+ // something else. That divergence surfaces exactly when someone is debugging a failed deploy, which
19
+ // is the worst possible moment to discover the build they are looking at is not the build that ran.
20
+ // 2. **An inline `zeropsYaml` would be frozen at the service's creation.** Verified live: re-applying
21
+ // an import at a service that already exists changes NOTHING — `override: true` only stops the
22
+ // hostname collision from failing the import, it does not update any field. So under option B a
23
+ // service's build specification would be whatever the FIRST import wrote, and every later edit to
24
+ // it would be accepted by the API and silently ignored. A build spec that cannot be changed after
25
+ // day one is worse than one that lives in the repository.
26
+ // 3. Setup name == service hostname is the platform's own default matching rule, so option A needs no
27
+ // extra wiring for the common case, and `zeropsSetup` stays available for the uncommon one.
28
+ // 4. It keeps `zeropsYaml` free for what it is genuinely good at: a targeted per-deploy OVERRIDE. The
29
+ // provider compiler still supports it (`ZeropsServiceSpec.zeropsYaml`); it is simply not how a
30
+ // service's normal build is described.
31
+ //
32
+ // ── The rule for `envVariables` in here ────────────────────────────────────────────────────────────
33
+ //
34
+ // This file is COMMITTED and PLAINTEXT, and it is the same file for every installation. So it carries
35
+ // only values that are true for EVERY installation: ports, asset directories, and intra-project
36
+ // hostnames. Everything else is a service-level variable written through the env API (ADR-0004).
37
+ //
38
+ // Three categories are therefore deliberately ABSENT, and each setup lists its own below:
39
+ //
40
+ // • per-installation configuration (public origins, cookie domains, authentication switches,
41
+ // OIDC/email coordinates, admission lists, `ENVIRONMENT`) — set as service-level variables through the env API;
42
+ // • every secret — set as service-level `envSecrets`, addressed by service, never through a document;
43
+ // • **every `${service_variable}` reference to a DATA service** — `${db_connectionString}`,
44
+ // `${storage_*}` and friends.
45
+ //
46
+ // That third category used to live here, on the reasoning that a reference is not a value: it points
47
+ // into the platform's variable store rather than carrying a credential. That reasoning is still sound
48
+ // and it is no longer sufficient, because a reference names a SERVICE — and which data services exist
49
+ // is exactly what differs between the `standard` and `light` tiers (`./topology.ts`). One committed
50
+ // file cannot say `${operationsdb_connectionString}` on behalf of a tier that has no `operationsdb`.
51
+ // So the file's own rule decides it: a data-service reference is not true for every installation, and
52
+ // it moves to the env API alongside the origins and the secrets.
53
+ //
54
+ // Verified live: a variable written through the env API resolves `${x_y}` at container start exactly as
55
+ // one written here does, so nothing is lost in the move.
56
+ //
57
+ // (The superseded `packages/iam/zerops.yaml` baked `ISSUER: https://iam.example.com`. That is
58
+ // per-installation, and a placeholder domain in a committed file is a value that boots wrong rather
59
+ // than not booting.)
60
+ //
61
+ // ── The canonical PostgreSQL DSN, wherever those variables are written ─────────────────────────────
62
+ //
63
+ // The URLs are not in this file, but their FORM is fabrika's to decide, so it is stated once here.
64
+ // Whichever service it names, a fabrika PostgreSQL URL on Zerops is written as:
65
+ //
66
+ // ${<host>_connectionTlsString}/${<host>_dbName}?sslmode=require
67
+ //
68
+ // Every part is load-bearing, and all of it was settled on a live account rather than inferred:
69
+ //
70
+ // • `connectionTlsString` is `postgresql://${user}:${password}@${hostname}:${portTls}` — it carries
71
+ // no database path, so without `/${…_dbName}` the driver falls back to the USER name. Every Zerops
72
+ // PostgreSQL service names its database AND its user `db`, whatever the service hostname is
73
+ // (checked on a service called `wu2db`), so the fallback is right — by an undocumented platform
74
+ // convention rather than by anything fabrika states. Naming it removes the dependency.
75
+ // • THE TLS PORT, NOT THE DIRECT ONE. `postgresql:single@18` speaks TLS on both, but
76
+ // `postgresql:ha@18` — what the `standard` tier runs — answers `sslmode=require` on the direct
77
+ // port with "Server does not support SSL". One form has to serve both tiers, and only this one does.
78
+ // • The TLS port preserves SESSION state on both types: same backend pid across statements, `SET
79
+ // SESSION` kept, advisory lock taken and visible. So the migration runner's session-level lock is
80
+ // safe there — it is a TLS endpoint, not the transaction pool the port number suggests.
81
+ // • `verify-full` fails on the self-signed certificate, so `require` is the strongest available mode,
82
+ // and pinning it beats inheriting whatever the client defaults to.
83
+ //
84
+ // ── What a build container can and cannot see ──────────────────────────────────────────────────────
85
+ //
86
+ // Verified live, and it is not what the platform documentation implies: a build container sees NONE of
87
+ // its own service's env-API variables. Reading `${FOO}` in a `buildCommand` yields an empty string, with
88
+ // no error. The bridge is `build.envVariables: { FOO: '${RUNTIME_FOO}' }`, which does work, and which
89
+ // resolves nested references (a `RUNTIME_` lift of a variable that is itself `${db_connectionString}`
90
+ // arrives fully resolved). Only the proxy needs this, and it declares it explicitly below.
91
+
92
+ import { FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
93
+ import type { ZeropsHealthCheckSpec, ZeropsReadinessCheckSpec, ZeropsYamlSetupSpec, ZeropsYamlSpec } from '../../index'
94
+
95
+ // ── Readiness and liveness: two mechanisms, two questions ──────────────────────────────────────────
96
+ //
97
+ // `run.healthCheck` is LIVENESS. It runs for the life of the container and answers "is this process
98
+ // still serving?"; a failure pulls the container out of the balancer and eventually restarts it.
99
+ //
100
+ // `deploy.readinessCheck` is a DEPLOY GATE. It answers "may this new version take traffic at all?"; a
101
+ // version that never passes it fails the deploy and the previous version keeps serving. Verified on a
102
+ // live account: a build whose readiness path answered 503 ended `DEPLOY_FAILED` and never activated,
103
+ // while the previously active version stayed `ACTIVE` and serving.
104
+ //
105
+ // Both point at `/healthz`, and that is a decision rather than an economy. The question a deploy gate
106
+ // has to answer here is exactly "did this build come up and start answering HTTP" — the DATABASE
107
+ // dependency is already gated one step earlier, because `run.initCommands` runs the migrations and a
108
+ // non-zero exit there aborts the deploy before any check runs. A readiness path that queried Postgres
109
+ // would therefore add no gate and would fail deploys during an unrelated database blip.
110
+ //
111
+ // ── Why every duration is a quoted string ──────────────────────────────────────────────────────────
112
+ //
113
+ // The published `zerops.yaml` JSON schema types all six of these as `integer`. The platform refuses an
114
+ // integer — `cannot unmarshal !!int into time.Duration` — and bounds every one of them to [10s, 1h].
115
+ // Both verified live; see `docs/reference/zerops-platform.md`. `ZeropsDuration` encodes the first fact
116
+ // in the type system; the second is why nothing below is faster than 10s.
117
+
118
+ /**
119
+ * Liveness timings, stated rather than defaulted.
120
+ *
121
+ * Probe at the platform's floor so a wedged container is noticed quickly; take it out of the balancer
122
+ * after three consecutive misses; put it back after one success; and only RESTART after two minutes of
123
+ * continuous failure. The asymmetry is the point — disconnecting is cheap and reversible, restarting is
124
+ * neither, and a short `failureTimeout` turns one slow dependency into a rolling restart of every
125
+ * container at once.
126
+ */
127
+ const LIVENESS: Omit<ZeropsHealthCheckSpec, 'httpGet' | 'exec'> = {
128
+ execPeriod: '10s',
129
+ disconnectTimeout: '30s',
130
+ recoveryTimeout: '10s',
131
+ failureTimeout: '120s',
132
+ }
133
+
134
+ /**
135
+ * Readiness timings for a service that migrates at container start.
136
+ *
137
+ * `failureTimeout` has to cover a cold container plus whatever `run.initCommands` does, which for these
138
+ * three is a schema migration; three minutes is generous on purpose, because the cost of it being too
139
+ * short is a failed deploy of a build that was fine.
140
+ */
141
+ const READINESS_WITH_MIGRATIONS: Omit<ZeropsReadinessCheckSpec, 'httpGet' | 'exec'> = {
142
+ retryPeriod: '10s',
143
+ failureTimeout: '180s',
144
+ }
145
+
146
+ /** Readiness for a service with no init step: a static binary that either answers quickly or never will. */
147
+ const READINESS_IMMEDIATE: Omit<ZeropsReadinessCheckSpec, 'httpGet' | 'exec'> = {
148
+ retryPeriod: '10s',
149
+ failureTimeout: '60s',
150
+ }
151
+
152
+ /**
153
+ * Deploy the whole workspace as complete top-level trees, not the individual packages.
154
+ *
155
+ * The workspace links `node_modules/@fabrika/*` at `packages/*`, so copying `node_modules` and
156
+ * `packages` whole keeps those links resolvable whether the platform preserves symlinks or dereferences
157
+ * them. Listing leaf packages individually breaks on the dereferencing case.
158
+ */
159
+ const WORKSPACE_DEPLOY_FILES = ['package.json', 'bun.lock', 'node_modules', 'packages']
160
+
161
+ /**
162
+ * The IAM service — identity, tokens, audit, and the private admin API.
163
+ *
164
+ * Per-installation variables (env API): `FABRIKA_IAM_DATABASE_URL`
165
+ * (`${db_connectionTlsString}/${db_dbName}?sslmode=require` — the canonical form above, on the TLS
166
+ * port, which is the only one an HA PostgreSQL service offers TLS on and which preserves the session
167
+ * state the migration runner's advisory lock needs),
168
+ * `ENVIRONMENT`, `ISSUER` (this service's public origin; it is the `iss` of every minted token AND the
169
+ * OIDC redirect base, so it must match the domain routed to the proxy in front of it),
170
+ * `HUMAN_EMAIL_DOMAINS`, `HUMAN_EMAILS`, `IAM_BOOTSTRAP_ADMINS`,
171
+ * `FABRIKA_IAM_OIDC_ENABLED`, `FABRIKA_IAM_PASSWORD_ENABLED`, `FABRIKA_EMAIL_PROVIDER`, and
172
+ * `FABRIKA_EMAIL_FROM` when email delivery is enabled. OIDC-enabled installations also set
173
+ * `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_SCOPES`, `OIDC_REQUIRE_VERIFIED_EMAIL`.
174
+ *
175
+ * Secrets (`envSecrets`): `FABRIKA_IAM_SIGNING_KEYS` (ES256 private JWKs — an empty value is refused at
176
+ * boot rather than silently issuing ephemeral tokens), `OIDC_CLIENT_SECRET` when OIDC is enabled,
177
+ * `FABRIKA_EMAIL_RESEND_API_KEY` when Resend is enabled, `FABRIKA_IAM_RPC_KEY` (gates
178
+ * the management `/rpc/*` surface; unset 404s it, which is the fail-closed default), `FABRIKA_IAM_PROXY_KEY`
179
+ * (gates `/auth/mint/*`, which the proxy calls on the cold path — a SEPARATE secret by least privilege,
180
+ * since the proxy is the only publicly routed component and must not also be able to write audit rows),
181
+ * `FABRIKA_IAM_PROVISIONING_KEY`. Both shared secrets must be at least 32 characters; a shorter one fails
182
+ * the boot rather than standing in front of an internet-reachable surface while looking configured.
183
+ */
184
+ const iam: ZeropsYamlSetupSpec = {
185
+ setup: 'iam',
186
+ build: {
187
+ // Bun on Alpine. `build.os` is deprecated in the published schema — the OS is part of the base id.
188
+ base: ['alpine/bun@1.3'],
189
+ buildCommands: ['bun install --frozen-lockfile'],
190
+ deployFiles: WORKSPACE_DEPLOY_FILES,
191
+ cache: ['node_modules'],
192
+ },
193
+ deploy: { readinessCheck: { httpGet: { port: 3000, path: '/healthz' }, ...READINESS_WITH_MIGRATIONS } },
194
+ run: {
195
+ base: 'alpine/bun@1.3',
196
+ // Migrations at container start — the Zerops analogue of a discrete migrate step in a Cloudflare
197
+ // plan, and the reason the Zerops deploy plan has no `migrate`. Safe to repeat: the runner records
198
+ // what it applied and serializes concurrent containers behind a session-level advisory lock.
199
+ // Runs in /var/www.
200
+ initCommands: ['bun packages/iam/src/node/migrate.ts'],
201
+ start: 'bun packages/iam/src/node/server.ts',
202
+ // `protocol` is omitted deliberately. TCP is the default at both ends, and the two published
203
+ // sources disagree about its spelling: the JSON schema's enum is lowercase (`tcp`/`udp`) while its
204
+ // own description — and every doc example — says `TCP`. Not writing it sidesteps a contradiction we
205
+ // cannot resolve without an account.
206
+ ports: [{
207
+ port: 3000,
208
+ // The project's L7 balancer terminates TLS and forwards plain HTTP over the private network, so
209
+ // this process holds no certificates.
210
+ httpSupport: true,
211
+ }],
212
+ // Liveness only — it never touches Postgres. A health check that queried the database would let
213
+ // one slow query restart every container at once. The deploy gate is `deploy.readinessCheck`.
214
+ healthCheck: { httpGet: { port: 3000, path: '/healthz' }, ...LIVENESS },
215
+ // The Cloudflare `scheduled` handler's replacement, running the same code path. `allContainers:
216
+ // false` so a horizontally scaled service still prunes once.
217
+ crontab: [{ timing: '0 3 * * *', command: 'bun packages/iam/src/node/prune.ts', allContainers: false }],
218
+ envVariables: { PORT: '3000' },
219
+ },
220
+ }
221
+
222
+ /**
223
+ * The control plane — registry, run lifecycle, vault, webhook, and the dashboard SPA.
224
+ *
225
+ * Per-installation variables (env API): `FABRIKA_CONTROL_DATABASE_URL`
226
+ * (`${db_connectionTlsString}/${db_dbName}?sslmode=require`), the
227
+ * four `FABRIKA_CONTROL_RUN_LOGS_*` coordinates of its object storage (`${storage_bucketName}`,
228
+ * `${storage_apiUrl}`, `${storage_accessKeyId}`, `${storage_secretAccessKey}` — `S3BlobStore` uses
229
+ * path-style addressing, which MinIO wants and R2/AWS accept, so one implementation serves both
230
+ * platforms), `FABRIKA_CONTROL_RUN_LOGS_REGION`, `ENVIRONMENT`, `FABRIKA_CONTROL_DOMAIN`,
231
+ * `FABRIKA_IAM_ISSUER` (the public IAM issuer),
232
+ * `OPERATIONS_ARTIFACT_ORIGIN` (the public proxy origin for source-map upload), `CLOUDFLARE_ACCOUNT_ID`,
233
+ * `FABRIKA_CONTROL_BOOTSTRAP_ADMINS`, `FABRIKA_ZEROPS_CLIENT_ID`, `FABRIKA_ZEROPS_PROXY_SOURCE_JSON`, and
234
+ * `FABRIKA_ZEROPS_PROXY_IAM_URL` (the public IAM origin reachable from application projects).
235
+ *
236
+ * The `FABRIKA_` prefix on that last group is not cosmetic: Zerops RESERVES the bare `ZEROPS_` prefix
237
+ * and rejects any custom variable using it (`userDataZeropsPrefixForbidden`), so the earlier names could
238
+ * not be written through the env API at all — on the one platform they were named for.
239
+ *
240
+ * Secrets (`envSecrets`): `FABRIKA_IAM_RPC_KEY` (authenticates this process to IAM's RPC surface — the
241
+ * same value IAM holds), `FABRIKA_CONTROL_VAULT_KEY` (the vault KEK; its loss is unrecoverable by design),
242
+ * `CLOUDFLARE_API_TOKEN`,
243
+ * `FABRIKA_IAM_PROVISIONING_KEY`, `FABRIKA_ZEROPS_PROXY_IAM_KEY` (the same credential IAM receives as
244
+ * `FABRIKA_IAM_PROXY_KEY`), `OPERATIONS_SYNC_KEY` (the same catalog credential Operations receives),
245
+ * and — when this control plane deploys to Zerops — `FABRIKA_ZEROPS_ACCESS_TOKEN`. Give it a Zerops
246
+ * INTEGRATION token scoped to the projects this installation manages (`POST
247
+ * /client/{id}/integration-token`, client role `NO_ACCESS`, per-project role `ADMIN`), never a personal
248
+ * access token: a personal token carries account-wide admin rights and is the single most dangerous
249
+ * thing an installation could hold.
250
+ *
251
+ * There is NO runner service anywhere in this topology, and that is ADR-0003, not an omission: Zerops
252
+ * has its own CI, so a deploy here is five HTTP calls made by this process.
253
+ */
254
+ const control: ZeropsYamlSetupSpec = {
255
+ setup: 'control',
256
+ build: {
257
+ base: ['alpine/bun@1.3'],
258
+ buildCommands: ['bun install --frozen-lockfile', 'bun run --filter @fabrika/dashboard build'],
259
+ deployFiles: WORKSPACE_DEPLOY_FILES,
260
+ cache: ['node_modules'],
261
+ },
262
+ deploy: { readinessCheck: { httpGet: { port: 3000, path: '/healthz' }, ...READINESS_WITH_MIGRATIONS } },
263
+ run: {
264
+ base: 'alpine/bun@1.3',
265
+ initCommands: ['bun packages/control/src/zerops/migrate.ts'],
266
+ start: 'bun packages/control/src/zerops/server.ts',
267
+ ports: [{ port: 3000, httpSupport: true }],
268
+ healthCheck: { httpGet: { port: 3000, path: '/healthz' }, ...LIVENESS },
269
+ crontab: [{ timing: '*/5 * * * *', command: 'bun packages/control/src/zerops/cron.ts', allContainers: false }],
270
+ envVariables: {
271
+ PORT: '3000',
272
+ FABRIKA_CONTROL_ASSETS_DIR: 'packages/dashboard/dist',
273
+ // Intra-project transport for management RPC. `FABRIKA_IAM_ISSUER` is deliberately absent here:
274
+ // it is the public token issuer and is supplied per installation through the env API.
275
+ FABRIKA_IAM_RPC_URL: 'http://iam:3000',
276
+ FABRIKA_OPERATIONS_URL: 'http://operations:3000',
277
+ },
278
+ },
279
+ }
280
+
281
+ /**
282
+ * Operations is private inside the platform project. The proxy is its only public ingress and its
283
+ * manifest must route only `/api/{projectId}/envelope/` for the configured public hostname.
284
+ *
285
+ * Per-installation variables (env API): `FABRIKA_OPERATIONS_DATABASE_URL`, the five
286
+ * `FABRIKA_OPERATIONS_BLOB_*` coordinates, `ENVIRONMENT`, `FABRIKA_OPERATIONS_PUBLIC_HOST` and
287
+ * `FABRIKA_IAM_ISSUER` (the public IAM issuer). `FABRIKA_OPERATIONS_DATABASE_URL` takes the canonical
288
+ * form above. On the `standard` tier the data references are `${operationsdb_*}` and
289
+ * `${operationsstorage_*}`; on `light` they are `${db_*}` and `${storage_*}`, which is precisely why
290
+ * they cannot be written here.
291
+ *
292
+ * Secrets (`envSecrets`): `OPERATIONS_SYNC_KEY`, shared only with control for catalog projection, and
293
+ * `FABRIKA_IAM_RPC_KEY`, used only for Operations → IAM management RPC.
294
+ */
295
+ const operations: ZeropsYamlSetupSpec = {
296
+ setup: 'operations',
297
+ build: {
298
+ base: ['alpine/bun@1.3'],
299
+ buildCommands: ['bun install --frozen-lockfile'],
300
+ deployFiles: WORKSPACE_DEPLOY_FILES,
301
+ cache: ['node_modules'],
302
+ },
303
+ deploy: { readinessCheck: { httpGet: { port: 3000, path: '/healthz' }, ...READINESS_WITH_MIGRATIONS } },
304
+ run: {
305
+ base: 'alpine/bun@1.3',
306
+ initCommands: ['bun packages/operations/src/node/migrate.ts'],
307
+ start: 'bun packages/operations/src/node/server.ts',
308
+ ports: [{ port: 3000, httpSupport: true }],
309
+ healthCheck: { httpGet: { port: 3000, path: '/healthz' }, ...LIVENESS },
310
+ crontab: [{ timing: '* * * * *', command: 'bun packages/operations/src/node/cron.ts', allContainers: false }],
311
+ envVariables: {
312
+ PORT: '3000',
313
+ FABRIKA_IAM_RPC_URL: 'http://iam:3000',
314
+ FABRIKA_CONTROL_URL: 'http://control:3000',
315
+ },
316
+ },
317
+ }
318
+
319
+ /**
320
+ * The auth enforcement proxy — Caddy for HTTP correctness, a compiled Bun auth service for every
321
+ * authorization decision (ADR-0022, as amended by ADR-0022: gates are NEVER compiled into Caddy routes).
322
+ *
323
+ * This is the only setup deployed into BOTH projects, and the only publicly routed service in either.
324
+ *
325
+ * Per-service variables (env API): `FABRIKA_IAM_ISSUER` — `http://iam:3000` in the platform project, the
326
+ * IAM service's PUBLIC origin in an apps project, because there is no private network between projects
327
+ * (ADR-0006). And `FABRIKA_PROXY_MANIFEST_JSON`; see below.
328
+ *
329
+ * Secret (`envSecrets`): `FABRIKA_IAM_KEY` — the value IAM knows as `FABRIKA_IAM_PROXY_KEY`.
330
+ *
331
+ * ── How the manifest reaches the build, and what is unverified about it ───────────────────────────
332
+ *
333
+ * `proxy.manifest.json` tells the proxy which apps it fronts, at which upstream, under which gates. It
334
+ * is baked into the artifact at BUILD time, which is what `docs/backlog/08` chose ("redeploy") over
335
+ * pushing to Caddy's admin API. But on Zerops fabrika has no filesystem in the deploy path — a Zerops
336
+ * deploy is five HTTP calls (ADR-0003) — so the driver cannot write a file into the build the way the
337
+ * Cloudflare path can. The only write channel it has is the service-level env API, the same one it uses
338
+ * for secrets, so the manifest travels as a service variable and the build materializes it to a file.
339
+ *
340
+ * The control plane writes `FABRIKA_PROXY_MANIFEST_JSON` through the service-level env API and rolls
341
+ * this service before each app deploy (`republishProxy`, `packages/provider-zerops/src/namespace/proxy-attempt.ts`).
342
+ *
343
+ * That live-account fact is now settled, and the answer was the bad one: **a build container sees none
344
+ * of its service's env-API variables.** Read directly, `${FABRIKA_PROXY_MANIFEST_JSON}` in a build
345
+ * command is the empty string — no error, no warning. Left uncorrected, every proxy build would have
346
+ * written an empty manifest, failed `generate-config.ts`, and made this service undeployable on Zerops.
347
+ *
348
+ * The fix is the `build.envVariables` declaration below rather than the move into `run.initCommands`
349
+ * this header used to anticipate: `${RUNTIME_x}` lifts a service's own runtime variable into its build
350
+ * context, and it resolves nested references too. The delivery channel and the fail-closed parser are
351
+ * unchanged, which is what that contingency promised.
352
+ *
353
+ * What IS structural: missing or malformed JSON makes `generate-config.ts` exit non-zero, so the
354
+ * pipeline fails and the previous version keeps serving. A valid empty app list generates only the
355
+ * terminal 404 route, and the auth service denies every forwarded request.
356
+ */
357
+ const proxyManifestReference = `\${${FABRIKA_PROXY_MANIFEST_JSON}}`
358
+
359
+ /**
360
+ * The proxy's public listeners.
361
+ *
362
+ * Routing is by HOST — one app owns one hostname, and `assertUniqueHosts` refuses to let two apps share
363
+ * one so that Caddy's route order can never decide which app answers. A production installation gets
364
+ * those hostnames from custom domains bound to the project's balancer, and needs exactly ONE listener:
365
+ * every domain arrives on the same port and the Host header decides.
366
+ *
367
+ * A `zerops-subdomain` installation has no such source of names. Zerops mints one generated hostname per
368
+ * HTTP PORT, so on that path a port IS a hostname — and the number of listeners is the number of
369
+ * distinct public hosts the installation can ever serve. Three are the platform's own (IAM's JWKS and
370
+ * OIDC redirect, the control dashboard, Operations ingest); the rest are a fixed pool of APPLICATION
371
+ * slots, and running out of them is what caps a light-tier installation rather than any resource limit.
372
+ *
373
+ * That ceiling is the honest cost of the subdomain path and the reason it is not the production one.
374
+ * A custom-domain installation ignores all of this: with `enableSubdomainAccess: false` nothing here is
375
+ * published, the extra listeners answer the same host-matched routes, and an unknown Host still falls
376
+ * through to the terminal 404. One setup serves both tiers rather than a second that must be kept in
377
+ * step with this one.
378
+ *
379
+ * 8081 is deliberately absent: it is the health listener, which is never published.
380
+ */
381
+ export const PROXY_PUBLIC_PORTS = [8080, 8082, 8083, 8084, 8085, 8086]
382
+
383
+ /** Liveness only, on its own unpublished listener. */
384
+ export const PROXY_HEALTH_PORT = 8081
385
+
386
+ const proxy: ZeropsYamlSetupSpec = {
387
+ setup: 'proxy',
388
+ build: {
389
+ // Two toolchains: Go builds Caddy, Bun compiles the auth service to a single musl binary so the
390
+ // runtime container needs no interpreter at all.
391
+ //
392
+ // `alpine/go@latest` rather than a pin, and that is a real trade stated out loud: Caddy v2.10.2
393
+ // requires a Go toolchain newer than the newest PINNED Go in the published schema's build enum
394
+ // (`alpine/go@1.22`). So the Caddy version is pinned by module — verified against the Go checksum
395
+ // database, a stronger supply-chain guarantee than a release tarball with a hand-copied hash — and
396
+ // the toolchain floats. If a future Go release breaks the build, this is the line that moved.
397
+ base: ['alpine/go@latest', 'alpine/bun@1.3'],
398
+ // Runtime values reach the build only through the explicit RUNTIME_ bridge.
399
+ envVariables: {
400
+ [FABRIKA_PROXY_MANIFEST_JSON]: `\${RUNTIME_${FABRIKA_PROXY_MANIFEST_JSON}}`,
401
+ FABRIKA_PROXY_SESSION_OWNER_JSON: '${RUNTIME_FABRIKA_PROXY_SESSION_OWNER_JSON}',
402
+ },
403
+ // CGO off makes the binary static, which is what lets it run on Alpine/musl.
404
+ prepareCommands: ['CGO_ENABLED=0 GOBIN=/tmp/gobin go install github.com/caddyserver/caddy/v2/cmd/caddy@v2.10.2'],
405
+ buildCommands: [
406
+ 'bun install --frozen-lockfile',
407
+ // See the header: the manifest arrives as a service variable because the driver has no
408
+ // filesystem. `printf` rather than `echo` so a leading `-` or a backslash survives intact.
409
+ `printf %s "${proxyManifestReference}" > ./proxy.manifest.json`,
410
+ // Missing/malformed JSON fails the build; an empty app list emits only deny/404 routes.
411
+ `bun run packages/proxy/src/generate-config.ts ./proxy.manifest.json ./caddy.json --auth-upstream 127.0.0.1:9000 --listen ${
412
+ PROXY_PUBLIC_PORTS.map((port) => `:${port}`).join(',')
413
+ }`,
414
+ 'bun build --compile --target=bun-linux-x64-musl ./packages/proxy/src/main.ts --outfile ./fabrika-proxy',
415
+ 'cp /tmp/gobin/caddy ./caddy',
416
+ ],
417
+ deployFiles: ['caddy', 'fabrika-proxy', 'caddy.json', 'proxy.manifest.json', 'packages/proxy/start.sh'],
418
+ cache: ['node_modules', '/root/go/pkg/mod'],
419
+ },
420
+ // The service this gate matters most for: it is the only publicly routed one in either project, so a
421
+ // version that builds but cannot serve would take the whole project's public surface with it.
422
+ deploy: { readinessCheck: { httpGet: { port: PROXY_HEALTH_PORT, path: '/healthz' }, ...READINESS_IMMEDIATE } },
423
+ run: {
424
+ // Alpine custom runtime: an arbitrary static binary with an arbitrary start command. No TLS config
425
+ // anywhere on purpose — the project's L7 balancer terminates TLS, so Caddy serves plain HTTP behind
426
+ // it and needs no ACME, no certificate storage and therefore no disk.
427
+ base: 'alpine@3.21',
428
+ ports: PROXY_PUBLIC_PORTS.map((port) => ({ port, httpSupport: true })),
429
+ // Deliberately NOT one of the public ports: a health path on a public listener would be one path per
430
+ // proxy that answers 200 without consulting the gates, and would shadow an app path of the same name.
431
+ healthCheck: { httpGet: { port: PROXY_HEALTH_PORT, path: '/healthz' }, ...LIVENESS },
432
+ envVariables: {
433
+ FABRIKA_PROXY_MANIFEST: '/var/www/proxy.manifest.json',
434
+ // Loopback only; Caddy dials it. It must never be routable.
435
+ FABRIKA_PROXY_PORT: '9000',
436
+ },
437
+ start: 'sh /var/www/packages/proxy/start.sh',
438
+ },
439
+ }
440
+
441
+ /** The repository-root `zerops.yaml`, in the order the setups appear in it. */
442
+ export const fabrikaZeropsYaml: ZeropsYamlSpec = { zerops: [iam, operations, proxy, control] }