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.
@@ -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&apos;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&apos;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&apos;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
+ }