@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,95 @@
1
+ // What a deploy reads back before it lets Zerops activate a version (ADR-0043). An import cannot turn subdomain
2
+ // access on, but an earlier enable survives it, and Zerops has no API to read a bucket policy back. So every declared
3
+ // service must read `subdomainAccess: false`, and every bucket must be one the platform created private.
4
+
5
+ import type { ZeropsImportResult } from './api'
6
+
7
+ /**
8
+ * The provenance marker: a service variable on the deploy service listing the object-storage service ids the platform
9
+ * created, each from an import that carried `objectStoragePolicy: private`. A bucket adopted by hostname has none.
10
+ */
11
+ export const STORAGE_PROVENANCE_KEY = 'FABRIKA_STORAGE_PROVENANCE'
12
+
13
+ const OBJECT_STORAGE_TYPES: ReadonlySet<string> = new Set(['object-storage', 'objectstorage'])
14
+
15
+ export const isObjectStorageType = (type: string): boolean => OBJECT_STORAGE_TYPES.has(type)
16
+
17
+ /** The action of the process an import starts for a service it creates (verified live, 2026-10-02). */
18
+ const CREATE_ACTION = 'stack.create'
19
+
20
+ export const parseStorageProvenance = (value: string | undefined): ReadonlySet<string> =>
21
+ new Set((value ?? '').split(',').filter((entry) => entry !== ''))
22
+
23
+ export const formatStorageProvenance = (ids: Iterable<string>): string => [...new Set(ids)].sort().join(',')
24
+
25
+ /** Hostname → service id of every service this import result reports creating. */
26
+ export const createdServices = (result: ZeropsImportResult): ReadonlyMap<string, string> =>
27
+ new Map(
28
+ result.services
29
+ .filter((service) => service.processes.some((process) => process.actionName === CREATE_ACTION))
30
+ .map((service) => [service.name, service.id]),
31
+ )
32
+
33
+ export interface ExposureFinding {
34
+ readonly hostname: string
35
+ readonly reason: string
36
+ }
37
+
38
+ export const formatExposureFindings = (findings: readonly ExposureFinding[]): string =>
39
+ `zerops: activation refused; ${findings.map((finding) => `service \`${finding.hostname}\` ${finding.reason}`).join('; ')}`
40
+
41
+ export interface ExposureAlert {
42
+ readonly appId: string
43
+ readonly env: string
44
+ readonly release: string | null
45
+ readonly findings: readonly ExposureFinding[]
46
+ }
47
+
48
+ const DSN_PUBLIC_KEY = /^[0-9a-f]{32}$/
49
+ const DSN_PROJECT = /^\/([1-9][0-9]{0,18})$/
50
+
51
+ /**
52
+ * The Operations ingest request that raises the alert: an error event in the application's own Operations project,
53
+ * which its `new_issue` alert rules deliver. Null for a DSN of any other form. The public key is not a secret, but it
54
+ * travels only in this request.
55
+ */
56
+ export function exposureAlertRequest(dsn: string, alert: ExposureAlert, eventId: string): { url: string; body: string } | null {
57
+ let url: URL
58
+ try {
59
+ url = new URL(dsn)
60
+ } catch {
61
+ return null
62
+ }
63
+ const project = DSN_PROJECT.exec(url.pathname)?.[1]
64
+ if ((url.protocol !== 'https:' && url.protocol !== 'http:') || !DSN_PUBLIC_KEY.test(url.username) || url.password !== '' || project === undefined) {
65
+ return null
66
+ }
67
+ const endpoint = new URL(`/api/${project}/envelope/`, url.origin)
68
+ endpoint.searchParams.set('sentry_key', url.username)
69
+ const event = {
70
+ event_id: eventId,
71
+ level: 'fatal',
72
+ platform: 'other',
73
+ environment: alert.env,
74
+ ...(alert.release === null ? {} : { release: alert.release }),
75
+ exception: { values: [{ type: 'FabrikaExposureRefused', value: formatExposureFindings(alert.findings) }] },
76
+ fingerprint: ['fabrika-exposure-refused', alert.appId, alert.env, ...alert.findings.map((finding) => finding.hostname).sort()],
77
+ }
78
+ const body = [JSON.stringify({ event_id: eventId }), JSON.stringify({ type: 'event' }), JSON.stringify(event)].join('\n')
79
+ return { url: endpoint.toString(), body: `${body}\n` }
80
+ }
81
+
82
+ export type ExposureAlerter = (input: { readonly dsn: string; readonly alert: ExposureAlert; readonly signal: AbortSignal }) => Promise<void>
83
+
84
+ /** Send the alert. The caller treats a failure as non-fatal: the refusal stands either way. */
85
+ export const sendExposureAlert: ExposureAlerter = async ({ dsn, alert, signal }) => {
86
+ const request = exposureAlertRequest(dsn, alert, crypto.randomUUID().replaceAll('-', ''))
87
+ if (request === null) throw new Error('zerops: the Operations DSN has an unexpected form')
88
+ const response = await fetch(request.url, {
89
+ method: 'POST',
90
+ headers: { 'content-type': 'application/x-sentry-envelope' },
91
+ body: request.body,
92
+ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
93
+ })
94
+ if (!response.ok) throw new Error(`zerops: the Operations ingest answered ${response.status}`)
95
+ }
@@ -13,6 +13,7 @@
13
13
  // error naming both the flag and the variable that would supply it.
14
14
 
15
15
  import { describeArgument, readEnv, requiredSetting, setting } from '@fabrika/installation-init'
16
+ import { type McpClient, parseMcpClientsJson } from './mcp'
16
17
  import { parsePlatformHosts, parsePlatformScheme } from './placement-options'
17
18
  import { readInstallationSourceDir } from './source-options'
18
19
 
@@ -35,6 +36,8 @@ export interface PlatformDeployInput {
35
36
  /** The scheme a browser speaks to this installation. `https` unless a local composition says otherwise. */
36
37
  readonly scheme: 'http' | 'https'
37
38
  readonly sourceDir: string
39
+ /** The MCP clients this installation trusts. Absent leaves MCP authoring off (ADR-0049). */
40
+ readonly mcpClients?: readonly McpClient[]
38
41
  /** IAM-issued `px_` admin key, used only to reconcile the console's schema. Environment only. */
39
42
  readonly iamAdminKey: string
40
43
  /** Read everything, write nothing, trigger nothing. */
@@ -51,6 +54,7 @@ const FLAGS = [
51
54
  '--operations-host',
52
55
  '--scheme',
53
56
  '--source-dir',
57
+ '--mcp-clients',
54
58
  ] as const
55
59
 
56
60
  const assertKnownArguments = (argv: readonly string[]): void => {
@@ -109,6 +113,12 @@ export const parsePlatformDeployArgs = (
109
113
  const dryRun = argv.includes('--dry-run')
110
114
  const hosts = readHosts(argv, env)
111
115
  const apiBaseUrl = readEnv(env, 'FABRIKA_ZEROPS_API_URL')
116
+ const mcpClients = setting(argv, env, '--mcp-clients', 'FABRIKA_PLATFORM_MCP_CLIENTS_JSON')
117
+ const scheme = readScheme(argv, env)
118
+ // Control reads a bare `FABRIKA_CONTROL_DOMAIN` as https, so an http resource would never match its own.
119
+ if (mcpClients !== undefined && scheme === 'http') {
120
+ throw new Error('MCP clients need --scheme=https: an OAuth resource on plain http would not match the one Control serves')
121
+ }
112
122
  return {
113
123
  project: readProject(argv, env),
114
124
  // A dry run reads, and reading still needs a token; it is the one credential it cannot do without.
@@ -118,10 +128,11 @@ export const parsePlatformDeployArgs = (
118
128
  iamAdminKey: dryRun
119
129
  ? readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY') ?? ''
120
130
  : requiredSetting(readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY'), '(no flag)', 'FABRIKA_IAM_PROVISIONING_KEY'),
121
- scheme: readScheme(argv, env),
131
+ scheme,
122
132
  dryRun,
123
133
  ...(hosts === undefined ? {} : { hosts }),
124
134
  sourceDir,
125
135
  ...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
136
+ ...(mcpClients === undefined ? {} : { mcpClients: parseMcpClientsJson(mcpClients) }),
126
137
  }
127
138
  }
@@ -10,7 +10,7 @@
10
10
  // deploying control at a new version while the previous, more permissive manifest is still in
11
11
  // front of it leaves `/api/*` open for the length of the deploy. The sequence is therefore
12
12
  // (IAM + Operations) → **proxy** → control: the two that order nothing against each
13
- // other built at once, with the enforcement point ahead of the service whose gates just widened.
13
+ // other built at once, with the enforcement point ahead of the service whose table just widened.
14
14
  // `__tests__/deploy.test.ts` pins it.
15
15
  //
16
16
  // Bringing `fabrika-test` to HEAD on 2026-08-05 took `zops push` from a laptop, once per service, in a
@@ -34,7 +34,7 @@
34
34
  // "deploy" means, and the platform keeps the previous version serving whenever the new one fails its
35
35
  // readiness gate.
36
36
 
37
- import { readEnvironmentName } from '@fabrika/auth-core'
37
+ import { FABRIKA_SERVICE_TABLE_DIGEST, readEnvironmentName } from '@fabrika/auth-core'
38
38
  import type { SchemaReconciler } from '@fabrika/provider-contract'
39
39
  import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
40
40
  import {
@@ -62,9 +62,10 @@ import {
62
62
  } from './installation-source'
63
63
  import type { DeployLog } from './log'
64
64
  import { mergePlatformProxyManifest, readLiveProxyManifest } from './manifest'
65
+ import { type IamOAuthConfig, platformIamOAuthConfig } from './mcp'
65
66
  import type { PreparedPlatformSource } from './platform-source'
66
67
  import { type PlatformUploadFetch, uploadPlatformArchive } from './platform-upload'
67
- import { platformProxyAppFor, resolvePlatformProxyManifest } from './proxy-manifest'
68
+ import { platformProxyAppFor, type PlatformProxyComposition, platformServiceTableDigest, resolvePlatformProxyManifest } from './proxy-manifest'
68
69
 
69
70
  /**
70
71
  * The four services this command touches, in the order it RESOLVES and WRITES them.
@@ -93,7 +94,7 @@ export type PlatformDeployService = (typeof PLATFORM_DEPLOY_ORDER)[number]
93
94
  *
94
95
  * What is NOT arbitrary stays: every environment write happens before any of these starts, the proxy
95
96
  * is deployed after both, and control after the proxy (ADR-0022 — the enforcement point ahead of
96
- * the service whose gates just widened). `deploy-order.test.ts` in `@fabrika/platform-artifacts` and
97
+ * the service whose table just widened). `deploy-order.test.ts` in `@fabrika/platform-artifacts` and
97
98
  * `__tests__/deploy.test.ts` fail if that sequence changes.
98
99
  */
99
100
  export const PLATFORM_CONCURRENT_DEPLOY = ['iam', 'operations'] as const
@@ -268,29 +269,36 @@ const resolvePlacement = (input: PlatformDeployInput, proxyEnv: ReadonlyMap<stri
268
269
  return { hosts: derivePlatformHosts(subdomains, listeners), scheme: input.scheme, zeropsSubdomain: true }
269
270
  }
270
271
 
272
+ const IAM_OAUTH_CONFIG = 'FABRIKA_IAM_OAUTH_CONFIG'
273
+
271
274
  /**
272
275
  * The per-installation configuration each service needs, derived from the resolved hosts.
273
276
  *
274
277
  * The namespace source default comes from the verified snapshot. Values are compared but never logged.
278
+ * IAM's OAuth configuration is written only when MCP authoring is enabled; without it the variable is
279
+ * left as it is, and the manifest — whose `/mcp` row then admits no OAuth token — is what turns MCP off.
275
280
  */
276
281
  const desiredServiceEnv = (
277
282
  environment: string,
278
283
  placement: PlatformPlacement,
279
284
  manifestJson: string,
280
285
  proxySource: ZeropsNamespaceProxySource,
286
+ oauthConfig: IamOAuthConfig | undefined,
287
+ controlTableDigest: string,
281
288
  ): ReadonlyMap<PlatformDeployService, ReadonlyMap<string, string>> => {
282
289
  const iamOrigin = platformOrigin(placement.scheme, placement.hosts.iam)
283
290
  const operationsOrigin = platformOrigin(placement.scheme, placement.hosts.operations)
291
+ const iam = new Map([
292
+ ['ENVIRONMENT', environment],
293
+ // The `iss` of every minted token AND the OIDC redirect base, so it must be the address
294
+ // routed to the proxy in front of this service.
295
+ ['ISSUER', iamOrigin],
296
+ ])
297
+ if (oauthConfig !== undefined) {
298
+ iam.set(IAM_OAUTH_CONFIG, JSON.stringify(oauthConfig))
299
+ }
284
300
  return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
285
- [
286
- 'iam',
287
- new Map([
288
- ['ENVIRONMENT', environment],
289
- // The `iss` of every minted token AND the OIDC redirect base, so it must be the address
290
- // routed to the proxy in front of this service.
291
- ['ISSUER', iamOrigin],
292
- ]),
293
- ],
301
+ ['iam', iam],
294
302
  [
295
303
  'operations',
296
304
  new Map([
@@ -316,9 +324,11 @@ const desiredServiceEnv = (
316
324
  [FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource(proxySource)],
317
325
  ['ENVIRONMENT', environment],
318
326
  ['FABRIKA_IAM_ISSUER', iamOrigin],
319
- // A bare host: `controlPublicOrigin` accepts either spelling, and this is also what the
320
- // same-origin check on both first-party gateways is decided against.
327
+ // A bare host: `controlPublicOrigin` accepts either spelling.
321
328
  ['FABRIKA_CONTROL_DOMAIN', placement.hosts.control],
329
+ // The digest of the console table the proxy above publishes: Control picks its own table, MCP or not,
330
+ // by it, and refuses to start under another, so the two cannot disagree at runtime.
331
+ [FABRIKA_SERVICE_TABLE_DIGEST, controlTableDigest],
322
332
  ['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
323
333
  // The public IAM origin an APPLICATION project's proxy reaches — there is no private
324
334
  // network between projects (ADR-0006).
@@ -589,14 +599,24 @@ export const deployPreparedPlatform = async (
589
599
  log.info(`iam ${placement.hosts.iam} · operations ${placement.hosts.operations}`)
590
600
 
591
601
  log.step('Compose the proxy manifest')
592
- const platformManifest = resolvePlatformProxyManifest(PLATFORM_PROXY_MANIFEST_TEMPLATE, {
602
+ const composition: PlatformProxyComposition = {
593
603
  scheme: placement.scheme,
594
604
  placement: {
595
605
  iam: { hosts: [placement.hosts.iam] },
596
606
  control: { hosts: [placement.hosts.control] },
597
607
  operations: { hosts: [placement.hosts.operations] },
598
608
  },
599
- })
609
+ ...(input.mcpClients === undefined ? {} : { authorizationServer: platformOrigin(placement.scheme, placement.hosts.iam) }),
610
+ }
611
+ const platformManifest = resolvePlatformProxyManifest(PLATFORM_PROXY_MANIFEST_TEMPLATE, composition)
612
+ const oauthConfig = input.mcpClients === undefined
613
+ ? undefined
614
+ : platformIamOAuthConfig(PLATFORM_PROXY_MANIFEST_TEMPLATE, composition, input.mcpClients)
615
+ log.info(
616
+ oauthConfig === undefined
617
+ ? 'MCP authoring: off'
618
+ : `MCP authoring: on, ${oauthConfig.clients.length} client(s), resource(s) ${oauthConfig.resources.map((entry) => entry.resource).join(', ')}`,
619
+ )
600
620
  const merged = mergePlatformProxyManifest(platformManifest, readLiveProxyManifest(proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)))
601
621
  log.info(`${merged.manifest.apps.length} app(s): ${merged.manifest.apps.map((app) => app.id).join(', ')}`)
602
622
  if (merged.carried.length > 0) {
@@ -610,11 +630,15 @@ export const deployPreparedPlatform = async (
610
630
  }
611
631
 
612
632
  log.step('Write the service environment')
613
- const desired = desiredServiceEnv(environment, placement, encodeProxyManifestJson(merged.manifest), proxySource)
633
+ const controlTableDigest = platformServiceTableDigest(platformManifest, platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control').id)
634
+ const desired = desiredServiceEnv(environment, placement, encodeProxyManifestJson(merged.manifest), proxySource, oauthConfig, controlTableDigest)
614
635
  for (const hostname of PLATFORM_DEPLOY_ORDER) {
615
636
  const service = serviceOf(services, hostname)
616
637
  const live = hostname === PLATFORM_PROXY_SERVICE ? proxyEnv : await readServiceEnv(service.id, collaborators)
617
638
  const keys = desired.get(hostname) ?? new Map<string, string>()
639
+ if (hostname === 'iam' && oauthConfig === undefined && (live.get(IAM_OAUTH_CONFIG) ?? '').trim() !== '') {
640
+ log.warn(`iam: MCP authoring is off but ${IAM_OAUTH_CONFIG} is still set; IAM keeps issuing MCP tokens until it is removed`)
641
+ }
618
642
  const written = await applyServiceEnv(service, keys, live, input, collaborators)
619
643
  log.info(
620
644
  written.length === 0
@@ -22,6 +22,10 @@ export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
22
22
  { action: 'namespace.manage', description: 'Manage deployment namespaces' },
23
23
  { action: 'secret.manage', description: 'Manage secret values + their references' },
24
24
  { action: 'source.connection.manage', description: 'Manage the platform GitHub source connection' },
25
+ { action: 'source.workspace.read', description: 'Read own Git workspaces on an authoring target' },
26
+ { action: 'source.workspace.write', description: 'Create and commit to own Git workspaces on an authoring target' },
27
+ { action: 'source.pull-request.create', description: 'Open a pull request from an own Git workspace' },
28
+ { action: 'access.approve', description: 'Approve an access change and protect an environment (a person in the console only)' },
25
29
  { action: 'operations.read', description: 'Read Operations errors and activity' },
26
30
  { action: 'operations.triage', description: 'Triage Operations errors' },
27
31
  { action: 'operations.manage', description: 'Manage Operations sources, alerts, and retention' },