repoaccess-core 0.2.3 → 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.
- 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 +32 -0
- package/src/claim.tsx +74 -1
- package/src/create-worker.ts +10 -1
- package/src/kv-keys.ts +9 -0
- package/src/types.ts +6 -0
- package/src/workflow.ts +33 -0
|
@@ -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.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.
|
|
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,14 @@ 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
|
+
| { 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' }
|
|
38
46
|
|
|
39
47
|
/**
|
|
40
48
|
* A claim-page template: pure `(brand, view) → HTML`. Injected via `createWorker({ claimTemplate })`.
|
|
@@ -166,6 +174,26 @@ const ClaimBusy = (props: { brand: Branding; token: string }) => (
|
|
|
166
174
|
</Layout>
|
|
167
175
|
)
|
|
168
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
|
+
|
|
169
197
|
const ClaimInvalid = (props: { brand: Branding }) => (
|
|
170
198
|
<Layout brand={props.brand} title="Claim unavailable">
|
|
171
199
|
<h2>This claim link is invalid or no longer active</h2>
|
|
@@ -203,6 +231,10 @@ export const defaultClaimTemplate: ClaimTemplate = ({ brand, view }) => {
|
|
|
203
231
|
)
|
|
204
232
|
case 'busy':
|
|
205
233
|
return <ClaimBusy brand={brand} token={view.token} />
|
|
234
|
+
case 'pending':
|
|
235
|
+
return <ClaimPending brand={brand} />
|
|
236
|
+
case 'granted':
|
|
237
|
+
return <ClaimGranted brand={brand} />
|
|
206
238
|
case 'invalid':
|
|
207
239
|
return <ClaimInvalid brand={brand} />
|
|
208
240
|
}
|
package/src/claim.tsx
CHANGED
|
@@ -12,7 +12,7 @@ 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 { claimKey, claimIndexKey, grantKey, sessionTxnKey } from './kv-keys'
|
|
16
16
|
import { claimGuard } from './claim-guard'
|
|
17
17
|
import type { ClaimTemplate } from './claim-template'
|
|
18
18
|
|
|
@@ -42,6 +42,12 @@ import type { ClaimTemplate } from './claim-template'
|
|
|
42
42
|
// segment is non-optional, so the param is always present.
|
|
43
43
|
type Ctx = Context<{ Bindings: CloudflareBindings }, '/claim/:token'>
|
|
44
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
|
+
|
|
45
51
|
interface PendingClaim {
|
|
46
52
|
adapter: string
|
|
47
53
|
product_id: string
|
|
@@ -242,3 +248,70 @@ export function makeClaimPost(
|
|
|
242
248
|
)
|
|
243
249
|
}
|
|
244
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
|
+
}
|
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
|
@@ -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.
|