@fabrika/provider-zerops 0.0.22 → 0.0.24
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 +6 -2
- package/src/control.ts +3 -6
- package/src/index.ts +1 -37
- package/src/namespace.ts +126 -33
- package/src/provider.ts +4 -4
- package/src/source-connection.ts +17 -240
- package/src/source.ts +5 -255
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
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.24",
|
|
36
|
+
"@fabrika/auth-core": "^0.0.24",
|
|
37
|
+
"@fabrika/provider-contract": "^0.0.24",
|
|
38
|
+
"@fabrika/proxy-contract": "^0.0.24"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/api.ts
CHANGED
|
@@ -784,6 +784,9 @@ export class ZeropsApiError extends Error {
|
|
|
784
784
|
/** The platform's code for "this service stack already has a variable under that key". */
|
|
785
785
|
const USER_DATA_DUPLICATE_KEY = 'userDataDuplicateKey'
|
|
786
786
|
|
|
787
|
+
/** The platform's code for a service stack it cannot find — sent with a 400, not a 404. */
|
|
788
|
+
const ZEROPS_SERVICE_STACK_NOT_FOUND = 'serviceStackNotFound'
|
|
789
|
+
|
|
787
790
|
/**
|
|
788
791
|
* The platform's code for "this service publishes no HTTP port", the answer `enableSubdomainAccess` gives
|
|
789
792
|
* before a service's first deploy. Exported so a caller can say what is missing instead of relaying prose.
|
|
@@ -1124,8 +1127,9 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1124
1127
|
try {
|
|
1125
1128
|
return readService(await request('find service', 'GET', `/service-stack-by-name/${projectId}/${encodeURIComponent(hostname)}`, { signal }))
|
|
1126
1129
|
} catch (error) {
|
|
1127
|
-
// A missing service is an answer, not a failure; anything else propagates.
|
|
1128
|
-
|
|
1130
|
+
// A missing service is an answer, not a failure; anything else propagates. The live platform
|
|
1131
|
+
// says it as 400 `serviceStackNotFound` (docs/reference/zerops-platform.md), not as a 404.
|
|
1132
|
+
if (error instanceof ZeropsApiError && (error.status === 404 || error.code === ZEROPS_SERVICE_STACK_NOT_FOUND)) {
|
|
1129
1133
|
return null
|
|
1130
1134
|
}
|
|
1131
1135
|
throw error
|
package/src/control.ts
CHANGED
|
@@ -418,18 +418,15 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
418
418
|
signal: input.signal,
|
|
419
419
|
}
|
|
420
420
|
let result: ZeropsSourceResolveResult
|
|
421
|
-
if (binding
|
|
421
|
+
if (binding === undefined) {
|
|
422
|
+
result = await options.source.resolve(base)
|
|
423
|
+
} else {
|
|
422
424
|
const resolveV2 = options.source.resolveV2
|
|
423
425
|
if (resolveV2 === undefined) throw new Error('Zerops keyed source transport is unavailable')
|
|
424
426
|
result = await resolveV2.call(options.source, {
|
|
425
427
|
...base,
|
|
426
428
|
privateBinding: { connectionId: binding.connectionId, installationId: binding.installationId },
|
|
427
429
|
})
|
|
428
|
-
} else {
|
|
429
|
-
result = await options.source.resolve({
|
|
430
|
-
...base,
|
|
431
|
-
...(binding === undefined ? {} : { githubInstallationId: binding.installationId }),
|
|
432
|
-
})
|
|
433
430
|
}
|
|
434
431
|
if (
|
|
435
432
|
result.runId !== input.runId
|
package/src/index.ts
CHANGED
|
@@ -99,21 +99,14 @@ export type * from './schema.generated'
|
|
|
99
99
|
export {
|
|
100
100
|
buildZeropsSourceCancelRequest,
|
|
101
101
|
buildZeropsSourceCancelResponse,
|
|
102
|
-
buildZeropsSourceCredentialActivateRequest,
|
|
103
102
|
buildZeropsSourceCredentialActivateRequestV2,
|
|
104
|
-
buildZeropsSourceCredentialActivateResponse,
|
|
105
103
|
buildZeropsSourceCredentialActivateResponseV2,
|
|
106
|
-
buildZeropsSourceCredentialBundle,
|
|
107
104
|
buildZeropsSourceCredentialBundleV2,
|
|
108
|
-
buildZeropsSourceCredentialStatusRequest,
|
|
109
105
|
buildZeropsSourceCredentialStatusRequestV2,
|
|
110
|
-
buildZeropsSourceCredentialStatusResponse,
|
|
111
106
|
buildZeropsSourceCredentialStatusResponseV2,
|
|
112
107
|
buildZeropsSourceErrorEnvelope,
|
|
113
108
|
buildZeropsSourceInstallationsVerifyRequest,
|
|
114
109
|
buildZeropsSourceInstallationsVerifyResponse,
|
|
115
|
-
buildZeropsSourceResolveInstallationRequest,
|
|
116
|
-
buildZeropsSourceResolveInstallationResponse,
|
|
117
110
|
buildZeropsSourceResolveRequest,
|
|
118
111
|
buildZeropsSourceResolveRequestV2,
|
|
119
112
|
buildZeropsSourceResolveResponse,
|
|
@@ -126,21 +119,14 @@ export {
|
|
|
126
119
|
buildZeropsSourceWebhookConfigureResponse,
|
|
127
120
|
decodeZeropsSourceCancelRequest,
|
|
128
121
|
decodeZeropsSourceCancelResponse,
|
|
129
|
-
decodeZeropsSourceCredentialActivateRequest,
|
|
130
122
|
decodeZeropsSourceCredentialActivateRequestV2,
|
|
131
|
-
decodeZeropsSourceCredentialActivateResponse,
|
|
132
123
|
decodeZeropsSourceCredentialActivateResponseV2,
|
|
133
|
-
decodeZeropsSourceCredentialBundle,
|
|
134
124
|
decodeZeropsSourceCredentialBundleV2,
|
|
135
|
-
decodeZeropsSourceCredentialStatusRequest,
|
|
136
125
|
decodeZeropsSourceCredentialStatusRequestV2,
|
|
137
|
-
decodeZeropsSourceCredentialStatusResponse,
|
|
138
126
|
decodeZeropsSourceCredentialStatusResponseV2,
|
|
139
127
|
decodeZeropsSourceErrorEnvelope,
|
|
140
128
|
decodeZeropsSourceInstallationsVerifyRequest,
|
|
141
129
|
decodeZeropsSourceInstallationsVerifyResponse,
|
|
142
|
-
decodeZeropsSourceResolveInstallationRequest,
|
|
143
|
-
decodeZeropsSourceResolveInstallationResponse,
|
|
144
130
|
decodeZeropsSourceResolveRequest,
|
|
145
131
|
decodeZeropsSourceResolveRequestV2,
|
|
146
132
|
decodeZeropsSourceResolveResponse,
|
|
@@ -152,24 +138,18 @@ export {
|
|
|
152
138
|
decodeZeropsSourceWebhookConfigureRequest,
|
|
153
139
|
decodeZeropsSourceWebhookConfigureResponse,
|
|
154
140
|
normalizeZeropsSourceRepository,
|
|
155
|
-
serializeZeropsSourceCredentialBundle,
|
|
156
141
|
serializeZeropsSourceCredentialBundleV2,
|
|
157
|
-
sha256ZeropsSourceCredentialBundle,
|
|
158
142
|
sha256ZeropsSourceCredentialBundleV2,
|
|
159
143
|
ZEROPS_SOURCE_CANCEL_PATH,
|
|
160
|
-
ZEROPS_SOURCE_CREDENTIAL_ACTIVATE_PATH,
|
|
161
144
|
ZEROPS_SOURCE_CREDENTIAL_ACTIVATE_PATH_V2,
|
|
162
|
-
ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION,
|
|
163
145
|
ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2,
|
|
164
146
|
ZEROPS_SOURCE_CREDENTIAL_ENV_V2_PREFIX,
|
|
165
|
-
ZEROPS_SOURCE_CREDENTIAL_STATUS_PATH,
|
|
166
147
|
ZEROPS_SOURCE_CREDENTIAL_STATUS_PATH_V2,
|
|
167
148
|
ZEROPS_SOURCE_ERROR_CODES,
|
|
168
149
|
ZEROPS_SOURCE_ERROR_STAGES,
|
|
169
150
|
ZEROPS_SOURCE_INSTALLATIONS_VERIFY_PATH,
|
|
170
151
|
ZEROPS_SOURCE_PROTOCOL_VERSION,
|
|
171
152
|
ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
|
|
172
|
-
ZEROPS_SOURCE_RESOLVE_INSTALLATION_PATH,
|
|
173
153
|
ZEROPS_SOURCE_RESOLVE_PATH,
|
|
174
154
|
ZEROPS_SOURCE_RESOLVE_PATH_V2,
|
|
175
155
|
ZEROPS_SOURCE_UPLOAD_PATH,
|
|
@@ -185,22 +165,15 @@ export type {
|
|
|
185
165
|
ZeropsSourceClient,
|
|
186
166
|
ZeropsSourceClientV2,
|
|
187
167
|
ZeropsSourceCredentialActivateInput,
|
|
188
|
-
ZeropsSourceCredentialActivateRequestV1,
|
|
189
168
|
ZeropsSourceCredentialActivateRequestV2,
|
|
190
|
-
ZeropsSourceCredentialActivateResponseV1,
|
|
191
169
|
ZeropsSourceCredentialActivateResponseV2,
|
|
192
|
-
ZeropsSourceCredentialActiveStatusResponseV1,
|
|
193
170
|
ZeropsSourceCredentialActiveStatusResponseV2,
|
|
194
|
-
ZeropsSourceCredentialAnonymousStatusResponseV1,
|
|
195
171
|
ZeropsSourceCredentialAnonymousStatusResponseV2,
|
|
196
|
-
ZeropsSourceCredentialBundleV1,
|
|
197
172
|
ZeropsSourceCredentialBundleV2,
|
|
198
173
|
ZeropsSourceCredentialManager,
|
|
199
174
|
ZeropsSourceCredentialManagerV2,
|
|
200
175
|
ZeropsSourceCredentialStatusInput,
|
|
201
|
-
ZeropsSourceCredentialStatusRequestV1,
|
|
202
176
|
ZeropsSourceCredentialStatusRequestV2,
|
|
203
|
-
ZeropsSourceCredentialStatusResponseV1,
|
|
204
177
|
ZeropsSourceCredentialStatusResponseV2,
|
|
205
178
|
ZeropsSourceDescriptor,
|
|
206
179
|
ZeropsSourceErrorCode,
|
|
@@ -216,8 +189,6 @@ export type {
|
|
|
216
189
|
ZeropsSourceRepository,
|
|
217
190
|
ZeropsSourceResolveInput,
|
|
218
191
|
ZeropsSourceResolveInputV2,
|
|
219
|
-
ZeropsSourceResolveInstallationRequestV1,
|
|
220
|
-
ZeropsSourceResolveInstallationResponseV1,
|
|
221
192
|
ZeropsSourceResolveRequestV1,
|
|
222
193
|
ZeropsSourceResolveRequestV2,
|
|
223
194
|
ZeropsSourceResolveResponseV1,
|
|
@@ -234,18 +205,11 @@ export type {
|
|
|
234
205
|
ZeropsSourceWebhookConfigureRequestV1,
|
|
235
206
|
ZeropsSourceWebhookConfigureResponseV1,
|
|
236
207
|
} from './source'
|
|
237
|
-
export {
|
|
238
|
-
createZeropsSourceConnectionAdmin,
|
|
239
|
-
SourceConnectionAdminError,
|
|
240
|
-
ZEROPS_SOURCE_CREDENTIAL_ENV,
|
|
241
|
-
ZEROPS_SOURCE_LEGACY_APP_ID_ENV,
|
|
242
|
-
ZEROPS_SOURCE_LEGACY_PRIVATE_KEY_ENV,
|
|
243
|
-
} from './source-connection'
|
|
208
|
+
export { createZeropsSourceConnectionAdmin, SourceConnectionAdminError } from './source-connection'
|
|
244
209
|
export type {
|
|
245
210
|
SourceConnectionActivateInput,
|
|
246
211
|
SourceConnectionAdmin,
|
|
247
212
|
SourceConnectionAdminErrorCode,
|
|
248
|
-
SourceConnectionAdoptExistingInput,
|
|
249
213
|
SourceConnectionInspection,
|
|
250
214
|
SourceConnectionStatus,
|
|
251
215
|
SourceConnectionStatusInput,
|
package/src/namespace.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import
|
|
2
|
-
JsonValue,
|
|
3
|
-
ProviderCodec,
|
|
4
|
-
ProviderDeploymentNamespace,
|
|
5
|
-
ProviderEnvelope,
|
|
6
|
-
ProviderNamespaceCapabilities,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import {
|
|
2
|
+
type JsonValue,
|
|
3
|
+
type ProviderCodec,
|
|
4
|
+
type ProviderDeploymentNamespace,
|
|
5
|
+
type ProviderEnvelope,
|
|
6
|
+
type ProviderNamespaceCapabilities,
|
|
7
|
+
ProviderNamespaceError,
|
|
8
|
+
type ProviderNamespaceFact,
|
|
9
|
+
type ProviderNamespaceMutationInput,
|
|
10
|
+
type ProviderNamespaceOperator,
|
|
11
|
+
type ProviderNamespacePlanInput,
|
|
12
|
+
type ProviderNamespacePresentation,
|
|
13
|
+
type ProviderNamespacePreset,
|
|
14
|
+
type ProviderRegistration,
|
|
13
15
|
} from '@fabrika/provider-contract'
|
|
14
16
|
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-contract'
|
|
15
17
|
import {
|
|
@@ -343,25 +345,36 @@ const registrationResourceClaims = (registration: ProviderRegistration): string[
|
|
|
343
345
|
}
|
|
344
346
|
|
|
345
347
|
/**
|
|
346
|
-
* The namespace-owned PostgreSQL service,
|
|
348
|
+
* The namespace-owned PostgreSQL service: the cheapest shape that runs, on EVERY environment.
|
|
349
|
+
*
|
|
350
|
+
* A profile sets the FLOOR and the tuning preset, never the cap — `oltp-hobby` and `oltp-production`
|
|
351
|
+
* share the same 8-core / 48 GB / 250 GB ceiling and the same vertical autoscaler, so the production
|
|
352
|
+
* preset does not buy headroom, it buys an idle bill. `oltp-hobby` starts at one SHARED core and
|
|
353
|
+
* 0.25 GB and grows under load; the shape is proven by a live app with dozens of tables.
|
|
347
354
|
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
355
|
+
* Keying this off `env === 'prod'` is what this default used to do, and it was wrong: most apps are
|
|
356
|
+
* small on every environment they have, and a name is not a size. HA and a bigger floor stay
|
|
357
|
+
* available and become a DELIBERATE act — `--postgres-type` and `--postgres-profile` on the namespace
|
|
358
|
+
* command — because the failure mode of over-provisioning is silent and monthly, while the failure
|
|
359
|
+
* mode of under-provisioning is visible and fixable at any time.
|
|
360
|
+
*
|
|
361
|
+
* Omitting `profile` would not be neutral: an HA service silently gets `oltp-production` (two
|
|
362
|
+
* DEDICATED cores and 4 GB per container, three containers) and a single one `oltp-staging`. Both
|
|
363
|
+
* read back off a live service.
|
|
353
364
|
*/
|
|
354
|
-
const defaultPostgres = (
|
|
355
|
-
env === 'prod' ? { type: 'postgresql:ha@18', profile: 'oltp-production' } : { type: 'postgresql:single@18', profile: 'oltp-hobby' }
|
|
365
|
+
const defaultPostgres = (): ZeropsNamespacePostgres => ({ type: 'postgresql:single@18', profile: 'oltp-hobby' })
|
|
356
366
|
|
|
357
367
|
export const zeropsNamespacePreset = (input: ZeropsNamespacePresetInput): ZeropsNamespaceTarget => ({
|
|
358
368
|
projectName: input.projectName,
|
|
359
|
-
|
|
369
|
+
// `LIGHT` on every environment. An APP namespace is not the platform project — the argument for
|
|
370
|
+
// `SERIOUS` in `installation-zerops` is that the platform repairs the apps project, which no app
|
|
371
|
+
// namespace does. `corePackage` is upgrade-only, so this stays the caller's explicit choice.
|
|
372
|
+
corePackage: input.corePackage ?? 'LIGHT',
|
|
360
373
|
publicAccess: input.publicAccess ?? 'custom-domain',
|
|
361
374
|
proxyBuildFromGit: input.proxyBuildFromGit,
|
|
362
375
|
managed: true,
|
|
363
376
|
ready: false,
|
|
364
|
-
...(input.preset === 'cheap' ? { postgres: input.postgres ?? defaultPostgres(
|
|
377
|
+
...(input.preset === 'cheap' ? { postgres: input.postgres ?? defaultPostgres() } : {}),
|
|
365
378
|
})
|
|
366
379
|
|
|
367
380
|
const projectMarker = (namespace: ProviderDeploymentNamespace): string => `Managed by Fabrika namespace ${namespace.id} (${namespace.env}).`
|
|
@@ -380,7 +393,11 @@ const proxyService = (publicAccess: ZeropsNamespacePublicAccess): ZeropsServiceS
|
|
|
380
393
|
type: PROXY_TYPE,
|
|
381
394
|
priority: 10,
|
|
382
395
|
enableSubdomainAccess: publicAccess === 'zerops-subdomain',
|
|
383
|
-
|
|
396
|
+
// One container is a real availability trade — the proxy is the only enforcement point (ADR-0022),
|
|
397
|
+
// so a restart is a short outage rather than a rolling one — taken because it is a whole core of
|
|
398
|
+
// idle floor on every namespace, and most namespaces hold one small app. `maxContainers` is
|
|
399
|
+
// unchanged, so load still scales out.
|
|
400
|
+
minContainers: 1,
|
|
384
401
|
maxContainers: 6,
|
|
385
402
|
})
|
|
386
403
|
|
|
@@ -481,7 +498,7 @@ const namespacePresentation = (
|
|
|
481
498
|
): ProviderNamespacePresentation => {
|
|
482
499
|
const preset = target.postgres !== undefined ? 'cheap' : namespace.exclusiveAppId === undefined ? 'mid' : 'full'
|
|
483
500
|
const projectName = target.projectName ?? namespace.id
|
|
484
|
-
const corePackage = target.corePackage ??
|
|
501
|
+
const corePackage = target.corePackage ?? 'LIGHT'
|
|
485
502
|
const publicAccess = target.publicAccess ?? 'custom-domain'
|
|
486
503
|
const postgres = target.postgres === undefined
|
|
487
504
|
? 'App-owned database services'
|
|
@@ -494,11 +511,16 @@ const namespacePresentation = (
|
|
|
494
511
|
? []
|
|
495
512
|
: ['Every app consuming the shared PostgreSQL binding shares its physical service and provider-issued credential.']),
|
|
496
513
|
]
|
|
514
|
+
// The project is the one fact that names a LIVE resource: it exists at the provider, it outlives the
|
|
515
|
+
// namespace row, and removal does not delete it (ADR-0034). Marked only once it actually exists.
|
|
516
|
+
const project: ProviderNamespaceFact = target.projectId === undefined
|
|
517
|
+
? { label: 'Project', value: projectName }
|
|
518
|
+
: { label: 'Project', value: `${projectName} (${target.projectId})`, resource: true }
|
|
497
519
|
return {
|
|
498
520
|
preset,
|
|
499
521
|
title: `${ZEROPS_NAMESPACE_PRESETS.find((candidate) => candidate.id === preset)?.label ?? preset} namespace`,
|
|
500
522
|
facts: [
|
|
501
|
-
|
|
523
|
+
project,
|
|
502
524
|
{ label: 'Environment', value: namespace.env },
|
|
503
525
|
{ label: 'Core package', value: corePackage },
|
|
504
526
|
{ label: 'Placement', value: namespace.exclusiveAppId === undefined ? 'Shared' : `Exclusive to ${namespace.exclusiveAppId}` },
|
|
@@ -574,7 +596,7 @@ const normalizeTarget = (
|
|
|
574
596
|
return {
|
|
575
597
|
...decoded,
|
|
576
598
|
projectName: decoded.projectName ?? namespace.id,
|
|
577
|
-
corePackage: decoded.corePackage ??
|
|
599
|
+
corePackage: decoded.corePackage ?? 'LIGHT',
|
|
578
600
|
publicAccess: decoded.publicAccess ?? 'custom-domain',
|
|
579
601
|
proxyBuildFromGit,
|
|
580
602
|
managed: decoded.managed ?? decoded.projectId === undefined,
|
|
@@ -827,8 +849,8 @@ const latestAfterBaseline = async (
|
|
|
827
849
|
return latest
|
|
828
850
|
}
|
|
829
851
|
|
|
830
|
-
const terminalFailure = (version: ZeropsAppVersion):
|
|
831
|
-
|
|
852
|
+
const terminalFailure = (version: ZeropsAppVersion): ProviderNamespaceError =>
|
|
853
|
+
namespaceError(ZEROPS_PROXY_DEPLOY_FAILED, `Zerops namespace proxy deploy ${version.id} finished as ${version.status ?? 'unknown'}`, false)
|
|
832
854
|
|
|
833
855
|
const waitForProxy = async (
|
|
834
856
|
appVersionId: string,
|
|
@@ -838,14 +860,14 @@ const waitForProxy = async (
|
|
|
838
860
|
const deadline = Date.now() + POLL_TIMEOUT_MS
|
|
839
861
|
for (;;) {
|
|
840
862
|
if (signal.aborted) {
|
|
841
|
-
throw
|
|
863
|
+
throw namespaceError(ZEROPS_NAMESPACE_CANCELLED, 'Zerops namespace provisioning was cancelled', true)
|
|
842
864
|
}
|
|
843
865
|
const version = await options.api.getAppVersion({ appVersionId, signal })
|
|
844
866
|
if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
|
|
845
867
|
return version
|
|
846
868
|
}
|
|
847
869
|
if (Date.now() > deadline) {
|
|
848
|
-
throw
|
|
870
|
+
throw namespaceError(ZEROPS_PROXY_DEPLOY_TIMEOUT, `Zerops namespace proxy deploy ${appVersionId} timed out`, true)
|
|
849
871
|
}
|
|
850
872
|
await (options.sleep ?? defaultSleep)(POLL_INTERVAL_MS, signal)
|
|
851
873
|
}
|
|
@@ -912,6 +934,60 @@ const deployProxy = async (
|
|
|
912
934
|
return { namespace: currentNamespace, target: currentTarget }
|
|
913
935
|
}
|
|
914
936
|
|
|
937
|
+
/**
|
|
938
|
+
* Lifecycle codes this provider defines where the platform has none of its own. They are an
|
|
939
|
+
* operator-facing vocabulary, so they are stable identifiers rather than prose (backlog 72): a
|
|
940
|
+
* platform code such as `insufficientPermissions` and one of these read the same way downstream.
|
|
941
|
+
*/
|
|
942
|
+
export const ZEROPS_NAMESPACE_CANCELLED = 'namespaceCancelled'
|
|
943
|
+
export const ZEROPS_NAMESPACE_LIFECYCLE = 'namespaceLifecycle'
|
|
944
|
+
export const ZEROPS_PROXY_DEPLOY_TIMEOUT = 'proxyDeployTimeout'
|
|
945
|
+
export const ZEROPS_PROXY_DEPLOY_FAILED = 'proxyDeployFailed'
|
|
946
|
+
export const ZEROPS_SUBDOMAIN_NOT_PUBLISHED = 'subdomainNotPublished'
|
|
947
|
+
|
|
948
|
+
const namespaceError = (code: string, message: string, retryable: boolean, detail?: string): ProviderNamespaceError =>
|
|
949
|
+
new ProviderNamespaceError(message, code, retryable, detail)
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* `apiError` composes `zerops: <label> failed (<status>) — <code>: <server message>`. The half before the
|
|
953
|
+
* first separator is OURS (a literal label and the status); the half after is the platform's, and it
|
|
954
|
+
* REPEATS the code, which travels separately. Splitting them keeps the summary safe to show while the
|
|
955
|
+
* upstream words survive in `detail`, where the control plane redacts them before they are stored or
|
|
956
|
+
* logged — discarding them is what left three different live failures indistinguishable (backlog 72).
|
|
957
|
+
*
|
|
958
|
+
* A `redactDetail: true` call carries the code and nothing else, so it yields no detail at all.
|
|
959
|
+
*/
|
|
960
|
+
const splitApiMessage = (message: string, code: string): { summary: string; detail?: string } => {
|
|
961
|
+
const at = message.indexOf(' — ')
|
|
962
|
+
if (at === -1) return { summary: message }
|
|
963
|
+
const summary = message.slice(0, at)
|
|
964
|
+
const rest = message.slice(at + 3)
|
|
965
|
+
if (code === '' || rest === code) return rest === code ? { summary } : { summary, detail: rest }
|
|
966
|
+
const detail = rest.startsWith(`${code}: `) ? rest.slice(code.length + 2) : rest
|
|
967
|
+
return detail === '' ? { summary } : { summary, detail }
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Every namespace lifecycle failure leaves this provider as one typed, coded error. */
|
|
971
|
+
const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile'): ProviderNamespaceError => {
|
|
972
|
+
if (cause instanceof ProviderNamespaceError) return cause
|
|
973
|
+
if (cause instanceof ZeropsApiError) {
|
|
974
|
+
const { summary, detail } = splitApiMessage(cause.message, cause.code)
|
|
975
|
+
return namespaceError(
|
|
976
|
+
cause.code === '' ? `zeropsHttp${cause.status}` : cause.code,
|
|
977
|
+
summary,
|
|
978
|
+
cause.status === 429 || cause.status >= 500,
|
|
979
|
+
detail,
|
|
980
|
+
)
|
|
981
|
+
}
|
|
982
|
+
if (cause instanceof Error && cause.name === 'AbortError') {
|
|
983
|
+
return namespaceError(ZEROPS_NAMESPACE_CANCELLED, `Zerops namespace ${mode} was cancelled`, true)
|
|
984
|
+
}
|
|
985
|
+
if (cause instanceof Error) {
|
|
986
|
+
return namespaceError(ZEROPS_NAMESPACE_LIFECYCLE, cause.message, false)
|
|
987
|
+
}
|
|
988
|
+
return namespaceError(ZEROPS_NAMESPACE_LIFECYCLE, `Zerops namespace ${mode} failed`, false)
|
|
989
|
+
}
|
|
990
|
+
|
|
915
991
|
/**
|
|
916
992
|
* Publish the proxy on its `*.zerops.app` subdomain — the ONE thing the import document cannot do.
|
|
917
993
|
*
|
|
@@ -945,32 +1021,49 @@ const ensureSubdomainAccess = async (
|
|
|
945
1021
|
await options.api.enableSubdomainAccess({ serviceId: proxyServiceId, signal })
|
|
946
1022
|
} catch (error) {
|
|
947
1023
|
if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
|
|
948
|
-
throw
|
|
1024
|
+
throw namespaceError(
|
|
1025
|
+
ZEROPS_SERVICE_NOT_HTTP,
|
|
949
1026
|
`Zerops namespace proxy ${proxyServiceId} cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
|
|
1027
|
+
false,
|
|
950
1028
|
)
|
|
951
1029
|
}
|
|
952
1030
|
throw error
|
|
953
1031
|
}
|
|
954
1032
|
for (let attempt = 0;; attempt += 1) {
|
|
955
1033
|
if (signal.aborted) {
|
|
956
|
-
throw
|
|
1034
|
+
throw namespaceError(ZEROPS_NAMESPACE_CANCELLED, 'Zerops namespace provisioning was cancelled', true)
|
|
957
1035
|
}
|
|
958
1036
|
if (await published()) {
|
|
959
1037
|
return
|
|
960
1038
|
}
|
|
961
1039
|
if (attempt >= SUBDOMAIN_READBACK_ATTEMPTS) {
|
|
962
|
-
throw
|
|
1040
|
+
throw namespaceError(
|
|
1041
|
+
ZEROPS_SUBDOMAIN_NOT_PUBLISHED,
|
|
963
1042
|
`Zerops namespace proxy ${proxyServiceId} accepted enable-subdomain-access but still reads subdomainAccess: false — the namespace has no public entry point`,
|
|
1043
|
+
true,
|
|
964
1044
|
)
|
|
965
1045
|
}
|
|
966
1046
|
await (options.sleep ?? defaultSleep)(POLL_INTERVAL_MS, signal)
|
|
967
1047
|
}
|
|
968
1048
|
}
|
|
969
1049
|
|
|
1050
|
+
/** Every exit from the lifecycle below is a `ProviderNamespaceError`, so a caller never has to guess. */
|
|
970
1051
|
const mutateNamespace = async (
|
|
971
1052
|
input: ProviderNamespaceMutationInput,
|
|
972
1053
|
options: ZeropsNamespaceOptions,
|
|
973
1054
|
mode: 'provision' | 'reconcile',
|
|
1055
|
+
): Promise<ProviderDeploymentNamespace> => {
|
|
1056
|
+
try {
|
|
1057
|
+
return await runNamespaceMutation(input, options, mode)
|
|
1058
|
+
} catch (cause) {
|
|
1059
|
+
throw asNamespaceError(cause, mode)
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const runNamespaceMutation = async (
|
|
1064
|
+
input: ProviderNamespaceMutationInput,
|
|
1065
|
+
options: ZeropsNamespaceOptions,
|
|
1066
|
+
mode: 'provision' | 'reconcile',
|
|
974
1067
|
): Promise<ProviderDeploymentNamespace> => {
|
|
975
1068
|
const suppliedTarget = decodeEnvelope(input.namespace.target)
|
|
976
1069
|
let namespace = normalizedNamespace(input.namespace, options)
|
package/src/provider.ts
CHANGED
|
@@ -41,7 +41,7 @@ type ZeropsRun = TypedProviderRun<ZeropsRuntimeTarget, FabrikaManifest>
|
|
|
41
41
|
export interface ZeropsSourceTransportBinding {
|
|
42
42
|
readonly connectionId: string
|
|
43
43
|
readonly installationId: number
|
|
44
|
-
readonly transportKind: '
|
|
44
|
+
readonly transportKind: 'keyed-v2'
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface ZeropsSourceTransportRouting {
|
|
@@ -334,14 +334,14 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
|
|
|
334
334
|
throw new Error('zerops: source transport binding has different installation coordinates')
|
|
335
335
|
}
|
|
336
336
|
let uploaded: ZeropsSourceUploadResult
|
|
337
|
-
if (binding
|
|
337
|
+
if (binding === undefined) {
|
|
338
|
+
uploaded = await source.client.upload(uploadInput)
|
|
339
|
+
} else {
|
|
338
340
|
if (sourceTransport === undefined) throw new Error('zerops: keyed source transport is unavailable')
|
|
339
341
|
uploaded = await sourceTransport.uploadV2({
|
|
340
342
|
...uploadInput,
|
|
341
343
|
privateBinding: { connectionId: binding.connectionId, installationId: binding.installationId },
|
|
342
344
|
})
|
|
343
|
-
} else {
|
|
344
|
-
uploaded = await source.client.upload(uploadInput)
|
|
345
345
|
}
|
|
346
346
|
if (
|
|
347
347
|
uploaded.runId !== source.runtime.runId
|
package/src/source-connection.ts
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
import type { ZeropsApi, ZeropsService, ZeropsServiceEnv } from './api'
|
|
2
2
|
import {
|
|
3
|
-
buildZeropsSourceCredentialBundle,
|
|
4
|
-
decodeZeropsSourceCredentialBundle,
|
|
5
3
|
decodeZeropsSourceCredentialBundleV2,
|
|
6
|
-
serializeZeropsSourceCredentialBundle,
|
|
7
|
-
sha256ZeropsSourceCredentialBundle,
|
|
8
4
|
sha256ZeropsSourceCredentialBundleV2,
|
|
9
5
|
ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2,
|
|
10
6
|
ZEROPS_SOURCE_PROTOCOL_VERSION_V2,
|
|
11
|
-
type ZeropsSourceCredentialActivateResponseV1,
|
|
12
7
|
type ZeropsSourceCredentialActivateResponseV2,
|
|
13
8
|
zeropsSourceCredentialEnvV2,
|
|
14
9
|
type ZeropsSourceCredentialManager,
|
|
15
10
|
type ZeropsSourceCredentialManagerV2,
|
|
16
|
-
type ZeropsSourceCredentialStatusResponseV1,
|
|
17
11
|
type ZeropsSourceCredentialStatusResponseV2,
|
|
18
12
|
type ZeropsSourceGitHubAppIdentityV1,
|
|
19
13
|
type ZeropsSourceInstallationsVerifyInput,
|
|
@@ -22,22 +16,14 @@ import {
|
|
|
22
16
|
type ZeropsSourceWebhookConfigureResponseV1,
|
|
23
17
|
} from './source'
|
|
24
18
|
|
|
25
|
-
export const ZEROPS_SOURCE_CREDENTIAL_ENV = 'GITHUB_APP_CREDENTIALS'
|
|
26
|
-
export const ZEROPS_SOURCE_LEGACY_APP_ID_ENV = 'GITHUB_APP_ID'
|
|
27
|
-
export const ZEROPS_SOURCE_LEGACY_PRIVATE_KEY_ENV = 'GITHUB_APP_PRIVATE_KEY'
|
|
28
|
-
|
|
29
19
|
const SOURCE_HOSTNAME = 'source'
|
|
30
20
|
const PROJECT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
|
|
31
21
|
const DEFAULT_REREAD_ATTEMPTS = 5
|
|
32
22
|
const DEFAULT_REREAD_DELAY_MS = 250
|
|
33
|
-
const ADOPTION_CONNECTION_DOMAIN = 'fabrika:zerops-source-connection:v1'
|
|
34
23
|
|
|
35
24
|
export type SourceConnectionInspection =
|
|
36
25
|
| { readonly state: 'unavailable' }
|
|
37
26
|
| { readonly state: 'anonymous' }
|
|
38
|
-
| { readonly state: 'legacy-complete' }
|
|
39
|
-
| { readonly state: 'legacy-partial' }
|
|
40
|
-
| { readonly state: 'durable'; readonly credentialSha256: string }
|
|
41
27
|
|
|
42
28
|
export type SourceConnectionStatus =
|
|
43
29
|
| SourceConnectionInspection
|
|
@@ -60,32 +46,20 @@ export interface SourceConnectionStatusInput {
|
|
|
60
46
|
readonly signal: AbortSignal
|
|
61
47
|
}
|
|
62
48
|
|
|
63
|
-
|
|
64
|
-
readonly signal: AbortSignal
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Provider-neutral lifecycle consumed by the future authenticated control connection flow. */
|
|
49
|
+
/** Provider-neutral lifecycle consumed by the authenticated control connection flow. */
|
|
68
50
|
export interface SourceConnectionAdmin {
|
|
69
51
|
inspect(signal: AbortSignal): Promise<SourceConnectionInspection>
|
|
70
|
-
adoptExisting(input: SourceConnectionAdoptExistingInput): Promise<ZeropsSourceCredentialActivateResponseV1>
|
|
71
|
-
activate(input: SourceConnectionActivateInput): Promise<ZeropsSourceCredentialActivateResponseV1>
|
|
72
|
-
activateV2?(input: SourceConnectionActivateInput): Promise<ZeropsSourceCredentialActivateResponseV2>
|
|
73
|
-
status(input: SourceConnectionStatusInput): Promise<SourceConnectionStatus>
|
|
74
|
-
statusV2?(input: SourceConnectionStatusInput): Promise<SourceConnectionStatus>
|
|
75
|
-
configureWebhook(input: ZeropsSourceWebhookConfigureInput): Promise<ZeropsSourceWebhookConfigureResponseV1>
|
|
76
|
-
verifyInstallations(input: ZeropsSourceInstallationsVerifyInput): Promise<ZeropsSourceInstallationsVerifyResponseV1>
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
interface CompleteSourceConnectionAdmin extends SourceConnectionAdmin {
|
|
80
52
|
activateV2(input: SourceConnectionActivateInput): Promise<ZeropsSourceCredentialActivateResponseV2>
|
|
81
53
|
statusV2(input: SourceConnectionStatusInput): Promise<SourceConnectionStatus>
|
|
54
|
+
configureWebhook(input: ZeropsSourceWebhookConfigureInput): Promise<ZeropsSourceWebhookConfigureResponseV1>
|
|
55
|
+
verifyInstallations(input: ZeropsSourceInstallationsVerifyInput): Promise<ZeropsSourceInstallationsVerifyResponseV1>
|
|
82
56
|
}
|
|
83
57
|
|
|
84
58
|
export type SourceConnectionZeropsApi = Pick<ZeropsApi, 'findService' | 'listServiceEnv' | 'createServiceEnv'>
|
|
85
59
|
|
|
86
60
|
export interface ZeropsSourceConnectionAdminOptions {
|
|
87
61
|
readonly api: SourceConnectionZeropsApi
|
|
88
|
-
readonly source: ZeropsSourceCredentialManager &
|
|
62
|
+
readonly source: ZeropsSourceCredentialManager & ZeropsSourceCredentialManagerV2
|
|
89
63
|
readonly projectId: string
|
|
90
64
|
readonly reread?: {
|
|
91
65
|
readonly attempts?: number
|
|
@@ -110,12 +84,6 @@ export class SourceConnectionAdminError extends Error {
|
|
|
110
84
|
}
|
|
111
85
|
}
|
|
112
86
|
|
|
113
|
-
interface DurableInspection {
|
|
114
|
-
readonly public: SourceConnectionInspection
|
|
115
|
-
readonly service: ZeropsService
|
|
116
|
-
readonly bundle?: string
|
|
117
|
-
}
|
|
118
|
-
|
|
119
87
|
interface DurableInspectionV2 {
|
|
120
88
|
readonly public:
|
|
121
89
|
| { readonly state: 'anonymous' }
|
|
@@ -125,7 +93,7 @@ interface DurableInspectionV2 {
|
|
|
125
93
|
readonly bundle?: string
|
|
126
94
|
}
|
|
127
95
|
|
|
128
|
-
export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectionAdminOptions):
|
|
96
|
+
export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectionAdminOptions): SourceConnectionAdmin {
|
|
129
97
|
if (!PROJECT_ID_PATTERN.test(options.projectId)) throw new SourceConnectionAdminError('invalid_configuration')
|
|
130
98
|
const attempts = options.reread?.attempts ?? DEFAULT_REREAD_ATTEMPTS
|
|
131
99
|
const delayMs = options.reread?.delayMs ?? DEFAULT_REREAD_DELAY_MS
|
|
@@ -148,11 +116,6 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
148
116
|
return { service, environment }
|
|
149
117
|
}
|
|
150
118
|
|
|
151
|
-
const inspectInternal = async (signal: AbortSignal): Promise<DurableInspection> => {
|
|
152
|
-
const current = await readEnvironment(signal)
|
|
153
|
-
return await withDigest(classify(current.service, current.environment))
|
|
154
|
-
}
|
|
155
|
-
|
|
156
119
|
const inspectV2Internal = async (connectionId: string, signal: AbortSignal): Promise<DurableInspectionV2> => {
|
|
157
120
|
let environmentKey: string
|
|
158
121
|
try {
|
|
@@ -164,99 +127,15 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
164
127
|
return await classifyV2(current.service, current.environment, environmentKey, connectionId)
|
|
165
128
|
}
|
|
166
129
|
|
|
167
|
-
const admin:
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
(existing.public.state !== 'legacy-complete' && existing.public.state !== 'durable')
|
|
174
|
-
|| existing.bundle === undefined
|
|
175
|
-
) throw new SourceConnectionAdminError('credential_conflict')
|
|
176
|
-
let credentialSha256: string
|
|
177
|
-
try {
|
|
178
|
-
credentialSha256 = existing.public.state === 'durable'
|
|
179
|
-
? existing.public.credentialSha256
|
|
180
|
-
: await sha256ZeropsSourceCredentialBundle(existing.bundle)
|
|
181
|
-
} catch {
|
|
182
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
183
|
-
}
|
|
184
|
-
return await admin.activate({
|
|
185
|
-
connectionId: await adoptionConnectionId(options.projectId, credentialSha256),
|
|
186
|
-
credentialBundle: existing.bundle,
|
|
187
|
-
credentialSha256,
|
|
188
|
-
signal: input.signal,
|
|
189
|
-
})
|
|
190
|
-
},
|
|
191
|
-
|
|
192
|
-
activate: async (input) => {
|
|
193
|
-
let digest: string
|
|
194
|
-
try {
|
|
195
|
-
decodeZeropsSourceCredentialBundle(input.credentialBundle)
|
|
196
|
-
digest = await sha256ZeropsSourceCredentialBundle(input.credentialBundle)
|
|
197
|
-
} catch {
|
|
198
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
199
|
-
}
|
|
200
|
-
if (digest !== input.credentialSha256) throw new SourceConnectionAdminError('credential_conflict')
|
|
201
|
-
const initial = await inspectInternal(input.signal)
|
|
202
|
-
if (initial.public.state === 'legacy-partial' || initial.public.state === 'unavailable') {
|
|
203
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
204
|
-
}
|
|
205
|
-
if (initial.public.state === 'durable') {
|
|
206
|
-
if (initial.bundle !== input.credentialBundle || initial.public.credentialSha256 !== digest) {
|
|
207
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
208
|
-
}
|
|
209
|
-
} else {
|
|
210
|
-
if (initial.public.state === 'legacy-complete' && initial.bundle !== input.credentialBundle) {
|
|
211
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
212
|
-
}
|
|
213
|
-
try {
|
|
214
|
-
await options.api.createServiceEnv({
|
|
215
|
-
serviceId: initial.service.id,
|
|
216
|
-
key: ZEROPS_SOURCE_CREDENTIAL_ENV,
|
|
217
|
-
value: input.credentialBundle,
|
|
218
|
-
signal: input.signal,
|
|
219
|
-
})
|
|
220
|
-
} catch (error) {
|
|
221
|
-
if (isAbort(error, input.signal)) throw error
|
|
222
|
-
}
|
|
223
|
-
await proveDurable(input.credentialBundle, digest, input.signal, attempts, delayMs, sleep, inspectInternal)
|
|
224
|
-
}
|
|
225
|
-
let activated: ZeropsSourceCredentialActivateResponseV1
|
|
226
|
-
try {
|
|
227
|
-
activated = await options.source.activate(input)
|
|
228
|
-
} catch (error) {
|
|
229
|
-
if (isAbort(error, input.signal)) throw error
|
|
230
|
-
let status: ZeropsSourceCredentialStatusResponseV1
|
|
231
|
-
try {
|
|
232
|
-
status = await options.source.status({ connectionId: input.connectionId, signal: input.signal })
|
|
233
|
-
} catch (statusError) {
|
|
234
|
-
if (isAbort(statusError, input.signal)) throw statusError
|
|
235
|
-
throw new SourceConnectionAdminError('credential_activation')
|
|
236
|
-
}
|
|
237
|
-
if (
|
|
238
|
-
status.state !== 'active' || status.connectionId !== input.connectionId || status.credentialSha256 !== digest
|
|
239
|
-
) throw new SourceConnectionAdminError('credential_activation')
|
|
240
|
-
activated = {
|
|
241
|
-
protocolVersion: 1,
|
|
242
|
-
connectionId: input.connectionId,
|
|
243
|
-
credentialVersion: status.credentialVersion,
|
|
244
|
-
credentialSha256: status.credentialSha256,
|
|
245
|
-
githubApp: status.githubApp,
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
if (activated.connectionId !== input.connectionId || activated.credentialSha256 !== digest) {
|
|
249
|
-
throw new SourceConnectionAdminError('credential_activation')
|
|
250
|
-
}
|
|
251
|
-
return activated
|
|
130
|
+
const admin: SourceConnectionAdmin = {
|
|
131
|
+
// Proves the exact `source` service AND that this token can read its environment. A setup that
|
|
132
|
+
// cannot do both fails later anyway; the console shows this probe before it starts one.
|
|
133
|
+
inspect: async (signal) => {
|
|
134
|
+
await readEnvironment(signal)
|
|
135
|
+
return { state: 'anonymous' }
|
|
252
136
|
},
|
|
253
137
|
|
|
254
138
|
activateV2: async (input) => {
|
|
255
|
-
if (options.source.activateV2 === undefined || options.source.statusV2 === undefined) {
|
|
256
|
-
throw new SourceConnectionAdminError('credential_activation')
|
|
257
|
-
}
|
|
258
|
-
const activateV2 = options.source.activateV2.bind(options.source)
|
|
259
|
-
const statusV2 = options.source.statusV2.bind(options.source)
|
|
260
139
|
let digest: string
|
|
261
140
|
try {
|
|
262
141
|
const bundle = decodeZeropsSourceCredentialBundleV2(input.credentialBundle)
|
|
@@ -286,12 +165,12 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
286
165
|
}
|
|
287
166
|
let activated: ZeropsSourceCredentialActivateResponseV2
|
|
288
167
|
try {
|
|
289
|
-
activated = await activateV2(input)
|
|
168
|
+
activated = await options.source.activateV2(input)
|
|
290
169
|
} catch (error) {
|
|
291
170
|
if (isAbort(error, input.signal)) throw error
|
|
292
171
|
let status: ZeropsSourceCredentialStatusResponseV2
|
|
293
172
|
try {
|
|
294
|
-
status = await statusV2({ connectionId: input.connectionId, signal: input.signal })
|
|
173
|
+
status = await options.source.statusV2({ connectionId: input.connectionId, signal: input.signal })
|
|
295
174
|
} catch (statusError) {
|
|
296
175
|
if (isAbort(statusError, input.signal)) throw statusError
|
|
297
176
|
throw new SourceConnectionAdminError('credential_activation')
|
|
@@ -313,34 +192,12 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
313
192
|
return activated
|
|
314
193
|
},
|
|
315
194
|
|
|
316
|
-
status: async (input) => {
|
|
317
|
-
const durable = await inspectInternal(input.signal)
|
|
318
|
-
if (durable.public.state !== 'durable') return durable.public
|
|
319
|
-
let runtime: ZeropsSourceCredentialStatusResponseV1
|
|
320
|
-
try {
|
|
321
|
-
runtime = await options.source.status(input)
|
|
322
|
-
} catch (error) {
|
|
323
|
-
if (isAbort(error, input.signal)) throw error
|
|
324
|
-
throw new SourceConnectionAdminError('credential_activation')
|
|
325
|
-
}
|
|
326
|
-
if (runtime.connectionId !== input.connectionId || runtime.state === 'anonymous') {
|
|
327
|
-
return { state: 'activation-required', credentialSha256: durable.public.credentialSha256 }
|
|
328
|
-
}
|
|
329
|
-
if (runtime.credentialSha256 !== durable.public.credentialSha256) {
|
|
330
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
331
|
-
}
|
|
332
|
-
return { state: 'active', credentialSha256: runtime.credentialSha256, githubApp: runtime.githubApp }
|
|
333
|
-
},
|
|
334
|
-
|
|
335
195
|
statusV2: async (input) => {
|
|
336
196
|
const durable = await inspectV2Internal(input.connectionId, input.signal)
|
|
337
197
|
if (durable.public.state !== 'durable') return durable.public
|
|
338
|
-
const statusV2 = options.source.statusV2
|
|
339
|
-
if (statusV2 === undefined) throw new SourceConnectionAdminError('credential_activation')
|
|
340
|
-
const readStatusV2 = statusV2.bind(options.source)
|
|
341
198
|
let runtime: ZeropsSourceCredentialStatusResponseV2
|
|
342
199
|
try {
|
|
343
|
-
runtime = await
|
|
200
|
+
runtime = await options.source.statusV2(input)
|
|
344
201
|
} catch (error) {
|
|
345
202
|
if (isAbort(error, input.signal)) throw error
|
|
346
203
|
throw new SourceConnectionAdminError('credential_activation')
|
|
@@ -355,7 +212,7 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
355
212
|
},
|
|
356
213
|
|
|
357
214
|
configureWebhook: async (input) => {
|
|
358
|
-
await requireDurableDigest(input.connectionId, input.credentialSha256, input.signal,
|
|
215
|
+
await requireDurableDigest(input.connectionId, input.credentialSha256, input.signal, inspectV2Internal)
|
|
359
216
|
try {
|
|
360
217
|
const response = await options.source.configureWebhook(input)
|
|
361
218
|
if (
|
|
@@ -371,7 +228,7 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
371
228
|
},
|
|
372
229
|
|
|
373
230
|
verifyInstallations: async (input) => {
|
|
374
|
-
await requireDurableDigest(input.connectionId, input.credentialSha256, input.signal,
|
|
231
|
+
await requireDurableDigest(input.connectionId, input.credentialSha256, input.signal, inspectV2Internal)
|
|
375
232
|
try {
|
|
376
233
|
const response = await options.source.verifyInstallations(input)
|
|
377
234
|
if (response.connectionId !== input.connectionId || response.credentialSha256 !== input.credentialSha256) {
|
|
@@ -388,26 +245,14 @@ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectio
|
|
|
388
245
|
return admin
|
|
389
246
|
}
|
|
390
247
|
|
|
391
|
-
async function adoptionConnectionId(projectId: string, credentialSha256: string): Promise<string> {
|
|
392
|
-
const material = `${ADOPTION_CONNECTION_DOMAIN}\0${projectId}\0${credentialSha256}`
|
|
393
|
-
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(material)))
|
|
394
|
-
return `zsrc-${[...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
|
|
395
|
-
}
|
|
396
|
-
|
|
397
248
|
async function requireDurableDigest(
|
|
398
249
|
connectionId: string,
|
|
399
250
|
digest: string,
|
|
400
251
|
signal: AbortSignal,
|
|
401
|
-
inspect: (signal: AbortSignal) => Promise<DurableInspection>,
|
|
402
252
|
inspectV2: (connectionId: string, signal: AbortSignal) => Promise<DurableInspectionV2>,
|
|
403
253
|
): Promise<void> {
|
|
404
254
|
const keyed = await inspectV2(connectionId, signal)
|
|
405
|
-
if (keyed.public.state
|
|
406
|
-
if (keyed.public.credentialSha256 === digest) return
|
|
407
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
408
|
-
}
|
|
409
|
-
const durable = await inspect(signal)
|
|
410
|
-
if (durable.public.state !== 'durable' || durable.public.credentialSha256 !== digest) {
|
|
255
|
+
if (keyed.public.state !== 'durable' || keyed.public.credentialSha256 !== digest) {
|
|
411
256
|
throw new SourceConnectionAdminError('credential_conflict')
|
|
412
257
|
}
|
|
413
258
|
}
|
|
@@ -427,45 +272,6 @@ async function exactSourceService(api: SourceConnectionZeropsApi, projectId: str
|
|
|
427
272
|
return service
|
|
428
273
|
}
|
|
429
274
|
|
|
430
|
-
function classify(service: ZeropsService, environment: readonly ZeropsServiceEnv[]): DurableInspection {
|
|
431
|
-
const bundleEntries = environment.filter((entry) => entry.key === ZEROPS_SOURCE_CREDENTIAL_ENV)
|
|
432
|
-
const legacyIdEntries = environment.filter((entry) => entry.key === ZEROPS_SOURCE_LEGACY_APP_ID_ENV)
|
|
433
|
-
const legacyKeyEntries = environment.filter((entry) => entry.key === ZEROPS_SOURCE_LEGACY_PRIVATE_KEY_ENV)
|
|
434
|
-
if (bundleEntries.length > 1 || legacyIdEntries.length > 1 || legacyKeyEntries.length > 1) {
|
|
435
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
436
|
-
}
|
|
437
|
-
const legacyCount = legacyIdEntries.length + legacyKeyEntries.length
|
|
438
|
-
let legacyBundle: string | undefined
|
|
439
|
-
if (legacyIdEntries.length === 1 && legacyKeyEntries.length === 1) {
|
|
440
|
-
const legacyAppId = legacyIdEntries[0]?.content
|
|
441
|
-
const legacyPrivateKeyPem = legacyKeyEntries[0]?.content
|
|
442
|
-
if (legacyAppId === undefined || legacyPrivateKeyPem === undefined) throw new SourceConnectionAdminError('credential_conflict')
|
|
443
|
-
try {
|
|
444
|
-
legacyBundle = serializeZeropsSourceCredentialBundle(buildZeropsSourceCredentialBundle({
|
|
445
|
-
githubAppId: legacyAppId,
|
|
446
|
-
privateKeyPem: legacyPrivateKeyPem,
|
|
447
|
-
}))
|
|
448
|
-
} catch {
|
|
449
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
if (bundleEntries.length === 1) {
|
|
453
|
-
if (legacyCount !== 0 && legacyBundle === undefined) throw new SourceConnectionAdminError('credential_conflict')
|
|
454
|
-
const bundle = bundleEntries[0]?.content
|
|
455
|
-
if (bundle === undefined) throw new SourceConnectionAdminError('credential_conflict')
|
|
456
|
-
try {
|
|
457
|
-
decodeZeropsSourceCredentialBundle(bundle)
|
|
458
|
-
} catch {
|
|
459
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
460
|
-
}
|
|
461
|
-
if (legacyBundle !== undefined && legacyBundle !== bundle) throw new SourceConnectionAdminError('credential_conflict')
|
|
462
|
-
return { public: { state: 'durable', credentialSha256: '' }, service, bundle }
|
|
463
|
-
}
|
|
464
|
-
if (legacyCount === 0) return { public: { state: 'anonymous' }, service }
|
|
465
|
-
if (legacyBundle !== undefined) return { public: { state: 'legacy-complete' }, service, bundle: legacyBundle }
|
|
466
|
-
return { public: { state: 'legacy-partial' }, service }
|
|
467
|
-
}
|
|
468
|
-
|
|
469
275
|
async function classifyV2(
|
|
470
276
|
service: ZeropsService,
|
|
471
277
|
environment: readonly ZeropsServiceEnv[],
|
|
@@ -491,35 +297,6 @@ async function classifyV2(
|
|
|
491
297
|
}
|
|
492
298
|
}
|
|
493
299
|
|
|
494
|
-
async function withDigest(inspection: DurableInspection): Promise<DurableInspection> {
|
|
495
|
-
if (inspection.public.state !== 'durable' || inspection.bundle === undefined) return inspection
|
|
496
|
-
return {
|
|
497
|
-
...inspection,
|
|
498
|
-
public: { state: 'durable', credentialSha256: await sha256ZeropsSourceCredentialBundle(inspection.bundle) },
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
async function proveDurable(
|
|
503
|
-
bundle: string,
|
|
504
|
-
digest: string,
|
|
505
|
-
signal: AbortSignal,
|
|
506
|
-
attempts: number,
|
|
507
|
-
delayMs: number,
|
|
508
|
-
sleep: (delayMs: number, signal: AbortSignal) => Promise<void>,
|
|
509
|
-
inspect: (signal: AbortSignal) => Promise<DurableInspection>,
|
|
510
|
-
): Promise<void> {
|
|
511
|
-
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
512
|
-
const durable = await withDigest(await inspect(signal))
|
|
513
|
-
if (durable.public.state === 'durable') {
|
|
514
|
-
if (durable.bundle === bundle && durable.public.credentialSha256 === digest) return
|
|
515
|
-
throw new SourceConnectionAdminError('credential_conflict')
|
|
516
|
-
}
|
|
517
|
-
if (durable.public.state !== 'anonymous') throw new SourceConnectionAdminError('credential_conflict')
|
|
518
|
-
if (attempt + 1 < attempts) await sleep(delayMs, signal)
|
|
519
|
-
}
|
|
520
|
-
throw new SourceConnectionAdminError('credential_persistence')
|
|
521
|
-
}
|
|
522
|
-
|
|
523
300
|
async function proveDurableV2(
|
|
524
301
|
input: SourceConnectionActivateInput,
|
|
525
302
|
digest: string,
|
package/src/source.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
export const ZEROPS_SOURCE_PROTOCOL_VERSION = 1
|
|
2
|
-
export const ZEROPS_SOURCE_RESOLVE_INSTALLATION_PATH = '/v1/installations/resolve'
|
|
3
2
|
export const ZEROPS_SOURCE_RESOLVE_PATH = '/v1/source/resolve'
|
|
4
3
|
export const ZEROPS_SOURCE_UPLOAD_PATH = '/v1/source/upload'
|
|
5
4
|
export const ZEROPS_SOURCE_CANCEL_PATH = '/v1/source/cancel'
|
|
6
|
-
export const ZEROPS_SOURCE_CREDENTIAL_ACTIVATE_PATH = '/v1/source/credentials/activate'
|
|
7
|
-
export const ZEROPS_SOURCE_CREDENTIAL_STATUS_PATH = '/v1/source/credentials/status'
|
|
8
5
|
export const ZEROPS_SOURCE_WEBHOOK_CONFIGURE_PATH = '/v1/source/github/webhook/configure'
|
|
9
6
|
export const ZEROPS_SOURCE_INSTALLATIONS_VERIFY_PATH = '/v1/source/github/installations/verify'
|
|
10
|
-
export const ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION = 1
|
|
11
7
|
export const ZEROPS_SOURCE_PROTOCOL_VERSION_V2 = 2
|
|
12
8
|
export const ZEROPS_SOURCE_RESOLVE_PATH_V2 = '/v2/source/resolve'
|
|
13
9
|
export const ZEROPS_SOURCE_UPLOAD_PATH_V2 = '/v2/source/upload'
|
|
@@ -33,12 +29,6 @@ const GITHUB_APP_ID_PATTERN = /^[1-9][0-9]{0,31}$/
|
|
|
33
29
|
const GITHUB_APP_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/
|
|
34
30
|
const GITHUB_LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,98}[A-Za-z0-9])?$/
|
|
35
31
|
|
|
36
|
-
export interface ZeropsSourceCredentialBundleV1 {
|
|
37
|
-
readonly version: typeof ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION
|
|
38
|
-
readonly githubAppId: string
|
|
39
|
-
readonly privateKeyPem: string
|
|
40
|
-
}
|
|
41
|
-
|
|
42
32
|
export interface ZeropsSourceCredentialBundleV2 {
|
|
43
33
|
readonly version: typeof ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION_V2
|
|
44
34
|
readonly connectionId: string
|
|
@@ -61,41 +51,6 @@ export interface ZeropsSourceGitHubAppIdentityV1 {
|
|
|
61
51
|
readonly events: readonly ['push']
|
|
62
52
|
}
|
|
63
53
|
|
|
64
|
-
export interface ZeropsSourceCredentialActivateRequestV1 {
|
|
65
|
-
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION
|
|
66
|
-
readonly connectionId: string
|
|
67
|
-
/** Canonical JSON that is byte-identical to the durable source environment value. */
|
|
68
|
-
readonly credentialBundle: string
|
|
69
|
-
readonly credentialSha256: string
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface ZeropsSourceCredentialActivateResponseV1 {
|
|
73
|
-
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION
|
|
74
|
-
readonly connectionId: string
|
|
75
|
-
readonly credentialVersion: typeof ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION
|
|
76
|
-
readonly credentialSha256: string
|
|
77
|
-
readonly githubApp: ZeropsSourceGitHubAppIdentityV1
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface ZeropsSourceCredentialStatusRequestV1 {
|
|
81
|
-
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION
|
|
82
|
-
readonly connectionId: string
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface ZeropsSourceCredentialAnonymousStatusResponseV1 {
|
|
86
|
-
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION
|
|
87
|
-
readonly connectionId: string
|
|
88
|
-
readonly state: 'anonymous'
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface ZeropsSourceCredentialActiveStatusResponseV1 extends ZeropsSourceCredentialActivateResponseV1 {
|
|
92
|
-
readonly state: 'active'
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export type ZeropsSourceCredentialStatusResponseV1 =
|
|
96
|
-
| ZeropsSourceCredentialAnonymousStatusResponseV1
|
|
97
|
-
| ZeropsSourceCredentialActiveStatusResponseV1
|
|
98
|
-
|
|
99
54
|
export interface ZeropsSourceCredentialActivateRequestV2 {
|
|
100
55
|
readonly protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION_V2
|
|
101
56
|
readonly connectionId: string
|
|
@@ -198,16 +153,6 @@ export interface ZeropsSourceDescriptor {
|
|
|
198
153
|
sha256: string
|
|
199
154
|
}
|
|
200
155
|
|
|
201
|
-
export interface ZeropsSourceResolveInstallationRequestV1 {
|
|
202
|
-
protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION
|
|
203
|
-
repository: ZeropsSourceRepository
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export interface ZeropsSourceResolveInstallationResponseV1 {
|
|
207
|
-
protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION
|
|
208
|
-
githubInstallationId: number | null
|
|
209
|
-
}
|
|
210
|
-
|
|
211
156
|
export interface ZeropsSourceResolveRequestV1 {
|
|
212
157
|
protocolVersion: typeof ZEROPS_SOURCE_PROTOCOL_VERSION
|
|
213
158
|
runId: string
|
|
@@ -340,7 +285,6 @@ export const ZEROPS_SOURCE_ERROR_CODES: readonly ZeropsSourceErrorCode[] = [
|
|
|
340
285
|
export type ZeropsSourceErrorStage =
|
|
341
286
|
| 'authenticate'
|
|
342
287
|
| 'validate'
|
|
343
|
-
| 'resolve-installation'
|
|
344
288
|
| 'resolve'
|
|
345
289
|
| 'archive'
|
|
346
290
|
| 'upload'
|
|
@@ -350,7 +294,6 @@ export type ZeropsSourceErrorStage =
|
|
|
350
294
|
export const ZEROPS_SOURCE_ERROR_STAGES: readonly ZeropsSourceErrorStage[] = [
|
|
351
295
|
'authenticate',
|
|
352
296
|
'validate',
|
|
353
|
-
'resolve-installation',
|
|
354
297
|
'resolve',
|
|
355
298
|
'archive',
|
|
356
299
|
'upload',
|
|
@@ -428,13 +371,12 @@ export interface ZeropsSourceCancelInput {
|
|
|
428
371
|
}
|
|
429
372
|
|
|
430
373
|
export interface ZeropsSourceClient {
|
|
431
|
-
resolveInstallationId(repoUrl: string, signal: AbortSignal): Promise<number | null>
|
|
432
374
|
resolve(input: ZeropsSourceResolveInput): Promise<ZeropsSourceResolveResult>
|
|
433
375
|
upload(input: ZeropsSourceUploadInput): Promise<ZeropsSourceUploadResult>
|
|
434
376
|
cancel(input: ZeropsSourceCancelInput): Promise<void>
|
|
435
377
|
}
|
|
436
378
|
|
|
437
|
-
/** Keyed source operations
|
|
379
|
+
/** Keyed source operations; the unkeyed v1 wire remains the anonymous public-repository path. */
|
|
438
380
|
export interface ZeropsSourceClientV2 {
|
|
439
381
|
resolveV2(input: ZeropsSourceResolveInputV2): Promise<ZeropsSourceResolveResult>
|
|
440
382
|
uploadV2(input: ZeropsSourceUploadInputV2): Promise<ZeropsSourceUploadResult>
|
|
@@ -454,13 +396,11 @@ export interface ZeropsSourceCredentialStatusInput {
|
|
|
454
396
|
|
|
455
397
|
/** Credential management is separate from the deploy source client and is composed only in the authenticated admin flow. */
|
|
456
398
|
export interface ZeropsSourceCredentialManager {
|
|
457
|
-
activate(input: ZeropsSourceCredentialActivateInput): Promise<ZeropsSourceCredentialActivateResponseV1>
|
|
458
|
-
status(input: ZeropsSourceCredentialStatusInput): Promise<ZeropsSourceCredentialStatusResponseV1>
|
|
459
399
|
configureWebhook(input: ZeropsSourceWebhookConfigureInput): Promise<ZeropsSourceWebhookConfigureResponseV1>
|
|
460
400
|
verifyInstallations(input: ZeropsSourceInstallationsVerifyInput): Promise<ZeropsSourceInstallationsVerifyResponseV1>
|
|
461
401
|
}
|
|
462
402
|
|
|
463
|
-
/** Keyed credential operations
|
|
403
|
+
/** Keyed credential operations — the only way a private credential reaches `source`. */
|
|
464
404
|
export interface ZeropsSourceCredentialManagerV2 {
|
|
465
405
|
activateV2(input: ZeropsSourceCredentialActivateInput): Promise<ZeropsSourceCredentialActivateResponseV2>
|
|
466
406
|
statusV2(input: ZeropsSourceCredentialStatusInput): Promise<ZeropsSourceCredentialStatusResponseV2>
|
|
@@ -539,23 +479,6 @@ const sha256 = (value: unknown, label: string): string => {
|
|
|
539
479
|
return value
|
|
540
480
|
}
|
|
541
481
|
|
|
542
|
-
const sourceCredentialBundleValue = (value: unknown): ZeropsSourceCredentialBundleV1 => {
|
|
543
|
-
const parsed = record(value, 'source credential bundle')
|
|
544
|
-
exactKeys(parsed, ['version', 'githubAppId', 'privateKeyPem'], 'source credential bundle')
|
|
545
|
-
if (parsed['version'] !== ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION) {
|
|
546
|
-
throw new Error('source credential bundle has an unsupported version')
|
|
547
|
-
}
|
|
548
|
-
const githubAppId = boundedString(parsed['githubAppId'], 'source credential bundle.githubAppId', 32)
|
|
549
|
-
if (!GITHUB_APP_ID_PATTERN.test(githubAppId)) throw new Error('source credential bundle.githubAppId is invalid')
|
|
550
|
-
const privateKeyPem = boundedString(
|
|
551
|
-
parsed['privateKeyPem'],
|
|
552
|
-
'source credential bundle.privateKeyPem',
|
|
553
|
-
MAX_GITHUB_APP_PRIVATE_KEY_LENGTH,
|
|
554
|
-
)
|
|
555
|
-
if (!isCanonicalPrivateKeyPem(privateKeyPem)) throw new Error('source credential bundle.privateKeyPem is invalid')
|
|
556
|
-
return { version: ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION, githubAppId, privateKeyPem }
|
|
557
|
-
}
|
|
558
|
-
|
|
559
482
|
const sourceCredentialBundleValueV2 = (value: unknown): ZeropsSourceCredentialBundleV2 => {
|
|
560
483
|
const parsed = record(value, 'source credential bundle v2')
|
|
561
484
|
exactKeys(parsed, ['version', 'connectionId', 'githubAppId', 'privateKeyPem'], 'source credential bundle v2')
|
|
@@ -592,38 +515,6 @@ const isCanonicalPrivateKeyPem = (value: string): boolean => {
|
|
|
592
515
|
}
|
|
593
516
|
}
|
|
594
517
|
|
|
595
|
-
export function buildZeropsSourceCredentialBundle(
|
|
596
|
-
input: Omit<ZeropsSourceCredentialBundleV1, 'version'>,
|
|
597
|
-
): ZeropsSourceCredentialBundleV1 {
|
|
598
|
-
return sourceCredentialBundleValue({ version: ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION, ...input })
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
export function serializeZeropsSourceCredentialBundle(bundle: ZeropsSourceCredentialBundleV1): string {
|
|
602
|
-
const parsed = sourceCredentialBundleValue(bundle)
|
|
603
|
-
return JSON.stringify({ version: parsed.version, githubAppId: parsed.githubAppId, privateKeyPem: parsed.privateKeyPem })
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
export function decodeZeropsSourceCredentialBundle(value: unknown): ZeropsSourceCredentialBundleV1 {
|
|
607
|
-
if (typeof value !== 'string' || new TextEncoder().encode(value).byteLength > MAX_SOURCE_CREDENTIAL_BUNDLE_BYTES) {
|
|
608
|
-
throw new Error('source credential bundle must be bounded canonical JSON')
|
|
609
|
-
}
|
|
610
|
-
let decoded: unknown
|
|
611
|
-
try {
|
|
612
|
-
decoded = JSON.parse(value)
|
|
613
|
-
} catch {
|
|
614
|
-
throw new Error('source credential bundle must be bounded canonical JSON')
|
|
615
|
-
}
|
|
616
|
-
const bundle = sourceCredentialBundleValue(decoded)
|
|
617
|
-
if (serializeZeropsSourceCredentialBundle(bundle) !== value) throw new Error('source credential bundle must be bounded canonical JSON')
|
|
618
|
-
return bundle
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
export async function sha256ZeropsSourceCredentialBundle(value: string): Promise<string> {
|
|
622
|
-
decodeZeropsSourceCredentialBundle(value)
|
|
623
|
-
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))
|
|
624
|
-
return [...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
625
|
-
}
|
|
626
|
-
|
|
627
518
|
export function buildZeropsSourceCredentialBundleV2(
|
|
628
519
|
input: Omit<ZeropsSourceCredentialBundleV2, 'version'>,
|
|
629
520
|
): ZeropsSourceCredentialBundleV2 {
|
|
@@ -810,115 +701,6 @@ export function normalizeZeropsSourceRepository(repoUrl: string): ZeropsSourceRe
|
|
|
810
701
|
return repository({ owner: owner.toLowerCase(), name: name.toLowerCase() }, 'source repository')
|
|
811
702
|
}
|
|
812
703
|
|
|
813
|
-
export function decodeZeropsSourceCredentialActivateRequest(value: unknown): ZeropsSourceCredentialActivateRequestV1 {
|
|
814
|
-
const parsed = record(value, 'source credential activate request')
|
|
815
|
-
exactKeys(
|
|
816
|
-
parsed,
|
|
817
|
-
['protocolVersion', 'connectionId', 'credentialBundle', 'credentialSha256'],
|
|
818
|
-
'source credential activate request',
|
|
819
|
-
)
|
|
820
|
-
const credentialBundle = boundedString(
|
|
821
|
-
parsed['credentialBundle'],
|
|
822
|
-
'source credential activate request.credentialBundle',
|
|
823
|
-
MAX_SOURCE_CREDENTIAL_BUNDLE_BYTES,
|
|
824
|
-
)
|
|
825
|
-
decodeZeropsSourceCredentialBundle(credentialBundle)
|
|
826
|
-
return {
|
|
827
|
-
protocolVersion: protocolVersion(parsed, 'source credential activate request'),
|
|
828
|
-
connectionId: identifier(parsed['connectionId'], 'source credential activate request.connectionId'),
|
|
829
|
-
credentialBundle,
|
|
830
|
-
credentialSha256: sha256(parsed['credentialSha256'], 'source credential activate request.credentialSha256'),
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
export function buildZeropsSourceCredentialActivateRequest(
|
|
835
|
-
input: ZeropsSourceCredentialActivateInput,
|
|
836
|
-
): ZeropsSourceCredentialActivateRequestV1 {
|
|
837
|
-
return decodeZeropsSourceCredentialActivateRequest({
|
|
838
|
-
protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION,
|
|
839
|
-
connectionId: input.connectionId,
|
|
840
|
-
credentialBundle: input.credentialBundle,
|
|
841
|
-
credentialSha256: input.credentialSha256,
|
|
842
|
-
})
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
export function decodeZeropsSourceCredentialActivateResponse(value: unknown): ZeropsSourceCredentialActivateResponseV1 {
|
|
846
|
-
const parsed = record(value, 'source credential activate response')
|
|
847
|
-
exactKeys(
|
|
848
|
-
parsed,
|
|
849
|
-
['protocolVersion', 'connectionId', 'credentialVersion', 'credentialSha256', 'githubApp'],
|
|
850
|
-
'source credential activate response',
|
|
851
|
-
)
|
|
852
|
-
if (parsed['credentialVersion'] !== ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION) {
|
|
853
|
-
throw new Error('source credential activate response has an unsupported credentialVersion')
|
|
854
|
-
}
|
|
855
|
-
return {
|
|
856
|
-
protocolVersion: protocolVersion(parsed, 'source credential activate response'),
|
|
857
|
-
connectionId: identifier(parsed['connectionId'], 'source credential activate response.connectionId'),
|
|
858
|
-
credentialVersion: ZEROPS_SOURCE_CREDENTIAL_BUNDLE_VERSION,
|
|
859
|
-
credentialSha256: sha256(parsed['credentialSha256'], 'source credential activate response.credentialSha256'),
|
|
860
|
-
githubApp: githubAppIdentity(parsed['githubApp'], 'source credential activate response.githubApp'),
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
export function buildZeropsSourceCredentialActivateResponse(
|
|
865
|
-
input: Omit<ZeropsSourceCredentialActivateResponseV1, 'protocolVersion'>,
|
|
866
|
-
): ZeropsSourceCredentialActivateResponseV1 {
|
|
867
|
-
return decodeZeropsSourceCredentialActivateResponse({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION, ...input })
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
export function decodeZeropsSourceCredentialStatusRequest(value: unknown): ZeropsSourceCredentialStatusRequestV1 {
|
|
871
|
-
const parsed = record(value, 'source credential status request')
|
|
872
|
-
exactKeys(parsed, ['protocolVersion', 'connectionId'], 'source credential status request')
|
|
873
|
-
return {
|
|
874
|
-
protocolVersion: protocolVersion(parsed, 'source credential status request'),
|
|
875
|
-
connectionId: identifier(parsed['connectionId'], 'source credential status request.connectionId'),
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
export function buildZeropsSourceCredentialStatusRequest(
|
|
880
|
-
input: ZeropsSourceCredentialStatusInput,
|
|
881
|
-
): ZeropsSourceCredentialStatusRequestV1 {
|
|
882
|
-
return decodeZeropsSourceCredentialStatusRequest({
|
|
883
|
-
protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION,
|
|
884
|
-
connectionId: input.connectionId,
|
|
885
|
-
})
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
export function decodeZeropsSourceCredentialStatusResponse(value: unknown): ZeropsSourceCredentialStatusResponseV1 {
|
|
889
|
-
const parsed = record(value, 'source credential status response')
|
|
890
|
-
if (parsed['state'] === 'anonymous') {
|
|
891
|
-
exactKeys(parsed, ['protocolVersion', 'connectionId', 'state'], 'source credential status response')
|
|
892
|
-
return {
|
|
893
|
-
protocolVersion: protocolVersion(parsed, 'source credential status response'),
|
|
894
|
-
connectionId: identifier(parsed['connectionId'], 'source credential status response.connectionId'),
|
|
895
|
-
state: 'anonymous',
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
if (parsed['state'] !== 'active') throw new Error('source credential status response.state is invalid')
|
|
899
|
-
exactKeys(
|
|
900
|
-
parsed,
|
|
901
|
-
['protocolVersion', 'connectionId', 'state', 'credentialVersion', 'credentialSha256', 'githubApp'],
|
|
902
|
-
'source credential status response',
|
|
903
|
-
)
|
|
904
|
-
const active = decodeZeropsSourceCredentialActivateResponse({
|
|
905
|
-
protocolVersion: parsed['protocolVersion'],
|
|
906
|
-
connectionId: parsed['connectionId'],
|
|
907
|
-
credentialVersion: parsed['credentialVersion'],
|
|
908
|
-
credentialSha256: parsed['credentialSha256'],
|
|
909
|
-
githubApp: parsed['githubApp'],
|
|
910
|
-
})
|
|
911
|
-
return { ...active, state: 'active' }
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
export function buildZeropsSourceCredentialStatusResponse(
|
|
915
|
-
input:
|
|
916
|
-
| { readonly connectionId: string; readonly state: 'anonymous' }
|
|
917
|
-
| Omit<ZeropsSourceCredentialActiveStatusResponseV1, 'protocolVersion'>,
|
|
918
|
-
): ZeropsSourceCredentialStatusResponseV1 {
|
|
919
|
-
return decodeZeropsSourceCredentialStatusResponse({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION, ...input })
|
|
920
|
-
}
|
|
921
|
-
|
|
922
704
|
export function decodeZeropsSourceCredentialActivateRequestV2(value: unknown): ZeropsSourceCredentialActivateRequestV2 {
|
|
923
705
|
const parsed = record(value, 'source credential activate request v2')
|
|
924
706
|
exactKeys(
|
|
@@ -1033,12 +815,11 @@ export function buildZeropsSourceCredentialStatusResponseV2(
|
|
|
1033
815
|
const WEBHOOK_PATH = '/webhooks/github'
|
|
1034
816
|
|
|
1035
817
|
/**
|
|
1036
|
-
*
|
|
1037
|
-
* so one App's deliveries cannot be replayed against another.
|
|
1038
|
-
*
|
|
818
|
+
* Only the Zerops `source` configure wire reaches this, and every Zerops connection appends its own id
|
|
819
|
+
* so one App's deliveries cannot be replayed against another. The bare path is a Cloudflare URL and is
|
|
820
|
+
* refused here (ADR-0039).
|
|
1039
821
|
*/
|
|
1040
822
|
const isWebhookPath = (pathname: string): boolean => {
|
|
1041
|
-
if (pathname === WEBHOOK_PATH) return true
|
|
1042
823
|
if (!pathname.startsWith(`${WEBHOOK_PATH}/`)) return false
|
|
1043
824
|
const connectionId = pathname.slice(WEBHOOK_PATH.length + 1)
|
|
1044
825
|
// `ID_PATTERN` has no `%`, so a percent-escape in the path fails here rather than round-tripping.
|
|
@@ -1188,37 +969,6 @@ export function buildZeropsSourceInstallationsVerifyResponse(
|
|
|
1188
969
|
return decodeZeropsSourceInstallationsVerifyResponse({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION, ...input })
|
|
1189
970
|
}
|
|
1190
971
|
|
|
1191
|
-
export function decodeZeropsSourceResolveInstallationRequest(value: unknown): ZeropsSourceResolveInstallationRequestV1 {
|
|
1192
|
-
const parsed = record(value, 'source resolve-installation request')
|
|
1193
|
-
exactKeys(parsed, ['protocolVersion', 'repository'], 'source resolve-installation request')
|
|
1194
|
-
return {
|
|
1195
|
-
protocolVersion: protocolVersion(parsed, 'source resolve-installation request'),
|
|
1196
|
-
repository: repository(parsed['repository'], 'source resolve-installation request.repository'),
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
export function buildZeropsSourceResolveInstallationRequest(repoUrl: string): ZeropsSourceResolveInstallationRequestV1 {
|
|
1201
|
-
return decodeZeropsSourceResolveInstallationRequest({
|
|
1202
|
-
protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION,
|
|
1203
|
-
repository: normalizeZeropsSourceRepository(repoUrl),
|
|
1204
|
-
})
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
export function decodeZeropsSourceResolveInstallationResponse(value: unknown): ZeropsSourceResolveInstallationResponseV1 {
|
|
1208
|
-
const parsed = record(value, 'source resolve-installation response')
|
|
1209
|
-
exactKeys(parsed, ['protocolVersion', 'githubInstallationId'], 'source resolve-installation response')
|
|
1210
|
-
return {
|
|
1211
|
-
protocolVersion: protocolVersion(parsed, 'source resolve-installation response'),
|
|
1212
|
-
githubInstallationId: parsed['githubInstallationId'] === null
|
|
1213
|
-
? null
|
|
1214
|
-
: installationId(parsed['githubInstallationId'], 'source resolve-installation response.githubInstallationId'),
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
export function buildZeropsSourceResolveInstallationResponse(githubInstallationId: number | null): ZeropsSourceResolveInstallationResponseV1 {
|
|
1219
|
-
return decodeZeropsSourceResolveInstallationResponse({ protocolVersion: ZEROPS_SOURCE_PROTOCOL_VERSION, githubInstallationId })
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
972
|
export function decodeZeropsSourceResolveRequest(value: unknown): ZeropsSourceResolveRequestV1 {
|
|
1223
973
|
const parsed = record(value, 'source resolve request')
|
|
1224
974
|
optionalFields(
|