repoaccess-core 0.2.2 → 0.2.4

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.
@@ -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.2",
3
+ "version": "0.2.4",
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
 
@@ -1,3 +1,4 @@
1
+ /** @jsxRuntime automatic */
1
2
  /** @jsxImportSource hono/jsx */
2
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
4
  // Copyright (C) 2026 Gary Stupak
@@ -34,6 +35,14 @@ export type ClaimView =
34
35
  | { kind: 'submitted'; token: string; username: string }
35
36
  | { kind: 'busy'; token: string }
36
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
+ | { kind: 'pending' }
42
+ // Resolve-by-transaction (ADR-0042): the access has already been granted directly (grant_mode
43
+ // `username` happy path, which produces no claim) - so one redirect URL serves both grant modes.
44
+ // No handle/teams/grant detail echoed.
45
+ | { kind: 'granted' }
37
46
 
38
47
  /**
39
48
  * A claim-page template: pure `(brand, view) → HTML`. Injected via `createWorker({ claimTemplate })`.
@@ -165,6 +174,26 @@ const ClaimBusy = (props: { brand: Branding; token: string }) => (
165
174
  </Layout>
166
175
  )
167
176
 
177
+ const ClaimPending = (props: { brand: Branding }) => (
178
+ <Layout brand={props.brand} title="Setting up your access">
179
+ <h2>Setting up your access</h2>
180
+ <p>
181
+ If you just completed payment, this can take a few seconds - refresh this
182
+ page. If it persists, contact support.
183
+ </p>
184
+ </Layout>
185
+ )
186
+
187
+ const ClaimGranted = (props: { brand: Branding }) => (
188
+ <Layout brand={props.brand} title="Access granted">
189
+ <h2>Access granted</h2>
190
+ <p>
191
+ Your access is set up. Check your email for the GitHub invitation and
192
+ accept it.
193
+ </p>
194
+ </Layout>
195
+ )
196
+
168
197
  const ClaimInvalid = (props: { brand: Branding }) => (
169
198
  <Layout brand={props.brand} title="Claim unavailable">
170
199
  <h2>This claim link is invalid or no longer active</h2>
@@ -202,6 +231,10 @@ export const defaultClaimTemplate: ClaimTemplate = ({ brand, view }) => {
202
231
  )
203
232
  case 'busy':
204
233
  return <ClaimBusy brand={brand} token={view.token} />
234
+ case 'pending':
235
+ return <ClaimPending brand={brand} />
236
+ case 'granted':
237
+ return <ClaimGranted brand={brand} />
205
238
  case 'invalid':
206
239
  return <ClaimInvalid brand={brand} />
207
240
  }
package/src/claim.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ /** @jsxRuntime automatic */
1
2
  /** @jsxImportSource hono/jsx */
2
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
4
  // Copyright (C) 2026 Gary Stupak
@@ -11,7 +12,7 @@ import type {
11
12
  } from './types'
12
13
  import { workflowInstanceId } from './workflow-id'
13
14
  import { isValidGithubUsername } from './username'
14
- import { claimKey } from './kv-keys'
15
+ import { claimKey, claimIndexKey, grantKey, sessionTxnKey } from './kv-keys'
15
16
  import { claimGuard } from './claim-guard'
16
17
  import type { ClaimTemplate } from './claim-template'
17
18
 
@@ -41,6 +42,12 @@ import type { ClaimTemplate } from './claim-template'
41
42
  // segment is non-optional, so the param is always present.
42
43
  type Ctx = Context<{ Bindings: CloudflareBindings }, '/claim/:token'>
43
44
 
45
+ // Same trick for the resolve-by-transaction route: both `:adapter` and `:txn` are non-optional.
46
+ type ByTxnCtx = Context<
47
+ { Bindings: CloudflareBindings },
48
+ '/claim/by-txn/:adapter/:txn'
49
+ >
50
+
44
51
  interface PendingClaim {
45
52
  adapter: string
46
53
  product_id: string
@@ -241,3 +248,70 @@ export function makeClaimPost(
241
248
  )
242
249
  }
243
250
  }
251
+
252
+ /**
253
+ * Build the resolve-by-transaction handler (claim-link delivery, DEC-11 / ADR-0042) bound to a claim
254
+ * template.
255
+ *
256
+ * GET /claim/by-txn/:adapter/:txn → three grant-aware landing states, so ONE redirect URL serves
257
+ * BOTH grant modes:
258
+ * a. `claim_txn:{adapter}:{txn}` present → 302 to the single-use `/claim/:token` flow
259
+ * (grant_mode `claim`, OR a username typo-fallback that produced a claim).
260
+ * b. else `grant:{adapter}:{txn}` present → a neutral `granted` view (200) - the access was
261
+ * granted directly (grant_mode `username` happy path produces no claim).
262
+ * c. else → a neutral `pending` view (200).
263
+ *
264
+ * This is the re-queryable delivery channel: it survives a dropped or closed post-checkout redirect
265
+ * (the deployer can resolve the same txn again). The grant workflow runs async AFTER the <100ms webhook
266
+ * ack, so neither key may exist yet at the instant of the redirect - the (c) case renders a neutral
267
+ * "preparing" page rather than 404, leaking no distinction between not-yet-written and never-existed and
268
+ * exposing no token. Read-only: KV existence checks + a redirect, no grant, no workflow enqueue, no side
269
+ * effects. The `granted` branch checks ONLY for the record's existence - it never parses or echoes the
270
+ * grant detail (handle / teams / org), so no purchase data is exposed by the txn-as-bearer-credential.
271
+ *
272
+ * The `:txn` segment is alias-resolved first (see `sessionTxnKey`): a merchant redirect that carries
273
+ * an id differing from the transaction_id (Stripe `cs_...` session id vs the `pi_...` claim key) is
274
+ * mapped through `session_txn:{adapter}:{id}` before the lookups; a direct transaction_id resolves
275
+ * unchanged. The mechanism is generic - adapters whose redirect id IS the transaction_id (Paddle)
276
+ * write no alias and need no change.
277
+ *
278
+ * `adapterNames` gates the `:adapter` segment (unknown adapter → 404), mirroring the `/wh` lookup.
279
+ */
280
+ export function makeClaimByTxn(
281
+ template: ClaimTemplate,
282
+ config: RepoAccessConfig,
283
+ adapterNames: ReadonlySet<string>,
284
+ ) {
285
+ return async function handleClaimByTxn(c: ByTxnCtx): Promise<Response> {
286
+ harden(c)
287
+ const adapter = c.req.param('adapter')
288
+ if (!adapterNames.has(adapter)) return c.notFound() // unknown adapter → 404
289
+
290
+ const txn = c.req.param('txn')
291
+ const brand = branding(config)
292
+
293
+ // Alias-resolve transparently: a merchant redirect may carry an id that differs from the
294
+ // transaction_id that keys the claim/grant (Stripe's success_url carries the checkout session id
295
+ // cs_..., while the claim is keyed by the payment_intent pi_...). The workflow wrote
296
+ // session_txn:{adapter}:{cs_} -> {pi_} on payment_success. A direct transaction_id (no alias
297
+ // entry) resolves unchanged (mapped === null → realTxn = txn).
298
+ const mapped = await c.env.ENTITLEMENTS.get(sessionTxnKey(adapter, txn))
299
+ const realTxn = mapped ?? txn
300
+
301
+ // (a) A pending claim exists → hand off to the single-use claim flow via a same-origin redirect.
302
+ const token = await c.env.ENTITLEMENTS.get(claimIndexKey(adapter, realTxn))
303
+ if (token) {
304
+ return c.redirect(`/claim/${token}`, 302)
305
+ }
306
+
307
+ // (b) No claim, but a grant record exists → access was granted directly (username happy path).
308
+ // Existence check only - never read/parse the record, so no handle/teams/org leaks here.
309
+ const granted = await c.env.ENTITLEMENTS.get(grantKey(adapter, realTxn))
310
+ if (granted !== null) {
311
+ return c.html(template({ brand, view: { kind: 'granted' } }))
312
+ }
313
+
314
+ // (c) Eventual consistency: neither key (yet) present → neutral preparing view, no token exposed.
315
+ return c.html(template({ brand, view: { kind: 'pending' } }))
316
+ }
317
+ }
@@ -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
@@ -20,3 +20,12 @@ export const claimKey = (token: string) => `claim:${token}`
20
20
  /** Reverse index so revoke can find a still-pending claim by transaction (KV can't query by value). */
21
21
  export const claimIndexKey = (adapter: string, txn: string) =>
22
22
  `claim_txn:${adapter}:${txn}`
23
+
24
+ /**
25
+ * Alias index: a merchant redirect id (e.g. Stripe checkout session id cs_...) -> transaction_id, so
26
+ * /claim/by-txn resolves a redirect whose id differs from the claim/grant key. GRANT_TTL_SEC so it
27
+ * outlives both the claim and the grant window. Adapters whose redirect id IS the transaction_id (e.g.
28
+ * Paddle) write no alias.
29
+ */
30
+ export const sessionTxnKey = (adapter: string, id: string) =>
31
+ `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
@@ -36,6 +36,7 @@ import {
36
36
  grantKey,
37
37
  claimKey,
38
38
  claimIndexKey,
39
+ sessionTxnKey,
39
40
  } from './kv-keys'
40
41
  import { claimGuard } from './claim-guard'
41
42
 
@@ -181,6 +182,34 @@ function collectAllTeams(map: ProductTeamMap): string[] {
181
182
 
182
183
  // --- grant ------------------------------------------------------------------
183
184
 
185
+ /**
186
+ * Write the redirect-alias index when the adapter set `redirect_alias_id` - the merchant's
187
+ * post-checkout redirect will carry an id that differs from transaction_id (Stripe: the checkout
188
+ * session id cs_... vs the payment_intent pi_... that keys the claim/grant). Maps the redirect id ->
189
+ * transaction_id so /claim/by-txn alias-resolves it transparently. No-op when unset (e.g. Paddle,
190
+ * whose redirect id IS the transaction_id). GRANT_TTL_SEC so the alias outlives BOTH the claim window
191
+ * and the grant window. NOT deleted on revoke: the refund/dispute event carries no redirect_alias_id,
192
+ * and the alias is a harmless indirection - once the underlying claim/grant are gone, by-txn falls
193
+ * through to the pending view; the alias then expires by TTL.
194
+ */
195
+ async function writeSessionAlias(
196
+ step: WorkflowStep,
197
+ env: CloudflareBindings,
198
+ adapter: string,
199
+ event: NormalizedEvent,
200
+ ): Promise<void> {
201
+ const aliasId = event.redirect_alias_id
202
+ if (!aliasId) return
203
+ await step.do(`claim-alias:${adapter}:${event.transaction_id}`, async () => {
204
+ await env.ENTITLEMENTS.put(
205
+ sessionTxnKey(adapter, aliasId),
206
+ event.transaction_id,
207
+ { expirationTtl: GRANT_TTL_SEC },
208
+ )
209
+ return true
210
+ })
211
+ }
212
+
184
213
  async function runGrant(
185
214
  step: WorkflowStep,
186
215
  env: CloudflareBindings,
@@ -191,6 +220,10 @@ async function runGrant(
191
220
  sink: EventSink,
192
221
  fromClaim: boolean,
193
222
  ): Promise<void> {
223
+ // Alias the merchant redirect id (if any) -> transaction_id BEFORE the grant-vs-claim branch, so
224
+ // /claim/by-txn resolves for BOTH outcomes (direct grant AND claim fallback).
225
+ await writeSessionAlias(step, env, adapter, event)
226
+
194
227
  const teams = config.teams ?? []
195
228
  // Claim completion forces `username` mode - the product's mode is `claim`, which would loop back
196
229
  // into another claim. The handle was already validated at the claim POST.