@fabrika/provider-zerops 0.0.2 → 0.0.3
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 +217 -1
- package/src/index.ts +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
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.3",
|
|
36
|
+
"@fabrika/auth-core": "^0.0.3",
|
|
37
|
+
"@fabrika/provider-contract": "^0.0.3",
|
|
38
|
+
"@fabrika/proxy-contract": "^0.0.3"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/api.ts
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
// fact; check it against a real account first.
|
|
11
11
|
//
|
|
12
12
|
// SECURITY. The token is a Zerops PERSONAL ACCESS TOKEN and carries account-wide admin rights. It is
|
|
13
|
-
// never logged, never included in an error message, and never returned from a method.
|
|
13
|
+
// never logged, never included in an error message, and never returned from a method. There is exactly
|
|
14
|
+
// one credential this module DOES hand back — the integration token `createIntegrationToken` mints, which
|
|
15
|
+
// exists so that nothing has to reuse the personal one; it obeys the same no-log, no-error-message rule.
|
|
16
|
+
|
|
17
|
+
import type { Sleeper } from './collaborators'
|
|
14
18
|
|
|
15
19
|
/** Every status a Zerops application version can be in. VERIFIED: `OutDtoGetAppVersion.status` enum. */
|
|
16
20
|
export type ZeropsAppVersionStatus =
|
|
@@ -68,6 +72,29 @@ export interface ZeropsProcess {
|
|
|
68
72
|
appVersionId?: string
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The status a process that succeeded carries.
|
|
77
|
+
*
|
|
78
|
+
* VERIFIED AS A VALUE, not as an enum member: `docs/reference/zerops-platform.md` records
|
|
79
|
+
* `stack.updateUserData`, `stack.enableSubdomainAccess` and an import's processes reaching exactly this
|
|
80
|
+
* string live. `ResponseProcess.status` is an untyped string in the published document, so the platform
|
|
81
|
+
* declares no set anywhere.
|
|
82
|
+
*/
|
|
83
|
+
export const ZEROPS_PROCESS_FINISHED = 'FINISHED'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Statuses a process never leaves.
|
|
87
|
+
*
|
|
88
|
+
* UNVERIFIED AS A CLOSED SET — the OpenAPI document gives `ResponseProcess.status` no enum at all. These
|
|
89
|
+
* are the values `zops process list --status` documents (`PENDING, RUNNING, FINISHED, FAILED, CANCELED`)
|
|
90
|
+
* minus the two that mean "still going". Note the single `L` in `CANCELED`: an app VERSION spells the same
|
|
91
|
+
* idea `CANCELLED`, and the two vocabularies are not the same one.
|
|
92
|
+
*
|
|
93
|
+
* `waitForProcess` treats everything not in here as "keep waiting", so a status this build has never seen
|
|
94
|
+
* can only ever cost time — it can never be read as success.
|
|
95
|
+
*/
|
|
96
|
+
export const ZEROPS_PROCESS_TERMINAL: ReadonlySet<string> = new Set(['FINISHED', 'FAILED', 'CANCELED'])
|
|
97
|
+
|
|
71
98
|
/** One service created or updated by an import. VERIFIED: `OutDtoProjectImportServiceStack`. */
|
|
72
99
|
export interface ZeropsImportedService {
|
|
73
100
|
id: string
|
|
@@ -156,6 +183,47 @@ export interface ZeropsProject {
|
|
|
156
183
|
tagList?: string[]
|
|
157
184
|
}
|
|
158
185
|
|
|
186
|
+
/**
|
|
187
|
+
* A Zerops access role. VERIFIED: the `roleCode` enum, shared by `RequestClientIntegrationToken` and
|
|
188
|
+
* `ResponseClientIntegrationTokenRaw`.
|
|
189
|
+
*
|
|
190
|
+
* Tiered, and the schema's own description states the order: `OWNER > ADMIN > BASIC_USER > READ_ONLY >
|
|
191
|
+
* NO_ACCESS`, with a PROJECT role overriding the client-wide one. That override is the whole mechanism
|
|
192
|
+
* behind a scoped token — client `NO_ACCESS` plus one project `ADMIN` reaches exactly one project.
|
|
193
|
+
*/
|
|
194
|
+
export type ZeropsRoleCode = 'OWNER' | 'ADMIN' | 'BASIC_USER' | 'READ_ONLY' | 'NO_ACCESS'
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* One project grant carried by an integration token. VERIFIED:
|
|
198
|
+
* `ResponseClientIntegrationTokenRaw.projects[]` — `projectId` is required and `roleCode` is not, so a
|
|
199
|
+
* grant read back can omit it (the schema documents `NO_ACCESS` as the default; this client does not
|
|
200
|
+
* substitute one, because an assumed grant is worse than an absent one).
|
|
201
|
+
*/
|
|
202
|
+
export interface ZeropsTokenProjectGrant {
|
|
203
|
+
projectId: string
|
|
204
|
+
roleCode?: ZeropsRoleCode
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A freshly minted integration token. VERIFIED: `ResponseClientIntegrationTokenRaw` — `id`, `name`,
|
|
209
|
+
* `projects` and `token` are all in its `required` list.
|
|
210
|
+
*
|
|
211
|
+
* THE ONE SHAPE ON THIS CLIENT THAT CARRIES A CREDENTIAL, and the `Raw` in the schema's name is why: the
|
|
212
|
+
* plaintext `token` appears in this one response and nowhere afterwards. Everything the module header says
|
|
213
|
+
* about the personal access token applies to it — never logged, never in an error message — with the
|
|
214
|
+
* single exception that it must reach whatever is going to store it.
|
|
215
|
+
*/
|
|
216
|
+
export interface ZeropsIntegrationToken {
|
|
217
|
+
id: string
|
|
218
|
+
name: string
|
|
219
|
+
/** The bearer value. NEVER log it. */
|
|
220
|
+
token: string
|
|
221
|
+
/** The grants the platform actually recorded, so a caller can check the scope it asked for. */
|
|
222
|
+
projects: ZeropsTokenProjectGrant[]
|
|
223
|
+
/** The client-wide role. Absent from the response's `required` list, so it can genuinely be missing. */
|
|
224
|
+
roleCode?: ZeropsRoleCode
|
|
225
|
+
}
|
|
226
|
+
|
|
159
227
|
/**
|
|
160
228
|
* ONE service-level environment variable. VERIFIED: `OutDtoUserData`.
|
|
161
229
|
*
|
|
@@ -276,6 +344,24 @@ export interface ZeropsApi {
|
|
|
276
344
|
/** Cancel an in-flight build — what a cancelled run does with the work it started. VERIFIED: `PUT /app-version/{id}/cancel-build`. */
|
|
277
345
|
cancelBuild(input: { appVersionId: string; signal: AbortSignal }): Promise<void>
|
|
278
346
|
|
|
347
|
+
// ── process: the asynchronous half of almost every write ──────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* One platform operation's current state. VERIFIED: `GET /process/{id}` (`GetProcessDetails`,
|
|
351
|
+
* response `ResponseProcess`).
|
|
352
|
+
*
|
|
353
|
+
* WHAT IT IS FOR. Several writes here return a process INSTEAD of their result and complete afterwards:
|
|
354
|
+
* an import hands back one per service it touched, and `POST /service-stack/{id}/user-data` answers with
|
|
355
|
+
* a process rather than the record it created. A caller that imports services and then immediately
|
|
356
|
+
* writes their environment needs to know the import's processes finished, and until this existed
|
|
357
|
+
* nothing could ask.
|
|
358
|
+
*
|
|
359
|
+
* UNVERIFIED BEHAVIOUR: the status VOCABULARY (see `ZEROPS_PROCESS_TERMINAL`). The published document
|
|
360
|
+
* types `status` as a bare string defaulting to `PENDING`, so it stays `string` here rather than being
|
|
361
|
+
* narrowed to a set nobody publishes.
|
|
362
|
+
*/
|
|
363
|
+
getProcess(input: { processId: string; signal: AbortSignal }): Promise<ZeropsProcess>
|
|
364
|
+
|
|
279
365
|
// ── service-stack: public access ──────────────────────────────────────────────
|
|
280
366
|
|
|
281
367
|
/**
|
|
@@ -354,6 +440,46 @@ export interface ZeropsApi {
|
|
|
354
440
|
*/
|
|
355
441
|
getProjectEnv(input: { projectEnvId: string; signal: AbortSignal }): Promise<ZeropsServiceEnv>
|
|
356
442
|
|
|
443
|
+
// ── client: minting a credential ──────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Mint a project-scoped INTEGRATION token. VERIFIED: `POST /client/{id}/integration-token`
|
|
447
|
+
* (`createIntegrationToken`), body `RequestClientIntegrationToken` — `name` and `projects` are its two
|
|
448
|
+
* required members — response `ResponseClientIntegrationTokenRaw`, whose `required` list contains
|
|
449
|
+
* `token`.
|
|
450
|
+
*
|
|
451
|
+
* WHAT IT IS FOR. A control plane deployed on Zerops holds a Zerops credential, and it must not be the
|
|
452
|
+
* personal access token an operator or a bootstrap authenticated with: that one carries account-wide
|
|
453
|
+
* admin rights (module header). An integration token left at client `NO_ACCESS` with one project
|
|
454
|
+
* granted `ADMIN` can do nothing outside that project.
|
|
455
|
+
*
|
|
456
|
+
* `roleCode` IS ALWAYS SENT, even when its value is the schema's own default, for the reason
|
|
457
|
+
* `compile.ts` writes `envIsolation` instead of inheriting it: a security-relevant field that never
|
|
458
|
+
* reaches the wire is a field nobody can review.
|
|
459
|
+
*
|
|
460
|
+
* NOT EXPOSED, deliberately: `canCreateProjects` — a token that creates projects receives `OWNER` on
|
|
461
|
+
* what it creates, which is exactly the scope this call exists to avoid — and `canViewFinances` /
|
|
462
|
+
* `canEditFinances`. All three default to `false`, and fabrika has no use for any of them.
|
|
463
|
+
*
|
|
464
|
+
* UNVERIFIED BEHAVIOUR: EVERYTHING AT RUNTIME. No token has ever been minted against a real account —
|
|
465
|
+
* doing so is a mutation, not an inspection — so the shapes below are the published document's and the
|
|
466
|
+
* behaviours are unmeasured: whether a repeated `name` conflicts, whether client `NO_ACCESS` alongside
|
|
467
|
+
* a project grant is accepted, and what the token's own prefix or length looks like.
|
|
468
|
+
*
|
|
469
|
+
* THE RESPONSE CARRIES A SECRET, so the implementation redacts the server's message from any error it
|
|
470
|
+
* raises. This is the one endpoint whose error envelope could quote a token value back.
|
|
471
|
+
*/
|
|
472
|
+
createIntegrationToken(input: {
|
|
473
|
+
clientId: string
|
|
474
|
+
/** The token's label in the Zerops UI. Not a secret and not unique as far as any document says. */
|
|
475
|
+
name: string
|
|
476
|
+
/** Per-project grants. `ADMIN` is what a control plane needs on a project it deploys into. */
|
|
477
|
+
projects: readonly { projectId: string; roleCode: ZeropsRoleCode }[]
|
|
478
|
+
/** The client-wide role. Defaults to `NO_ACCESS`, which is the entire point of a scoped token. */
|
|
479
|
+
roleCode?: ZeropsRoleCode
|
|
480
|
+
signal: AbortSignal
|
|
481
|
+
}): Promise<ZeropsIntegrationToken>
|
|
482
|
+
|
|
357
483
|
// ── logs ──────────────────────────────────────────────────────────────────────
|
|
358
484
|
|
|
359
485
|
/** A short-lived grant for the project's log service. VERIFIED: `GET /project/{id}/log`. */
|
|
@@ -556,6 +682,22 @@ const readProject = (value: unknown): ZeropsProject => ({
|
|
|
556
682
|
...(stringList(value, 'tagList') !== undefined ? { tagList: stringList(value, 'tagList') } : {}),
|
|
557
683
|
})
|
|
558
684
|
|
|
685
|
+
const ROLE_CODES: readonly ZeropsRoleCode[] = ['OWNER', 'ADMIN', 'BASIC_USER', 'READ_ONLY', 'NO_ACCESS']
|
|
686
|
+
|
|
687
|
+
/** `undefined` means Zerops sent a role this build does not know — reported as absent, never as a guess. */
|
|
688
|
+
const asRoleCode = (value: unknown): ZeropsRoleCode | undefined => ROLE_CODES.find((role) => role === value)
|
|
689
|
+
|
|
690
|
+
const readIntegrationToken = (value: unknown): ZeropsIntegrationToken => ({
|
|
691
|
+
id: str(value, 'id') ?? '',
|
|
692
|
+
name: str(value, 'name') ?? '',
|
|
693
|
+
token: str(value, 'token') ?? '',
|
|
694
|
+
projects: arr(value, 'projects').map((entry) => ({
|
|
695
|
+
projectId: str(entry, 'projectId') ?? '',
|
|
696
|
+
roleCode: asRoleCode(prop(entry, 'roleCode')),
|
|
697
|
+
})),
|
|
698
|
+
roleCode: asRoleCode(prop(value, 'roleCode')),
|
|
699
|
+
})
|
|
700
|
+
|
|
559
701
|
const readServiceEnv = (value: unknown): ZeropsServiceEnv => ({
|
|
560
702
|
id: str(value, 'id') ?? '',
|
|
561
703
|
key: str(value, 'key') ?? '',
|
|
@@ -719,6 +861,8 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
719
861
|
await request('cancel-build', 'PUT', `/app-version/${appVersionId}/cancel-build`, { signal })
|
|
720
862
|
},
|
|
721
863
|
|
|
864
|
+
getProcess: async ({ processId, signal }) => readProcess(await request('get process', 'GET', `/process/${processId}`, { signal })),
|
|
865
|
+
|
|
722
866
|
enableSubdomainAccess: async ({ serviceId, signal }) => {
|
|
723
867
|
// No body: the operation takes none, and sending one would add a content-type the platform did not ask for.
|
|
724
868
|
await request('enable subdomain access', 'PUT', `/service-stack/${serviceId}/enable-subdomain-access`, { signal })
|
|
@@ -794,6 +938,21 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
794
938
|
getProjectEnv: async ({ projectEnvId, signal }) =>
|
|
795
939
|
readServiceEnv(await request('get project env', 'GET', `/project-env/${projectEnvId}`, { signal })),
|
|
796
940
|
|
|
941
|
+
createIntegrationToken: async ({ clientId, name, projects, roleCode, signal }) =>
|
|
942
|
+
// `redactDetail`: unlike every other write here the SECRET is in the RESPONSE, not the request, so
|
|
943
|
+
// this is the one error envelope that could quote a minted token back at us.
|
|
944
|
+
readIntegrationToken(
|
|
945
|
+
await request('create integration token', 'POST', `/client/${clientId}/integration-token`, {
|
|
946
|
+
body: {
|
|
947
|
+
name,
|
|
948
|
+
roleCode: roleCode ?? 'NO_ACCESS',
|
|
949
|
+
projects: projects.map((grant) => ({ projectId: grant.projectId, roleCode: grant.roleCode })),
|
|
950
|
+
},
|
|
951
|
+
signal,
|
|
952
|
+
redactDetail: true,
|
|
953
|
+
}),
|
|
954
|
+
),
|
|
955
|
+
|
|
797
956
|
getLogAccess: async ({ projectId, signal }) => {
|
|
798
957
|
const payload = await request('get log access', 'GET', `/project/${projectId}/log`, { signal })
|
|
799
958
|
return {
|
|
@@ -823,3 +982,60 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
823
982
|
},
|
|
824
983
|
}
|
|
825
984
|
}
|
|
985
|
+
|
|
986
|
+
// ── waiting on a process ────────────────────────────────────────────────────────
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* How often `waitForProcess` polls. The two process durations anyone has measured are 0.7 s
|
|
990
|
+
* (`stack.enableSubdomainAccess`) and 2.6 s (`stack.updateUserData`), so 5 s — the platform DEPLOY's
|
|
991
|
+
* interval — would spend most of its time asleep on a call that already finished.
|
|
992
|
+
*/
|
|
993
|
+
const PROCESS_POLL_INTERVAL_MS = 2_000
|
|
994
|
+
|
|
995
|
+
/** ~5 minutes. The slow case is an import that creates managed services, not a build; a build is polled through `/app-version`. */
|
|
996
|
+
const PROCESS_POLL_ATTEMPTS = 150
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Poll ONE process to completion.
|
|
1000
|
+
*
|
|
1001
|
+
* The same loop `installation-zerops`'s `deployService` runs over `/app-version`, run over `/process`
|
|
1002
|
+
* instead: read, decide ONLY on a status this build knows to be terminal, sleep, and give up on a deadline
|
|
1003
|
+
* of our own rather than on a coincidence. Returns the finished process so a caller can read
|
|
1004
|
+
* `actionName`/`appVersionId` off it.
|
|
1005
|
+
*
|
|
1006
|
+
* Anything outside `ZEROPS_PROCESS_TERMINAL` means "keep waiting", including a status this build has never
|
|
1007
|
+
* seen — which is why an unpublished vocabulary (see that constant) can cost a timeout and never a false
|
|
1008
|
+
* success. A non-`FINISHED` terminal status throws, naming the status and nothing else.
|
|
1009
|
+
*/
|
|
1010
|
+
export const waitForProcess = async (input: {
|
|
1011
|
+
api: Pick<ZeropsApi, 'getProcess'>
|
|
1012
|
+
processId: string
|
|
1013
|
+
sleep: Sleeper
|
|
1014
|
+
signal: AbortSignal
|
|
1015
|
+
/** What to call this process in a failure or timeout message. Defaults to its id. */
|
|
1016
|
+
label?: string
|
|
1017
|
+
intervalMs?: number
|
|
1018
|
+
attempts?: number
|
|
1019
|
+
}): Promise<ZeropsProcess> => {
|
|
1020
|
+
const label = input.label ?? input.processId
|
|
1021
|
+
const intervalMs = input.intervalMs ?? PROCESS_POLL_INTERVAL_MS
|
|
1022
|
+
const attempts = input.attempts ?? PROCESS_POLL_ATTEMPTS
|
|
1023
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1024
|
+
// A `Sleeper` resolves early on abort, so without this the loop would spin out its whole budget
|
|
1025
|
+
// against a cancelled run.
|
|
1026
|
+
if (input.signal.aborted) {
|
|
1027
|
+
throw new Error(`zerops: waiting for ${label} was cancelled`)
|
|
1028
|
+
}
|
|
1029
|
+
const process = await input.api.getProcess({ processId: input.processId, signal: input.signal })
|
|
1030
|
+
if (process.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status)) {
|
|
1031
|
+
if (process.status !== ZEROPS_PROCESS_FINISHED) {
|
|
1032
|
+
throw new Error(`zerops: ${label} finished as ${process.status}`)
|
|
1033
|
+
}
|
|
1034
|
+
return process
|
|
1035
|
+
}
|
|
1036
|
+
if (attempt >= attempts) {
|
|
1037
|
+
throw new Error(`zerops: ${label} did not finish in time`)
|
|
1038
|
+
}
|
|
1039
|
+
await input.sleep(intervalMs, input.signal)
|
|
1040
|
+
}
|
|
1041
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
asAppVersionStatus,
|
|
3
|
+
createZeropsApi,
|
|
4
|
+
waitForProcess,
|
|
5
|
+
ZEROPS_ACTIVE,
|
|
6
|
+
ZEROPS_API_BASE,
|
|
7
|
+
ZEROPS_PROCESS_FINISHED,
|
|
8
|
+
ZEROPS_PROCESS_TERMINAL,
|
|
9
|
+
ZEROPS_SERVICE_NOT_HTTP,
|
|
10
|
+
ZEROPS_TERMINAL,
|
|
11
|
+
ZeropsApiError,
|
|
12
|
+
} from './api'
|
|
2
13
|
export type {
|
|
3
14
|
FetchLike,
|
|
4
15
|
ZeropsApi,
|
|
@@ -7,15 +18,18 @@ export type {
|
|
|
7
18
|
ZeropsAppVersionStatus,
|
|
8
19
|
ZeropsImportedService,
|
|
9
20
|
ZeropsImportResult,
|
|
21
|
+
ZeropsIntegrationToken,
|
|
10
22
|
ZeropsLogAccess,
|
|
11
23
|
ZeropsLogLine,
|
|
12
24
|
ZeropsProcess,
|
|
13
25
|
ZeropsProject,
|
|
14
26
|
ZeropsProjectMode,
|
|
15
27
|
ZeropsProjectStatus,
|
|
28
|
+
ZeropsRoleCode,
|
|
16
29
|
ZeropsService,
|
|
17
30
|
ZeropsServiceEnv,
|
|
18
31
|
ZeropsServiceStatus,
|
|
32
|
+
ZeropsTokenProjectGrant,
|
|
19
33
|
} from './api'
|
|
20
34
|
export { defineApp, isZeropsAppConfig, useSharedPostgres, zeropsSharedServiceHostname, zeropsSharedServicePrefix } from './authoring'
|
|
21
35
|
export type { ZeropsAuthoredAppConfig } from './authoring'
|