repoaccess-core 0.2.0

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,926 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import {
5
+ WorkflowEntrypoint,
6
+ type WorkflowEvent,
7
+ type WorkflowStep,
8
+ } from 'cloudflare:workers'
9
+ import { NonRetryableError } from 'cloudflare:workflows'
10
+ import type {
11
+ AccessWorkflowParams,
12
+ ApiCallbackPing,
13
+ NormalizedEvent,
14
+ PaymentAdapter,
15
+ ProductConfig,
16
+ ProductTeamMap,
17
+ RawRequest,
18
+ RepoAccessConfig,
19
+ } from './types'
20
+ import { assertProductTeamMap, resolveProductConfig } from './config'
21
+ import { sha256Hex } from './workflow-id'
22
+ import { verifyApiCallback } from './verify'
23
+ import { isValidGithubUsername } from './username'
24
+ import { github, isRateLimited, type GithubResult } from './github'
25
+ import {
26
+ buildEnvelope,
27
+ createEventSink,
28
+ logSink,
29
+ type EnvelopeField,
30
+ type EventSink,
31
+ type OutboundEventType,
32
+ } from './events'
33
+ import {
34
+ CLAIM_TTL_SEC,
35
+ GRANT_TTL_SEC,
36
+ grantKey,
37
+ claimKey,
38
+ claimIndexKey,
39
+ } from './kv-keys'
40
+ import { claimGuard } from './claim-guard'
41
+
42
+ const KV_MIN_TTL_SEC = 60 // Cloudflare KV floor for expirationTtl
43
+
44
+ const MAX_GH_ATTEMPTS = 8 // rate-limit / 5xx backoff cap; durable sleeps span >1 day before giving up
45
+
46
+ // Outbound delivery retry policy. The emit step lets the durable engine retry transient
47
+ // delivery failures; after exhaustion the emit step swallows the error (the grant already happened -
48
+ // delivery must NEVER fail the grant).
49
+ const EMIT_RETRY = {
50
+ retries: { limit: 5, delay: '10 seconds', backoff: 'exponential' },
51
+ } as const
52
+
53
+ // api_callback entity-fetch retry policy. A THROWN fetch error (network/5xx - Gumroad-class APIs can
54
+ // be slow/down) is retried durably by the engine; after exhaustion the step throws and the workflow
55
+ // surfaces access.failed. A returned null (not-found/forged id) is NOT retried - it is terminal.
56
+ const FETCH_ENTITY_RETRY = {
57
+ retries: { limit: 5, delay: '10 seconds', backoff: 'exponential' },
58
+ } as const
59
+
60
+ /**
61
+ * The stable, coarse vocabulary for the `reason` field on a delivered `access.failed` envelope. The
62
+ * wire value is ALWAYS one of these fixed codes - never raw error text, an HTTP status, a team slug, or
63
+ * `String(err)` (those leak internal/inconsistent detail to the seller's endpoint). The full,
64
+ * descriptive detail is preserved in the structured `log()` calls (the `detail` field) so debugging
65
+ * loses nothing. Pre-0.2.0 hardening (Info-1): the event SHAPE is unchanged (`reason` stays a string),
66
+ * only its VALUE space is fixed.
67
+ *
68
+ * - invalid_username handle absent/malformed/nonexistent where a valid one was required
69
+ * - github_error a GitHub API call failed un-correctably (auth/permission/validation/status)
70
+ * - fetch_failed an api_callback entity fetch threw, or returned not-found/unverifiable
71
+ * - parse_failed an api_callback adapter's parse() threw on the fetched entity
72
+ * - unhandled_event the fetched entity parsed to null (an event kind we don't act on)
73
+ * - unverifiable_adapter the enqueued api_callback adapter wasn't passed to createAccessWorkflow
74
+ * - grant_error a grant died on an exhausted-retry / unexpected throw (terminal catch)
75
+ */
76
+ export type AccessFailedReason =
77
+ | 'invalid_username'
78
+ | 'github_error'
79
+ | 'fetch_failed'
80
+ | 'parse_failed'
81
+ | 'unhandled_event'
82
+ | 'unverifiable_adapter'
83
+ | 'grant_error'
84
+
85
+ interface GrantRecord {
86
+ github_username: string
87
+ org: string
88
+ teams: string[]
89
+ product_id: string
90
+ granted_at: string
91
+ }
92
+
93
+ function log(
94
+ level: string,
95
+ msg: string,
96
+ extra: Record<string, unknown> = {},
97
+ ): void {
98
+ console.log(JSON.stringify({ level, msg, ...extra }))
99
+ }
100
+
101
+ function backoffMs(result: GithubResult, attempt: number): number {
102
+ if (result.retryAfterSec !== null)
103
+ return Math.max(1, result.retryAfterSec) * 1000
104
+ return Math.min(60 * 2 ** attempt, 3600) * 1000 // 1 min → cap 1 h (hours-scale, durable)
105
+ }
106
+
107
+ /**
108
+ * Run one GitHub op inside a durable step. 5xx and rate-limit (429 / 403+signal) → `step.sleep`
109
+ * backoff and retry (NEVER fail the grant on a transient/limit) up to a generous cap.
110
+ * Returns the (serializable) result for the caller to classify (e.g. 404 vs 200).
111
+ */
112
+ async function ghStep(
113
+ step: WorkflowStep,
114
+ env: CloudflareBindings,
115
+ label: string,
116
+ op: (env: CloudflareBindings) => Promise<GithubResult>,
117
+ ): Promise<GithubResult> {
118
+ for (let attempt = 0; attempt <= MAX_GH_ATTEMPTS; attempt++) {
119
+ const result = await step.do(`${label}#${attempt}`, () => op(env))
120
+ if (result.status < 500 && !isRateLimited(result)) return result
121
+ if (attempt === MAX_GH_ATTEMPTS) {
122
+ throw new NonRetryableError(
123
+ `${label}: GitHub unavailable after ${attempt} retries (last status ${result.status})`,
124
+ )
125
+ }
126
+ log('warn', 'github backoff', { label, attempt, status: result.status })
127
+ await step.sleep(`${label} backoff#${attempt}`, backoffMs(result, attempt))
128
+ }
129
+ throw new NonRetryableError(`${label}: exhausted`)
130
+ }
131
+
132
+ async function emitEvent(
133
+ step: WorkflowStep,
134
+ org: string,
135
+ sink: EventSink,
136
+ type: OutboundEventType,
137
+ event: NormalizedEvent,
138
+ extra: Record<string, EnvelopeField>,
139
+ ): Promise<void> {
140
+ try {
141
+ // The sink throws on a transient delivery failure → the durable engine retries this step per
142
+ // EMIT_RETRY. Side-effect-free retry: re-running only re-sends the event.
143
+ await step.do(
144
+ `emit:${type}:${event.transaction_id}`,
145
+ EMIT_RETRY,
146
+ async () => {
147
+ const envelope = buildEnvelope(org, type, event, extra)
148
+ await sink(envelope)
149
+ return envelope
150
+ },
151
+ )
152
+ } catch (err) {
153
+ // Retries exhausted (or a non-retryable error). Log and continue - the grant already happened;
154
+ // outbound delivery must never fail it.
155
+ log('warn', 'event delivery exhausted', {
156
+ type,
157
+ transaction_id: event.transaction_id,
158
+ error: String(err),
159
+ })
160
+ }
161
+ }
162
+
163
+ function generateToken(): string {
164
+ const bytes = crypto.getRandomValues(new Uint8Array(32)) // 256-bit, ≥128-bit requirement
165
+ return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
166
+ }
167
+
168
+ function collectAllTeams(map: ProductTeamMap): string[] {
169
+ const teams = new Set<string>()
170
+ const add = (cfg: ProductConfig | undefined) => {
171
+ for (const slug of cfg?.teams ?? []) teams.add(slug)
172
+ }
173
+ for (const [key, value] of Object.entries(map)) {
174
+ if (key === 'defaults') add(value as ProductConfig)
175
+ else
176
+ for (const cfg of Object.values(value as Record<string, ProductConfig>))
177
+ add(cfg)
178
+ }
179
+ return [...teams]
180
+ }
181
+
182
+ // --- grant ------------------------------------------------------------------
183
+
184
+ async function runGrant(
185
+ step: WorkflowStep,
186
+ env: CloudflareBindings,
187
+ org: string,
188
+ adapter: string,
189
+ event: NormalizedEvent,
190
+ config: ProductConfig,
191
+ sink: EventSink,
192
+ fromClaim: boolean,
193
+ ): Promise<void> {
194
+ const teams = config.teams ?? []
195
+ // Claim completion forces `username` mode - the product's mode is `claim`, which would loop back
196
+ // into another claim. The handle was already validated at the claim POST.
197
+ const mode = fromClaim ? 'username' : (config.grant_mode ?? 'claim')
198
+ const username = event.github_username
199
+
200
+ // `username` mode requires a present + well-formed handle; otherwise fall back to `claim`.
201
+ // (`email` mode is not in v1 → also falls back to claim.) Malformed handles never reach the API,
202
+ // so they can't burn the 50/24h invitation quota.
203
+ if (mode !== 'username' || !isValidGithubUsername(username)) {
204
+ if (fromClaim) {
205
+ // Unreachable in practice (the claim POST validates first); fail loudly rather than spawn
206
+ // another claim and loop. Not a user-not-found → consume the token.
207
+ await terminalFailure(
208
+ step,
209
+ env,
210
+ org,
211
+ adapter,
212
+ event,
213
+ sink,
214
+ teams,
215
+ username,
216
+ 'invalid_username',
217
+ 'claim completion with invalid username',
218
+ false,
219
+ true,
220
+ )
221
+ return
222
+ }
223
+ const reason =
224
+ mode === 'username'
225
+ ? username
226
+ ? 'malformed username'
227
+ : 'no username'
228
+ : mode === 'email'
229
+ ? 'email mode not supported'
230
+ : 'claim mode'
231
+ await runClaimFallback(step, env, org, adapter, event, teams, sink, reason)
232
+ return
233
+ }
234
+
235
+ const grantedTeams: string[] = []
236
+ for (const slug of teams) {
237
+ // Reconcile: 200 = already active OR pending → converged, skip. 404 = not a member → invite.
238
+ const current = await ghStep(
239
+ step,
240
+ env,
241
+ `team-get:${slug}:${username}`,
242
+ (e) => github.getTeamMembership(e, org, slug, username),
243
+ )
244
+ if (current.status === 200) {
245
+ grantedTeams.push(slug)
246
+ continue
247
+ }
248
+ if (current.status !== 404) {
249
+ // Not user-correctable (auth/permission/unexpected) → consume any claim token.
250
+ await terminalFailure(
251
+ step,
252
+ env,
253
+ org,
254
+ adapter,
255
+ event,
256
+ sink,
257
+ teams,
258
+ username,
259
+ 'github_error',
260
+ `team-get ${slug} → ${current.status}`,
261
+ false,
262
+ fromClaim,
263
+ )
264
+ return
265
+ }
266
+ // PUT auto-invites non-members; an existing org member is added directly.
267
+ const put = await ghStep(step, env, `team-put:${slug}:${username}`, (e) =>
268
+ github.addTeamMembership(e, org, slug, username),
269
+ )
270
+ if (put.status !== 200 && put.status !== 201) {
271
+ const userNotFound = put.status === 404
272
+ // 404 = the GitHub login does not exist (user not found). For a NON-claim grant the buyer's
273
+ // up-front handle was simply wrong; this 404 lands on the FIRST team-add (a nonexistent login
274
+ // fails immediately, so no teams are granted yet), so fall the whole grant back to a claim -
275
+ // mint a token + emit claim.pending so the buyer can self-correct, rather than terminal
276
+ // access.failed. A claim-originated 404 instead retains the existing token for a fixed resubmit.
277
+ if (userNotFound && !fromClaim) {
278
+ await runClaimFallback(
279
+ step,
280
+ env,
281
+ org,
282
+ adapter,
283
+ event,
284
+ teams,
285
+ sink,
286
+ 'username not found, falling back to claim',
287
+ )
288
+ return
289
+ }
290
+ // Otherwise terminal: a claim-originated 404 RETAINS the token for a corrected resubmit
291
+ // (userNotFound); 422 etc. are not correctable here. A 404 means the GitHub login doesn't exist
292
+ // (invalid_username); any other status is an un-correctable GitHub error.
293
+ await terminalFailure(
294
+ step,
295
+ env,
296
+ org,
297
+ adapter,
298
+ event,
299
+ sink,
300
+ teams,
301
+ username,
302
+ userNotFound ? 'invalid_username' : 'github_error',
303
+ `team-put ${slug} → ${put.status}`,
304
+ userNotFound,
305
+ fromClaim,
306
+ )
307
+ return
308
+ }
309
+ grantedTeams.push(slug)
310
+ }
311
+
312
+ const record: GrantRecord = {
313
+ github_username: username,
314
+ org,
315
+ teams: grantedTeams,
316
+ product_id: event.product_id,
317
+ granted_at: new Date().toISOString(),
318
+ }
319
+ await step.do(`grant-record:${adapter}:${event.transaction_id}`, async () => {
320
+ // 180d TTL - covers the refund + ~120d card-chargeback window so a late chargeback can
321
+ // still resolve; also deleted on revoke.
322
+ await env.ENTITLEMENTS.put(
323
+ grantKey(adapter, event.transaction_id),
324
+ JSON.stringify(record),
325
+ { expirationTtl: GRANT_TTL_SEC },
326
+ )
327
+ return true
328
+ })
329
+
330
+ await emitEvent(step, org, sink, 'access.granted', event, {
331
+ github_username: username,
332
+ teams: grantedTeams,
333
+ status: 'success',
334
+ })
335
+
336
+ // A grant that originated from a completed claim also closes the claim: emit claim.completed and
337
+ // consume the single-use token (+ reverse index) here in the workflow's terminal step - NOT at the
338
+ // route, so a failed attempt can retain the token for a corrected retry.
339
+ if (fromClaim) {
340
+ await emitEvent(step, org, sink, 'claim.completed', event, {
341
+ github_username: username,
342
+ teams: grantedTeams,
343
+ status: 'success',
344
+ })
345
+ await consumeClaim(step, env, adapter, event.transaction_id)
346
+ await guardFinalize(step, env, adapter, event.transaction_id)
347
+ }
348
+ }
349
+
350
+ async function runClaimFallback(
351
+ step: WorkflowStep,
352
+ env: CloudflareBindings,
353
+ org: string,
354
+ adapter: string,
355
+ event: NormalizedEvent,
356
+ teams: string[],
357
+ sink: EventSink,
358
+ reason: string,
359
+ ): Promise<void> {
360
+ log('info', 'grant → claim fallback', {
361
+ adapter,
362
+ transaction_id: event.transaction_id,
363
+ reason,
364
+ })
365
+ const token = await step.do(
366
+ `claim-token:${adapter}:${event.transaction_id}`,
367
+ async () => {
368
+ const t = generateToken()
369
+ // Anchor expiry at creation (epoch seconds) so re-puts (last_error) preserve it, never
370
+ // resetting a fresh 30 days.
371
+ const expiresAt = Math.floor(Date.now() / 1000) + CLAIM_TTL_SEC
372
+ const pending = JSON.stringify({
373
+ adapter,
374
+ product_id: event.product_id,
375
+ teams,
376
+ buyer_email: event.buyer_email,
377
+ transaction_id: event.transaction_id,
378
+ expires_at: expiresAt,
379
+ })
380
+ await env.ENTITLEMENTS.put(claimKey(t), pending, {
381
+ expirationTtl: CLAIM_TTL_SEC,
382
+ })
383
+ await env.ENTITLEMENTS.put(
384
+ claimIndexKey(adapter, event.transaction_id),
385
+ t,
386
+ {
387
+ expirationTtl: CLAIM_TTL_SEC,
388
+ },
389
+ )
390
+ return t
391
+ },
392
+ )
393
+
394
+ // claim_url is relative until the claim page lands / the seller prepends their domain.
395
+ await emitEvent(step, org, sink, 'claim.pending', event, {
396
+ claim_url: `/claim/${token}`,
397
+ teams,
398
+ })
399
+ }
400
+
401
+ async function fail(
402
+ step: WorkflowStep,
403
+ org: string,
404
+ event: NormalizedEvent,
405
+ sink: EventSink,
406
+ teams: string[],
407
+ username: string | null,
408
+ reason: AccessFailedReason,
409
+ detail?: string,
410
+ ): Promise<void> {
411
+ // The wire envelope carries ONLY the coarse code; the raw detail stays in the log.
412
+ log('error', 'grant failed', {
413
+ transaction_id: event.transaction_id,
414
+ username,
415
+ reason,
416
+ detail,
417
+ })
418
+ await emitEvent(step, org, sink, 'access.failed', event, {
419
+ github_username: username,
420
+ teams,
421
+ status: 'failure',
422
+ reason,
423
+ })
424
+ }
425
+
426
+ /**
427
+ * A terminal grant failure. Always emits `access.failed` (seller-facing). For a claim-originated
428
+ * grant it then manages the claim-token lifecycle:
429
+ * - `userNotFound` (GitHub login doesn't exist) is buyer-correctable → RETAIN the token and stamp
430
+ * `last_error` so `GET /claim/:token` re-shows the form with the error;
431
+ * - any other terminal failure is not correctable on the claim page → consume the token.
432
+ */
433
+ async function terminalFailure(
434
+ step: WorkflowStep,
435
+ env: CloudflareBindings,
436
+ org: string,
437
+ adapter: string,
438
+ event: NormalizedEvent,
439
+ sink: EventSink,
440
+ teams: string[],
441
+ username: string | null,
442
+ reason: AccessFailedReason,
443
+ detail: string | undefined,
444
+ userNotFound: boolean,
445
+ fromClaim: boolean,
446
+ ): Promise<void> {
447
+ await fail(step, org, event, sink, teams, username, reason, detail)
448
+ if (!fromClaim) return
449
+ if (userNotFound) {
450
+ // Buyer-correctable → retain the token AND release the single-flight lock so a later sequential
451
+ // resubmit with a corrected handle can acquire.
452
+ await recordClaimError(
453
+ step,
454
+ env,
455
+ adapter,
456
+ event.transaction_id,
457
+ `GitHub user "${username}" was not found - check the spelling and try again.`,
458
+ )
459
+ await guardRelease(step, env, adapter, event.transaction_id)
460
+ } else {
461
+ // Not correctable here → consume the token and lock the claim for good.
462
+ await consumeClaim(step, env, adapter, event.transaction_id)
463
+ await guardFinalize(step, env, adapter, event.transaction_id)
464
+ }
465
+ }
466
+
467
+ /** Release the single-flight lock (back to idle) so a corrected sequential resubmit can acquire. */
468
+ async function guardRelease(
469
+ step: WorkflowStep,
470
+ env: CloudflareBindings,
471
+ adapter: string,
472
+ txn: string,
473
+ ): Promise<void> {
474
+ await step.do(`claim-guard-release:${adapter}:${txn}`, async () => {
475
+ await claimGuard(env, adapter, txn).release()
476
+ return true
477
+ })
478
+ }
479
+
480
+ /** Lock the claim terminally (granted/closed) so no further attempt can acquire. */
481
+ async function guardFinalize(
482
+ step: WorkflowStep,
483
+ env: CloudflareBindings,
484
+ adapter: string,
485
+ txn: string,
486
+ ): Promise<void> {
487
+ await step.do(`claim-guard-finalize:${adapter}:${txn}`, async () => {
488
+ await claimGuard(env, adapter, txn).finalize()
489
+ return true
490
+ })
491
+ }
492
+
493
+ /** Delete the single-use claim token (+ reverse index) for this transaction, if one exists. */
494
+ async function consumeClaim(
495
+ step: WorkflowStep,
496
+ env: CloudflareBindings,
497
+ adapter: string,
498
+ txn: string,
499
+ ): Promise<void> {
500
+ await step.do(`claim-consume:${adapter}:${txn}`, async () => {
501
+ const token = await env.ENTITLEMENTS.get(claimIndexKey(adapter, txn))
502
+ if (token) {
503
+ await env.ENTITLEMENTS.delete(claimKey(token))
504
+ await env.ENTITLEMENTS.delete(claimIndexKey(adapter, txn))
505
+ }
506
+ return true
507
+ })
508
+ }
509
+
510
+ /** Stamp `last_error` on the retained claim record so the buyer sees it on GET and can retry. */
511
+ async function recordClaimError(
512
+ step: WorkflowStep,
513
+ env: CloudflareBindings,
514
+ adapter: string,
515
+ txn: string,
516
+ message: string,
517
+ ): Promise<void> {
518
+ await step.do(`claim-error:${adapter}:${txn}`, async () => {
519
+ const token = await env.ENTITLEMENTS.get(claimIndexKey(adapter, txn))
520
+ if (!token) return false
521
+ const claim = (await env.ENTITLEMENTS.get(
522
+ claimKey(token),
523
+ 'json',
524
+ )) as Record<string, unknown> | null
525
+ if (!claim) return false
526
+ claim.last_error = message
527
+ // KV has no in-place update; re-put must restate the TTL. Preserve the ORIGINAL expiry anchored at
528
+ // creation - never reset to a fresh 30 days, or repeated failures would extend the
529
+ // token indefinitely. Floor at the KV minimum; fall back to a full window for legacy records.
530
+ const expiresAt =
531
+ typeof claim.expires_at === 'number' ? claim.expires_at : null
532
+ const ttl = expiresAt
533
+ ? Math.max(KV_MIN_TTL_SEC, expiresAt - Math.floor(Date.now() / 1000))
534
+ : CLAIM_TTL_SEC
535
+ await env.ENTITLEMENTS.put(claimKey(token), JSON.stringify(claim), {
536
+ expirationTtl: ttl,
537
+ })
538
+ return true
539
+ })
540
+ }
541
+
542
+ // --- revoke -----------------------------------------------------------------
543
+
544
+ async function runRevoke(
545
+ step: WorkflowStep,
546
+ env: CloudflareBindings,
547
+ org: string,
548
+ adapter: string,
549
+ event: NormalizedEvent,
550
+ map: ProductTeamMap,
551
+ sink: EventSink,
552
+ ): Promise<void> {
553
+ // Read the grant record FIRST - it, not the event, is the authoritative source of which product was
554
+ // sold. Refund/adjustment events frequently lack a usable product_id (e.g. a Paddle adjustment
555
+ // references item_id, so product_id is ''), so resolving the revoke policy from the EVENT would fall
556
+ // through to `defaults` (log_only) and wrongly SKIP an auto_revoke product. Resolve the policy from
557
+ // the GRANT RECORD's product_id instead.
558
+ const record = (await step.do(
559
+ `grant-read:${adapter}:${event.transaction_id}`,
560
+ () => env.ENTITLEMENTS.get(grantKey(adapter, event.transaction_id), 'json'),
561
+ )) as GrantRecord | null
562
+
563
+ if (!record) {
564
+ // No grant record (refund events often lack github_username) → can't resolve teams. Warn and
565
+ // stop; reconciliation from buyer_email needs an email→login index we don't keep in v1.
566
+ log('warn', 'revoke: grant record absent', {
567
+ transaction_id: event.transaction_id,
568
+ has_buyer_email: Boolean(event.buyer_email), // never log raw PII
569
+ })
570
+ return
571
+ }
572
+
573
+ const config = resolveProductConfig(map, adapter, record.product_id)
574
+ const policy = config.revoke_policy ?? { mode: 'log_only' }
575
+
576
+ if (policy.mode !== 'auto_revoke') {
577
+ log('info', 'revoke skipped: log_only', {
578
+ transaction_id: event.transaction_id,
579
+ })
580
+ return
581
+ }
582
+ // Partial refund does NOT revoke; chargeback (is_full_refund null) always revokes.
583
+ if (
584
+ event.event_type === 'refund' &&
585
+ policy.full_refund_only &&
586
+ event.is_full_refund !== true
587
+ ) {
588
+ log('info', 'revoke skipped: partial refund', {
589
+ transaction_id: event.transaction_id,
590
+ })
591
+ return
592
+ }
593
+
594
+ const username = record.github_username
595
+ const teams = record.teams ?? []
596
+
597
+ for (const slug of teams) {
598
+ // DELETE is idempotent: 204 (removed) or 404 (already gone) both converge.
599
+ await ghStep(step, env, `team-del:${slug}:${username}`, (e) =>
600
+ github.removeTeamMembership(e, org, slug, username),
601
+ )
602
+ }
603
+
604
+ // Cancel any pending org invitation for this user.
605
+ const invites = await ghStep(step, env, 'invites-list', (e) =>
606
+ github.listInvitations(e, org),
607
+ )
608
+ if (Array.isArray(invites.json)) {
609
+ for (const invite of invites.json as Array<{
610
+ id?: number
611
+ login?: string
612
+ }>) {
613
+ if (invite.login === username && typeof invite.id === 'number') {
614
+ await ghStep(step, env, `invite-cancel:${invite.id}`, (e) =>
615
+ github.cancelInvitation(e, org, invite.id as number),
616
+ )
617
+ }
618
+ }
619
+ }
620
+
621
+ // Reconcile org membership against LIVE state: drop org membership only if the user is in no
622
+ // product team anymore (they may hold other entitlements). Never a KV scan.
623
+ let stillInATeam = false
624
+ for (const slug of collectAllTeams(map)) {
625
+ const m = await ghStep(step, env, `team-check:${slug}:${username}`, (e) =>
626
+ github.getTeamMembership(e, org, slug, username),
627
+ )
628
+ if (m.status === 200) {
629
+ stillInATeam = true
630
+ break
631
+ }
632
+ }
633
+ if (!stillInATeam) {
634
+ await ghStep(step, env, `org-del:${username}`, (e) =>
635
+ github.removeOrgMembership(e, org, username),
636
+ )
637
+ }
638
+
639
+ // Clean up KV: pending claim (if any) + the grant record.
640
+ await step.do(`cleanup:${adapter}:${event.transaction_id}`, async () => {
641
+ const token = await env.ENTITLEMENTS.get(
642
+ claimIndexKey(adapter, event.transaction_id),
643
+ )
644
+ if (token) {
645
+ await env.ENTITLEMENTS.delete(claimKey(token))
646
+ await env.ENTITLEMENTS.delete(
647
+ claimIndexKey(adapter, event.transaction_id),
648
+ )
649
+ }
650
+ await env.ENTITLEMENTS.delete(grantKey(adapter, event.transaction_id))
651
+ return true
652
+ })
653
+
654
+ await emitEvent(step, org, sink, 'access.revoked', event, {
655
+ github_username: username,
656
+ teams,
657
+ trigger: event.event_type,
658
+ })
659
+ }
660
+
661
+ // --- api_callback (fetch-in-workflow) ---------------------------------------
662
+
663
+ type ApiCallbackResolution =
664
+ | { ok: true; event: NormalizedEvent }
665
+ | { ok: false; reason: AccessFailedReason; detail?: string }
666
+
667
+ /**
668
+ * Resolve a `NormalizedEvent` for an api_callback ping by fetching the authoritative entity and
669
+ * parsing it - the never-trust-the-payload anchor. The fetch + parse run in ONE durable step so the
670
+ * opaque `VerifiedEntity` never has to cross the step boundary (it may not be JSON-serializable) and
671
+ * `parse` (pure) stays beside the fetch. A THROWN fetch error retries per FETCH_ENTITY_RETRY; a null
672
+ * entity (forged/unknown id) and a null/throwing parse (unhandled) are terminal.
673
+ */
674
+ async function resolveApiCallbackEvent(
675
+ step: WorkflowStep,
676
+ env: CloudflareBindings,
677
+ adapterName: string,
678
+ adapters: PaymentAdapter[],
679
+ ping: ApiCallbackPing,
680
+ ): Promise<ApiCallbackResolution> {
681
+ const adapter = adapters.find((a) => a.name === adapterName)
682
+ if (!adapter || adapter.verification.kind !== 'api_callback') {
683
+ // Fail-closed: the deploy enqueued an api_callback ping but didn't pass this adapter to
684
+ // createAccessWorkflow(config, adapters). Terminal - never grant from an unverifiable ping.
685
+ return {
686
+ ok: false,
687
+ reason: 'unverifiable_adapter',
688
+ detail: `no api_callback adapter "${adapterName}" in the workflow's adapter set`,
689
+ }
690
+ }
691
+ const strategy = adapter.verification
692
+ // Minimal RawRequest from the enqueued ping. Headers are intentionally empty - the entity is
693
+ // fetched from the provider API, never derived from (untrusted) inbound headers.
694
+ const raw: RawRequest = {
695
+ bodyText: ping.bodyText,
696
+ bodyForm: new URLSearchParams(ping.form),
697
+ headers: new Headers(),
698
+ }
699
+ return step.do(
700
+ `fetch-entity:${adapterName}`,
701
+ FETCH_ENTITY_RETRY,
702
+ async () => {
703
+ // Reuse the engine's fetch + null-reject (the single audited "never trust the payload" point); it
704
+ // just runs here, inside a durable retriable step, instead of on the ack path. The opaque entity
705
+ // is consumed by parse() in THIS step, so it never crosses the step boundary (where it might not be
706
+ // JSON-serializable).
707
+ const verified = await verifyApiCallback(strategy, raw, env)
708
+ if (!verified.ok) {
709
+ return {
710
+ ok: false,
711
+ reason: 'fetch_failed',
712
+ detail: 'entity fetch failed or not found',
713
+ } as const
714
+ }
715
+ let parsed: NormalizedEvent | null
716
+ try {
717
+ parsed = adapter.parse(raw, verified.entity)
718
+ } catch (err) {
719
+ return {
720
+ ok: false,
721
+ reason: 'parse_failed',
722
+ detail: `parse error: ${String(err)}`,
723
+ } as const
724
+ }
725
+ if (!parsed) return { ok: false, reason: 'unhandled_event' } as const
726
+ return { ok: true, event: parsed } as const
727
+ },
728
+ )
729
+ }
730
+
731
+ /**
732
+ * Emit access.failed for an api_callback ping that never resolved to a real event (entity fetch
733
+ * failed/not found, or parse returned null). No NormalizedEvent exists, so synthesize a minimal one
734
+ * whose transaction_id is derived (deterministically) from the ping body for correlation.
735
+ */
736
+ async function emitApiCallbackFailure(
737
+ step: WorkflowStep,
738
+ org: string,
739
+ sink: EventSink,
740
+ ping: ApiCallbackPing,
741
+ reason: AccessFailedReason,
742
+ detail?: string,
743
+ ): Promise<void> {
744
+ const digest = await sha256Hex(ping.bodyText)
745
+ const synthetic: NormalizedEvent = {
746
+ event_type: 'payment_success',
747
+ product_id: '',
748
+ transaction_id: `apicallback-${digest.slice(0, 32)}`,
749
+ buyer_email: null,
750
+ github_username: null,
751
+ is_full_refund: null,
752
+ }
753
+ // The wire envelope carries ONLY the coarse code; the raw detail stays in the log.
754
+ log('error', 'api_callback resolution failed', {
755
+ adapter_event: 'access.failed',
756
+ transaction_id: synthetic.transaction_id,
757
+ reason,
758
+ detail,
759
+ })
760
+ await emitEvent(step, org, sink, 'access.failed', synthetic, {
761
+ github_username: null,
762
+ teams: [],
763
+ status: 'failure',
764
+ reason,
765
+ })
766
+ }
767
+
768
+ // --- entrypoint -------------------------------------------------------------
769
+
770
+ /**
771
+ * Grant vs revoke decided by `event.event_type`. Reconciliation-based: every step reads
772
+ * current GitHub state and converges, so duplicate/retried runs are no-ops. `appConfig` carries the
773
+ * deployment config (org + product map) - no longer read from env vars. `sink` is
774
+ * injectable for tests; production uses the signed-HTTP delivery sink (`createEventSink`).
775
+ *
776
+ * `adapters` is needed ONLY for api_callback pings (to run the in-workflow entity fetch + parse);
777
+ * hmac enqueues carry the already-parsed `event` and ignore it (default `[]`).
778
+ */
779
+ export async function executeAccessWorkflow(
780
+ step: WorkflowStep,
781
+ env: CloudflareBindings,
782
+ appConfig: RepoAccessConfig,
783
+ params: AccessWorkflowParams,
784
+ sink: EventSink = logSink,
785
+ adapters: PaymentAdapter[] = [],
786
+ ): Promise<void> {
787
+ const { adapter } = params
788
+ const org = appConfig.githubOrg
789
+ const map = assertProductTeamMap(appConfig.productTeamMap)
790
+
791
+ // Resolve the NormalizedEvent. hmac path: parsed on the (fast) ack path → carried in params.event.
792
+ // api_callback path: fetch the authoritative entity + parse here, in a durable step (outbound I/O
793
+ // kept off the ack path; the ping body is never trusted).
794
+ let event: NormalizedEvent
795
+ if (params.ping) {
796
+ let resolution: ApiCallbackResolution
797
+ try {
798
+ resolution = await resolveApiCallbackEvent(
799
+ step,
800
+ env,
801
+ adapter,
802
+ adapters,
803
+ params.ping,
804
+ )
805
+ } catch (err) {
806
+ // Transient fetch retries exhausted (or unexpected throw). Emit access.failed, then re-throw to
807
+ // mark the instance failed for observability (mirrors the grant exhausted-retry path).
808
+ await emitApiCallbackFailure(
809
+ step,
810
+ org,
811
+ sink,
812
+ params.ping,
813
+ 'fetch_failed',
814
+ `entity fetch error: ${String(err)}`,
815
+ )
816
+ throw err
817
+ }
818
+ if (!resolution.ok) {
819
+ // Clean terminal (forged/unknown id, or unhandled event) → access.failed, no retry storm.
820
+ await emitApiCallbackFailure(
821
+ step,
822
+ org,
823
+ sink,
824
+ params.ping,
825
+ resolution.reason,
826
+ resolution.detail,
827
+ )
828
+ return
829
+ }
830
+ event = resolution.event
831
+ } else if (params.event) {
832
+ event = params.event
833
+ } else {
834
+ log('error', 'workflow params missing both event and ping', { adapter })
835
+ return
836
+ }
837
+
838
+ const config = resolveProductConfig(map, adapter, event.product_id)
839
+
840
+ try {
841
+ if (event.event_type === 'payment_success') {
842
+ await runGrant(
843
+ step,
844
+ env,
845
+ org,
846
+ adapter,
847
+ event,
848
+ config,
849
+ sink,
850
+ Boolean(params.from_claim),
851
+ )
852
+ } else {
853
+ // runRevoke resolves its own product config from the grant record's product_id - the event's
854
+ // product_id is unreliable on refund/adjustment events (see runRevoke).
855
+ await runRevoke(step, env, org, adapter, event, map, sink)
856
+ }
857
+ } catch (err) {
858
+ log('error', 'workflow terminal failure', {
859
+ adapter,
860
+ transaction_id: event.transaction_id,
861
+ event_type: event.event_type,
862
+ error: String(err),
863
+ })
864
+ // Surface an access.failed for grants that died on an exhausted-retry / unexpected error. The wire
865
+ // reason is the coarse code; the raw String(err) detail is in the log line above.
866
+ if (event.event_type === 'payment_success') {
867
+ await emitEvent(step, org, sink, 'access.failed', event, {
868
+ github_username: event.github_username,
869
+ teams: config.teams ?? [],
870
+ status: 'failure',
871
+ reason: 'grant_error',
872
+ })
873
+ // A claim-originated grant that died on a transient/unexpected error must release its
874
+ // single-flight lock so the buyer can resubmit (the token was retained).
875
+ if (params.from_claim) {
876
+ await guardRelease(step, env, adapter, event.transaction_id)
877
+ }
878
+ }
879
+ throw err // mark the Workflow instance failed for observability (the event already fired)
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Factory for the single static Workflow class, bound to a deployment `config`. The
885
+ * `AccessWorkflow` is instantiated by the Workers runtime - NOT by `createWorker` - so the
886
+ * `createWorker` closure can't reach it; the factory's closure is how config crosses that boundary.
887
+ * The user entry exports the result under the name wrangler's `class_name` expects:
888
+ *
889
+ * const adapters = [stripe]
890
+ * export class AccessWorkflow extends createAccessWorkflow(config, adapters) {}
891
+ *
892
+ * `extends <factory()>` (not a bare `const`) so the export is a class - a value AND a type - which
893
+ * `worker-configuration.d.ts` references as `import('./src/index').AccessWorkflow`.
894
+ * `run()` reads SECRETS from `this.env` (GITHUB_TOKEN, EVENT_WEBHOOK_SECRET) and everything else from
895
+ * the closed-over `config`. Grant/revoke are run params.
896
+ *
897
+ * `adapters` (additive 2nd param, default `[]`) is the SAME list passed to `createWorker` - the
898
+ * Workflow needs it ONLY to run an api_callback adapter's `fetchEntity` + `parse` in-step. A deploy
899
+ * with only hmac adapters can omit it; one composing an api_callback adapter MUST pass it, or
900
+ * api_callback pings fail closed (access.failed, no grant). Pre-0.2.0 signature change (intentional).
901
+ */
902
+ export function createAccessWorkflow(
903
+ config: RepoAccessConfig,
904
+ adapters: PaymentAdapter[] = [],
905
+ ) {
906
+ return class extends WorkflowEntrypoint<
907
+ CloudflareBindings,
908
+ AccessWorkflowParams
909
+ > {
910
+ async run(
911
+ event: WorkflowEvent<AccessWorkflowParams>,
912
+ step: WorkflowStep,
913
+ ): Promise<void> {
914
+ // Production sink = structured log + signed HTTP delivery. Tests call executeAccessWorkflow
915
+ // directly with their own sink.
916
+ await executeAccessWorkflow(
917
+ step,
918
+ this.env,
919
+ config,
920
+ event.payload,
921
+ createEventSink(this.env, config),
922
+ adapters,
923
+ )
924
+ }
925
+ }
926
+ }