@fabrika/provider-zerops 0.3.2 → 0.4.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.
Files changed (36) hide show
  1. package/package.json +7 -6
  2. package/src/api/client.ts +11 -2
  3. package/src/api/types.ts +13 -0
  4. package/src/cli.ts +21 -4
  5. package/src/collaborators.ts +4 -0
  6. package/src/control.ts +37 -8
  7. package/src/execution.ts +371 -0
  8. package/src/exposure.ts +95 -0
  9. package/src/installation/deploy-options.ts +12 -1
  10. package/src/installation/deploy.ts +42 -18
  11. package/src/installation/generated/platform-console-schema.ts +4 -0
  12. package/src/installation/generated/platform-proxy-manifest.ts +533 -33
  13. package/src/installation/index.ts +11 -0
  14. package/src/installation/install-environment.ts +1 -2
  15. package/src/installation/machine-key.ts +3 -4
  16. package/src/installation/manifest.ts +1 -1
  17. package/src/installation/mcp.ts +102 -0
  18. package/src/installation/placement-options.ts +2 -1
  19. package/src/installation/proxy-manifest.ts +124 -23
  20. package/src/installation/secrets.ts +2 -2
  21. package/src/installation/templates/README.md +20 -11
  22. package/src/installation/templates/platform.yml +2 -0
  23. package/src/installation/topology/setups.ts +5 -5
  24. package/src/managed-env.ts +94 -6
  25. package/src/manifest.ts +248 -14
  26. package/src/namespace/codec.ts +23 -12
  27. package/src/namespace/errors.ts +1 -1
  28. package/src/namespace/manifest.ts +25 -6
  29. package/src/namespace/provisioning.ts +10 -29
  30. package/src/namespace/proxy-attempt.ts +13 -2
  31. package/src/namespace/proxy-secrets.ts +21 -0
  32. package/src/namespace/update.ts +212 -0
  33. package/src/namespace/validation.ts +43 -0
  34. package/src/plan.ts +6 -0
  35. package/src/provider.ts +159 -19
  36. package/src/types.ts +14 -3
@@ -0,0 +1,102 @@
1
+ // MCP authoring, opt-in per installation (ADR-0049).
2
+ //
3
+ // The operator names the MCP clients it trusts; everything else is derived. One input turns on BOTH
4
+ // halves, so they cannot disagree: the console's `oauth` row with its registered resource in the proxy
5
+ // manifest, and IAM's `FABRIKA_IAM_OAUTH_CONFIG`, which issues tokens for exactly that resource.
6
+
7
+ import { type OAuthManagedClient, type OAuthResourceDto, parseOAuthManagedClient } from '@fabrika/iam-contract'
8
+ import { platformOAuthResourceUris, type PlatformProxyComposition, type PlatformProxyManifestTemplate } from './proxy-manifest'
9
+
10
+ /** One operator-registered MCP client: its CIMD identity, plus where IAM fetches it when that differs. */
11
+ export interface McpClient extends OAuthManagedClient {
12
+ readonly metadataUrl?: string
13
+ }
14
+
15
+ /** The document IAM parses from `FABRIKA_IAM_OAUTH_CONFIG`. */
16
+ export interface IamOAuthConfig {
17
+ readonly clients: readonly McpClient[]
18
+ readonly resources: readonly OAuthResourceDto[]
19
+ }
20
+
21
+ const CLIENT_KEYS = ['provider', 'clientId', 'redirectUris', 'metadataUrl']
22
+ /** IAM's own ceiling: it refuses to boot with more. */
23
+ const MAX_CLIENTS = 16
24
+
25
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
26
+
27
+ const isHttpsUrl = (value: string): boolean => {
28
+ try {
29
+ const url = new URL(value)
30
+ return url.protocol === 'https:' && url.username === '' && url.password === '' && url.hash === ''
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ const parseClient = (value: unknown, index: number): McpClient => {
37
+ const client = parseOAuthManagedClient(value)
38
+ if (client === null || !isObject(value) || !Object.keys(value).every((key) => CLIENT_KEYS.includes(key))) {
39
+ throw new Error(`MCP client ${index} must be { provider: 'claude' | 'chatgpt', clientId: <https URL>, redirectUris: [...], metadataUrl? }`)
40
+ }
41
+ const metadataUrl = value['metadataUrl']
42
+ if (metadataUrl === undefined) {
43
+ return client
44
+ }
45
+ if (typeof metadataUrl !== 'string' || !isHttpsUrl(metadataUrl)) {
46
+ throw new Error(`MCP client ${index}: metadataUrl must be an https URL`)
47
+ }
48
+ return { ...client, metadataUrl }
49
+ }
50
+
51
+ /**
52
+ * Parse the operator's MCP client list — a JSON array. Anything malformed, an empty list or a repeated
53
+ * client id is refused here, before a deploy writes a value IAM would refuse to boot with.
54
+ */
55
+ export const parseMcpClientsJson = (raw: string): readonly McpClient[] => {
56
+ let value: unknown
57
+ try {
58
+ value = JSON.parse(raw)
59
+ } catch {
60
+ throw new Error('the MCP client list is not valid JSON')
61
+ }
62
+ if (!Array.isArray(value) || value.length === 0) {
63
+ throw new Error('the MCP client list must be a non-empty JSON array')
64
+ }
65
+ if (value.length > MAX_CLIENTS) {
66
+ throw new Error(`the MCP client list names more than ${MAX_CLIENTS} clients, which IAM refuses`)
67
+ }
68
+ const clients = value.map(parseClient)
69
+ const ids = new Set(clients.map((client) => client.clientId))
70
+ if (ids.size !== clients.length) {
71
+ throw new Error('the MCP client list names one client id twice')
72
+ }
73
+ return clients
74
+ }
75
+
76
+ /** IAM's OAuth configuration for one placed installation: the clients, and one resource per app that serves one. */
77
+ export const platformIamOAuthConfig = (
78
+ template: PlatformProxyManifestTemplate,
79
+ composition: PlatformProxyComposition,
80
+ clients: readonly McpClient[],
81
+ ): IamOAuthConfig => {
82
+ const resources: OAuthResourceDto[] = []
83
+ for (const app of template.apps) {
84
+ if (app.oauthResource === undefined) {
85
+ continue
86
+ }
87
+ const { resource } = platformOAuthResourceUris(composition, composition.placement[app.service].hosts, app.oauthResource)
88
+ resources.push({
89
+ app: app.id,
90
+ resource,
91
+ defaultScopes: [...app.oauthResource.scopes],
92
+ scopeGrants: app.oauthResource.scopeGrants.map((grant) => ({
93
+ scope: grant.scope,
94
+ permissions: grant.actions.map((action) => ({ action })),
95
+ })),
96
+ })
97
+ }
98
+ if (resources.length === 0) {
99
+ throw new Error('the platform proxy manifest template declares no OAuth resource for MCP clients to reach')
100
+ }
101
+ return { clients, resources }
102
+ }
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  const scheme = z.enum(['http', 'https']).default('https')
4
+ // Lowercased because every consumer compares origins as `URL` writes them, and `URL` lowercases the host.
4
5
  const bareHostname = z.string().refine(
5
6
  (host) => !host.includes('/') && !host.includes(':'),
6
7
  (host) => ({ message: `\`${host}\` is not a bare hostname — a platform host carries no scheme and no port` }),
7
- )
8
+ ).transform((host) => host.toLowerCase())
8
9
  const hosts = z.object({ iam: bareHostname, control: bareHostname, operations: bareHostname })
9
10
 
10
11
  export const parsePlatformScheme = (raw: string | undefined): 'http' | 'https' => {
@@ -4,30 +4,57 @@
4
4
  // ── Why a template rather than a `ProxyManifest` with the hosts left out ──────────────────────────
5
5
  //
6
6
  // `parseProxyManifest` refuses an app that declares no hosts, and rightly — a host-less app is fronted
7
- // by nothing and gates nothing. So the committed half cannot BE a manifest with empty hosts; it is a
7
+ // by nothing and protects nothing. So the committed half cannot BE a manifest with empty hosts; it is a
8
8
  // type of its own, and `resolvePlatformProxyManifest` is the one way to turn it into the document the
9
9
  // proxy parses.
10
10
  //
11
11
  // ── The split ────────────────────────────────────────────────────────────────────────────────────
12
12
  //
13
13
  // template which apps exist, their ids, the private service each is dialed at, the public listener
14
- // each answers on, and their gates. True of every installation, so it is GENERATED into
14
+ // each answers on, and their service table. True of every installation, so it is GENERATED into
15
15
  // `src/generated/` and reviewed in a diff.
16
16
  // placement the hostnames and the browser-facing scheme. `proxy-292c-8080.prg1.zerops.app` names one
17
17
  // account's project, and a production installation binds custom domains instead, so
18
18
  // whoever is deploying supplies these.
19
19
  //
20
- // Nothing in this file imports the packages that OWN the gates. That is the point of the split: the
21
- // gate sets live in `@fabrika/control` (private) and reach a deployed installation as DATA, through the
20
+ // Nothing in this file imports the packages that OWN the tables. That is the point of the split:
21
+ // they live in the private service packages and reach a deployed installation as DATA, through the
22
22
  // generated artifact, so the published deploy path never depends on a package it cannot resolve.
23
23
 
24
- import type { AppGates } from '@fabrika/auth-core'
25
- import { parseProxyManifest, type ProxyManifest } from '@fabrika/proxy-core/manifest'
24
+ import { type InstallationEndpointTable, installationTableDigest } from '@fabrika/auth-core'
25
+ import {
26
+ type InstallationProxyApp,
27
+ isApplicationEntry,
28
+ type OAuthProtectedResource,
29
+ parseProxyManifest,
30
+ serviceProxyTable,
31
+ } from '@fabrika/proxy-core/manifest'
26
32
 
27
33
  /** The platform services an installation's proxy fronts. Each name is a service hostname in the topology. */
28
34
  export type PlatformProxyService = 'iam' | 'control' | 'operations'
29
35
 
30
- /** One fronted app, in the form that is the same on every installation. */
36
+ /** What one OAuth scope lets a token do: the owning app's actions, resolved by IAM at the token endpoint. */
37
+ export interface PlatformOAuthScopeGrant {
38
+ readonly scope: string
39
+ readonly actions: readonly string[]
40
+ }
41
+
42
+ /**
43
+ * A path an app serves to OAuth clients, protected only on an installation that enables it.
44
+ *
45
+ * The resource URI is the app's public origin plus `path`, so the template holds the path and a deploy
46
+ * binds the origin. Copied from the owning service module by the generator, like `table`.
47
+ */
48
+ export interface PlatformOAuthResourceTemplate {
49
+ readonly path: string
50
+ readonly scopes: readonly string[]
51
+ readonly scopeGrants: readonly PlatformOAuthScopeGrant[]
52
+ }
53
+
54
+ /**
55
+ * One fronted app, in the form that is the same on every installation: protected by its service contract (ADR-0022
56
+ * "Installation entries"), copied in by the generator and never written by hand.
57
+ */
31
58
  export interface PlatformProxyApp {
32
59
  /** The IAM app id. It is the audience of every token the proxy mints for this app's host. */
33
60
  readonly id: string
@@ -43,8 +70,15 @@ export interface PlatformProxyApp {
43
70
  readonly port: number
44
71
  /** Where the proxy dials it over the project's private network. */
45
72
  readonly upstream: string
46
- /** Owned by the service's own gate module and copied in by the generator. Never written by hand. */
47
- readonly gates: AppGates
73
+ /** The service's rows; with an `oauthResource`, the table served when the composition names an authorization server. */
74
+ readonly table: InstallationEndpointTable
75
+ /** Present only on an app that can serve OAuth clients; off unless the composition names an authorization server. */
76
+ readonly oauthResource?: PlatformOAuthResourceTemplate
77
+ /**
78
+ * Present exactly with `oauthResource`: the service's table when it serves no OAuth client. The service picks the
79
+ * same one, by the digest the deploy writes beside it (`platformServiceTableDigest`).
80
+ */
81
+ readonly tableWithoutOAuth?: InstallationEndpointTable
48
82
  }
49
83
 
50
84
  /** Everything about an installation's manifest that does not depend on WHICH installation it is. */
@@ -83,6 +117,64 @@ export interface PlatformProxyComposition {
83
117
  /** The scheme the BROWSER speaks to these apps — configuration, never a header (see `ProxyApp.scheme`). */
84
118
  readonly scheme: 'http' | 'https'
85
119
  readonly placement: Readonly<Record<PlatformProxyService, PlatformProxyPlacement>>
120
+ /**
121
+ * The public IAM origin that issues OAuth tokens. Present only when the installation enables MCP
122
+ * authoring; absent leaves every `oauthResource` unregistered and serves each service's table without OAuth.
123
+ */
124
+ readonly authorizationServer?: string
125
+ /** The port browsers reach every app on, when it is not the scheme's default. The local composition's is 18080. */
126
+ readonly publicPort?: number
127
+ }
128
+
129
+ /**
130
+ * The RFC 8707 resource URI and RFC 9728 metadata URL of an app's OAuth resource, bound to the app's
131
+ * FIRST host: the proxy refuses a resource on a host the app does not answer on.
132
+ */
133
+ export const platformOAuthResourceUris = (
134
+ composition: Pick<PlatformProxyComposition, 'scheme' | 'publicPort'>,
135
+ hosts: readonly string[],
136
+ template: PlatformOAuthResourceTemplate,
137
+ ): { readonly resource: string; readonly resourceMetadata: string } => {
138
+ const host = hosts[0]
139
+ if (host === undefined) {
140
+ throw new Error('an OAuth resource needs a host to stand on')
141
+ }
142
+ const origin = composition.publicPort === undefined
143
+ ? `${composition.scheme}://${host}`
144
+ : `${composition.scheme}://${host}:${composition.publicPort}`
145
+ return {
146
+ resource: `${origin}${template.path}`,
147
+ resourceMetadata: `${origin}/.well-known/oauth-protected-resource${template.path}`,
148
+ }
149
+ }
150
+
151
+ const registeredResource = (
152
+ hosts: readonly string[],
153
+ template: PlatformOAuthResourceTemplate,
154
+ composition: PlatformProxyComposition,
155
+ authorizationServer: string,
156
+ ): OAuthProtectedResource => ({
157
+ ...platformOAuthResourceUris(composition, hosts, template),
158
+ authorizationServers: [authorizationServer],
159
+ scopesSupported: [...template.scopes],
160
+ })
161
+
162
+ /** Only an installation that names an authorization server registers the resource and serves its OAuth rows. */
163
+ const resolvedApp = (app: PlatformProxyApp, composition: PlatformProxyComposition): InstallationProxyApp => {
164
+ const placement = composition.placement[app.service]
165
+ const hosts = [...placement.hosts]
166
+ const registered = app.oauthResource === undefined || composition.authorizationServer === undefined
167
+ ? undefined
168
+ : registeredResource(hosts, app.oauthResource, composition, composition.authorizationServer)
169
+ const table = registered === undefined ? (app.tableWithoutOAuth ?? app.table) : app.table
170
+ return {
171
+ id: app.id,
172
+ hosts,
173
+ upstream: placement.upstream ?? app.upstream,
174
+ scheme: composition.scheme,
175
+ ...serviceProxyTable(table, { oauth: registered !== undefined }),
176
+ ...(registered === undefined ? {} : { oauthResources: [registered] }),
177
+ }
86
178
  }
87
179
 
88
180
  /**
@@ -94,20 +186,29 @@ export interface PlatformProxyComposition {
94
186
  export const resolvePlatformProxyManifest = (
95
187
  template: PlatformProxyManifestTemplate,
96
188
  composition: PlatformProxyComposition,
97
- ): ProxyManifest => {
98
- const apps = template.apps.map((app) => {
99
- const placement = composition.placement[app.service]
100
- return {
101
- id: app.id,
102
- hosts: [...placement.hosts],
103
- upstream: placement.upstream ?? app.upstream,
104
- gates: app.gates,
105
- scheme: composition.scheme,
106
- }
107
- })
108
- const manifest = parseProxyManifest({ apps })
109
- if (manifest === null) {
189
+ ): PlatformProxyManifest => {
190
+ const manifest = parseProxyManifest({ apps: template.apps.map((app) => resolvedApp(app, composition)) })
191
+ // Every platform service is an installation entry, never an application.
192
+ const services = manifest?.apps.flatMap((app) => isApplicationEntry(app) ? [] : [app]) ?? []
193
+ if (manifest === null || services.length !== manifest.apps.length) {
110
194
  throw new Error('the resolved platform proxy manifest is not one the proxy would accept')
111
195
  }
112
- return manifest
196
+ return { apps: services }
197
+ }
198
+
199
+ /** The platform proxy's manifest: one installation entry per platform service. */
200
+ export interface PlatformProxyManifest {
201
+ apps: InstallationProxyApp[]
202
+ }
203
+
204
+ /**
205
+ * `FABRIKA_SERVICE_TABLE_DIGEST` for a service: the digest of the table this manifest publishes for it, which the
206
+ * service compares with its own and refuses to start under another.
207
+ */
208
+ export const platformServiceTableDigest = (manifest: PlatformProxyManifest, appId: string): string => {
209
+ const entry = manifest.apps.find((app) => app.id === appId)
210
+ if (entry === undefined) {
211
+ throw new Error(`the platform proxy manifest fronts no \`${appId}\``)
212
+ }
213
+ return installationTableDigest(entry)
113
214
  }
@@ -55,9 +55,9 @@ const generateEs256Jwk = (): JsonWebKey & { kid: string; alg: string; use: strin
55
55
  export interface InstallationSecrets {
56
56
  /** IAM's token-signing material: a JSON array of ES256 private JWKs, index 0 active. */
57
57
  readonly signingKeys: string
58
- /** Gates IAM's management `/rpc/*` surface. Held by IAM, control and Operations. */
58
+ /** Authenticates IAM's management `/rpc/*` surface. Held by IAM, control and Operations. */
59
59
  readonly rpcKey: string
60
- /** Gates IAM's `/auth/mint/*` surface, which the proxy calls on the cold path. */
60
+ /** Authenticates IAM's `/auth/mint/*` surface, which the proxy calls on the cold path. */
61
61
  readonly proxyKey: string
62
62
  /** The `px_` admin bearer IAM admits as a synthetic global admin. The one value this command prints. */
63
63
  readonly provisioningKey: string
@@ -30,7 +30,7 @@ resolve the project and its services
30
30
  ```
31
31
 
32
32
  That order is a security property, not just a dependency: a fabrika application enforces nothing by
33
- itself, so the enforcement proxy is deployed **before** the control plane whose gates just changed. It
33
+ itself, so the enforcement proxy is deployed **before** the control plane whose endpoint table just changed. It
34
34
  therefore lives in code with a test, rather than in a workflow file that can be edited. The Cloudflare
35
35
  sidecar repository keeps its order in the workflow instead; the two providers differ deliberately.
36
36
 
@@ -45,7 +45,7 @@ permissive manifest.
45
45
  branch. The workflow refuses anything that does not look like a tag.
46
46
 
47
47
  That tag decides everything this pipeline DOES: the deploy order, the proxy manifest it composes (the
48
- gate rules that enforce the whole installation), the console schema it reconciles, and every variable
48
+ endpoint tables that enforce the whole installation), the console schema it reconciles, and every variable
49
49
  it writes.
50
50
 
51
51
  The workflow runs with `--source-dir=.` from the selected checkout and uploads one exact committed
@@ -81,21 +81,30 @@ installation already holds.
81
81
 
82
82
  **Variables** — the rest of the configuration, all non-secret.
83
83
 
84
- | Variable | Required | What it is |
85
- | ---------------------------------- | -------- | -------------------------------------------------------------------------------------------------- |
86
- | `FABRIKA_ZEROPS_PROJECT_ID` | yes | The Zerops project holding the four platform runtime services. |
87
- | `FABRIKA_PLATFORM_ENVIRONMENT` | yes | The name this installation calls itself; written to every service as `ENVIRONMENT`. Never `local`. |
88
- | `FABRIKA_PLATFORM_SCHEME` | yes | `https` for anything reachable from a browser. |
89
- | `FABRIKA_PLATFORM_IAM_HOST` | no | Where IAM answers publicly. |
90
- | `FABRIKA_PLATFORM_CONSOLE_HOST` | no | Where the console answers publicly. |
91
- | `FABRIKA_PLATFORM_OPERATIONS_HOST` | no | Where Operations ingest answers publicly. |
92
- | `FABRIKA_ZEROPS_API_URL` | no | Region API base, when this installation is not on the default one. |
84
+ | Variable | Required | What it is |
85
+ | ----------------------------------- | -------- | -------------------------------------------------------------------------------------------------- |
86
+ | `FABRIKA_ZEROPS_PROJECT_ID` | yes | The Zerops project holding the four platform runtime services. |
87
+ | `FABRIKA_PLATFORM_ENVIRONMENT` | yes | The name this installation calls itself; written to every service as `ENVIRONMENT`. Never `local`. |
88
+ | `FABRIKA_PLATFORM_SCHEME` | yes | `https` for anything reachable from a browser. |
89
+ | `FABRIKA_PLATFORM_IAM_HOST` | no | Where IAM answers publicly. |
90
+ | `FABRIKA_PLATFORM_CONSOLE_HOST` | no | Where the console answers publicly. |
91
+ | `FABRIKA_PLATFORM_OPERATIONS_HOST` | no | Where Operations ingest answers publicly. |
92
+ | `FABRIKA_ZEROPS_API_URL` | no | Region API base, when this installation is not on the default one. |
93
+ | `FABRIKA_PLATFORM_MCP_CLIENTS_JSON` | no | The MCP clients that may author applications (JSON array). Unset leaves MCP authoring off. |
93
94
 
94
95
  The three host variables go together: **all three, or none.** Naming none derives them from the
95
96
  proxy's generated Zerops subdomains, and the deploy publishes that subdomain. Naming all three makes
96
97
  this a custom-domain installation — the domains are bound to the project balancer out of band, and the
97
98
  deploy publishes no subdomain, because that would add a second public address nobody asked for.
98
99
 
100
+ `FABRIKA_PLATFORM_MCP_CLIENTS_JSON` turns on MCP authoring (ADR-0049). A workflow generated before
101
+ that variable existed does not forward it, and `platform upgrade` never rewrites the workflow — add
102
+ this line to the `env:` of the _Deploy the platform_ step in `.github/workflows/platform.yml`:
103
+
104
+ ```yaml
105
+ FABRIKA_PLATFORM_MCP_CLIENTS_JSON: ${{ vars.FABRIKA_PLATFORM_MCP_CLIENTS_JSON }}
106
+ ```
107
+
99
108
  There is deliberately **no bootstrap-admin variable here.** This pipeline writes no credential and no
100
109
  admission list — it configures where the installation answers and deploys it. Who may administer the
101
110
  installation is IAM's to say, and it is said through IAM's own admin surface. Nothing about this
@@ -129,6 +129,8 @@ jobs:
129
129
  FABRIKA_PLATFORM_OPERATIONS_HOST: ${{ vars.FABRIKA_PLATFORM_OPERATIONS_HOST }}
130
130
  # Only for an installation outside the default region.
131
131
  FABRIKA_ZEROPS_API_URL: ${{ vars.FABRIKA_ZEROPS_API_URL }}
132
+ # MCP authoring (ADR-0049): a JSON array of the MCP clients this installation trusts. Unset = off.
133
+ FABRIKA_PLATFORM_MCP_CLIENTS_JSON: ${{ vars.FABRIKA_PLATFORM_MCP_CLIENTS_JSON }}
132
134
  run: fabrika platform deploy --provider=zerops --source-dir=. ${{ inputs.dry_run && '--dry-run' || '' }}
133
135
 
134
136
  - name: Summary
@@ -174,9 +174,9 @@ const WORKSPACE_DEPLOY_FILES = ['package.json', 'bun.lock', 'node_modules', 'pac
174
174
  *
175
175
  * Secrets (`envSecrets`): `FABRIKA_IAM_SIGNING_KEYS` (ES256 private JWKs — an empty value is refused at
176
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
177
+ * `FABRIKA_EMAIL_RESEND_API_KEY` when Resend is enabled, `FABRIKA_IAM_RPC_KEY` (authenticates
178
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,
179
+ * (authenticates `/auth/mint/*`, which the proxy calls on the cold path — a SEPARATE secret by least privilege,
180
180
  * since the proxy is the only publicly routed component and must not also be able to write audit rows),
181
181
  * `FABRIKA_IAM_PROVISIONING_KEY`. Both shared secrets must be at least 32 characters; a shorter one fails
182
182
  * the boot rather than standing in front of an internet-reachable surface while looking configured.
@@ -318,7 +318,7 @@ const operations: ZeropsYamlSetupSpec = {
318
318
 
319
319
  /**
320
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).
321
+ * authorization decision (ADR-0022: the endpoint table is NEVER compiled into Caddy routes).
322
322
  *
323
323
  * This is the only setup deployed into BOTH projects, and the only publicly routed service in either.
324
324
  *
@@ -330,7 +330,7 @@ const operations: ZeropsYamlSetupSpec = {
330
330
  *
331
331
  * ── How the manifest reaches the build, and what is unverified about it ───────────────────────────
332
332
  *
333
- * `proxy.manifest.json` tells the proxy which apps it fronts, at which upstream, under which gates. It
333
+ * `proxy.manifest.json` tells the proxy which apps it fronts, at which upstream, under which endpoint table. It
334
334
  * is baked into the artifact at BUILD time, which is what `docs/backlog/08` chose ("redeploy") over
335
335
  * pushing to Caddy's admin API. But on Zerops fabrika has no filesystem in the deploy path — a Zerops
336
336
  * deploy is five HTTP calls (ADR-0003) — so the driver cannot write a file into the build the way the
@@ -427,7 +427,7 @@ const proxy: ZeropsYamlSetupSpec = {
427
427
  base: 'alpine@3.21',
428
428
  ports: PROXY_PUBLIC_PORTS.map((port) => ({ port, httpSupport: true })),
429
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.
430
+ // proxy that answers 200 without consulting the endpoint table, and would shadow an app path of the same name.
431
431
  healthCheck: { httpGet: { port: PROXY_HEALTH_PORT, path: '/healthz' }, ...LIVENESS },
432
432
  envVariables: {
433
433
  FABRIKA_PROXY_MANIFEST: '/var/www/proxy.manifest.json',
@@ -1,3 +1,12 @@
1
+ import {
2
+ FRAMEWORK_VARIABLE_PREFIXES,
3
+ type FrameworkVariableName,
4
+ frameworkVariableReference,
5
+ isConnectKeyVariable,
6
+ isConnectUrlVariable,
7
+ isFrameworkVariableName,
8
+ isWorkloadKeyVariable,
9
+ } from '@fabrika/auth-core'
1
10
  import type { JsonValue } from '@fabrika/provider-contract'
2
11
  import { z } from 'zod'
3
12
  import { renderYaml } from './compile'
@@ -9,30 +18,45 @@ import {
9
18
  selectZeropsDescriptorSetup,
10
19
  } from './descriptor'
11
20
 
12
- export type ManagedKey =
21
+ /** The framework-only names Control hands every Zerops deploy; the rest of the list is the provider's own. */
22
+ export type ManagedKey = Extract<
23
+ FrameworkVariableName,
13
24
  | 'FABRIKA_RELEASE'
14
25
  | 'FABRIKA_OPERATIONS_DSN'
15
26
  | 'FABRIKA_IAM_ISSUER'
27
+ | 'FABRIKA_IAM_APP_ID'
16
28
  | 'FABRIKA_APP_ID'
17
29
  | 'FABRIKA_ENVIRONMENT'
18
30
  | 'FABRIKA_SERVICE_KEY'
31
+ | 'FABRIKA_PROXY_SECRET'
32
+ | 'FABRIKA_IAM_RPC_URL'
33
+ | 'FABRIKA_IAM_RPC_KEY'
34
+ >
19
35
 
20
36
  export const MANAGED_KEYS: readonly ManagedKey[] = [
21
37
  'FABRIKA_RELEASE',
22
38
  'FABRIKA_OPERATIONS_DSN',
23
39
  'FABRIKA_IAM_ISSUER',
40
+ 'FABRIKA_IAM_APP_ID',
24
41
  'FABRIKA_APP_ID',
25
42
  'FABRIKA_ENVIRONMENT',
26
43
  'FABRIKA_SERVICE_KEY',
44
+ 'FABRIKA_PROXY_SECRET',
45
+ 'FABRIKA_IAM_RPC_URL',
46
+ 'FABRIKA_IAM_RPC_KEY',
27
47
  ]
28
48
 
29
49
  /** The managed keys written as service variables; `FABRIKA_RELEASE` travels only in the execution descriptor (ADR-0044). */
30
50
  export const SERVICE_MANAGED_KEYS: readonly Exclude<ManagedKey, 'FABRIKA_RELEASE'>[] = [
31
51
  'FABRIKA_OPERATIONS_DSN',
32
52
  'FABRIKA_IAM_ISSUER',
53
+ 'FABRIKA_IAM_APP_ID',
33
54
  'FABRIKA_APP_ID',
34
55
  'FABRIKA_ENVIRONMENT',
35
56
  'FABRIKA_SERVICE_KEY',
57
+ 'FABRIKA_PROXY_SECRET',
58
+ 'FABRIKA_IAM_RPC_URL',
59
+ 'FABRIKA_IAM_RPC_KEY',
36
60
  ]
37
61
 
38
62
  export type ManagedValues = Record<ManagedKey, string | null>
@@ -41,31 +65,91 @@ const managedValuesSchema = z.object({
41
65
  FABRIKA_RELEASE: z.string().nullable(),
42
66
  FABRIKA_OPERATIONS_DSN: z.string().nullable(),
43
67
  FABRIKA_IAM_ISSUER: z.string().nullable(),
68
+ FABRIKA_IAM_APP_ID: z.string().nullable(),
44
69
  FABRIKA_APP_ID: z.string().nullable(),
45
70
  FABRIKA_ENVIRONMENT: z.string().nullable(),
46
71
  FABRIKA_SERVICE_KEY: z.string().nullable(),
72
+ FABRIKA_PROXY_SECRET: z.string().nullable(),
73
+ // IAM's membership transport (ADR-0022 rule 6): its public origin and this environment's key, or both null.
74
+ FABRIKA_IAM_RPC_URL: z.string().nullable(),
75
+ FABRIKA_IAM_RPC_KEY: z.string().nullable(),
47
76
  }).strict()
48
77
 
49
78
  export function parseManagedValues(value: unknown): ManagedValues {
50
79
  const result = managedValuesSchema.safeParse(value)
51
- if (!result.success) throw new Error('zerops: managed environment must name exactly the six managed keys')
80
+ if (!result.success) throw new Error('zerops: managed environment must name exactly the ten managed keys')
52
81
  return result.data
53
82
  }
54
83
 
55
- const isManagedKey = (name: string): boolean => MANAGED_KEYS.some((key) => key === name)
84
+ /**
85
+ * What Control hands a deploy: the ten managed keys, the key of each job and webhook of the approved contract, and the
86
+ * key and owner origin of each connector import.
87
+ */
88
+ export interface ManagedEnvironment {
89
+ readonly values: ManagedValues
90
+ /** `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, and `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`. */
91
+ readonly familyVariables: Readonly<Record<string, string>>
92
+ }
93
+
94
+ const keySchema = z.string().startsWith('px_').min(4)
95
+ const originSchema = z.string().refine((value) => {
96
+ try {
97
+ return new URL(value).origin === value && value.startsWith('https://')
98
+ } catch {
99
+ return false
100
+ }
101
+ })
102
+
103
+ export function parseManagedEnvironment(value: Readonly<Record<string, string | null>>): ManagedEnvironment {
104
+ const managed: Record<string, string | null> = {}
105
+ const familyVariables: Record<string, string> = {}
106
+ for (const [name, entry] of Object.entries(value)) {
107
+ if (!isFrameworkFamilyVariable(name)) {
108
+ managed[name] = entry
109
+ continue
110
+ }
111
+ const origin = isConnectUrlVariable(name)
112
+ if (!origin && !isWorkloadKeyVariable(name) && !isConnectKeyVariable(name)) {
113
+ throw new Error(`zerops: ${name} is not a variable a deploy writes`)
114
+ }
115
+ const parsed = (origin ? originSchema : keySchema).safeParse(entry)
116
+ if (!parsed.success) {
117
+ throw new Error(`zerops: ${name} must be ${origin ? 'an https origin' : 'a px_ key'}`)
118
+ }
119
+ familyVariables[name] = parsed.data
120
+ }
121
+ return { values: parseManagedValues(managed), familyVariables }
122
+ }
123
+
124
+ /** A service variable only the platform writes per workload or import: a stale one is removed with it. */
125
+ export function isFrameworkFamilyVariable(name: string): boolean {
126
+ return FRAMEWORK_VARIABLE_PREFIXES.some((prefix) => name.startsWith(prefix))
127
+ }
56
128
 
57
129
  const refuse = (): never => {
58
130
  throw new Error('zerops: application configuration uses a Fabrika-managed environment name')
59
131
  }
60
132
 
133
+ /** Refuse every framework-only name (ADR-0043), not only the ones a deploy writes today. */
61
134
  export function assertAuthoredManagedName(name: string): void {
62
- if (isManagedKey(name)) refuse()
135
+ if (isFrameworkVariableName(name)) refuse()
63
136
  }
64
137
 
65
138
  type AuthoredDocumentValue = string | number | boolean | null | readonly AuthoredDocumentValue[] | { readonly [key: string]: AuthoredDocumentValue }
66
139
  const isSequence = (value: AuthoredDocumentValue): value is readonly AuthoredDocumentValue[] => Array.isArray(value)
67
140
 
68
- /** Refuse a managed name as a key of any `envVariables`/`envSecrets` mapping, at any depth. */
141
+ /** Refuse a value that references a framework-only variable (`${RUNTIME_FABRIKA_PROXY_SECRET}`), naming only the reference. */
142
+ export function assertNoFrameworkReference(text: string): void {
143
+ const reference = frameworkVariableReference(text)
144
+ if (reference !== null) {
145
+ throw new Error(`zerops: application configuration references the Fabrika-managed variable \`${reference}\``)
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Refuse, in any `envVariables`/`envSecrets` mapping at any depth, a managed name as a key and a reference to one in a
151
+ * key or a value: Zerops resolves the reference into a variable the application controls, and lifts it into a build.
152
+ */
69
153
  export function assertAuthoredManagedDocument(value: AuthoredDocumentValue): void {
70
154
  const visit = (entry: AuthoredDocumentValue, environmentMapping: boolean): void => {
71
155
  if (isSequence(entry)) {
@@ -74,7 +158,11 @@ export function assertAuthoredManagedDocument(value: AuthoredDocumentValue): voi
74
158
  }
75
159
  if (typeof entry !== 'object' || entry === null) return
76
160
  for (const [key, item] of Object.entries(entry)) {
77
- if (environmentMapping) assertAuthoredManagedName(key)
161
+ if (environmentMapping) {
162
+ assertAuthoredManagedName(key)
163
+ assertNoFrameworkReference(key)
164
+ if (typeof item === 'string') assertNoFrameworkReference(item)
165
+ }
78
166
  visit(item, key === 'envVariables' || key === 'envSecrets')
79
167
  }
80
168
  }