repoaccess-core 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +149 -0
- package/docs/agentic-setup-github-core-walkthrough.md +139 -0
- package/docs/agentic-setup-stripe-core-walkthrough.md +223 -0
- package/docs/setup-guide.md +422 -0
- package/docs/setup-wizard.md +339 -0
- package/docs/user-guide-stripe.md +215 -0
- package/package.json +35 -0
- package/src/adapters/stripe.ts +172 -0
- package/src/claim-guard.ts +59 -0
- package/src/claim-template.tsx +207 -0
- package/src/claim.tsx +242 -0
- package/src/config.ts +39 -0
- package/src/create-worker.ts +156 -0
- package/src/events.ts +195 -0
- package/src/fetch-entity.ts +79 -0
- package/src/github.ts +140 -0
- package/src/index.production.ts +19 -0
- package/src/index.ts +55 -0
- package/src/kv-keys.ts +22 -0
- package/src/raw-request.ts +24 -0
- package/src/repoaccess.config.ts +39 -0
- package/src/ssrf.ts +156 -0
- package/src/types.ts +238 -0
- package/src/username.ts +15 -0
- package/src/verify.ts +173 -0
- package/src/worker-env.d.ts +14 -0
- package/src/workflow-id.ts +77 -0
- package/src/workflow.ts +926 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Gary Stupak
|
|
3
|
+
|
|
4
|
+
import type { NormalizedEvent, PaymentAdapter, RawRequest } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stripe reference adapter - the canonical `hmac_sha256` example, verified against the provider's
|
|
8
|
+
* webhook signature reference (docs.stripe.com/webhooks). Ships in the free core.
|
|
9
|
+
*
|
|
10
|
+
* Verification: `Stripe-Signature: t=<unix>,v1=<hex>[,v1=…][,v0=…]`. signed_payload = `${t}.${body}`,
|
|
11
|
+
* HMAC-SHA256 keyed by the endpoint signing secret (`whsec_…`). Ignore every scheme except `v1`
|
|
12
|
+
* (the `v0` test scheme is a downgrade-attack vector). Multiple `v1` appear during secret rotation -
|
|
13
|
+
* the engine matches any. Reject outside the timestamp tolerance.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SIGNATURE_HEADER = 'stripe-signature'
|
|
17
|
+
const TOLERANCE_SEC = 300 // Stripe libraries' default replay window.
|
|
18
|
+
|
|
19
|
+
interface StripeSignature {
|
|
20
|
+
t: string | undefined
|
|
21
|
+
v1: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parse `t=…,v1=…,v0=…` → timestamp + the v1 signatures (v0/other schemes ignored). */
|
|
25
|
+
function parseStripeSignature(header: string): StripeSignature {
|
|
26
|
+
let t: string | undefined
|
|
27
|
+
const v1: string[] = []
|
|
28
|
+
for (const part of header.split(',')) {
|
|
29
|
+
const eq = part.indexOf('=')
|
|
30
|
+
if (eq === -1) continue
|
|
31
|
+
const key = part.slice(0, eq).trim()
|
|
32
|
+
const value = part.slice(eq + 1).trim()
|
|
33
|
+
if (key === 't') t = value
|
|
34
|
+
else if (key === 'v1') v1.push(value)
|
|
35
|
+
}
|
|
36
|
+
return { t, v1 }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function asString(value: unknown): string | null {
|
|
40
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** github_username from a Checkout Session: `metadata.github_username`, else a github custom field. */
|
|
44
|
+
function githubUsername(session: Record<string, unknown>): string | null {
|
|
45
|
+
const metadata = session.metadata as Record<string, unknown> | undefined
|
|
46
|
+
const fromMetadata = asString(metadata?.github_username)
|
|
47
|
+
if (fromMetadata) return fromMetadata
|
|
48
|
+
|
|
49
|
+
const fields = session.custom_fields
|
|
50
|
+
if (Array.isArray(fields)) {
|
|
51
|
+
for (const field of fields) {
|
|
52
|
+
const key = asString((field as Record<string, unknown>)?.key)
|
|
53
|
+
if (!key || !key.toLowerCase().includes('github')) continue
|
|
54
|
+
const text = (field as Record<string, unknown>).text as
|
|
55
|
+
| Record<string, unknown>
|
|
56
|
+
| undefined
|
|
57
|
+
const dropdown = (field as Record<string, unknown>).dropdown as
|
|
58
|
+
| Record<string, unknown>
|
|
59
|
+
| undefined
|
|
60
|
+
const value = asString(text?.value) ?? asString(dropdown?.value)
|
|
61
|
+
if (value) return value
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Product id for the team lookup - sellers set it in `metadata.product_id`, which the product→team map keys by. */
|
|
68
|
+
function productId(object: Record<string, unknown>): string {
|
|
69
|
+
const metadata = object.metadata as Record<string, unknown> | undefined
|
|
70
|
+
return asString(metadata?.product_id) ?? ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface StripeEvent {
|
|
74
|
+
type?: unknown
|
|
75
|
+
data?: { object?: Record<string, unknown> }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const stripe: PaymentAdapter = {
|
|
79
|
+
name: 'stripe',
|
|
80
|
+
|
|
81
|
+
verification: {
|
|
82
|
+
kind: 'hmac',
|
|
83
|
+
algo: 'SHA-256',
|
|
84
|
+
secret: (env) => env.STRIPE_WEBHOOK_SECRET, // undefined → engine rejects (fail-closed)
|
|
85
|
+
canonical: (raw: RawRequest) => {
|
|
86
|
+
const { t } = parseStripeSignature(
|
|
87
|
+
raw.headers.get(SIGNATURE_HEADER) ?? '',
|
|
88
|
+
)
|
|
89
|
+
return `${t ?? ''}.${raw.bodyText}`
|
|
90
|
+
},
|
|
91
|
+
extract: (headers) => {
|
|
92
|
+
const { t, v1 } = parseStripeSignature(
|
|
93
|
+
headers.get(SIGNATURE_HEADER) ?? '',
|
|
94
|
+
)
|
|
95
|
+
return { signature: v1, ts: t }
|
|
96
|
+
},
|
|
97
|
+
toleranceSec: TOLERANCE_SEC,
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
parse: (raw: RawRequest): NormalizedEvent | null => {
|
|
101
|
+
let event: StripeEvent
|
|
102
|
+
try {
|
|
103
|
+
event = JSON.parse(raw.bodyText) as StripeEvent
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
const object = event.data?.object
|
|
108
|
+
if (typeof event.type !== 'string' || !object) return null
|
|
109
|
+
|
|
110
|
+
switch (event.type) {
|
|
111
|
+
case 'checkout.session.completed': {
|
|
112
|
+
// Gate: a session can fire before the async payment settles for some methods.
|
|
113
|
+
if (object.payment_status !== 'paid') return null
|
|
114
|
+
// transaction_id = payment_intent (stable across the order + its refund/dispute), NOT
|
|
115
|
+
// checkout.session.id (absent from charge events).
|
|
116
|
+
const transactionId = asString(object.payment_intent)
|
|
117
|
+
if (!transactionId) return null
|
|
118
|
+
const customer = object.customer_details as
|
|
119
|
+
| Record<string, unknown>
|
|
120
|
+
| undefined
|
|
121
|
+
return {
|
|
122
|
+
event_type: 'payment_success',
|
|
123
|
+
product_id: productId(object),
|
|
124
|
+
transaction_id: transactionId,
|
|
125
|
+
buyer_email:
|
|
126
|
+
asString(customer?.email) ?? asString(object.customer_email),
|
|
127
|
+
github_username: githubUsername(object),
|
|
128
|
+
is_full_refund: null,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case 'charge.refunded': {
|
|
133
|
+
const transactionId = asString(object.payment_intent)
|
|
134
|
+
if (!transactionId) return null
|
|
135
|
+
const amount = object.amount
|
|
136
|
+
const refunded = object.amount_refunded
|
|
137
|
+
const isFullRefund =
|
|
138
|
+
typeof amount === 'number' && typeof refunded === 'number'
|
|
139
|
+
? refunded === amount
|
|
140
|
+
: null
|
|
141
|
+
const billing = object.billing_details as
|
|
142
|
+
| Record<string, unknown>
|
|
143
|
+
| undefined
|
|
144
|
+
return {
|
|
145
|
+
event_type: 'refund',
|
|
146
|
+
product_id: productId(object),
|
|
147
|
+
transaction_id: transactionId,
|
|
148
|
+
buyer_email: asString(billing?.email),
|
|
149
|
+
github_username: null,
|
|
150
|
+
is_full_refund: isFullRefund,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case 'charge.dispute.created': {
|
|
155
|
+
// data.object is a dispute; it carries payment_intent (the same correlation key).
|
|
156
|
+
const transactionId = asString(object.payment_intent)
|
|
157
|
+
if (!transactionId) return null
|
|
158
|
+
return {
|
|
159
|
+
event_type: 'chargeback',
|
|
160
|
+
product_id: productId(object),
|
|
161
|
+
transaction_id: transactionId,
|
|
162
|
+
buyer_email: null,
|
|
163
|
+
github_username: null,
|
|
164
|
+
is_full_refund: null, // chargebacks always revoke under auto_revoke
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
default:
|
|
169
|
+
return null // unhandled event type → route returns 400
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Gary Stupak
|
|
3
|
+
|
|
4
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Single-flight guard for claim completion (closes the claim over-grant race).
|
|
8
|
+
*
|
|
9
|
+
* The claim token is a bearer credential. Without serialization, two concurrent POSTs of DISTINCT
|
|
10
|
+
* valid handles for the same token both read the (still-present) claim and enqueue distinct
|
|
11
|
+
* `claim_completed` instances → two grants for one purchase (over-grant / invite-spam). KV has no
|
|
12
|
+
* atomic compare-and-swap, so we serialize through a Durable Object keyed by the claim
|
|
13
|
+
* (`{adapter}:{transaction_id}`): a DO is single-threaded, so `acquire()` is atomic.
|
|
14
|
+
*
|
|
15
|
+
* State machine: `idle → processing → (idle on release | granted on finalize)`.
|
|
16
|
+
* - route POST calls `acquire()` before enqueuing; a second concurrent submit sees `processing`
|
|
17
|
+
* and is rejected → at most one grant attempt in flight per claim.
|
|
18
|
+
* - the workflow terminal step calls `finalize()` on success / non-user-not-found (locked for good;
|
|
19
|
+
* the token is consumed anyway) or `release()` on user-not-found / transient exhaustion so a
|
|
20
|
+
* later SEQUENTIAL resubmit with a corrected handle can acquire and run (preserves the corrected-handle retry).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type AcquireResult =
|
|
24
|
+
| { ok: true }
|
|
25
|
+
| { ok: false; code: 'in_progress' | 'already_claimed' }
|
|
26
|
+
|
|
27
|
+
type GuardStatus = 'idle' | 'processing' | 'granted'
|
|
28
|
+
|
|
29
|
+
export class ClaimGuard extends DurableObject {
|
|
30
|
+
async acquire(): Promise<AcquireResult> {
|
|
31
|
+
const status = (await this.ctx.storage.get<GuardStatus>('status')) ?? 'idle'
|
|
32
|
+
if (status === 'granted') return { ok: false, code: 'already_claimed' }
|
|
33
|
+
if (status === 'processing') return { ok: false, code: 'in_progress' }
|
|
34
|
+
await this.ctx.storage.put('status', 'processing')
|
|
35
|
+
return { ok: true }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Allow a sequential retry: only steps back from `processing` (never resurrects `granted`). */
|
|
39
|
+
async release(): Promise<void> {
|
|
40
|
+
if ((await this.ctx.storage.get<GuardStatus>('status')) === 'processing') {
|
|
41
|
+
await this.ctx.storage.put('status', 'idle')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Terminal: the claim is done; no further attempt may acquire. */
|
|
46
|
+
async finalize(): Promise<void> {
|
|
47
|
+
await this.ctx.storage.put('status', 'granted')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolve the guard stub for a claim, keyed by adapter + transaction_id (route + workflow both have these). */
|
|
52
|
+
export function claimGuard(
|
|
53
|
+
env: CloudflareBindings,
|
|
54
|
+
adapter: string,
|
|
55
|
+
txn: string,
|
|
56
|
+
) {
|
|
57
|
+
const ns = env.CLAIM_GUARD
|
|
58
|
+
return ns.get(ns.idFromName(`${adapter}:${txn}`))
|
|
59
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Gary Stupak
|
|
3
|
+
|
|
4
|
+
import { raw } from 'hono/html'
|
|
5
|
+
import type { Branding } from './types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Claim-page template contract. The claim CONTROLLER
|
|
9
|
+
* (`claim.tsx`) owns all logic - token validation, inline username validation, ClaimGuard single-flight, workflow
|
|
10
|
+
* enqueue, `harden()` headers, and the JSON projections - and renders each HTML state through an
|
|
11
|
+
* injected `ClaimTemplate`. This module is the PUBLIC extension point: a downstream worker (or Pro, in
|
|
12
|
+
* its own repo) supplies its own `ClaimTemplate` via `createWorker({ claimTemplate })` to
|
|
13
|
+
* restyle every claim state without forking the controller. Core ships ONLY this contract +
|
|
14
|
+
* `defaultClaimTemplate`. JSON responses stay controller-owned - templates render HTML only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Seller-configurable branding, resolved from `config.branding` by the controller.
|
|
19
|
+
* The shape lives in `types.ts` (the config contract); re-exported here for template authors.
|
|
20
|
+
*/
|
|
21
|
+
export type { Branding } from './types'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The view the controller hands the template - one variant per claim state. The `form` variant
|
|
25
|
+
* carries `submitScript`: core's central submit-feedback JS (kept core-owned so the
|
|
26
|
+
* disable-button + spinner behaviour is uniform across templates). A template MUST embed it as
|
|
27
|
+
* `<script>{raw(view.submitScript)}</script>` and give its form `id="claim-form"` + its submit
|
|
28
|
+
* button `id="claim-btn"` (the script targets those ids); it should style a `.spinner` but degrades
|
|
29
|
+
* gracefully if absent.
|
|
30
|
+
*/
|
|
31
|
+
export type ClaimView =
|
|
32
|
+
| { kind: 'form'; token: string; error?: string; submitScript: string }
|
|
33
|
+
| { kind: 'submitted'; token: string; username: string }
|
|
34
|
+
| { kind: 'busy'; token: string }
|
|
35
|
+
| { kind: 'invalid' }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A claim-page template: pure `(brand, view) → HTML`. Injected via `createWorker({ claimTemplate })`.
|
|
39
|
+
* The return is whatever `c.html()` accepts - a Hono JSX node satisfies this (a JSX node is an
|
|
40
|
+
* `HtmlEscapedString`, i.e. a `string` subtype), as does a plain HTML string or a Promise of one.
|
|
41
|
+
*/
|
|
42
|
+
export type ClaimTemplate = (ctx: {
|
|
43
|
+
brand: Branding
|
|
44
|
+
view: ClaimView
|
|
45
|
+
}) => string | Promise<string>
|
|
46
|
+
|
|
47
|
+
// --- default template --------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
// Plain descendant selectors only (no `>`/`&`) so the stylesheet survives unescaped via raw().
|
|
50
|
+
const CSS = `
|
|
51
|
+
:root { color-scheme: light dark }
|
|
52
|
+
body { font-family: system-ui, sans-serif; margin: 0; padding: 2rem 1rem;
|
|
53
|
+
display: flex; justify-content: center; background: #f6f7f9; color: #111 }
|
|
54
|
+
.card { background: #fff; max-width: 28rem; width: 100%; padding: 2rem;
|
|
55
|
+
border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.12) }
|
|
56
|
+
.logo { max-height: 48px; margin-bottom: 1rem }
|
|
57
|
+
.brand { font-size: 1.1rem; margin: 0 0 1rem; color: #555 }
|
|
58
|
+
h2 { margin: 0 0 .5rem; font-size: 1.4rem }
|
|
59
|
+
p { line-height: 1.5; color: #333 }
|
|
60
|
+
label { display: block; font-weight: 600; margin: 1rem 0 .35rem }
|
|
61
|
+
input { width: 100%; box-sizing: border-box; padding: .6rem .75rem; font-size: 1rem;
|
|
62
|
+
border: 1px solid #ccc; border-radius: 8px }
|
|
63
|
+
button { margin-top: 1.25rem; width: 100%; padding: .7rem; font-size: 1rem; font-weight: 600;
|
|
64
|
+
color: #fff; background: #1f6feb; border: 0; border-radius: 8px; cursor: pointer }
|
|
65
|
+
button[disabled] { opacity: .75; cursor: default }
|
|
66
|
+
.error { color: #b00020; font-weight: 600 }
|
|
67
|
+
.spinner { display: inline-block; width: 1em; height: 1em; margin-right: .5em; vertical-align: -.15em;
|
|
68
|
+
border: 2px solid rgba(255,255,255,.55); border-top-color: #fff; border-radius: 50%;
|
|
69
|
+
animation: spin .6s linear infinite }
|
|
70
|
+
@keyframes spin { to { transform: rotate(360deg) } }
|
|
71
|
+
`
|
|
72
|
+
|
|
73
|
+
const Layout = (props: {
|
|
74
|
+
brand: Branding
|
|
75
|
+
title: string
|
|
76
|
+
children: unknown
|
|
77
|
+
}) => (
|
|
78
|
+
<html lang="en">
|
|
79
|
+
<head>
|
|
80
|
+
<meta charset="utf-8" />
|
|
81
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
82
|
+
<title>
|
|
83
|
+
{props.title} · {props.brand.name}
|
|
84
|
+
</title>
|
|
85
|
+
{props.brand.faviconUrl ? (
|
|
86
|
+
<link rel="icon" href={props.brand.faviconUrl} />
|
|
87
|
+
) : null}
|
|
88
|
+
<style>{raw(CSS)}</style>
|
|
89
|
+
</head>
|
|
90
|
+
<body>
|
|
91
|
+
<main class="card">
|
|
92
|
+
{props.brand.logoUrl ? (
|
|
93
|
+
<img class="logo" src={props.brand.logoUrl} alt={props.brand.name} />
|
|
94
|
+
) : (
|
|
95
|
+
<p class="brand">{props.brand.name}</p>
|
|
96
|
+
)}
|
|
97
|
+
{props.children as never}
|
|
98
|
+
</main>
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const ClaimForm = (props: {
|
|
104
|
+
brand: Branding
|
|
105
|
+
token: string
|
|
106
|
+
error?: string
|
|
107
|
+
submitScript: string
|
|
108
|
+
}) => (
|
|
109
|
+
<Layout brand={props.brand} title="Claim your access">
|
|
110
|
+
<h2>Claim your access</h2>
|
|
111
|
+
<p>
|
|
112
|
+
Enter your GitHub username to be added to the {props.brand.name}{' '}
|
|
113
|
+
repositories for your purchase.
|
|
114
|
+
</p>
|
|
115
|
+
{props.error ? <p class="error">{props.error}</p> : null}
|
|
116
|
+
<form id="claim-form" method="post" action={`/claim/${props.token}`}>
|
|
117
|
+
<label for="github_username">GitHub username</label>
|
|
118
|
+
<input
|
|
119
|
+
id="github_username"
|
|
120
|
+
name="github_username"
|
|
121
|
+
autocomplete="off"
|
|
122
|
+
autofocus
|
|
123
|
+
required
|
|
124
|
+
placeholder="octocat"
|
|
125
|
+
/>
|
|
126
|
+
<button id="claim-btn" type="submit">
|
|
127
|
+
Claim access
|
|
128
|
+
</button>
|
|
129
|
+
</form>
|
|
130
|
+
<script>{raw(props.submitScript)}</script>
|
|
131
|
+
</Layout>
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const ClaimSubmitted = (props: {
|
|
135
|
+
brand: Branding
|
|
136
|
+
token: string
|
|
137
|
+
username: string
|
|
138
|
+
}) => (
|
|
139
|
+
<Layout brand={props.brand} title="Processing your claim">
|
|
140
|
+
<h2>Processing your claim</h2>
|
|
141
|
+
<p>
|
|
142
|
+
We're adding <strong>{props.username}</strong> to your repositories.
|
|
143
|
+
Watch for a GitHub invitation in your email and notifications.
|
|
144
|
+
</p>
|
|
145
|
+
<p>
|
|
146
|
+
If you don't receive a GitHub invitation (email + notifications)
|
|
147
|
+
within a minute, you may have mistyped your username -{' '}
|
|
148
|
+
<a href={`/claim/${props.token}`}>reload this page</a> to correct it and
|
|
149
|
+
try again.
|
|
150
|
+
</p>
|
|
151
|
+
</Layout>
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const ClaimBusy = (props: { brand: Branding; token: string }) => (
|
|
155
|
+
<Layout brand={props.brand} title="Claim in progress">
|
|
156
|
+
<h2>This claim is already being processed</h2>
|
|
157
|
+
<p>
|
|
158
|
+
A submission for this link is in flight or already completed.{' '}
|
|
159
|
+
<a href={`/claim/${props.token}`}>Reload this page</a> in a moment to see
|
|
160
|
+
the result - if no GitHub invitation arrives (email + notifications)
|
|
161
|
+
within a minute, the username may have been mistyped, and you'll be
|
|
162
|
+
able to correct it and try again.
|
|
163
|
+
</p>
|
|
164
|
+
</Layout>
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const ClaimInvalid = (props: { brand: Branding }) => (
|
|
168
|
+
<Layout brand={props.brand} title="Claim unavailable">
|
|
169
|
+
<h2>This claim link is invalid or no longer active</h2>
|
|
170
|
+
<p>
|
|
171
|
+
If you just submitted your username, your access may already be granted -
|
|
172
|
+
check your GitHub invitations and notifications. Otherwise this link may
|
|
173
|
+
have expired; contact support with your order details.
|
|
174
|
+
</p>
|
|
175
|
+
</Layout>
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Core's default claim template - the current markup, verbatim. Rendered HTML is byte-identical to
|
|
180
|
+
* the original inline components (that identity is the regression proof: the existing claim tests
|
|
181
|
+
* pass unchanged). A downstream template can replace this wholesale via createWorker({ claimTemplate }).
|
|
182
|
+
*/
|
|
183
|
+
export const defaultClaimTemplate: ClaimTemplate = ({ brand, view }) => {
|
|
184
|
+
switch (view.kind) {
|
|
185
|
+
case 'form':
|
|
186
|
+
return (
|
|
187
|
+
<ClaimForm
|
|
188
|
+
brand={brand}
|
|
189
|
+
token={view.token}
|
|
190
|
+
error={view.error}
|
|
191
|
+
submitScript={view.submitScript}
|
|
192
|
+
/>
|
|
193
|
+
)
|
|
194
|
+
case 'submitted':
|
|
195
|
+
return (
|
|
196
|
+
<ClaimSubmitted
|
|
197
|
+
brand={brand}
|
|
198
|
+
token={view.token}
|
|
199
|
+
username={view.username}
|
|
200
|
+
/>
|
|
201
|
+
)
|
|
202
|
+
case 'busy':
|
|
203
|
+
return <ClaimBusy brand={brand} token={view.token} />
|
|
204
|
+
case 'invalid':
|
|
205
|
+
return <ClaimInvalid brand={brand} />
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/claim.tsx
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Gary Stupak
|
|
3
|
+
|
|
4
|
+
import type { Context } from 'hono'
|
|
5
|
+
import type {
|
|
6
|
+
AccessWorkflowParams,
|
|
7
|
+
Branding,
|
|
8
|
+
NormalizedEvent,
|
|
9
|
+
RepoAccessConfig,
|
|
10
|
+
} from './types'
|
|
11
|
+
import { workflowInstanceId } from './workflow-id'
|
|
12
|
+
import { isValidGithubUsername } from './username'
|
|
13
|
+
import { claimKey } from './kv-keys'
|
|
14
|
+
import { claimGuard } from './claim-guard'
|
|
15
|
+
import type { ClaimTemplate } from './claim-template'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Claim flow CONTROLLER. A claim is created by the
|
|
19
|
+
* grant workflow's `claim`-mode fallback when no valid GitHub username is known. This module owns the
|
|
20
|
+
* LOGIC for the page the buyer lands on; the HTML VIEW is an injected `ClaimTemplate` (the open
|
|
21
|
+
* extension point - see `claim-template.tsx`):
|
|
22
|
+
*
|
|
23
|
+
* GET /claim/:token → JSON (Accept: application/json) or the template's `form` state after KV
|
|
24
|
+
* token validation. A prior failed attempt (user-not-found) re-shows `last_error`.
|
|
25
|
+
* POST /claim/:token → validate the handle inline → enqueue a grant in `username` mode
|
|
26
|
+
* under the instance id `{adapter}-claim_completed-{transaction_id}-{handle}`.
|
|
27
|
+
*
|
|
28
|
+
* The handle is folded INTO the instance id: a corrected handle is a new id and re-runs,
|
|
29
|
+
* while resubmitting the SAME handle dedups against the in-flight/failed instance. The claim token
|
|
30
|
+
* lifecycle (consume on success / non-user-not-found, RETAIN on user-not-found) lives in the WORKFLOW
|
|
31
|
+
* terminal step - NOT here - so a failed attempt leaves the token usable for a corrected retry. The
|
|
32
|
+
* route therefore does not delete the token; it tells the buyer to reload to see the result.
|
|
33
|
+
*
|
|
34
|
+
* Handlers are built from a template via `makeClaimGet`/`makeClaimPost` (wired in create-worker.ts).
|
|
35
|
+
* JSON responses are controller-owned; the template renders HTML only.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// Encode the route path so `c.req.param('token')` types as `string` (not `string | undefined`):
|
|
39
|
+
// these handlers are only ever mounted at `/claim/:token` (see create-worker.ts), and the `:token`
|
|
40
|
+
// segment is non-optional, so the param is always present.
|
|
41
|
+
type Ctx = Context<{ Bindings: CloudflareBindings }, '/claim/:token'>
|
|
42
|
+
|
|
43
|
+
interface PendingClaim {
|
|
44
|
+
adapter: string
|
|
45
|
+
product_id: string
|
|
46
|
+
teams: string[]
|
|
47
|
+
buyer_email: string | null
|
|
48
|
+
transaction_id: string
|
|
49
|
+
/** Absolute expiry (epoch seconds) anchored at claim creation - preserved across re-puts. */
|
|
50
|
+
expires_at?: number
|
|
51
|
+
/** Set by the workflow on a user-not-found failure so GET re-shows the form with the error. */
|
|
52
|
+
last_error?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Seller-configurable branding from `config.branding`. Neutral per-field defaults -
|
|
56
|
+
// never hard-code EdgeKits; a partial config sets only the fields it wants.
|
|
57
|
+
function branding(config: RepoAccessConfig): Branding {
|
|
58
|
+
const b = config.branding
|
|
59
|
+
return {
|
|
60
|
+
name: b?.name || 'RepoAccess',
|
|
61
|
+
logoUrl: b?.logoUrl || '',
|
|
62
|
+
faviconUrl: b?.faviconUrl || '',
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readClaim(
|
|
67
|
+
env: CloudflareBindings,
|
|
68
|
+
token: string,
|
|
69
|
+
): Promise<PendingClaim | null> {
|
|
70
|
+
return (await env.ENTITLEMENTS.get(
|
|
71
|
+
claimKey(token),
|
|
72
|
+
'json',
|
|
73
|
+
)) as PendingClaim | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function wantsJson(c: Ctx): boolean {
|
|
77
|
+
return Boolean(c.req.header('accept')?.includes('application/json'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Submit-feedback for the claim form (UX only - ClaimGuard enforces single-flight server-side). On
|
|
81
|
+
// submit: disable the button + swap in a spinner so the buyer sees progress during the ~1.5s grant and
|
|
82
|
+
// is discouraged from double-submitting. The button carries no `name`, so disabling it drops no field.
|
|
83
|
+
// Kept core-owned + central (passed into the `form` view as `submitScript`) so the behaviour is uniform
|
|
84
|
+
// across templates; a template embeds it via <script>{raw(view.submitScript)}</script> and targets the
|
|
85
|
+
// `claim-form`/`claim-btn` ids. NOTE: served inline with no nonce because the claim responses set NO
|
|
86
|
+
// Content-Security-Policy (the page already relies on an inline <style>). If a strict script-src CSP is
|
|
87
|
+
// ever added to these responses, nonce this <script> (do NOT add 'unsafe-inline').
|
|
88
|
+
const SUBMIT_JS = `
|
|
89
|
+
;(function () {
|
|
90
|
+
var f = document.getElementById('claim-form')
|
|
91
|
+
if (!f) return
|
|
92
|
+
f.addEventListener('submit', function () {
|
|
93
|
+
var b = document.getElementById('claim-btn')
|
|
94
|
+
if (!b || b.disabled) return
|
|
95
|
+
b.disabled = true
|
|
96
|
+
b.setAttribute('aria-busy', 'true')
|
|
97
|
+
b.innerHTML = '<span class="spinner" aria-hidden="true"></span>Claiming…'
|
|
98
|
+
})
|
|
99
|
+
})()
|
|
100
|
+
`
|
|
101
|
+
|
|
102
|
+
// The token lives in the URL path → keep it out of Referer (a seller-set brand image host would
|
|
103
|
+
// otherwise receive the full claim URL) and out of shared caches.
|
|
104
|
+
function harden(c: Ctx): void {
|
|
105
|
+
c.header('Referrer-Policy', 'no-referrer')
|
|
106
|
+
c.header('Cache-Control', 'no-store')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Build the GET handler bound to a claim template. Logic is template-agnostic. */
|
|
110
|
+
export function makeClaimGet(
|
|
111
|
+
template: ClaimTemplate,
|
|
112
|
+
config: RepoAccessConfig,
|
|
113
|
+
) {
|
|
114
|
+
return async function handleClaimGet(c: Ctx): Promise<Response> {
|
|
115
|
+
harden(c)
|
|
116
|
+
const token = c.req.param('token')
|
|
117
|
+
const brand = branding(config)
|
|
118
|
+
const claim = await readClaim(c.env, token)
|
|
119
|
+
|
|
120
|
+
if (!claim) {
|
|
121
|
+
return wantsJson(c)
|
|
122
|
+
? c.json({ error: 'invalid_or_expired' }, 404)
|
|
123
|
+
: c.html(template({ brand, view: { kind: 'invalid' } }), 404)
|
|
124
|
+
}
|
|
125
|
+
if (wantsJson(c)) {
|
|
126
|
+
// Minimal projection - no buyer_email (PII) over the wire here.
|
|
127
|
+
return c.json({
|
|
128
|
+
adapter: claim.adapter,
|
|
129
|
+
product_id: claim.product_id,
|
|
130
|
+
teams: claim.teams,
|
|
131
|
+
last_error: claim.last_error ?? null,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
// A retained claim carries last_error from a failed prior attempt → re-show the form with it.
|
|
135
|
+
return c.html(
|
|
136
|
+
template({
|
|
137
|
+
brand,
|
|
138
|
+
view: {
|
|
139
|
+
kind: 'form',
|
|
140
|
+
token,
|
|
141
|
+
error: claim.last_error,
|
|
142
|
+
submitScript: SUBMIT_JS,
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build the POST handler bound to a claim template. Logic is template-agnostic. */
|
|
150
|
+
export function makeClaimPost(
|
|
151
|
+
template: ClaimTemplate,
|
|
152
|
+
config: RepoAccessConfig,
|
|
153
|
+
) {
|
|
154
|
+
return async function handleClaimPost(c: Ctx): Promise<Response> {
|
|
155
|
+
harden(c)
|
|
156
|
+
const token = c.req.param('token')
|
|
157
|
+
const brand = branding(config)
|
|
158
|
+
const claim = await readClaim(c.env, token)
|
|
159
|
+
|
|
160
|
+
if (!claim) {
|
|
161
|
+
return wantsJson(c)
|
|
162
|
+
? c.json({ error: 'invalid_or_expired' }, 404)
|
|
163
|
+
: c.html(template({ brand, view: { kind: 'invalid' } }), 404)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const body = await c.req.parseBody()
|
|
167
|
+
const username =
|
|
168
|
+
typeof body.github_username === 'string'
|
|
169
|
+
? body.github_username.trim()
|
|
170
|
+
: ''
|
|
171
|
+
|
|
172
|
+
// Inline username validation → re-prompt on failure (never enqueue a malformed handle).
|
|
173
|
+
if (!isValidGithubUsername(username)) {
|
|
174
|
+
const error =
|
|
175
|
+
'Enter a valid GitHub username - letters, digits and single hyphens, up to 39 characters.'
|
|
176
|
+
return wantsJson(c)
|
|
177
|
+
? c.json({ error: 'invalid_username' }, 400)
|
|
178
|
+
: c.html(
|
|
179
|
+
template({
|
|
180
|
+
brand,
|
|
181
|
+
view: { kind: 'form', token, error, submitScript: SUBMIT_JS },
|
|
182
|
+
}),
|
|
183
|
+
400,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Build the grant event. event_type is `payment_success` so the workflow runs a GRANT; the instance
|
|
188
|
+
// id below uses the distinct `claim_completed` event_type so it can't collide with the original
|
|
189
|
+
// claim-mode `payment_success` instance for this transaction.
|
|
190
|
+
const event: NormalizedEvent = {
|
|
191
|
+
event_type: 'payment_success',
|
|
192
|
+
product_id: claim.product_id,
|
|
193
|
+
transaction_id: claim.transaction_id,
|
|
194
|
+
buyer_email: claim.buyer_email,
|
|
195
|
+
github_username: username,
|
|
196
|
+
is_full_refund: null,
|
|
197
|
+
}
|
|
198
|
+
// Fold the submitted handle into the id: a corrected handle → new id → re-runs the grant
|
|
199
|
+
// even while a failed attempt is still in its retention window; the SAME handle → same id → dedups.
|
|
200
|
+
// `username` is charset-safe; workflowInstanceId hash-falls-back if the combined value is
|
|
201
|
+
// out-of-charset/over-long (the hash includes the handle, so it stays distinct per handle).
|
|
202
|
+
const id = await workflowInstanceId(
|
|
203
|
+
claim.adapter,
|
|
204
|
+
'claim_completed',
|
|
205
|
+
`${claim.transaction_id}-${username}`,
|
|
206
|
+
)
|
|
207
|
+
const params: AccessWorkflowParams = {
|
|
208
|
+
adapter: claim.adapter,
|
|
209
|
+
event,
|
|
210
|
+
from_claim: true,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Single-flight: serialize through the per-claim Durable Object so two concurrent submits
|
|
214
|
+
// (esp. of DISTINCT handles) can't both enqueue → at most one grant per claim. A second concurrent
|
|
215
|
+
// submit while one is in flight is rejected; the workflow releases the lock on user-not-found so a
|
|
216
|
+
// later sequential corrected resubmit can acquire.
|
|
217
|
+
const guard = claimGuard(c.env, claim.adapter, claim.transaction_id)
|
|
218
|
+
const acq = await guard.acquire()
|
|
219
|
+
if (!acq.ok) {
|
|
220
|
+
return wantsJson(c)
|
|
221
|
+
? c.json({ error: acq.code }, 409)
|
|
222
|
+
: c.html(template({ brand, view: { kind: 'busy', token } }), 409)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await c.env.ACCESS_WORKFLOW.createBatch([{ id, params }])
|
|
227
|
+
} catch (err) {
|
|
228
|
+
// Enqueue failed → release the lock so the buyer can retry (otherwise the claim is stuck).
|
|
229
|
+
await guard.release()
|
|
230
|
+
throw err
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Do NOT delete the token here - the workflow consumes it on success (or a non-user-not-found
|
|
234
|
+
// failure) and RETAINS it on user-not-found so the buyer can correct the handle. Tell
|
|
235
|
+
// the buyer to reload to see the result; `claim.completed` is emitted by the workflow on success.
|
|
236
|
+
return wantsJson(c)
|
|
237
|
+
? c.json({ status: 'processing', github_username: username })
|
|
238
|
+
: c.html(
|
|
239
|
+
template({ brand, view: { kind: 'submitted', token, username } }),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
}
|