@fabrika/provider-zerops 0.0.26 → 0.3.1

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 (83) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +75 -20
  14. package/src/control.ts +217 -447
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +24 -128
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +208 -315
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1260
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +399 -254
  73. package/src/service-references.ts +28 -0
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
@@ -0,0 +1,135 @@
1
+ import type { JsonValue } from '@fabrika/provider-contract'
2
+ import { isAlias, isMap, isNode, isScalar, isSeq, parseAllDocuments } from 'yaml'
3
+ import { z } from 'zod'
4
+ import { renderYaml } from './compile'
5
+
6
+ export interface ZeropsDescriptor {
7
+ document: { readonly [key: string]: JsonValue }
8
+ setups: readonly string[]
9
+ }
10
+
11
+ export interface PreparedZeropsDescriptor {
12
+ contents: string
13
+ setup: string
14
+ }
15
+
16
+ const invalid = (reason: string): never => {
17
+ throw new Error(`invalid fabrika manifest: zerops.yaml ${reason}`)
18
+ }
19
+
20
+ const descriptorJsonSchema: z.ZodType<JsonValue, z.ZodTypeDef, unknown> = z.lazy(() =>
21
+ z.union([
22
+ z.string(),
23
+ z.number(),
24
+ z.boolean(),
25
+ z.null(),
26
+ z.array(descriptorJsonSchema),
27
+ descriptorMappingSchema,
28
+ ])
29
+ )
30
+ // Zod records drop __proto__; descriptor mappings must preserve every decoded key and its order.
31
+ const descriptorMappingSchema: z.ZodType<{ [key: string]: JsonValue }, z.ZodTypeDef, unknown> = z.preprocess(
32
+ (value) => typeof value === 'object' && value !== null && !Array.isArray(value) ? Object.entries(value) : null,
33
+ z.array(z.tuple([z.string(), descriptorJsonSchema])).transform((entries) => Object.fromEntries(entries)),
34
+ )
35
+ const phaseSchema = z.intersection(descriptorMappingSchema, z.object({ envVariables: z.record(z.string()).optional() }))
36
+ const setupSchema = z.intersection(
37
+ descriptorMappingSchema,
38
+ z.object({
39
+ setup: z.string().refine((value) => value.trim() !== ''),
40
+ build: phaseSchema.optional(),
41
+ run: phaseSchema.optional(),
42
+ }),
43
+ )
44
+ const descriptorDocumentSchema = z.intersection(descriptorMappingSchema, z.object({ zerops: z.array(setupSchema).min(1) }))
45
+
46
+ // Compare decimal significands, not binary floats: parsing must not silently round source digits.
47
+ const decimalIdentity = (source: string): string => {
48
+ const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(source)
49
+ if (match === null) return invalid('contains an unsupported numeric representation')
50
+ const fraction = match[3] ?? ''
51
+ const digits = `${match[2] ?? ''}${fraction}`.replace(/^0+/, '')
52
+ if (digits === '') return '0'
53
+ const significant = digits.replace(/0+$/, '')
54
+ const exponent = BigInt(match[4] ?? '0') - BigInt(fraction.length) + BigInt(digits.length - significant.length)
55
+ return `${match[1] === '-' ? '-' : ''}${significant}e${exponent}`
56
+ }
57
+
58
+ const decodeNode = (node: unknown): JsonValue => {
59
+ if (isAlias(node)) return invalid('must not contain aliases')
60
+ if (!isNode(node)) return invalid('contains an unsupported node')
61
+ if (node.tag !== undefined) return invalid('must not contain explicit tags')
62
+ if ('anchor' in node && node.anchor !== undefined) return invalid('must not contain anchors')
63
+ if (isMap(node)) {
64
+ const entries: Array<[string, JsonValue]> = []
65
+ const keys = new Set<string>()
66
+ for (const pair of node.items) {
67
+ const key = decodeNode(pair.key)
68
+ if (typeof key !== 'string') return invalid('requires string mapping keys')
69
+ if (key === '<<') return invalid('must not contain merge keys')
70
+ if (keys.has(key)) return invalid('must not contain duplicate keys')
71
+ keys.add(key)
72
+ entries.push([key, decodeNode(pair.value)])
73
+ }
74
+ return Object.fromEntries(entries)
75
+ }
76
+ if (isSeq(node)) return node.items.map(decodeNode)
77
+ if (!isScalar(node)) return invalid('contains an unsupported node')
78
+ const value: unknown = node.value
79
+ if (typeof value === 'bigint') {
80
+ const number = Number(value)
81
+ if (!Number.isSafeInteger(number)) return invalid('contains an inexact number')
82
+ return number
83
+ }
84
+ if (typeof value === 'number') {
85
+ if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) return invalid('contains an inexact number')
86
+ if (node.source === undefined || decimalIdentity(node.source) !== decimalIdentity(String(value))) return invalid('contains an inexact number')
87
+ return value
88
+ }
89
+ if (typeof value === 'string' && /\p{Surrogate}/u.test(value)) return invalid('contains an invalid Unicode scalar')
90
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
91
+ return invalid('contains an unsupported scalar')
92
+ }
93
+
94
+ const parseDocument = (contents: string, schema: 'core' | 'yaml-1.1'): JsonValue => {
95
+ const documents = parseAllDocuments(contents, { schema, intAsBigInt: true, merge: false, resolveKnownTags: false, uniqueKeys: true })
96
+ const document = documents[0]
97
+ if (documents.length !== 1 || document === undefined) return invalid('requires exactly one mapping document')
98
+ if (document.errors.length > 0 || document.warnings.length > 0) return invalid('is not parseable YAML without errors or warnings')
99
+ return decodeNode(document.contents)
100
+ }
101
+
102
+ export const parseZeropsDescriptor = (contents: string): ZeropsDescriptor => {
103
+ // Go readers fold or reject these raw characters; escaped string data remains supported.
104
+ if (/[\u007f-\u009f\u2028\u2029\ufffe\uffff]/u.test(contents)) return invalid('contains raw characters unsupported by YAML-1.1 readers')
105
+ const document = parseDocument(contents, 'core')
106
+ const legacy = parseDocument(contents, 'yaml-1.1')
107
+ if (JSON.stringify(document) !== JSON.stringify(legacy)) return invalid('contains ambiguous plain scalars; quote them explicitly')
108
+ const result = descriptorDocumentSchema.safeParse(document)
109
+ if (!result.success) {
110
+ const path = result.error.issues[0]?.path ?? []
111
+ if (path.includes('envVariables')) return invalid('requires envVariables string mappings')
112
+ if (path.includes('build') || path.includes('run')) return invalid('requires build and run mappings')
113
+ if (path.length > 1) return invalid('requires string setup names')
114
+ return invalid(path.length === 0 ? 'requires a mapping document' : 'requires a nonempty zerops sequence')
115
+ }
116
+ const setups: string[] = []
117
+ for (const entry of result.data.zerops) {
118
+ if (setups.includes(entry.setup)) return invalid('must not contain duplicate setup names')
119
+ setups.push(entry.setup)
120
+ }
121
+ return { document: result.data, setups }
122
+ }
123
+
124
+ export const selectZeropsDescriptorSetup = (parsed: ZeropsDescriptor, setup?: string): string => {
125
+ const selected = setup ?? (parsed.setups.length === 1 ? parsed.setups[0] : undefined)
126
+ if (selected === undefined) return invalid('requires an explicit setup selector for multiple setups')
127
+ if (!parsed.setups.includes(selected)) return invalid('does not contain the selected setup')
128
+ return selected
129
+ }
130
+
131
+ export const prepareZeropsDescriptor = (contents: string, setup?: string): PreparedZeropsDescriptor => {
132
+ const parsed = parseZeropsDescriptor(contents)
133
+ const selected = selectZeropsDescriptorSetup(parsed, setup)
134
+ return { contents: renderYaml(parsed.document), setup: selected }
135
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  export {
2
- asAppVersionStatus,
3
2
  createZeropsApi,
4
3
  waitForProcess,
5
4
  ZEROPS_ACTIVE,
@@ -36,7 +35,6 @@ export { defineApp, isZeropsAppConfig, useSharedPostgres, zeropsSharedServiceHos
36
35
  export type { ZeropsAuthoredAppConfig } from './authoring'
37
36
  export { parseZeropsCliArgs } from './cli-args'
38
37
  export type { ZeropsCliArgs } from './cli-args'
39
- export { zeropsTargetCodec } from './codec'
40
38
  export { defaultSleep, defaultZeropsCollaborators } from './collaborators'
41
39
  export type { Sleeper, ZeropsCollaboratorFactory, ZeropsCollaborators } from './collaborators'
42
40
  export {
@@ -49,10 +47,12 @@ export {
49
47
  renderImportYaml,
50
48
  renderYaml,
51
49
  } from './compile'
52
- export type { CompileInput, ZeropsImportDocument } from './compile'
53
- export { createZeropsControlProvider, zeropsStoredTargetCodec } from './control'
54
- export type { ZeropsBeforeDeploy, ZeropsBeforeDeployInput, ZeropsControlProviderOptions, ZeropsProviderExecutor, ZeropsStoredTarget } from './control'
50
+ export type { CompileInput, ZeropsImportDocument, ZeropsJsonImportDocument } from './compile'
51
+ export { createZeropsControlProvider, parseZeropsCheckpoint, zeropsStoredTargetCodec } from './control'
52
+ export type { ZeropsControlProviderOptions, ZeropsStoredTarget } from './control'
53
+ export { type PreparedZeropsDescriptor, prepareZeropsDescriptor } from './descriptor'
55
54
  export {
55
+ assertFabrikaManifestCoordinates,
56
56
  compileFabrikaManifest,
57
57
  createZeropsArtifactSourceDescriptor,
58
58
  FABRIKA_MANIFEST_VERSION,
@@ -69,7 +69,9 @@ export {
69
69
  compileZeropsNamespaceTopology,
70
70
  createZeropsNamespaceCapabilities,
71
71
  createZeropsNamespaceOperator,
72
- ensureNamespaceProxyIam,
72
+ decodeZeropsNamespaceProxySource,
73
+ republishProxy,
74
+ verifyZeropsNamespaceProxySource,
73
75
  ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE,
74
76
  ZEROPS_NAMESPACE_IAM_KEY_VARIABLE,
75
77
  ZEROPS_NAMESPACE_POSTGRES_HOSTNAME,
@@ -79,143 +81,37 @@ export {
79
81
  zeropsNamespaceTargetCodec,
80
82
  } from './namespace'
81
83
  export type {
82
- NamespaceProxyIamInput,
83
84
  ZeropsNamespaceOperatorOptions,
84
85
  ZeropsNamespaceOptions,
85
86
  ZeropsNamespacePlanOptions,
86
87
  ZeropsNamespacePostgres,
87
88
  ZeropsNamespacePresetInput,
89
+ ZeropsNamespaceProxyBuild,
90
+ ZeropsNamespaceProxyDeployment,
91
+ ZeropsNamespaceProxySource,
88
92
  ZeropsNamespacePublicAccess,
89
93
  ZeropsNamespaceTarget,
90
94
  ZeropsNamespaceTopology,
95
+ ZeropsProxyApi,
96
+ ZeropsProxyRepublishInput,
91
97
  } from './namespace'
92
- export { runZeropsNamespaceCommand } from './namespace-command'
93
- export type { ZeropsNamespaceCommandDependencies } from './namespace-command'
98
+ export { postgresSchema as zeropsNamespacePostgresSchema } from './namespace/codec'
94
99
  export { buildZeropsPlan, resolveDeployHostname } from './plan'
95
- export type { ZeropsJobSpec, ZeropsPlan, ZeropsStepKind } from './plan'
96
- export { CANCELLED, createZeropsProvider, interpolateManifest, zeropsProvider } from './provider'
97
- export type { ZeropsProvider } from './provider'
100
+ export type { ZeropsStep, ZeropsStepKind } from './plan'
101
+ export { CANCELLED, createZeropsDeployer, interpolateManifest } from './provider'
102
+ export type { ZeropsDeployResult, ZeropsDeployRun } from './provider'
98
103
  export type * from './schema.generated'
99
- export {
100
- buildZeropsSourceCancelRequest,
101
- buildZeropsSourceCancelResponse,
102
- buildZeropsSourceCredentialActivateRequestV2,
103
- buildZeropsSourceCredentialActivateResponseV2,
104
- buildZeropsSourceCredentialBundleV2,
105
- buildZeropsSourceCredentialStatusRequestV2,
106
- buildZeropsSourceCredentialStatusResponseV2,
107
- buildZeropsSourceErrorEnvelope,
108
- buildZeropsSourceInstallationsVerifyRequest,
109
- buildZeropsSourceInstallationsVerifyResponse,
110
- buildZeropsSourceResolveRequest,
111
- buildZeropsSourceResolveRequestV2,
112
- buildZeropsSourceResolveResponse,
113
- buildZeropsSourceResolveResponseV2,
114
- buildZeropsSourceUploadRequest,
115
- buildZeropsSourceUploadRequestV2,
116
- buildZeropsSourceUploadResponse,
117
- buildZeropsSourceUploadResponseV2,
118
- buildZeropsSourceWebhookConfigureRequest,
119
- buildZeropsSourceWebhookConfigureResponse,
120
- decodeZeropsSourceCancelRequest,
121
- decodeZeropsSourceCancelResponse,
122
- decodeZeropsSourceCredentialActivateRequestV2,
123
- decodeZeropsSourceCredentialActivateResponseV2,
124
- decodeZeropsSourceCredentialBundleV2,
125
- decodeZeropsSourceCredentialStatusRequestV2,
126
- decodeZeropsSourceCredentialStatusResponseV2,
127
- decodeZeropsSourceErrorEnvelope,
128
- decodeZeropsSourceInstallationsVerifyRequest,
129
- decodeZeropsSourceInstallationsVerifyResponse,
130
- decodeZeropsSourceResolveRequest,
131
- decodeZeropsSourceResolveRequestV2,
132
- decodeZeropsSourceResolveResponse,
133
- decodeZeropsSourceResolveResponseV2,
134
- decodeZeropsSourceUploadRequest,
135
- decodeZeropsSourceUploadRequestV2,
136
- decodeZeropsSourceUploadResponse,
137
- decodeZeropsSourceUploadResponseV2,
138
- decodeZeropsSourceWebhookConfigureRequest,
139
- decodeZeropsSourceWebhookConfigureResponse,
140
- normalizeZeropsSourceRepository,
141
- serializeZeropsSourceCredentialBundleV2,
142
- sha256ZeropsSourceCredentialBundleV2,
143
- ZEROPS_SOURCE_CANCEL_PATH,
144
- ZEROPS_SOURCE_CREDENTIAL_ACTIVATE_PATH_V2,
145
- ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2,
146
- ZEROPS_SOURCE_CREDENTIAL_ENV_V2_PREFIX,
147
- ZEROPS_SOURCE_CREDENTIAL_STATUS_PATH_V2,
148
- ZEROPS_SOURCE_ERROR_CODES,
149
- ZEROPS_SOURCE_ERROR_STAGES,
150
- ZEROPS_SOURCE_INSTALLATIONS_VERIFY_PATH,
151
- ZEROPS_SOURCE_PROTOCOL_VERSION,
152
- ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
153
- ZEROPS_SOURCE_RESOLVE_PATH,
154
- ZEROPS_SOURCE_RESOLVE_PATH_V2,
155
- ZEROPS_SOURCE_UPLOAD_PATH,
156
- ZEROPS_SOURCE_UPLOAD_PATH_V2,
157
- ZEROPS_SOURCE_WEBHOOK_CONFIGURE_PATH,
158
- zeropsSourceCredentialEnvV2,
159
- } from './source'
104
+ export { isZeropsUploadDestination, normalizeZeropsSourceRepository } from './source'
105
+ export type { ZeropsSourceRepository } from './source'
160
106
  export type {
161
- ZeropsSourceCancelInput,
162
- ZeropsSourceCancelRequestV1,
163
- ZeropsSourceCancelResponseV1,
164
- ZeropsSourceCancelResult,
165
- ZeropsSourceClient,
166
- ZeropsSourceClientV2,
167
- ZeropsSourceCredentialActivateInput,
168
- ZeropsSourceCredentialActivateRequestV2,
169
- ZeropsSourceCredentialActivateResponseV2,
170
- ZeropsSourceCredentialActiveStatusResponseV2,
171
- ZeropsSourceCredentialAnonymousStatusResponseV2,
172
- ZeropsSourceCredentialBundleV2,
173
- ZeropsSourceCredentialManager,
174
- ZeropsSourceCredentialManagerV2,
175
- ZeropsSourceCredentialStatusInput,
176
- ZeropsSourceCredentialStatusRequestV2,
177
- ZeropsSourceCredentialStatusResponseV2,
178
- ZeropsSourceDescriptor,
179
- ZeropsSourceErrorCode,
180
- ZeropsSourceErrorEnvelope,
181
- ZeropsSourceErrorStage,
182
- ZeropsSourceGitHubAppIdentityV1,
183
- ZeropsSourceInstallationScopeV1,
184
- ZeropsSourceInstallationsVerifyInput,
185
- ZeropsSourceInstallationsVerifyRequestV1,
186
- ZeropsSourceInstallationsVerifyResponseV1,
187
- ZeropsSourceInstallationVerificationV1,
188
- ZeropsSourcePrivateBindingV2,
189
- ZeropsSourceRepository,
107
+ ZeropsSourceArchive,
108
+ ZeropsSourcePort,
109
+ ZeropsSourcePrivateBinding,
110
+ ZeropsSourceResolution,
190
111
  ZeropsSourceResolveInput,
191
- ZeropsSourceResolveInputV2,
192
- ZeropsSourceResolveRequestV1,
193
- ZeropsSourceResolveRequestV2,
194
- ZeropsSourceResolveResponseV1,
195
- ZeropsSourceResolveResponseV2,
196
- ZeropsSourceResolveResult,
197
112
  ZeropsSourceUploadInput,
198
- ZeropsSourceUploadInputV2,
199
- ZeropsSourceUploadRequestV1,
200
- ZeropsSourceUploadRequestV2,
201
- ZeropsSourceUploadResponseV1,
202
- ZeropsSourceUploadResponseV2,
203
113
  ZeropsSourceUploadResult,
204
- ZeropsSourceWebhookConfigureInput,
205
- ZeropsSourceWebhookConfigureRequestV1,
206
- ZeropsSourceWebhookConfigureResponseV1,
207
- } from './source'
208
- export { createZeropsSourceConnectionAdmin, SourceConnectionAdminError } from './source-connection'
209
- export type {
210
- SourceConnectionActivateInput,
211
- SourceConnectionAdmin,
212
- SourceConnectionAdminErrorCode,
213
- SourceConnectionInspection,
214
- SourceConnectionStatus,
215
- SourceConnectionStatusInput,
216
- SourceConnectionZeropsApi,
217
- ZeropsSourceConnectionAdminOptions,
218
- } from './source-connection'
114
+ } from './source/port'
219
115
  export type {
220
116
  ZeropsAppConfig,
221
117
  ZeropsCompilerOwnedProjectField,
@@ -0,0 +1,128 @@
1
+ // The CLI surface of `fabrika platform admin --provider=zerops`, parsed once.
2
+ //
3
+ // The same two rules `install-options.ts` and `deploy-options.ts` state: the CREDENTIAL comes from the
4
+ // environment and only from the environment (there is no `--key`, and an unknown flag is an error, so
5
+ // one cannot arrive on a command line), and everything else may come from a flag OR the environment.
6
+ //
7
+ // The command is unattended by construction — it is additive and idempotent, so it confirms nothing
8
+ // and prompts for nothing — and a missing required value is an error naming both the flag and the
9
+ // variable that would supply it.
10
+
11
+ import { parsePlatformHostname, parsePlatformScheme } from './placement-options'
12
+
13
+ /** Every input `runPlatformAdmin` takes from the operator. */
14
+ export interface PlatformAdminInput {
15
+ /** The mailbox that identifies the first administrator. */
16
+ readonly email: string
17
+ /** IAM's public hostname — the address the proxy fronts, as `platform deploy` names it. */
18
+ readonly iamHost: string
19
+ /** The scheme a browser speaks to this installation. With `iamHost` it composes IAM's public origin. */
20
+ readonly scheme: 'http' | 'https'
21
+ /** The `px_` provisioning key `platform install` printed. Environment only. */
22
+ readonly provisioningKey: string
23
+ /** Replace an outstanding enrollment instead of reporting it. */
24
+ readonly reissue: boolean
25
+ }
26
+
27
+ const FLAGS = ['--email', '--iam-host', '--scheme'] as const
28
+
29
+ /**
30
+ * The one way a second enrollment URL is ever issued.
31
+ *
32
+ * Printing the URL once is the property a re-run has to keep, so an outstanding enrollment is
33
+ * REPORTED rather than replaced. An enrollment that expired unopened would otherwise be a dead end —
34
+ * the only other way to issue one is a console nobody can sign in to yet — so the way out is a flag,
35
+ * which makes replacing the outstanding one an explicit choice.
36
+ */
37
+ export const REISSUE_FLAG = '--reissue'
38
+
39
+ const readFlag = (argv: readonly string[], name: string): string | undefined => {
40
+ const prefix = `${name}=`
41
+ const matches = argv.filter((arg) => arg.startsWith(prefix))
42
+ if (matches.length > 1) {
43
+ throw new Error(`${name} was given more than once`)
44
+ }
45
+ const value = matches[0]?.slice(prefix.length)
46
+ return value === undefined || value.trim() === '' ? undefined : value.trim()
47
+ }
48
+
49
+ const readEnv = (env: Record<string, string | undefined>, name: string): string | undefined => {
50
+ const value = env[name]
51
+ return value === undefined || value.trim() === '' ? undefined : value.trim()
52
+ }
53
+
54
+ const setting = (
55
+ argv: readonly string[],
56
+ env: Record<string, string | undefined>,
57
+ flag: string,
58
+ variable: string,
59
+ ): string | undefined => readFlag(argv, flag) ?? readEnv(env, variable)
60
+
61
+ const required = (value: string | undefined, flag: string, variable: string): string => {
62
+ if (value === undefined) {
63
+ throw new Error(`${flag}=<value> or ${variable} is required`)
64
+ }
65
+ return value
66
+ }
67
+
68
+ const assertKnownArguments = (argv: readonly string[]): void => {
69
+ for (const arg of argv) {
70
+ if (arg === REISSUE_FLAG) {
71
+ continue
72
+ }
73
+ const name = arg.split('=')[0] ?? arg
74
+ if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
75
+ throw new Error(
76
+ `unexpected argument \`${arg}\`. Credentials are read from the environment and have no flag; run `
77
+ + '`fabrika platform admin --provider=zerops --help` for the surface',
78
+ )
79
+ }
80
+ }
81
+ }
82
+
83
+ const readScheme = (argv: readonly string[], env: Record<string, string | undefined>): 'http' | 'https' => {
84
+ const raw = setting(argv, env, '--scheme', 'FABRIKA_PLATFORM_SCHEME')
85
+ return parsePlatformScheme(raw)
86
+ }
87
+
88
+ /**
89
+ * IAM's public hostname, checked to be one.
90
+ *
91
+ * A BARE hostname, the same coordinate `platform deploy` takes under the same name: the origin is
92
+ * composed from it and `--scheme`, so a value carrying either would compose a second one.
93
+ */
94
+ const readIamHost = (argv: readonly string[], env: Record<string, string | undefined>): string => {
95
+ const host = required(setting(argv, env, '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST'), '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST')
96
+ return parsePlatformHostname(host)
97
+ }
98
+
99
+ /**
100
+ * The credential, and the one message that says why there is no flag for it.
101
+ *
102
+ * `required` would name a flag beside the variable, and there is none — a key on a command line lands
103
+ * in a CI log, a process listing and the shell history of whoever reproduced the run.
104
+ */
105
+ const provisioningKey = (env: Record<string, string | undefined>): string => {
106
+ const value = readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY')
107
+ if (value === undefined) {
108
+ throw new Error(
109
+ 'FABRIKA_IAM_PROVISIONING_KEY is required. It has no flag: the key platform install printed comes from the environment only.',
110
+ )
111
+ }
112
+ return value
113
+ }
114
+
115
+ /** Parse the command's arguments and environment into the one input `runPlatformAdmin` takes. */
116
+ export const parsePlatformAdminArgs = (
117
+ argv: readonly string[],
118
+ env: Record<string, string | undefined>,
119
+ ): PlatformAdminInput => {
120
+ assertKnownArguments(argv)
121
+ return {
122
+ email: required(setting(argv, env, '--email', 'FABRIKA_PLATFORM_ADMIN_EMAIL'), '--email', 'FABRIKA_PLATFORM_ADMIN_EMAIL'),
123
+ iamHost: readIamHost(argv, env),
124
+ scheme: readScheme(argv, env),
125
+ provisioningKey: provisioningKey(env),
126
+ reissue: argv.includes(REISSUE_FLAG),
127
+ }
128
+ }
@@ -0,0 +1,123 @@
1
+ // `fabrika platform admin --provider=zerops` — admit the first administrator to a running installation.
2
+ //
3
+ // `platform install` ends with a provisioning key and nobody who can sign in, and until this command
4
+ // existed the four `/admin/rpc` calls that fix that lived in a throwaway script. The mechanics are
5
+ // provider-neutral and live in `@fabrika/installation-init`; what belongs HERE is the Zerops-shaped
6
+ // half — where this installation's IAM answers, and which credential reaches it.
7
+ //
8
+ // It confirms nothing and prompts for nothing. Every step is additive and idempotent, so there is
9
+ // nothing for an operator to agree to, and `install`'s stdin rule (a non-terminal stdin is refused
10
+ // without `--yes`) has nothing to guard here.
11
+
12
+ import {
13
+ action as consoleAction,
14
+ createIamAdminClient,
15
+ ensureFirstAdministrator,
16
+ type FirstAdministratorApi,
17
+ type FirstAdministratorResult,
18
+ } from '@fabrika/installation-init'
19
+ import type { PlatformAdminInput } from './admin-options'
20
+ import { REISSUE_FLAG } from './admin-options'
21
+ import { platformOrigin } from './hosts'
22
+ import { MACHINE_KEY_NEXT_STEP_TITLE, machineKeyNextStepLines } from './machine-key'
23
+
24
+ /**
25
+ * Where this command's progress goes.
26
+ *
27
+ * The same rule the rest of this package's logging states: no helper here takes a secret value. The
28
+ * enrollment URL is a credential in transit and never travels through the log — it goes to `print`,
29
+ * which is stdout and nothing else.
30
+ */
31
+ export interface AdminLog {
32
+ step(title: string): void
33
+ info(message: string): void
34
+ warn(message: string): void
35
+ ok(message: string): void
36
+ }
37
+
38
+ /** `AdminLog` plus the boxed hand-off the closing block uses — composed in below, not asked of the caller. */
39
+ export interface AdminReportLog extends AdminLog {
40
+ action(title: string, lines: readonly string[]): void
41
+ }
42
+
43
+ export interface PlatformAdminCollaborators {
44
+ readonly client: FirstAdministratorApi
45
+ readonly log: AdminReportLog
46
+ /** stdout, for the command's one piece of DATA: the enrollment URL, on a line of its own. */
47
+ readonly print: (line: string) => void
48
+ }
49
+
50
+ /** The real collaborators: the typed admin client for this installation's public IAM, and the console. */
51
+ export const consoleAdminCollaborators = (input: PlatformAdminInput, log: AdminLog): PlatformAdminCollaborators => ({
52
+ client: createIamAdminClient({ origin: platformOrigin(input.scheme, input.iamHost), provisioningKey: input.provisioningKey }),
53
+ log: { ...log, action: (title, lines) => consoleAction(title, [...lines]) },
54
+ print: (line) => console.info(line),
55
+ })
56
+
57
+ /**
58
+ * Admit the first administrator, and report exactly what was found rather than what was assumed.
59
+ *
60
+ * Nothing is printed before the whole sequence has succeeded except its progress: a failure — an
61
+ * unreachable IAM, a refused key, a 4xx — throws, and the CLI exits non-zero with that one message.
62
+ * No partial result and, above all, no URL.
63
+ */
64
+ export const runPlatformAdmin = async (input: PlatformAdminInput, collaborators: PlatformAdminCollaborators): Promise<void> => {
65
+ const { client, log, print } = collaborators
66
+ const origin = platformOrigin(input.scheme, input.iamHost)
67
+
68
+ log.step('Admit the first administrator')
69
+ log.info(`${origin} · ${input.email}`)
70
+ if (input.scheme === 'http' && !isLoopback(input.iamHost)) {
71
+ // Two credentials cross this connection: the provisioning key going out, the enrollment URL
72
+ // coming back. On a loopback address that is a local composition; anywhere else it is a mistake.
73
+ log.warn('this speaks plain http to a host that is not loopback — the provisioning key and the enrollment URL both cross it in the clear')
74
+ }
75
+
76
+ const result = await ensureFirstAdministrator(client, { email: input.email, reissueEnrollment: input.reissue })
77
+ report(result, log)
78
+ announceEnrollment(result, log, print)
79
+ // The next command an operator runs needs an origin this one already holds and two keys it must not
80
+ // print. Naming both places is the difference between one `key issue` and a guessed hostname.
81
+ log.action(MACHINE_KEY_NEXT_STEP_TITLE, machineKeyNextStepLines({ iamOrigin: origin }))
82
+ }
83
+
84
+ const report = (result: FirstAdministratorResult, log: AdminLog): void => {
85
+ log.ok(
86
+ result.principal === 'invited'
87
+ ? `invited ${result.email} as principal ${result.principalId}`
88
+ : `${result.email} already exists as principal ${result.principalId}`,
89
+ )
90
+ // `app: null` is the whole point: an `admin` grant scoped to the console's own app id leaves
91
+ // Delivery and Operations working while the Access plane refuses.
92
+ log.ok(result.grant === 'created' ? 'granted the cross-app `admin` role' : 'the cross-app `admin` role was already granted')
93
+ }
94
+
95
+ /**
96
+ * The one place the enrollment URL is ever written, and it is written once.
97
+ *
98
+ * A URL that sets a password is a credential: it goes to stdout on a line of its own, is stored
99
+ * nowhere, and is not repeated. The three other outcomes carry no URL at all and say what was found.
100
+ */
101
+ const announceEnrollment = (result: FirstAdministratorResult, log: AdminLog, print: (line: string) => void): void => {
102
+ if (result.enrollment.state === 'already-set') {
103
+ log.ok('a password is already set for this principal — no enrollment was issued')
104
+ log.info('Sign in at the console. A forgotten password is a reset, which the Access plane issues to a signed-in administrator.')
105
+ return
106
+ }
107
+ if (result.enrollment.state === 'outstanding') {
108
+ log.warn(`an enrollment is already outstanding — a second one was NOT issued. Pass ${REISSUE_FLAG} to replace it if the first expired unopened`)
109
+ return
110
+ }
111
+ if (result.enrollment.state === 'emailed') {
112
+ log.ok(`the enrollment was emailed to ${result.enrollment.email}, valid until ${expiry(result.enrollment.expiresAt)}`)
113
+ return
114
+ }
115
+ log.ok(`enrollment issued, valid until ${expiry(result.enrollment.expiresAt)}`)
116
+ log.info('The URL below sets the first password. It is a credential: it is printed once, stored nowhere, and it expires.')
117
+ print(result.enrollment.url)
118
+ }
119
+
120
+ const expiry = (unixSeconds: number): string => new Date(unixSeconds * 1000).toISOString()
121
+
122
+ /** Where `http` is a local composition rather than a credential crossing the open network. */
123
+ const isLoopback = (host: string): boolean => host === 'localhost' || host.endsWith('.localhost') || host === '127.0.0.1' || host === '::1'