repoaccess-core 0.2.3 → 0.2.5
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/README.md +5 -0
- package/docs/user-guide-stripe.md +39 -3
- package/package.json +2 -2
- package/src/adapters/stripe.ts +3 -0
- package/src/claim-template.tsx +56 -0
- package/src/claim.tsx +126 -1
- package/src/create-worker.ts +10 -1
- package/src/kv-keys.ts +22 -0
- package/src/types.ts +6 -0
- package/src/workflow.ts +82 -4
package/README.md
CHANGED
|
@@ -57,6 +57,11 @@ org and team plus the privacy settings that keep your repos private, the Stripe
|
|
|
57
57
|
your secrets, deploy, and a live end-to-end test. It edits the config files for you and guides the
|
|
58
58
|
dashboard clicks (no digging through docs), and it never sees your secret values.
|
|
59
59
|
|
|
60
|
+
During the wizard, your coding agent runs setup commands (wrangler, npm) and edits two config files
|
|
61
|
+
(`src/repoaccess.config.ts` and `wrangler.jsonc`) for you. It will ask you to approve each step -
|
|
62
|
+
approve them to continue (you can pick 'yes, and don't ask again' for a repeated command). The agent
|
|
63
|
+
never reads your secret file (`.dev.vars`); you paste your secrets there yourself.
|
|
64
|
+
|
|
60
65
|
**Other agents (Codex, OpenCode, Cursor, ...):** the wizard is agent-agnostic. Claude Code and
|
|
61
66
|
[OpenCode](https://opencode.ai) both expose it as the `/repoaccess-setup` command; for any other coding
|
|
62
67
|
agent, open the cloned repo and ask your agent to follow `docs/setup-wizard.md` - the same shared
|
|
@@ -66,7 +66,11 @@ export const config: RepoAccessConfig = {
|
|
|
66
66
|
githubOrg: 'your-org-slug',
|
|
67
67
|
productTeamMap: {
|
|
68
68
|
stripe: { 'prod_...': { teams: ['pro'], grant_mode: 'username' } }, // product id filled in Step 4
|
|
69
|
-
defaults: {
|
|
69
|
+
defaults: {
|
|
70
|
+
teams: [],
|
|
71
|
+
grant_mode: 'claim',
|
|
72
|
+
revoke_policy: { mode: 'log_only' },
|
|
73
|
+
},
|
|
70
74
|
},
|
|
71
75
|
}
|
|
72
76
|
```
|
|
@@ -134,6 +138,7 @@ needs it. Open `/health` and confirm `{"status":"ok"}`.
|
|
|
134
138
|
|
|
135
139
|
It is obscurity only (the HMAC signature is the real gate; the worker never reads the path), so it
|
|
136
140
|
is NOT a worker secret. Keep it in your notes.
|
|
141
|
+
|
|
137
142
|
- Reveal the **Signing secret** (`whsec_...`) for the next step.
|
|
138
143
|
|
|
139
144
|
## 5. Add the secret and re-deploy
|
|
@@ -176,7 +181,8 @@ Run the three flows **in this order** - refund before the typo test, so each tes
|
|
|
176
181
|
`https://<worker>.workers.dev/claim/<token>`, enter a real handle (reuse the Step-1 handle, now that
|
|
177
182
|
it was revoked), and submit. The claim grant creates a NEW GitHub invitation too - **accept the email
|
|
178
183
|
invite** (or at `https://github.com/orgs/<org>/invitation`) to become a member. A typo never strands a
|
|
179
|
-
paying buyer.
|
|
184
|
+
paying buyer. (In production you do not dig this link out by hand: Step 7 wires a post-payment redirect
|
|
185
|
+
that lands the buyer on the claim page automatically.)
|
|
180
186
|
|
|
181
187
|
Benign noise: `AccessWorkflow.run - Exception Thrown` and `Cancelled` in `wrangler tail` are how
|
|
182
188
|
Cloudflare Workflows logs durable step suspension - they appear on fully successful runs too. Judge a run
|
|
@@ -186,7 +192,37 @@ by raw tail lines.
|
|
|
186
192
|
That single live grant verifies the whole chain: the PAT, the org hardening, the config, the webhook
|
|
187
193
|
signature, and the deploy.
|
|
188
194
|
|
|
189
|
-
## 7.
|
|
195
|
+
## 7. Deliver the claim link automatically (the post-payment redirect)
|
|
196
|
+
|
|
197
|
+
In `claim` mode every buyer needs the one-time claim link, and in `username` mode a buyer who mistypes
|
|
198
|
+
their handle falls back to one (Step 6, flow 3). Rather than make them dig it out of a dashboard, send
|
|
199
|
+
them straight there: point your Stripe checkout's post-payment redirect at the worker, which resolves the
|
|
200
|
+
buyer's transaction to their claim page.
|
|
201
|
+
|
|
202
|
+
The worker exposes a resolver: `GET /claim/by-txn/stripe/<id>`. Hand it the buyer's transaction and it
|
|
203
|
+
302-redirects to the live claim page (`/claim/<token>`); if access was already granted directly (the
|
|
204
|
+
`username` happy path) it shows a short "you are all set, accept the GitHub invite" page; and in the few
|
|
205
|
+
seconds before the grant workflow finishes it shows a neutral "setting up, refresh shortly" page. It is
|
|
206
|
+
read-only and re-queryable, so a buyer who closes the tab can open the same link again.
|
|
207
|
+
|
|
208
|
+
Wire it on the Payment Link: open the link, then **Confirmation page**, choose **Don't show a
|
|
209
|
+
confirmation page**, and set the redirect URL to
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
https://<your-worker>.workers.dev/claim/by-txn/stripe/{CHECKOUT_SESSION_ID}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Stripe substitutes `{CHECKOUT_SESSION_ID}` with the real id at redirect time. That id is the **checkout
|
|
216
|
+
session** (`cs_...`), while the worker keys the claim by the **payment intent** (`pi_...`); the worker
|
|
217
|
+
bridges the two automatically (it recorded the mapping when the payment arrived), so the path just works.
|
|
218
|
+
For a custom, API-created Checkout Session, set `success_url` to the same path with the same
|
|
219
|
+
`{CHECKOUT_SESSION_ID}` placeholder.
|
|
220
|
+
|
|
221
|
+
To test it, repeat the Step 6 grant in `claim` mode (or the typo fallback) with the redirect configured:
|
|
222
|
+
after paying you land on the claim page directly, no dashboard lookup. The Workflow-dashboard method in
|
|
223
|
+
Step 6 stays available as a manual fallback.
|
|
224
|
+
|
|
225
|
+
## 8. Going live and operating
|
|
190
226
|
|
|
191
227
|
- **Add more products:** map each new Stripe product id to a team in `productTeamMap` and re-deploy.
|
|
192
228
|
- **Production:** repeat with the `production` config profile and `.dev.vars.production`, deploying with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "repoaccess-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"exports": {
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"prettier": "^3.8.4",
|
|
32
32
|
"typescript": "5.9.3",
|
|
33
33
|
"vitest": "^4.1.0",
|
|
34
|
-
"wrangler": "^4.
|
|
34
|
+
"wrangler": "^4.105.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/adapters/stripe.ts
CHANGED
|
@@ -126,6 +126,9 @@ export const stripe: PaymentAdapter = {
|
|
|
126
126
|
asString(customer?.email) ?? asString(object.customer_email),
|
|
127
127
|
github_username: githubUsername(object),
|
|
128
128
|
is_full_refund: null,
|
|
129
|
+
// The success_url redirect carries the checkout session id (cs_...), not the
|
|
130
|
+
// payment_intent. Alias it -> transaction_id so /claim/by-txn resolves from the redirect.
|
|
131
|
+
redirect_alias_id: asString(object.id) ?? undefined, // cs_... (checkout session id)
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
134
|
|
package/src/claim-template.tsx
CHANGED
|
@@ -35,6 +35,21 @@ export type ClaimView =
|
|
|
35
35
|
| { kind: 'submitted'; token: string; username: string }
|
|
36
36
|
| { kind: 'busy'; token: string }
|
|
37
37
|
| { kind: 'invalid' }
|
|
38
|
+
// Resolve-by-transaction (claim-link delivery, ADR-0042): the neutral "preparing" state shown when
|
|
39
|
+
// the claim is not yet resolvable by transaction (the grant workflow runs async after the webhook
|
|
40
|
+
// ack, so `claim_txn` may be absent at the instant of the post-checkout redirect). No token here.
|
|
41
|
+
// Carries `pollScript`: core's central auto-poll JS (kept core-owned, like `form`'s submitScript) so
|
|
42
|
+
// the page self-refreshes across the KV eventual-consistency window and the loop self-terminates once
|
|
43
|
+
// a terminal view replaces it. A template MUST embed it as `<script>{raw(view.pollScript)}</script>`.
|
|
44
|
+
| { kind: 'pending'; pollScript: string }
|
|
45
|
+
// Resolve-by-transaction (ADR-0042): the access has already been granted directly (grant_mode
|
|
46
|
+
// `username` happy path, which produces no claim) - so one redirect URL serves both grant modes.
|
|
47
|
+
// No handle/teams/grant detail echoed.
|
|
48
|
+
| { kind: 'granted' }
|
|
49
|
+
// Resolve-by-transaction (0.2.5): a TERMINAL grant failure was recorded for this transaction (bad
|
|
50
|
+
// handle on a username grant / un-correctable GitHub error / exhausted retries). Neutral copy, no
|
|
51
|
+
// detail echoed - so the buyer gets a definite signal instead of looping on `pending` forever.
|
|
52
|
+
| { kind: 'failed' }
|
|
38
53
|
|
|
39
54
|
/**
|
|
40
55
|
* A claim-page template: pure `(brand, view) → HTML`. Injected via `createWorker({ claimTemplate })`.
|
|
@@ -166,6 +181,41 @@ const ClaimBusy = (props: { brand: Branding; token: string }) => (
|
|
|
166
181
|
</Layout>
|
|
167
182
|
)
|
|
168
183
|
|
|
184
|
+
const ClaimPending = (props: { brand: Branding; pollScript: string }) => (
|
|
185
|
+
<Layout brand={props.brand} title="Setting up your access">
|
|
186
|
+
<h2>Setting up your access</h2>
|
|
187
|
+
<p>
|
|
188
|
+
If you just completed payment, this can take a few seconds - this page
|
|
189
|
+
refreshes itself automatically. If it persists, contact support.
|
|
190
|
+
</p>
|
|
191
|
+
<p id="bytxn-slow" class="error" style="display:none">
|
|
192
|
+
This is taking longer than expected - refresh the page, or contact support
|
|
193
|
+
if it persists.
|
|
194
|
+
</p>
|
|
195
|
+
<script>{raw(props.pollScript)}</script>
|
|
196
|
+
</Layout>
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const ClaimGranted = (props: { brand: Branding }) => (
|
|
200
|
+
<Layout brand={props.brand} title="Access granted">
|
|
201
|
+
<h2>Access granted</h2>
|
|
202
|
+
<p>
|
|
203
|
+
Your access is set up. Check your email for the GitHub invitation and
|
|
204
|
+
accept it.
|
|
205
|
+
</p>
|
|
206
|
+
</Layout>
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const ClaimFailed = (props: { brand: Branding }) => (
|
|
210
|
+
<Layout brand={props.brand} title="Access setup failed">
|
|
211
|
+
<h2>Something went wrong setting up your access</h2>
|
|
212
|
+
<p>
|
|
213
|
+
We could not finish setting up your access. Please contact support with
|
|
214
|
+
your order details and we'll sort it out.
|
|
215
|
+
</p>
|
|
216
|
+
</Layout>
|
|
217
|
+
)
|
|
218
|
+
|
|
169
219
|
const ClaimInvalid = (props: { brand: Branding }) => (
|
|
170
220
|
<Layout brand={props.brand} title="Claim unavailable">
|
|
171
221
|
<h2>This claim link is invalid or no longer active</h2>
|
|
@@ -203,6 +253,12 @@ export const defaultClaimTemplate: ClaimTemplate = ({ brand, view }) => {
|
|
|
203
253
|
)
|
|
204
254
|
case 'busy':
|
|
205
255
|
return <ClaimBusy brand={brand} token={view.token} />
|
|
256
|
+
case 'pending':
|
|
257
|
+
return <ClaimPending brand={brand} pollScript={view.pollScript} />
|
|
258
|
+
case 'granted':
|
|
259
|
+
return <ClaimGranted brand={brand} />
|
|
260
|
+
case 'failed':
|
|
261
|
+
return <ClaimFailed brand={brand} />
|
|
206
262
|
case 'invalid':
|
|
207
263
|
return <ClaimInvalid brand={brand} />
|
|
208
264
|
}
|
package/src/claim.tsx
CHANGED
|
@@ -12,7 +12,13 @@ import type {
|
|
|
12
12
|
} from './types'
|
|
13
13
|
import { workflowInstanceId } from './workflow-id'
|
|
14
14
|
import { isValidGithubUsername } from './username'
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
claimKey,
|
|
17
|
+
claimIndexKey,
|
|
18
|
+
grantKey,
|
|
19
|
+
sessionTxnKey,
|
|
20
|
+
failKey,
|
|
21
|
+
} from './kv-keys'
|
|
16
22
|
import { claimGuard } from './claim-guard'
|
|
17
23
|
import type { ClaimTemplate } from './claim-template'
|
|
18
24
|
|
|
@@ -42,6 +48,12 @@ import type { ClaimTemplate } from './claim-template'
|
|
|
42
48
|
// segment is non-optional, so the param is always present.
|
|
43
49
|
type Ctx = Context<{ Bindings: CloudflareBindings }, '/claim/:token'>
|
|
44
50
|
|
|
51
|
+
// Same trick for the resolve-by-transaction route: both `:adapter` and `:txn` are non-optional.
|
|
52
|
+
type ByTxnCtx = Context<
|
|
53
|
+
{ Bindings: CloudflareBindings },
|
|
54
|
+
'/claim/by-txn/:adapter/:txn'
|
|
55
|
+
>
|
|
56
|
+
|
|
45
57
|
interface PendingClaim {
|
|
46
58
|
adapter: string
|
|
47
59
|
product_id: string
|
|
@@ -101,6 +113,36 @@ const SUBMIT_JS = `
|
|
|
101
113
|
})()
|
|
102
114
|
`
|
|
103
115
|
|
|
116
|
+
// Auto-poll for the resolve-by-transaction `pending` view: the grant workflow runs async AFTER the
|
|
117
|
+
// <100ms webhook ack, and a KV key-miss is edge-cached for ~60s (cacheTtl floor, cannot go lower), so
|
|
118
|
+
// even a SUCCESSFUL grant can read as `pending` for up to ~60s until the miss-cache expires. This
|
|
119
|
+
// reloads the page every ~4s so the buyer lands on the terminal view (302 / granted / failed) without
|
|
120
|
+
// manually refreshing. The loop SELF-TERMINATES because only the `pending` view carries this script -
|
|
121
|
+
// a terminal view has no script. Capped at ~15 reloads (~60-75s) via a per-path sessionStorage counter
|
|
122
|
+
// so a genuinely stuck case stops looping and reveals a quiet "taking longer" line (#bytxn-slow)
|
|
123
|
+
// instead of refreshing forever. Core-owned + central (passed into the `pending` view as `pollScript`)
|
|
124
|
+
// like SUBMIT_JS, so the behaviour is uniform across templates. Served inline with no nonce for the
|
|
125
|
+
// same reason as SUBMIT_JS (these responses set no CSP); if a strict script-src CSP is ever added,
|
|
126
|
+
// nonce this <script> (do NOT add 'unsafe-inline').
|
|
127
|
+
const POLL_JS = `
|
|
128
|
+
;(function () {
|
|
129
|
+
try {
|
|
130
|
+
var KEY = 'repoaccess_bytxn:' + location.pathname
|
|
131
|
+
var MAX = 15
|
|
132
|
+
var n = 0
|
|
133
|
+
try { n = parseInt(sessionStorage.getItem(KEY) || '0', 10) || 0 } catch (e) {}
|
|
134
|
+
if (n >= MAX) {
|
|
135
|
+
try { sessionStorage.removeItem(KEY) } catch (e) {}
|
|
136
|
+
var el = document.getElementById('bytxn-slow')
|
|
137
|
+
if (el) el.style.display = ''
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
try { sessionStorage.setItem(KEY, String(n + 1)) } catch (e) {}
|
|
141
|
+
setTimeout(function () { location.reload() }, 4000)
|
|
142
|
+
} catch (e) {}
|
|
143
|
+
})()
|
|
144
|
+
`
|
|
145
|
+
|
|
104
146
|
// The token lives in the URL path → keep it out of Referer (a seller-set brand image host would
|
|
105
147
|
// otherwise receive the full claim URL) and out of shared caches.
|
|
106
148
|
function harden(c: Ctx): void {
|
|
@@ -242,3 +284,86 @@ export function makeClaimPost(
|
|
|
242
284
|
)
|
|
243
285
|
}
|
|
244
286
|
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build the resolve-by-transaction handler (claim-link delivery, DEC-11 / ADR-0042) bound to a claim
|
|
290
|
+
* template.
|
|
291
|
+
*
|
|
292
|
+
* GET /claim/by-txn/:adapter/:txn → four grant-aware landing states, so ONE redirect URL serves
|
|
293
|
+
* BOTH grant modes and reflects a terminal failure:
|
|
294
|
+
* a. `claim_txn:{adapter}:{txn}` present → 302 to the single-use `/claim/:token` flow
|
|
295
|
+
* (grant_mode `claim`, OR a username typo-fallback that produced a claim).
|
|
296
|
+
* b. else `grant:{adapter}:{txn}` present → a neutral `granted` view (200) - the access was
|
|
297
|
+
* granted directly (grant_mode `username` happy path produces no claim).
|
|
298
|
+
* c. else `fail:{adapter}:{txn}` present → a neutral `failed` view (200) - the grant failed
|
|
299
|
+
* terminally (the workflow wrote the marker), so the buyer gets a definite signal.
|
|
300
|
+
* d. else → a neutral `pending` view (200) that auto-polls across the KV consistency window.
|
|
301
|
+
*
|
|
302
|
+
* This is the re-queryable delivery channel: it survives a dropped or closed post-checkout redirect
|
|
303
|
+
* (the deployer can resolve the same txn again). The grant workflow runs async AFTER the <100ms webhook
|
|
304
|
+
* ack, so no key may exist yet at the instant of the redirect - the (d) case renders a neutral
|
|
305
|
+
* "preparing" page rather than 404, leaking no distinction between not-yet-written and never-existed and
|
|
306
|
+
* exposing no token; its client poll (POLL_JS) reloads every ~4s (capped ~15x) so a successful grant
|
|
307
|
+
* surfaces without a manual refresh once KV's ~60s key-miss cache expires. Read-only: KV existence
|
|
308
|
+
* checks + a redirect, no grant, no workflow enqueue, no side effects. The `granted` and `failed`
|
|
309
|
+
* branches check ONLY for the record's existence - they never parse or echo the grant detail (handle /
|
|
310
|
+
* teams / org) or the failure detail, so no purchase data is exposed by the txn-as-bearer-credential.
|
|
311
|
+
*
|
|
312
|
+
* The `:txn` segment is alias-resolved first (see `sessionTxnKey`): a merchant redirect that carries
|
|
313
|
+
* an id differing from the transaction_id (Stripe `cs_...` session id vs the `pi_...` claim key) is
|
|
314
|
+
* mapped through `session_txn:{adapter}:{id}` before the lookups; a direct transaction_id resolves
|
|
315
|
+
* unchanged. The mechanism is generic - adapters whose redirect id IS the transaction_id (Paddle)
|
|
316
|
+
* write no alias and need no change.
|
|
317
|
+
*
|
|
318
|
+
* `adapterNames` gates the `:adapter` segment (unknown adapter → 404), mirroring the `/wh` lookup.
|
|
319
|
+
*/
|
|
320
|
+
export function makeClaimByTxn(
|
|
321
|
+
template: ClaimTemplate,
|
|
322
|
+
config: RepoAccessConfig,
|
|
323
|
+
adapterNames: ReadonlySet<string>,
|
|
324
|
+
) {
|
|
325
|
+
return async function handleClaimByTxn(c: ByTxnCtx): Promise<Response> {
|
|
326
|
+
harden(c)
|
|
327
|
+
const adapter = c.req.param('adapter')
|
|
328
|
+
if (!adapterNames.has(adapter)) return c.notFound() // unknown adapter → 404
|
|
329
|
+
|
|
330
|
+
const txn = c.req.param('txn')
|
|
331
|
+
const brand = branding(config)
|
|
332
|
+
|
|
333
|
+
// Alias-resolve transparently: a merchant redirect may carry an id that differs from the
|
|
334
|
+
// transaction_id that keys the claim/grant (Stripe's success_url carries the checkout session id
|
|
335
|
+
// cs_..., while the claim is keyed by the payment_intent pi_...). The workflow wrote
|
|
336
|
+
// session_txn:{adapter}:{cs_} -> {pi_} on payment_success. A direct transaction_id (no alias
|
|
337
|
+
// entry) resolves unchanged (mapped === null → realTxn = txn).
|
|
338
|
+
const mapped = await c.env.ENTITLEMENTS.get(sessionTxnKey(adapter, txn))
|
|
339
|
+
const realTxn = mapped ?? txn
|
|
340
|
+
|
|
341
|
+
// (a) A pending claim exists → hand off to the single-use claim flow via a same-origin redirect.
|
|
342
|
+
const token = await c.env.ENTITLEMENTS.get(claimIndexKey(adapter, realTxn))
|
|
343
|
+
if (token) {
|
|
344
|
+
return c.redirect(`/claim/${token}`, 302)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// (b) No claim, but a grant record exists → access was granted directly (username happy path).
|
|
348
|
+
// Existence check only - never read/parse the record, so no handle/teams/org leaks here.
|
|
349
|
+
const granted = await c.env.ENTITLEMENTS.get(grantKey(adapter, realTxn))
|
|
350
|
+
if (granted !== null) {
|
|
351
|
+
return c.html(template({ brand, view: { kind: 'granted' } }))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// (c) No claim and no grant, but a terminal-failure marker exists → the grant failed for good
|
|
355
|
+
// (bad handle on a username grant / un-correctable GitHub error / exhausted retries). Show a
|
|
356
|
+
// terminal `failed` view so the buyer gets a definite signal instead of looping on `pending`.
|
|
357
|
+
// Existence check only - the marker value is the coarse code and is never echoed.
|
|
358
|
+
const failed = await c.env.ENTITLEMENTS.get(failKey(adapter, realTxn))
|
|
359
|
+
if (failed !== null) {
|
|
360
|
+
return c.html(template({ brand, view: { kind: 'failed' } }))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// (d) Eventual consistency: no key (yet) present → neutral preparing view (no token exposed) that
|
|
364
|
+
// auto-polls (POLL_JS) across the ~60s KV miss-cache window until a terminal view replaces it.
|
|
365
|
+
return c.html(
|
|
366
|
+
template({ brand, view: { kind: 'pending', pollScript: POLL_JS } }),
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
}
|
package/src/create-worker.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
import { captureRawRequest } from './raw-request'
|
|
12
12
|
import { apiCallbackInstanceId, workflowInstanceId } from './workflow-id'
|
|
13
13
|
import { timingSafeEqualString, verifyRequest } from './verify'
|
|
14
|
-
import { makeClaimGet, makeClaimPost } from './claim'
|
|
14
|
+
import { makeClaimGet, makeClaimPost, makeClaimByTxn } from './claim'
|
|
15
15
|
import { defaultClaimTemplate, type ClaimTemplate } from './claim-template'
|
|
16
16
|
|
|
17
17
|
export interface CreateWorkerOptions {
|
|
@@ -57,6 +57,15 @@ export function createWorker({
|
|
|
57
57
|
app.get('/claim/:token', makeClaimGet(claimTemplate, config))
|
|
58
58
|
app.post('/claim/:token', makeClaimPost(claimTemplate, config))
|
|
59
59
|
|
|
60
|
+
// Resolve-by-transaction (claim-link delivery, DEC-11 / ADR-0042). A re-queryable lookup the deployer
|
|
61
|
+
// wires their post-checkout redirect to: claim_txn:{adapter}:{txn} -> token -> 302 /claim/{token},
|
|
62
|
+
// or a neutral `pending` view while the (async) grant workflow has not yet written claim_txn. No
|
|
63
|
+
// route collision: this is 4 path segments vs the 2 of /claim/:token. Read-only (KV read + redirect).
|
|
64
|
+
app.get(
|
|
65
|
+
'/claim/by-txn/:adapter/:txn',
|
|
66
|
+
makeClaimByTxn(claimTemplate, config, new Set(adaptersByName.keys())),
|
|
67
|
+
)
|
|
68
|
+
|
|
60
69
|
/**
|
|
61
70
|
* Inbound payment webhooks. The request path does ONLY: resolve adapter → capture raw body →
|
|
62
71
|
* [verify | secret-path check] → enqueue the Workflow (deterministic id) → ack. No GitHub calls,
|
package/src/kv-keys.ts
CHANGED
|
@@ -10,6 +10,11 @@ export const CLAIM_TTL_SEC = 30 * 24 * 60 * 60 // 30 days
|
|
|
10
10
|
// so records don't grow unbounded.
|
|
11
11
|
export const GRANT_TTL_SEC = 180 * 24 * 60 * 60
|
|
12
12
|
|
|
13
|
+
// 24 hours - a terminal grant-failure marker so a buyer returning to /claim/by-txn within a day sees
|
|
14
|
+
// the `failed` state instead of perpetual `pending`. Deliberately short: this is a transient,
|
|
15
|
+
// best-effort UX signal; the authoritative failure record is the emitted access.failed event.
|
|
16
|
+
export const FAIL_TTL_SEC = 24 * 60 * 60
|
|
17
|
+
|
|
13
18
|
/** Grant correlation record - 180d TTL, also deleted on revoke. */
|
|
14
19
|
export const grantKey = (adapter: string, txn: string) =>
|
|
15
20
|
`grant:${adapter}:${txn}`
|
|
@@ -20,3 +25,20 @@ export const claimKey = (token: string) => `claim:${token}`
|
|
|
20
25
|
/** Reverse index so revoke can find a still-pending claim by transaction (KV can't query by value). */
|
|
21
26
|
export const claimIndexKey = (adapter: string, txn: string) =>
|
|
22
27
|
`claim_txn:${adapter}:${txn}`
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Terminal grant-failure marker, keyed by transaction so /claim/by-txn can show a `failed` state for a
|
|
31
|
+
* doomed transaction instead of looping on `pending`. Value is the coarse, non-sensitive failure code
|
|
32
|
+
* only (never handle/teams/secret). FAIL_TTL_SEC; written by the workflow's terminal-failure paths.
|
|
33
|
+
*/
|
|
34
|
+
export const failKey = (adapter: string, txn: string) =>
|
|
35
|
+
`fail:${adapter}:${txn}`
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Alias index: a merchant redirect id (e.g. Stripe checkout session id cs_...) -> transaction_id, so
|
|
39
|
+
* /claim/by-txn resolves a redirect whose id differs from the claim/grant key. GRANT_TTL_SEC so it
|
|
40
|
+
* outlives both the claim and the grant window. Adapters whose redirect id IS the transaction_id (e.g.
|
|
41
|
+
* Paddle) write no alias.
|
|
42
|
+
*/
|
|
43
|
+
export const sessionTxnKey = (adapter: string, id: string) =>
|
|
44
|
+
`session_txn:${adapter}:${id}`
|
package/src/types.ts
CHANGED
|
@@ -100,6 +100,12 @@ export interface NormalizedEvent {
|
|
|
100
100
|
github_username: string | null
|
|
101
101
|
/** Refund events only: true=full, false=partial, null=n/a. */
|
|
102
102
|
is_full_refund: boolean | null
|
|
103
|
+
/**
|
|
104
|
+
* An id the merchant's post-checkout redirect will carry when it differs from transaction_id
|
|
105
|
+
* (e.g. Stripe checkout session id cs_..., while transaction_id is the payment_intent pi_...).
|
|
106
|
+
* Used to alias-resolve /claim/by-txn. Unset when the redirect id IS the transaction_id (e.g. Paddle).
|
|
107
|
+
*/
|
|
108
|
+
redirect_alias_id?: string
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
export interface RawRequest {
|
package/src/workflow.ts
CHANGED
|
@@ -33,9 +33,12 @@ import {
|
|
|
33
33
|
import {
|
|
34
34
|
CLAIM_TTL_SEC,
|
|
35
35
|
GRANT_TTL_SEC,
|
|
36
|
+
FAIL_TTL_SEC,
|
|
36
37
|
grantKey,
|
|
37
38
|
claimKey,
|
|
38
39
|
claimIndexKey,
|
|
40
|
+
sessionTxnKey,
|
|
41
|
+
failKey,
|
|
39
42
|
} from './kv-keys'
|
|
40
43
|
import { claimGuard } from './claim-guard'
|
|
41
44
|
|
|
@@ -181,6 +184,34 @@ function collectAllTeams(map: ProductTeamMap): string[] {
|
|
|
181
184
|
|
|
182
185
|
// --- grant ------------------------------------------------------------------
|
|
183
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Write the redirect-alias index when the adapter set `redirect_alias_id` - the merchant's
|
|
189
|
+
* post-checkout redirect will carry an id that differs from transaction_id (Stripe: the checkout
|
|
190
|
+
* session id cs_... vs the payment_intent pi_... that keys the claim/grant). Maps the redirect id ->
|
|
191
|
+
* transaction_id so /claim/by-txn alias-resolves it transparently. No-op when unset (e.g. Paddle,
|
|
192
|
+
* whose redirect id IS the transaction_id). GRANT_TTL_SEC so the alias outlives BOTH the claim window
|
|
193
|
+
* and the grant window. NOT deleted on revoke: the refund/dispute event carries no redirect_alias_id,
|
|
194
|
+
* and the alias is a harmless indirection - once the underlying claim/grant are gone, by-txn falls
|
|
195
|
+
* through to the pending view; the alias then expires by TTL.
|
|
196
|
+
*/
|
|
197
|
+
async function writeSessionAlias(
|
|
198
|
+
step: WorkflowStep,
|
|
199
|
+
env: CloudflareBindings,
|
|
200
|
+
adapter: string,
|
|
201
|
+
event: NormalizedEvent,
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
const aliasId = event.redirect_alias_id
|
|
204
|
+
if (!aliasId) return
|
|
205
|
+
await step.do(`claim-alias:${adapter}:${event.transaction_id}`, async () => {
|
|
206
|
+
await env.ENTITLEMENTS.put(
|
|
207
|
+
sessionTxnKey(adapter, aliasId),
|
|
208
|
+
event.transaction_id,
|
|
209
|
+
{ expirationTtl: GRANT_TTL_SEC },
|
|
210
|
+
)
|
|
211
|
+
return true
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
184
215
|
async function runGrant(
|
|
185
216
|
step: WorkflowStep,
|
|
186
217
|
env: CloudflareBindings,
|
|
@@ -191,6 +222,10 @@ async function runGrant(
|
|
|
191
222
|
sink: EventSink,
|
|
192
223
|
fromClaim: boolean,
|
|
193
224
|
): Promise<void> {
|
|
225
|
+
// Alias the merchant redirect id (if any) -> transaction_id BEFORE the grant-vs-claim branch, so
|
|
226
|
+
// /claim/by-txn resolves for BOTH outcomes (direct grant AND claim fallback).
|
|
227
|
+
await writeSessionAlias(step, env, adapter, event)
|
|
228
|
+
|
|
194
229
|
const teams = config.teams ?? []
|
|
195
230
|
// Claim completion forces `username` mode - the product's mode is `claim`, which would loop back
|
|
196
231
|
// into another claim. The handle was already validated at the claim POST.
|
|
@@ -445,10 +480,17 @@ async function terminalFailure(
|
|
|
445
480
|
fromClaim: boolean,
|
|
446
481
|
): Promise<void> {
|
|
447
482
|
await fail(step, org, event, sink, teams, username, reason, detail)
|
|
448
|
-
if (!fromClaim)
|
|
483
|
+
if (!fromClaim) {
|
|
484
|
+
// Non-claim grant, terminally failed (e.g. a bad up-front handle that 404s mid-grant, or a 403/
|
|
485
|
+
// auth error): no claim and no grant record will be written, so mark it failed → /claim/by-txn
|
|
486
|
+
// shows `failed` rather than perpetual `pending`.
|
|
487
|
+
await writeFailMarker(step, env, adapter, event.transaction_id, reason)
|
|
488
|
+
return
|
|
489
|
+
}
|
|
449
490
|
if (userNotFound) {
|
|
450
491
|
// Buyer-correctable → retain the token AND release the single-flight lock so a later sequential
|
|
451
|
-
// resubmit with a corrected handle can acquire.
|
|
492
|
+
// resubmit with a corrected handle can acquire. NOT terminal: write NO fail marker (and
|
|
493
|
+
// /claim/by-txn 302s to the still-present claim regardless).
|
|
452
494
|
await recordClaimError(
|
|
453
495
|
step,
|
|
454
496
|
env,
|
|
@@ -458,9 +500,11 @@ async function terminalFailure(
|
|
|
458
500
|
)
|
|
459
501
|
await guardRelease(step, env, adapter, event.transaction_id)
|
|
460
502
|
} else {
|
|
461
|
-
// Not correctable here → consume the token
|
|
503
|
+
// Not correctable here → consume the token, lock the claim for good, and mark it failed so
|
|
504
|
+
// /claim/by-txn (the claim is now consumed) shows `failed` instead of `pending`.
|
|
462
505
|
await consumeClaim(step, env, adapter, event.transaction_id)
|
|
463
506
|
await guardFinalize(step, env, adapter, event.transaction_id)
|
|
507
|
+
await writeFailMarker(step, env, adapter, event.transaction_id, reason)
|
|
464
508
|
}
|
|
465
509
|
}
|
|
466
510
|
|
|
@@ -507,6 +551,29 @@ async function consumeClaim(
|
|
|
507
551
|
})
|
|
508
552
|
}
|
|
509
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Write a terminal grant-failure marker so /claim/by-txn shows a definite `failed` state for a doomed
|
|
556
|
+
* transaction instead of looping on `pending`. Stores ONLY the coarse, non-sensitive
|
|
557
|
+
* `AccessFailedReason` code (the same value already carried on the access.failed wire envelope) - never
|
|
558
|
+
* raw detail, secrets, the handle, or team slugs. FAIL_TTL_SEC is the backstop; a later revoke cleanup
|
|
559
|
+
* for the same txn may remove it sooner, and the TTL guarantees it never lingers. Deterministic step
|
|
560
|
+
* id → idempotent across durable retries.
|
|
561
|
+
*/
|
|
562
|
+
async function writeFailMarker(
|
|
563
|
+
step: WorkflowStep,
|
|
564
|
+
env: CloudflareBindings,
|
|
565
|
+
adapter: string,
|
|
566
|
+
txn: string,
|
|
567
|
+
reason: AccessFailedReason,
|
|
568
|
+
): Promise<void> {
|
|
569
|
+
await step.do(`fail-marker:${adapter}:${txn}`, async () => {
|
|
570
|
+
await env.ENTITLEMENTS.put(failKey(adapter, txn), reason, {
|
|
571
|
+
expirationTtl: FAIL_TTL_SEC,
|
|
572
|
+
})
|
|
573
|
+
return true
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
|
|
510
577
|
/** Stamp `last_error` on the retained claim record so the buyer sees it on GET and can retry. */
|
|
511
578
|
async function recordClaimError(
|
|
512
579
|
step: WorkflowStep,
|
|
@@ -871,9 +938,20 @@ export async function executeAccessWorkflow(
|
|
|
871
938
|
reason: 'grant_error',
|
|
872
939
|
})
|
|
873
940
|
// 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)
|
|
941
|
+
// single-flight lock so the buyer can resubmit (the token was retained) - /claim/by-txn 302s to
|
|
942
|
+
// that retained claim, so no fail marker. A NON-claim grant (rate-limit / 5xx exhausted, or an
|
|
943
|
+
// unexpected throw) leaves no claim and no grant record, so mark it failed → /claim/by-txn shows
|
|
944
|
+
// `failed` rather than perpetual `pending`.
|
|
875
945
|
if (params.from_claim) {
|
|
876
946
|
await guardRelease(step, env, adapter, event.transaction_id)
|
|
947
|
+
} else {
|
|
948
|
+
await writeFailMarker(
|
|
949
|
+
step,
|
|
950
|
+
env,
|
|
951
|
+
adapter,
|
|
952
|
+
event.transaction_id,
|
|
953
|
+
'grant_error',
|
|
954
|
+
)
|
|
877
955
|
}
|
|
878
956
|
}
|
|
879
957
|
throw err // mark the Workflow instance failed for observability (the event already fired)
|