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 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: { teams: [], grant_mode: 'claim', revoke_policy: { mode: 'log_only' } },
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. Going live and operating
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",
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.104.0"
34
+ "wrangler": "^4.105.0"
35
35
  }
36
36
  }
@@ -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
 
@@ -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&apos;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 { claimKey } from './kv-keys'
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
+ }
@@ -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) return
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 and lock the claim for good.
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)