@fabrika/provider-zerops 0.0.2 → 0.0.4

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 (3) hide show
  1. package/package.json +5 -5
  2. package/src/api.ts +217 -1
  3. 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.2",
3
+ "version": "0.0.4",
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.2",
36
- "@fabrika/auth-core": "^0.0.2",
37
- "@fabrika/provider-contract": "^0.0.2",
38
- "@fabrika/proxy-contract": "^0.0.2"
35
+ "@fabrika/auth": "^0.0.4",
36
+ "@fabrika/auth-core": "^0.0.4",
37
+ "@fabrika/provider-contract": "^0.0.4",
38
+ "@fabrika/proxy-contract": "^0.0.4"
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 { asAppVersionStatus, createZeropsApi, ZEROPS_ACTIVE, ZEROPS_API_BASE, ZEROPS_SERVICE_NOT_HTTP, ZEROPS_TERMINAL, ZeropsApiError } from './api'
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'