@fabrika/provider-zerops 0.0.4 → 0.0.6

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.
@@ -0,0 +1,394 @@
1
+ import type { ZeropsApi, ZeropsService, ZeropsServiceEnv } from './api'
2
+ import {
3
+ buildZeropsSourceCredentialBundle,
4
+ decodeZeropsSourceCredentialBundle,
5
+ serializeZeropsSourceCredentialBundle,
6
+ sha256ZeropsSourceCredentialBundle,
7
+ type ZeropsSourceCredentialActivateResponseV1,
8
+ type ZeropsSourceCredentialManager,
9
+ type ZeropsSourceCredentialStatusResponseV1,
10
+ type ZeropsSourceGitHubAppIdentityV1,
11
+ type ZeropsSourceInstallationsVerifyInput,
12
+ type ZeropsSourceInstallationsVerifyResponseV1,
13
+ type ZeropsSourceWebhookConfigureInput,
14
+ type ZeropsSourceWebhookConfigureResponseV1,
15
+ } from './source'
16
+
17
+ export const ZEROPS_SOURCE_CREDENTIAL_ENV = 'GITHUB_APP_CREDENTIALS'
18
+ export const ZEROPS_SOURCE_LEGACY_APP_ID_ENV = 'GITHUB_APP_ID'
19
+ export const ZEROPS_SOURCE_LEGACY_PRIVATE_KEY_ENV = 'GITHUB_APP_PRIVATE_KEY'
20
+
21
+ const SOURCE_HOSTNAME = 'source'
22
+ const MAX_ENV_ENTRIES = 256
23
+ const PROJECT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
24
+ const DEFAULT_REREAD_ATTEMPTS = 5
25
+ const DEFAULT_REREAD_DELAY_MS = 250
26
+ const ADOPTION_CONNECTION_DOMAIN = 'fabrika:zerops-source-connection:v1'
27
+
28
+ export type SourceConnectionInspection =
29
+ | { readonly state: 'unavailable' }
30
+ | { readonly state: 'anonymous' }
31
+ | { readonly state: 'legacy-complete' }
32
+ | { readonly state: 'legacy-partial' }
33
+ | { readonly state: 'durable'; readonly credentialSha256: string }
34
+
35
+ export type SourceConnectionStatus =
36
+ | SourceConnectionInspection
37
+ | { readonly state: 'activation-required'; readonly credentialSha256: string }
38
+ | {
39
+ readonly state: 'active'
40
+ readonly credentialSha256: string
41
+ readonly githubApp: ZeropsSourceGitHubAppIdentityV1
42
+ }
43
+
44
+ export interface SourceConnectionActivateInput {
45
+ readonly connectionId: string
46
+ readonly credentialBundle: string
47
+ readonly credentialSha256: string
48
+ readonly signal: AbortSignal
49
+ }
50
+
51
+ export interface SourceConnectionStatusInput {
52
+ readonly connectionId: string
53
+ readonly signal: AbortSignal
54
+ }
55
+
56
+ export interface SourceConnectionAdoptExistingInput {
57
+ readonly signal: AbortSignal
58
+ }
59
+
60
+ /** Provider-neutral lifecycle consumed by the future authenticated control connection flow. */
61
+ export interface SourceConnectionAdmin {
62
+ inspect(signal: AbortSignal): Promise<SourceConnectionInspection>
63
+ adoptExisting(input: SourceConnectionAdoptExistingInput): Promise<ZeropsSourceCredentialActivateResponseV1>
64
+ activate(input: SourceConnectionActivateInput): Promise<ZeropsSourceCredentialActivateResponseV1>
65
+ status(input: SourceConnectionStatusInput): Promise<SourceConnectionStatus>
66
+ configureWebhook(input: ZeropsSourceWebhookConfigureInput): Promise<ZeropsSourceWebhookConfigureResponseV1>
67
+ verifyInstallations(input: ZeropsSourceInstallationsVerifyInput): Promise<ZeropsSourceInstallationsVerifyResponseV1>
68
+ }
69
+
70
+ export type SourceConnectionZeropsApi = Pick<ZeropsApi, 'findService' | 'listServiceEnv' | 'createServiceEnv'>
71
+
72
+ export interface ZeropsSourceConnectionAdminOptions {
73
+ readonly api: SourceConnectionZeropsApi
74
+ readonly source: ZeropsSourceCredentialManager
75
+ readonly projectId: string
76
+ readonly reread?: {
77
+ readonly attempts?: number
78
+ readonly delayMs?: number
79
+ readonly sleep?: (delayMs: number, signal: AbortSignal) => Promise<void>
80
+ }
81
+ }
82
+
83
+ export type SourceConnectionAdminErrorCode =
84
+ | 'invalid_configuration'
85
+ | 'source_not_found'
86
+ | 'source_mismatch'
87
+ | 'credential_conflict'
88
+ | 'credential_persistence'
89
+ | 'credential_activation'
90
+
91
+ /** Stable detail-free failure. It never carries a project id, service id, environment value, or upstream body. */
92
+ export class SourceConnectionAdminError extends Error {
93
+ constructor(readonly code: SourceConnectionAdminErrorCode) {
94
+ super(`source connection administration failed (${code})`)
95
+ this.name = 'SourceConnectionAdminError'
96
+ }
97
+ }
98
+
99
+ interface DurableInspection {
100
+ readonly public: SourceConnectionInspection
101
+ readonly service: ZeropsService
102
+ readonly bundle?: string
103
+ }
104
+
105
+ export function createZeropsSourceConnectionAdmin(options: ZeropsSourceConnectionAdminOptions): SourceConnectionAdmin {
106
+ if (!PROJECT_ID_PATTERN.test(options.projectId)) throw new SourceConnectionAdminError('invalid_configuration')
107
+ const attempts = options.reread?.attempts ?? DEFAULT_REREAD_ATTEMPTS
108
+ const delayMs = options.reread?.delayMs ?? DEFAULT_REREAD_DELAY_MS
109
+ if (!Number.isSafeInteger(attempts) || attempts <= 0 || attempts > 20 || !Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > 5_000) {
110
+ throw new SourceConnectionAdminError('invalid_configuration')
111
+ }
112
+ const sleep = options.reread?.sleep ?? defaultSleep
113
+
114
+ const inspectInternal = async (signal: AbortSignal): Promise<DurableInspection> => {
115
+ throwIfAborted(signal)
116
+ const service = await exactSourceService(options.api, options.projectId, signal)
117
+ let environment: ZeropsServiceEnv[]
118
+ try {
119
+ environment = await options.api.listServiceEnv({ serviceId: service.id, signal })
120
+ } catch (error) {
121
+ if (isAbort(error, signal)) throw error
122
+ throw new SourceConnectionAdminError('credential_persistence')
123
+ }
124
+ throwIfAborted(signal)
125
+ return await withDigest(classify(service, environment))
126
+ }
127
+
128
+ const admin: SourceConnectionAdmin = {
129
+ inspect: async (signal) => (await inspectInternal(signal)).public,
130
+
131
+ adoptExisting: async (input) => {
132
+ const existing = await inspectInternal(input.signal)
133
+ if (
134
+ (existing.public.state !== 'legacy-complete' && existing.public.state !== 'durable')
135
+ || existing.bundle === undefined
136
+ ) throw new SourceConnectionAdminError('credential_conflict')
137
+ let credentialSha256: string
138
+ try {
139
+ credentialSha256 = existing.public.state === 'durable'
140
+ ? existing.public.credentialSha256
141
+ : await sha256ZeropsSourceCredentialBundle(existing.bundle)
142
+ } catch {
143
+ throw new SourceConnectionAdminError('credential_conflict')
144
+ }
145
+ return await admin.activate({
146
+ connectionId: await adoptionConnectionId(options.projectId, credentialSha256),
147
+ credentialBundle: existing.bundle,
148
+ credentialSha256,
149
+ signal: input.signal,
150
+ })
151
+ },
152
+
153
+ activate: async (input) => {
154
+ let digest: string
155
+ try {
156
+ decodeZeropsSourceCredentialBundle(input.credentialBundle)
157
+ digest = await sha256ZeropsSourceCredentialBundle(input.credentialBundle)
158
+ } catch {
159
+ throw new SourceConnectionAdminError('credential_conflict')
160
+ }
161
+ if (digest !== input.credentialSha256) throw new SourceConnectionAdminError('credential_conflict')
162
+ const initial = await inspectInternal(input.signal)
163
+ if (initial.public.state === 'legacy-partial' || initial.public.state === 'unavailable') {
164
+ throw new SourceConnectionAdminError('credential_conflict')
165
+ }
166
+ if (initial.public.state === 'durable') {
167
+ if (initial.bundle !== input.credentialBundle || initial.public.credentialSha256 !== digest) {
168
+ throw new SourceConnectionAdminError('credential_conflict')
169
+ }
170
+ } else {
171
+ if (initial.public.state === 'legacy-complete' && initial.bundle !== input.credentialBundle) {
172
+ throw new SourceConnectionAdminError('credential_conflict')
173
+ }
174
+ try {
175
+ await options.api.createServiceEnv({
176
+ serviceId: initial.service.id,
177
+ key: ZEROPS_SOURCE_CREDENTIAL_ENV,
178
+ value: input.credentialBundle,
179
+ signal: input.signal,
180
+ })
181
+ } catch (error) {
182
+ if (isAbort(error, input.signal)) throw error
183
+ }
184
+ await proveDurable(input.credentialBundle, digest, input.signal, attempts, delayMs, sleep, inspectInternal)
185
+ }
186
+ let activated: ZeropsSourceCredentialActivateResponseV1
187
+ try {
188
+ activated = await options.source.activate(input)
189
+ } catch (error) {
190
+ if (isAbort(error, input.signal)) throw error
191
+ let status: ZeropsSourceCredentialStatusResponseV1
192
+ try {
193
+ status = await options.source.status({ connectionId: input.connectionId, signal: input.signal })
194
+ } catch (statusError) {
195
+ if (isAbort(statusError, input.signal)) throw statusError
196
+ throw new SourceConnectionAdminError('credential_activation')
197
+ }
198
+ if (
199
+ status.state !== 'active' || status.connectionId !== input.connectionId || status.credentialSha256 !== digest
200
+ ) throw new SourceConnectionAdminError('credential_activation')
201
+ activated = {
202
+ protocolVersion: 1,
203
+ connectionId: input.connectionId,
204
+ credentialVersion: status.credentialVersion,
205
+ credentialSha256: status.credentialSha256,
206
+ githubApp: status.githubApp,
207
+ }
208
+ }
209
+ if (activated.connectionId !== input.connectionId || activated.credentialSha256 !== digest) {
210
+ throw new SourceConnectionAdminError('credential_activation')
211
+ }
212
+ return activated
213
+ },
214
+
215
+ status: async (input) => {
216
+ const durable = await inspectInternal(input.signal)
217
+ if (durable.public.state !== 'durable') return durable.public
218
+ let runtime: ZeropsSourceCredentialStatusResponseV1
219
+ try {
220
+ runtime = await options.source.status(input)
221
+ } catch (error) {
222
+ if (isAbort(error, input.signal)) throw error
223
+ throw new SourceConnectionAdminError('credential_activation')
224
+ }
225
+ if (runtime.connectionId !== input.connectionId || runtime.state === 'anonymous') {
226
+ return { state: 'activation-required', credentialSha256: durable.public.credentialSha256 }
227
+ }
228
+ if (runtime.credentialSha256 !== durable.public.credentialSha256) {
229
+ throw new SourceConnectionAdminError('credential_conflict')
230
+ }
231
+ return { state: 'active', credentialSha256: runtime.credentialSha256, githubApp: runtime.githubApp }
232
+ },
233
+
234
+ configureWebhook: async (input) => {
235
+ await requireDurableDigest(input.credentialSha256, input.signal, inspectInternal)
236
+ try {
237
+ const response = await options.source.configureWebhook(input)
238
+ if (
239
+ response.connectionId !== input.connectionId || response.credentialSha256 !== input.credentialSha256
240
+ || response.webhook.url !== input.url || response.webhook.contentType !== 'json' || response.webhook.insecureSsl !== '0'
241
+ ) throw new SourceConnectionAdminError('credential_activation')
242
+ return response
243
+ } catch (error) {
244
+ if (isAbort(error, input.signal)) throw error
245
+ if (error instanceof SourceConnectionAdminError) throw error
246
+ throw new SourceConnectionAdminError('credential_activation')
247
+ }
248
+ },
249
+
250
+ verifyInstallations: async (input) => {
251
+ await requireDurableDigest(input.credentialSha256, input.signal, inspectInternal)
252
+ try {
253
+ const response = await options.source.verifyInstallations(input)
254
+ if (response.connectionId !== input.connectionId || response.credentialSha256 !== input.credentialSha256) {
255
+ throw new SourceConnectionAdminError('credential_activation')
256
+ }
257
+ return response
258
+ } catch (error) {
259
+ if (isAbort(error, input.signal)) throw error
260
+ if (error instanceof SourceConnectionAdminError) throw error
261
+ throw new SourceConnectionAdminError('credential_activation')
262
+ }
263
+ },
264
+ }
265
+ return admin
266
+ }
267
+
268
+ async function adoptionConnectionId(projectId: string, credentialSha256: string): Promise<string> {
269
+ const material = `${ADOPTION_CONNECTION_DOMAIN}\0${projectId}\0${credentialSha256}`
270
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(material)))
271
+ return `zsrc-${[...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
272
+ }
273
+
274
+ async function requireDurableDigest(
275
+ digest: string,
276
+ signal: AbortSignal,
277
+ inspect: (signal: AbortSignal) => Promise<DurableInspection>,
278
+ ): Promise<void> {
279
+ const durable = await inspect(signal)
280
+ if (durable.public.state !== 'durable' || durable.public.credentialSha256 !== digest) {
281
+ throw new SourceConnectionAdminError('credential_conflict')
282
+ }
283
+ }
284
+
285
+ async function exactSourceService(api: SourceConnectionZeropsApi, projectId: string, signal: AbortSignal): Promise<ZeropsService> {
286
+ let service: ZeropsService | null
287
+ try {
288
+ service = await api.findService({ projectId, hostname: SOURCE_HOSTNAME, signal })
289
+ } catch (error) {
290
+ if (isAbort(error, signal)) throw error
291
+ throw new SourceConnectionAdminError('source_not_found')
292
+ }
293
+ if (service === null) throw new SourceConnectionAdminError('source_not_found')
294
+ if (service.id === '' || service.name !== SOURCE_HOSTNAME || service.projectId !== projectId) {
295
+ throw new SourceConnectionAdminError('source_mismatch')
296
+ }
297
+ return service
298
+ }
299
+
300
+ function classify(service: ZeropsService, environment: readonly ZeropsServiceEnv[]): DurableInspection {
301
+ if (environment.length > MAX_ENV_ENTRIES) throw new SourceConnectionAdminError('source_mismatch')
302
+ const bundleEntries = environment.filter((entry) => entry.key === ZEROPS_SOURCE_CREDENTIAL_ENV)
303
+ const legacyIdEntries = environment.filter((entry) => entry.key === ZEROPS_SOURCE_LEGACY_APP_ID_ENV)
304
+ const legacyKeyEntries = environment.filter((entry) => entry.key === ZEROPS_SOURCE_LEGACY_PRIVATE_KEY_ENV)
305
+ if (bundleEntries.length > 1 || legacyIdEntries.length > 1 || legacyKeyEntries.length > 1) {
306
+ throw new SourceConnectionAdminError('credential_conflict')
307
+ }
308
+ const legacyCount = legacyIdEntries.length + legacyKeyEntries.length
309
+ let legacyBundle: string | undefined
310
+ if (legacyIdEntries.length === 1 && legacyKeyEntries.length === 1) {
311
+ const legacyAppId = legacyIdEntries[0]?.content
312
+ const legacyPrivateKeyPem = legacyKeyEntries[0]?.content
313
+ if (legacyAppId === undefined || legacyPrivateKeyPem === undefined) throw new SourceConnectionAdminError('credential_conflict')
314
+ try {
315
+ legacyBundle = serializeZeropsSourceCredentialBundle(buildZeropsSourceCredentialBundle({
316
+ githubAppId: legacyAppId,
317
+ privateKeyPem: legacyPrivateKeyPem,
318
+ }))
319
+ } catch {
320
+ throw new SourceConnectionAdminError('credential_conflict')
321
+ }
322
+ }
323
+ if (bundleEntries.length === 1) {
324
+ if (legacyCount !== 0 && legacyBundle === undefined) throw new SourceConnectionAdminError('credential_conflict')
325
+ const bundle = bundleEntries[0]?.content
326
+ if (bundle === undefined) throw new SourceConnectionAdminError('credential_conflict')
327
+ try {
328
+ decodeZeropsSourceCredentialBundle(bundle)
329
+ } catch {
330
+ throw new SourceConnectionAdminError('credential_conflict')
331
+ }
332
+ if (legacyBundle !== undefined && legacyBundle !== bundle) throw new SourceConnectionAdminError('credential_conflict')
333
+ return { public: { state: 'durable', credentialSha256: '' }, service, bundle }
334
+ }
335
+ if (legacyCount === 0) return { public: { state: 'anonymous' }, service }
336
+ if (legacyBundle !== undefined) return { public: { state: 'legacy-complete' }, service, bundle: legacyBundle }
337
+ return { public: { state: 'legacy-partial' }, service }
338
+ }
339
+
340
+ async function withDigest(inspection: DurableInspection): Promise<DurableInspection> {
341
+ if (inspection.public.state !== 'durable' || inspection.bundle === undefined) return inspection
342
+ return {
343
+ ...inspection,
344
+ public: { state: 'durable', credentialSha256: await sha256ZeropsSourceCredentialBundle(inspection.bundle) },
345
+ }
346
+ }
347
+
348
+ async function proveDurable(
349
+ bundle: string,
350
+ digest: string,
351
+ signal: AbortSignal,
352
+ attempts: number,
353
+ delayMs: number,
354
+ sleep: (delayMs: number, signal: AbortSignal) => Promise<void>,
355
+ inspect: (signal: AbortSignal) => Promise<DurableInspection>,
356
+ ): Promise<void> {
357
+ for (let attempt = 0; attempt < attempts; attempt++) {
358
+ const durable = await withDigest(await inspect(signal))
359
+ if (durable.public.state === 'durable') {
360
+ if (durable.bundle === bundle && durable.public.credentialSha256 === digest) return
361
+ throw new SourceConnectionAdminError('credential_conflict')
362
+ }
363
+ if (durable.public.state !== 'anonymous') throw new SourceConnectionAdminError('credential_conflict')
364
+ if (attempt + 1 < attempts) await sleep(delayMs, signal)
365
+ }
366
+ throw new SourceConnectionAdminError('credential_persistence')
367
+ }
368
+
369
+ const defaultSleep = (delayMs: number, signal: AbortSignal): Promise<void> => {
370
+ throwIfAborted(signal)
371
+ return new Promise((resolve, reject) => {
372
+ const timer = setTimeout(() => {
373
+ cleanup()
374
+ resolve()
375
+ }, delayMs)
376
+ const abort = (): void => {
377
+ cleanup()
378
+ reject(abortError())
379
+ }
380
+ const cleanup = (): void => {
381
+ clearTimeout(timer)
382
+ signal.removeEventListener('abort', abort)
383
+ }
384
+ signal.addEventListener('abort', abort, { once: true })
385
+ })
386
+ }
387
+
388
+ const throwIfAborted = (signal: AbortSignal): void => {
389
+ if (signal.aborted) throw abortError()
390
+ }
391
+
392
+ const isAbort = (error: unknown, signal: AbortSignal): boolean => signal.aborted || (error instanceof Error && error.name === 'AbortError')
393
+
394
+ const abortError = (): DOMException => new DOMException('Source connection administration was aborted', 'AbortError')