@fabrika/provider-zerops 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,9 +32,9 @@
32
32
  "gen:schema": "bun scripts/generate-zerops-types.ts"
33
33
  },
34
34
  "dependencies": {
35
- "@fabrika/auth": "^0.0.10",
36
- "@fabrika/auth-core": "^0.0.10",
37
- "@fabrika/provider-contract": "^0.0.10",
38
- "@fabrika/proxy-contract": "^0.0.10"
35
+ "@fabrika/auth": "^0.0.12",
36
+ "@fabrika/auth-core": "^0.0.12",
37
+ "@fabrika/provider-contract": "^0.0.12",
38
+ "@fabrika/proxy-contract": "^0.0.12"
39
39
  }
40
40
  }
package/src/api.ts CHANGED
@@ -32,6 +32,15 @@ export type ZeropsAppVersionStatus =
32
32
  | 'BACKUP'
33
33
  | 'CANCELLED'
34
34
 
35
+ /**
36
+ * `sensitive` on every user-data WRITE, create and update alike. Required by the platform — omitting it answers
37
+ * `400 invalidUserInput` with `{"sensitive":["field is required"]}` (measured live, 2026-08-18) — and
38
+ * `true` because most of what fabrika writes is a credential. It costs nothing to read: a sensitive
39
+ * variable's `content` still comes back verbatim from `GET /service-stack/{id}/env`, also measured, so the
40
+ * compare-before-write both `platform deploy` and the create-only credential slots depend on still works.
41
+ */
42
+ const SENSITIVE_USER_DATA = true
43
+
35
44
  /** The one status that means "this version is live". */
36
45
  export const ZEROPS_ACTIVE: ZeropsAppVersionStatus = 'ACTIVE'
37
46
 
@@ -497,9 +506,14 @@ export interface ZeropsApi {
497
506
  * `compile.ts` writes `envIsolation` instead of inheriting it: a security-relevant field that never
498
507
  * reaches the wire is a field nobody can review.
499
508
  *
500
- * NOT EXPOSED, deliberately: `canCreateProjects` — a token that creates projects receives `OWNER` on
501
- * what it creates, which is exactly the scope this call exists to avoid — and `canViewFinances` /
502
- * `canEditFinances`. All three default to `false`, and fabrika has no use for any of them.
509
+ * `canCreateProjects` IS EXPOSED, because a control plane that provisions app namespaces cannot work
510
+ * without it: every namespace preset creates a project, and `POST /client/{id}/project/import` answers
511
+ * `403 insufficientPermissions` to a token without the flag (measured live, 2026-08-18; ADR-0034).
512
+ * The cost is real and bounded — the token receives `OWNER` on what it creates, and nothing else:
513
+ * `roleCode` stays `NO_ACCESS`, so a project it never created and was never granted stays unreachable.
514
+ *
515
+ * NOT EXPOSED, deliberately: `canViewFinances` / `canEditFinances`. Both default to `false`, and
516
+ * fabrika has no use for either.
503
517
  *
504
518
  * UNVERIFIED BEHAVIOUR: EVERYTHING AT RUNTIME. No token has ever been minted against a real account —
505
519
  * doing so is a mutation, not an inspection — so the shapes below are the published document's and the
@@ -517,6 +531,8 @@ export interface ZeropsApi {
517
531
  projects: readonly { projectId: string; roleCode: ZeropsRoleCode }[]
518
532
  /** The client-wide role. Defaults to `NO_ACCESS`, which is the entire point of a scoped token. */
519
533
  roleCode?: ZeropsRoleCode
534
+ /** Let the token create projects, which it then OWNS. Required to provision app namespaces (ADR-0034). */
535
+ canCreateProjects?: boolean
520
536
  signal: AbortSignal
521
537
  }): Promise<ZeropsIntegrationToken>
522
538
 
@@ -1087,7 +1103,7 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1087
1103
  createServiceEnv: async ({ serviceId, key, value, signal }) => {
1088
1104
  try {
1089
1105
  await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
1090
- body: { key, content: value },
1106
+ body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
1091
1107
  signal,
1092
1108
  omitErrorResponseDetails: true,
1093
1109
  })
@@ -1101,7 +1117,7 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1101
1117
  // `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
1102
1118
  try {
1103
1119
  await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
1104
- body: { key, content: value },
1120
+ body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
1105
1121
  signal,
1106
1122
  redactDetail: true,
1107
1123
  })
@@ -1121,7 +1137,11 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1121
1137
  `zerops: service ${serviceId} already defines \`${key}\` outside the environment API — it cannot be written`,
1122
1138
  )
1123
1139
  }
1124
- await request('update service env', 'PUT', `/user-data/${existing.id}`, { body: { key, content: value }, signal, redactDetail: true })
1140
+ await request('update service env', 'PUT', `/user-data/${existing.id}`, {
1141
+ body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
1142
+ signal,
1143
+ redactDetail: true,
1144
+ })
1125
1145
  },
1126
1146
 
1127
1147
  deleteServiceEnv: async ({ envId, signal }) => {
@@ -1131,7 +1151,7 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1131
1151
  getProjectEnv: async ({ projectEnvId, signal }) =>
1132
1152
  readServiceEnv(await request('get project env', 'GET', `/project-env/${projectEnvId}`, { signal })),
1133
1153
 
1134
- createIntegrationToken: async ({ clientId, name, projects, roleCode, signal }) =>
1154
+ createIntegrationToken: async ({ clientId, name, projects, roleCode, canCreateProjects, signal }) =>
1135
1155
  // `redactDetail`: unlike every other write here the SECRET is in the RESPONSE, not the request, so
1136
1156
  // this is the one error envelope that could quote a minted token back at us.
1137
1157
  readIntegrationToken(
@@ -1139,6 +1159,9 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1139
1159
  body: {
1140
1160
  name,
1141
1161
  roleCode: roleCode ?? 'NO_ACCESS',
1162
+ // Always sent, like `roleCode`: a security-relevant field that never reaches the wire is a
1163
+ // field nobody can review.
1164
+ canCreateProjects: canCreateProjects ?? false,
1142
1165
  projects: projects.map((grant) => ({ projectId: grant.projectId, roleCode: grant.roleCode })),
1143
1166
  },
1144
1167
  signal,
package/src/control.ts CHANGED
@@ -92,6 +92,8 @@ export interface ZeropsBeforeDeployInput {
92
92
  readonly target: ZeropsStoredTarget & {
93
93
  readonly projectId: string
94
94
  readonly proxyServiceId: string
95
+ /** The namespace proxy has no Git integration, so its pipeline must be told what to build. */
96
+ readonly proxyBuildFromGit: string
95
97
  }
96
98
  readonly artifact: FabrikaManifest
97
99
  readonly api: ZeropsApi
@@ -175,7 +177,7 @@ const decodeNamespace = (
175
177
  const resolvedEnvironment = (
176
178
  environment: ProviderEnvironment,
177
179
  options: { requireReady: boolean },
178
- ): DecodedZeropsEnvironment & { projectId: string; proxyServiceId?: string } => {
180
+ ): DecodedZeropsEnvironment & { projectId: string; proxyServiceId?: string; proxyBuildFromGit?: string } => {
179
181
  const decoded = decodeEnvironment(environment)
180
182
  const projectId = decoded.namespaceTarget.projectId
181
183
  if (projectId === undefined) {
@@ -188,10 +190,15 @@ const resolvedEnvironment = (
188
190
  if (options.requireReady && proxyServiceId === undefined) {
189
191
  throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` has no proxy service id`)
190
192
  }
193
+ const proxyBuildFromGit = decoded.namespaceTarget.proxyBuildFromGit
194
+ if (options.requireReady && proxyBuildFromGit === undefined) {
195
+ throw new Error(`Zerops deployment namespace \`${decoded.namespace.id}\` has no proxy build source`)
196
+ }
191
197
  return {
192
198
  ...decoded,
193
199
  projectId,
194
200
  ...(proxyServiceId === undefined ? {} : { proxyServiceId }),
201
+ ...(proxyBuildFromGit === undefined ? {} : { proxyBuildFromGit }),
195
202
  }
196
203
  }
197
204
 
@@ -443,6 +450,10 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
443
450
  if (proxyServiceId === undefined) {
444
451
  throw new Error(`Zerops deployment namespace \`${placement.namespace.id}\` has no proxy service id`)
445
452
  }
453
+ const proxyBuildFromGit = placement.proxyBuildFromGit
454
+ if (proxyBuildFromGit === undefined) {
455
+ throw new Error(`Zerops deployment namespace \`${placement.namespace.id}\` has no proxy build source`)
456
+ }
446
457
  const artifact = decodeEnvelope('artifact', registration.environment.artifact, zeropsArtifactCodec)
447
458
  await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
448
459
  let source: ZeropsRuntimeTarget['source']
@@ -468,6 +479,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
468
479
  serviceId: placement.target.serviceId,
469
480
  projectId: placement.projectId,
470
481
  proxyServiceId,
482
+ proxyBuildFromGit,
471
483
  },
472
484
  artifact,
473
485
  api,
package/src/provider.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { createProvider, type ProviderDeploySession, type ProviderModule, type TypedProviderRun } from '@fabrika/provider-contract'
13
13
  import {
14
+ waitForProcess,
14
15
  ZEROPS_ACTIVE,
15
16
  ZEROPS_PROCESS_FINISHED,
16
17
  ZEROPS_PROCESS_TERMINAL,
@@ -273,6 +274,17 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
273
274
  // `override: true` on every service (written by the compiler) is what makes re-applying safe.
274
275
  const result = await zerops.api.importServices({ projectId: target.projectId, yaml: compiled.yaml, signal })
275
276
  log(` imported: ${result.services.map((service) => `${service.name} (${service.id})`).join(', ') || '(none reported)'}`)
277
+ // An import ANSWERS before its services exist. Triggering a pipeline on a service Zerops is
278
+ // still creating answers 400 `projectImportInvalidParameter`, which is what the first deploy
279
+ // into a fresh namespace hit; a re-apply onto services that already exist reports none.
280
+ const pending = result.services.flatMap((service) => service.processes.map((process) => ({ service: service.name, id: process.id })))
281
+ for (const process of pending) {
282
+ assertRunning(signal)
283
+ await waitForProcess({ api: zerops.api, processId: process.id, sleep: zerops.sleep, signal, label: `the ${process.service} import` })
284
+ }
285
+ if (pending.length > 0) {
286
+ log(` awaited ${pending.length} import process(es)`)
287
+ }
276
288
  return
277
289
  }
278
290