@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.0.22",
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.22",
36
- "@fabrika/auth-core": "^0.0.22",
37
- "@fabrika/provider-contract": "^0.0.22",
38
- "@fabrika/proxy-contract": "^0.0.22"
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
- if (error instanceof Error && error.message.includes('(404)')) {
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?.transportKind === 'keyed-v2') {
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 type {
2
- JsonValue,
3
- ProviderCodec,
4
- ProviderDeploymentNamespace,
5
- ProviderEnvelope,
6
- ProviderNamespaceCapabilities,
7
- ProviderNamespaceMutationInput,
8
- ProviderNamespaceOperator,
9
- ProviderNamespacePlanInput,
10
- ProviderNamespacePresentation,
11
- ProviderNamespacePreset,
12
- ProviderRegistration,
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, sized rather than defaulted.
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
- * Omitting `profile` is not neutral: an HA service silently gets `oltp-production` (two DEDICATED cores
349
- * and 4 GB per container, three containers) and a single one gets `oltp-staging`. Both verified by
350
- * reading the profile back off a live service. Production takes the production preset deliberately;
351
- * everything else takes the cheapest one its type offers, because a namespace database for a staging
352
- * app should cost what staging is worth.
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 = (env: string): ZeropsNamespacePostgres =>
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
- corePackage: input.corePackage ?? (input.env === 'prod' ? 'SERIOUS' : 'LIGHT'),
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(input.env) } : {}),
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
- minContainers: 2,
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 ?? (namespace.env === 'prod' ? 'SERIOUS' : 'LIGHT')
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
- { label: 'Project', value: projectName },
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 ?? (namespace.env === 'prod' ? 'SERIOUS' : 'LIGHT'),
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): Error =>
831
- new Error(`Zerops namespace proxy deploy ${version.id} finished as ${version.status ?? 'unknown'}`)
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 new Error('Zerops namespace provisioning was cancelled')
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 new Error(`Zerops namespace proxy deploy ${appVersionId} timed out`)
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 new Error(
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 new Error('Zerops namespace provisioning was cancelled')
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 new Error(
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: 'legacy-v1' | 'keyed-v2'
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?.transportKind === 'keyed-v2') {
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
@@ -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
- export interface SourceConnectionAdoptExistingInput {
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 & Partial<ZeropsSourceCredentialManagerV2>
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): CompleteSourceConnectionAdmin {
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: CompleteSourceConnectionAdmin = {
168
- inspect: async (signal) => (await inspectInternal(signal)).public,
169
-
170
- adoptExisting: async (input) => {
171
- const existing = await inspectInternal(input.signal)
172
- if (
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 readStatusV2(input)
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, inspectInternal, inspectV2Internal)
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, inspectInternal, inspectV2Internal)
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 === 'durable') {
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 are additive so legacy client implementations remain valid. */
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 are additive so legacy credential managers remain valid. */
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
- * `/webhooks/github` is the legacy single-connection route; every keyed connection appends its own id
1037
- * so one App's deliveries cannot be replayed against another. Requiring the bare path rejected every
1038
- * multi-connection setup at `configureWebhook`.
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(