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.
- package/LICENSE +661 -0
- package/README.md +149 -0
- package/docs/agentic-setup-github-core-walkthrough.md +139 -0
- package/docs/agentic-setup-stripe-core-walkthrough.md +223 -0
- package/docs/setup-guide.md +422 -0
- package/docs/setup-wizard.md +339 -0
- package/docs/user-guide-stripe.md +215 -0
- package/package.json +35 -0
- package/src/adapters/stripe.ts +172 -0
- package/src/claim-guard.ts +59 -0
- package/src/claim-template.tsx +207 -0
- package/src/claim.tsx +242 -0
- package/src/config.ts +39 -0
- package/src/create-worker.ts +156 -0
- package/src/events.ts +195 -0
- package/src/fetch-entity.ts +79 -0
- package/src/github.ts +140 -0
- package/src/index.production.ts +19 -0
- package/src/index.ts +55 -0
- package/src/kv-keys.ts +22 -0
- package/src/raw-request.ts +24 -0
- package/src/repoaccess.config.ts +39 -0
- package/src/ssrf.ts +156 -0
- package/src/types.ts +238 -0
- package/src/username.ts +15 -0
- package/src/verify.ts +173 -0
- package/src/worker-env.d.ts +14 -0
- package/src/workflow-id.ts +77 -0
- package/src/workflow.ts +926 -0
package/src/workflow.ts
ADDED
|
@@ -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
|
+
}
|