@fabrika/provider-zerops 0.0.9 → 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 +5 -5
- package/src/api.ts +30 -7
- package/src/control.ts +13 -1
- package/src/provider.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
36
|
-
"@fabrika/auth-core": "^0.0.
|
|
37
|
-
"@fabrika/provider-contract": "^0.0.
|
|
38
|
-
"@fabrika/proxy-contract": "^0.0.
|
|
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
|
-
*
|
|
501
|
-
*
|
|
502
|
-
* `
|
|
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}`, {
|
|
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
|
|