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/src/kv-keys.ts ADDED
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ // Centralized ENTITLEMENTS key builders + TTL. Shared by the grant/revoke workflow and
5
+ // the claim flow so the wire formats can never drift apart.
6
+
7
+ export const CLAIM_TTL_SEC = 30 * 24 * 60 * 60 // 30 days
8
+
9
+ // 180 days - covers the refund window + the ~120d card-chargeback window, and bounds KV accumulation
10
+ // so records don't grow unbounded.
11
+ export const GRANT_TTL_SEC = 180 * 24 * 60 * 60
12
+
13
+ /** Grant correlation record - 180d TTL, also deleted on revoke. */
14
+ export const grantKey = (adapter: string, txn: string) =>
15
+ `grant:${adapter}:${txn}`
16
+
17
+ /** Pending claim by single-use token. */
18
+ export const claimKey = (token: string) => `claim:${token}`
19
+
20
+ /** Reverse index so revoke can find a still-pending claim by transaction (KV can't query by value). */
21
+ export const claimIndexKey = (adapter: string, txn: string) =>
22
+ `claim_txn:${adapter}:${txn}`
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import type { RawRequest } from './types'
5
+
6
+ const FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded'
7
+
8
+ /**
9
+ * Capture the request body byte-exact BEFORE any JSON parse. HMAC verification breaks if the body
10
+ * is parsed and re-serialized (Paddle, Coinbase Commerce), so adapters must receive the raw text.
11
+ * The body is read exactly once.
12
+ *
13
+ * For form-urlencoded providers (Gumroad/Mollie), a parsed `URLSearchParams` view is attached
14
+ * alongside the raw text - the raw text remains the source of truth for signatures.
15
+ */
16
+ export async function captureRawRequest(request: Request): Promise<RawRequest> {
17
+ const bodyText = await request.text()
18
+ const { headers } = request
19
+ const contentType = headers.get('content-type') ?? ''
20
+ const bodyForm = contentType.includes(FORM_CONTENT_TYPE)
21
+ ? new URLSearchParams(bodyText)
22
+ : undefined
23
+ return { bodyText, bodyForm, headers }
24
+ }
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import type { RepoAccessConfig } from './types'
5
+
6
+ /**
7
+ * Deployment config as code - the typed, user-owned replacement for the old non-secret
8
+ * `wrangler vars`. SECRETS are NOT here: `GITHUB_TOKEN`, the adapters' `*_WEBHOOK_SECRET`, and the
9
+ * optional `EVENT_WEBHOOK_SECRET` stay in the runtime env.
10
+ *
11
+ * Core ships a NEUTRAL template - never hard-code a real org/product map here. A deployer
12
+ * copies this file and fills in their values. Two shapes are supported:
13
+ *
14
+ * • single-env - export one config and point both `createWorker`/`createAccessWorkflow` at it;
15
+ * • sandbox/prod split - export two profiles (below) and select per-environment via wrangler's
16
+ * per-env `main` (`src/index.ts` → sandbox, `src/index.production.ts` → production).
17
+ *
18
+ * `env` is unavailable at module top-level in Workers, so the profile is chosen at build/deploy time
19
+ * by which entry wrangler loads - not from a runtime var.
20
+ */
21
+
22
+ /** Shared base - neutral defaults. `defaults` is log_only/empty so an unmapped product grants nothing. */
23
+ const base: RepoAccessConfig = {
24
+ githubOrg: '',
25
+ productTeamMap: {
26
+ defaults: {
27
+ teams: [],
28
+ grant_mode: 'claim',
29
+ revoke_policy: { mode: 'log_only' },
30
+ },
31
+ },
32
+ // branding omitted → the claim controller fills neutral defaults (name "RepoAccess").
33
+ }
34
+
35
+ /** Default/sandbox profile (loaded by `src/index.ts`). */
36
+ export const sandbox: RepoAccessConfig = base
37
+
38
+ /** Production profile (loaded by `src/index.production.ts`). Neutral in core - a deployer overrides. */
39
+ export const production: RepoAccessConfig = base
package/src/ssrf.ts ADDED
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ /**
5
+ * SSRF guard for outbound event delivery. **Always on** - there is no flag to
6
+ * disable it. `validateWebhookUrl` runs before every outbound fetch and enforces:
7
+ * - https only (http only when explicitly opted in);
8
+ * - reject IP-literal hosts in private / reserved / loopback / link-local ranges, including the
9
+ * `169.254.169.254` cloud-metadata address;
10
+ * - an optional exact-or-suffix domain allowlist.
11
+ *
12
+ * ⚠️ workerd LIMITATION: a Worker cannot resolve a hostname to its IP address in-process, so a
13
+ * hostname that *resolves* to a private IP (DNS rebinding) is NOT caught by the IP-literal checks
14
+ * below. The domain **allowlist** is the strong control against that - recommend sellers set it.
15
+ * `redirect: 'manual'` at the fetch call site blocks redirect-to-internal. (Residual documented in
16
+ * the security audit.)
17
+ *
18
+ * Pure + dependency-free so it can be unit-tested directly and read in one sitting (security
19
+ * code readability is a feature).
20
+ */
21
+
22
+ export interface SsrfOptions {
23
+ /** Host allowlist; when non-empty the host must match one entry (exact or suffix). */
24
+ allowlist?: string[]
25
+ /** Permit `http:` (default false → https only). */
26
+ allowHttp?: boolean
27
+ }
28
+
29
+ export type SsrfResult = { ok: true; url: URL } | { ok: false; reason: string }
30
+
31
+ export function validateWebhookUrl(
32
+ raw: string,
33
+ opts: SsrfOptions = {},
34
+ ): SsrfResult {
35
+ let url: URL
36
+ try {
37
+ url = new URL(raw)
38
+ } catch {
39
+ return { ok: false, reason: 'invalid url' }
40
+ }
41
+
42
+ if (
43
+ url.protocol !== 'https:' &&
44
+ !(opts.allowHttp && url.protocol === 'http:')
45
+ ) {
46
+ return { ok: false, reason: `scheme ${url.protocol} not allowed` }
47
+ }
48
+
49
+ // URL.hostname keeps brackets for IPv6 literals on some runtimes - strip them defensively.
50
+ const host = url.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
51
+
52
+ const v4 = parseIPv4(host)
53
+ if (v4) {
54
+ if (isPrivateIPv4(v4)) return { ok: false, reason: 'private/reserved IPv4' }
55
+ } else if (host.includes(':')) {
56
+ const v6 = parseIPv6(host)
57
+ if (!v6) return { ok: false, reason: 'unparseable IPv6 literal' }
58
+ if (isBlockedIPv6(v6)) return { ok: false, reason: 'private/reserved IPv6' }
59
+ }
60
+ // else: a DNS hostname - cannot resolve in-worker (see header note); the allowlist is the control.
61
+
62
+ const allow = parseAllowlist(opts.allowlist)
63
+ if (allow.length > 0 && !hostMatchesAllowlist(host, allow)) {
64
+ return { ok: false, reason: 'host not in allowlist' }
65
+ }
66
+
67
+ return { ok: true, url }
68
+ }
69
+
70
+ // --- IPv4 -------------------------------------------------------------------
71
+
72
+ function parseIPv4(host: string): number[] | null {
73
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
74
+ if (!m) return null
75
+ const octets = m.slice(1, 5).map((s) => Number(s))
76
+ return octets.some((o) => o > 255) ? null : octets
77
+ }
78
+
79
+ function isPrivateIPv4([a, b]: number[]): boolean {
80
+ if (a === 10) return true // 10/8 private
81
+ if (a === 172 && b >= 16 && b <= 31) return true // 172.16/12 private
82
+ if (a === 192 && b === 168) return true // 192.168/16 private
83
+ if (a === 127) return true // 127/8 loopback
84
+ if (a === 0) return true // 0.0.0.0/8 "this network"
85
+ if (a === 169 && b === 254) return true // 169.254/16 link-local (incl. 169.254.169.254 metadata)
86
+ if (a === 100 && b >= 64 && b <= 127) return true // 100.64/10 CGNAT (defense-in-depth)
87
+ if (a >= 224) return true // 224/4 multicast + 240/4 reserved + 255.255.255.255 (defense-in-depth)
88
+ return false
89
+ }
90
+
91
+ // --- IPv6 -------------------------------------------------------------------
92
+
93
+ function parseIPv6(input: string): Uint8Array | null {
94
+ let s = input
95
+ const pct = s.indexOf('%')
96
+ if (pct !== -1) s = s.slice(0, pct) // strip zone id
97
+
98
+ // Embedded IPv4 tail (e.g. ::ffff:1.2.3.4) → fold into two hextets.
99
+ if (s.includes('.')) {
100
+ const i = s.lastIndexOf(':')
101
+ if (i === -1) return null
102
+ const v4 = parseIPv4(s.slice(i + 1))
103
+ if (!v4) return null
104
+ const hi = ((v4[0] << 8) | v4[1]).toString(16)
105
+ const lo = ((v4[2] << 8) | v4[3]).toString(16)
106
+ s = `${s.slice(0, i + 1)}${hi}:${lo}`
107
+ }
108
+
109
+ const halves = s.split('::')
110
+ if (halves.length > 2) return null
111
+ const head = halves[0] ? halves[0].split(':') : []
112
+ const tail =
113
+ halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : null
114
+
115
+ let groups: string[]
116
+ if (tail === null) {
117
+ groups = head
118
+ } else {
119
+ const missing = 8 - head.length - tail.length
120
+ if (missing < 1) return null // '::' must stand in for ≥1 group
121
+ groups = [...head, ...Array(missing).fill('0'), ...tail]
122
+ }
123
+ if (groups.length !== 8) return null
124
+
125
+ const bytes = new Uint8Array(16)
126
+ for (let i = 0; i < 8; i++) {
127
+ if (!/^[0-9a-f]{1,4}$/.test(groups[i])) return null
128
+ const v = parseInt(groups[i], 16)
129
+ bytes[i * 2] = v >> 8
130
+ bytes[i * 2 + 1] = v & 0xff
131
+ }
132
+ return bytes
133
+ }
134
+
135
+ function isBlockedIPv6(b: Uint8Array): boolean {
136
+ if (b.every((x) => x === 0)) return true // :: unspecified
137
+ if (b.slice(0, 15).every((x) => x === 0) && b[15] === 1) return true // ::1 loopback
138
+ if ((b[0] & 0xfe) === 0xfc) return true // fc00::/7 unique-local
139
+ if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) return true // fe80::/10 link-local
140
+ // IPv4-mapped ::ffff:a.b.c.d → apply the v4 rules to the embedded address.
141
+ const mapped =
142
+ b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff
143
+ if (mapped && isPrivateIPv4([b[12], b[13], b[14], b[15]])) return true
144
+ return false
145
+ }
146
+
147
+ // --- allowlist --------------------------------------------------------------
148
+
149
+ function parseAllowlist(raw?: string[]): string[] {
150
+ if (!raw) return []
151
+ return raw.map((s) => s.trim().toLowerCase()).filter(Boolean)
152
+ }
153
+
154
+ function hostMatchesAllowlist(host: string, allow: string[]): boolean {
155
+ return allow.some((entry) => host === entry || host.endsWith(`.${entry}`))
156
+ }
package/src/types.ts ADDED
@@ -0,0 +1,238 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ /**
5
+ * Pinned core contract.
6
+ *
7
+ * This is the public surface every adapter implements. Keep it minimal and provider-agnostic:
8
+ * provider specifics live in adapters, never here.
9
+ */
10
+
11
+ /**
12
+ * A JSON value - the serializable shape that survives a `step.do` boundary. Cloudflare Workflows
13
+ * persists every step result, so `step.do<T>()` constrains `T` to `Serializable`; bare `unknown`
14
+ * fails that constraint. Used for GitHub response bodies (`GithubResult.json`), which ARE JSON at
15
+ * runtime.
16
+ *
17
+ * Both composite arms are named interfaces (not inline `Json[]` / `{ [k]: Json }`): routing the
18
+ * recursion through `ReadonlyArray` and an interface lets the workers-types `Serializable<T>` mapped
19
+ * type resolve each arm via its dedicated `ReadonlyArray` / object branch and reuse the memoized
20
+ * `Serializable<Json>`, instead of re-expanding to infinite instantiation depth (TS2589).
21
+ */
22
+ export type Json = null | boolean | number | string | JsonArray | JsonObject
23
+ export interface JsonArray extends ReadonlyArray<Json> {}
24
+ export interface JsonObject {
25
+ readonly [key: string]: Json
26
+ }
27
+
28
+ export type GrantMode = 'username' | 'email' | 'claim' // `email` is for a later release
29
+
30
+ export interface RevokePolicy {
31
+ mode: 'auto_revoke' | 'log_only'
32
+ /** Gates `refund` events only; chargebacks always revoke under auto_revoke. */
33
+ full_refund_only?: boolean
34
+ }
35
+
36
+ export interface ProductConfig {
37
+ /** Team slugs → resolved to numeric ids at runtime (KV `team:{slug}`). */
38
+ teams: string[]
39
+ grant_mode?: GrantMode
40
+ revoke_policy?: RevokePolicy
41
+ }
42
+
43
+ /**
44
+ * Product→team map - flat shape: adapter keys + a sibling `defaults`. `defaults` is a
45
+ * RESERVED key: `resolveProductConfig` guards it so an adapter literally named "defaults" cannot
46
+ * shadow the fallback. Authored as a typed object in `RepoAccessConfig.productTeamMap`,
47
+ * no longer a JSON-string var.
48
+ */
49
+ export interface ProductTeamMap {
50
+ defaults: ProductConfig
51
+ [adapter: string]: ProductConfig | { [product_id: string]: ProductConfig }
52
+ }
53
+
54
+ /**
55
+ * Seller-configurable claim-page branding. The contract type for both
56
+ * `RepoAccessConfig.branding` (as a `Partial`, defaulted by the controller) and the claim template's
57
+ * view (re-exported from `claim-template.tsx`).
58
+ */
59
+ export interface Branding {
60
+ name: string
61
+ logoUrl: string
62
+ faviconUrl: string
63
+ }
64
+
65
+ /**
66
+ * Outbound event delivery. Optional/opt-in - omit (or leave `url` empty) for the log-only
67
+ * sink. The signing secret is NOT here: it stays in the env as `EVENT_WEBHOOK_SECRET`.
68
+ */
69
+ export interface EventWebhookConfig {
70
+ /** Destination URL; unset/empty → delivery is a no-op (log-only). */
71
+ url?: string
72
+ /** SSRF host allowlist (exact-or-suffix match). Empty/unset → any public host. */
73
+ allowlist?: string[]
74
+ }
75
+
76
+ /**
77
+ * Deployment config supplied as a typed, user-owned object - `repoaccess.config.ts` handed to
78
+ * `createWorker({ config })` (request path) and `createAccessWorkflow(config, adapters)` (Workflow path).
79
+ * SECRETS are NOT here - they
80
+ * stay in the runtime env: `GITHUB_TOKEN`, the adapters' `*_WEBHOOK_SECRET`, and the optional
81
+ * `EVENT_WEBHOOK_SECRET`.
82
+ */
83
+ export interface RepoAccessConfig {
84
+ /** GitHub org that grants/revokes target. (was the `GITHUB_ORG` var) */
85
+ githubOrg: string
86
+ /** Product→team map as a typed object. (was the `PRODUCT_TEAM_MAP` JSON-string var) */
87
+ productTeamMap: ProductTeamMap
88
+ /** Claim-page branding; optional - the controller fills neutral defaults. (was `CLAIM_BRAND_*`) */
89
+ branding?: Partial<Branding>
90
+ /** Outbound event delivery; optional/opt-in. (was `EVENT_WEBHOOK_URL` / `EVENT_WEBHOOK_ALLOWLIST`) */
91
+ eventWebhook?: EventWebhookConfig
92
+ }
93
+
94
+ export interface NormalizedEvent {
95
+ event_type: 'payment_success' | 'refund' | 'chargeback'
96
+ product_id: string
97
+ /** Stable correlation key - identical across an order and its later refund/chargeback. */
98
+ transaction_id: string
99
+ buyer_email: string | null
100
+ github_username: string | null
101
+ /** Refund events only: true=full, false=partial, null=n/a. */
102
+ is_full_refund: boolean | null
103
+ }
104
+
105
+ export interface RawRequest {
106
+ /** Byte-exact body - HMAC verification breaks on re-serialization. */
107
+ bodyText: string
108
+ /** Parsed form body for form-urlencoded adapters (Gumroad/Mollie). */
109
+ bodyForm?: URLSearchParams
110
+ headers: Headers
111
+ }
112
+
113
+ /**
114
+ * The adapter's API-fetched entity under `api_callback` - opaque to core; shape is adapter-defined
115
+ * (first real use is Gumroad, a later release). Under api_callback the inbound payload is never trusted; grant
116
+ * decisions read ONLY this verified entity.
117
+ */
118
+ export type VerifiedEntity = Record<string, unknown>
119
+
120
+ export type VerificationStrategy =
121
+ | {
122
+ kind: 'hmac'
123
+ algo: 'SHA-256' | 'SHA-512'
124
+ /**
125
+ * The signing secret, read from the runtime env. The adapter is self-describing about which
126
+ * var holds it - e.g. `(env) => env.STRIPE_WEBHOOK_SECRET`. Returns `undefined`
127
+ * when unset → the engine rejects. (Contract extension over the original base contract - the pinned shape had
128
+ * no way for the generic engine to obtain the per-adapter key.)
129
+ */
130
+ secret(env: CloudflareBindings): string | undefined
131
+ /** Canonical string to sign: raw body | `ts:body` | manifest template. */
132
+ canonical(raw: RawRequest): string
133
+ /**
134
+ * Pull the signature(s) + optional timestamp from the headers. `signature` may be an array
135
+ * when a provider sends multiple candidates (e.g. Stripe `v1` during secret rotation) - the
136
+ * engine accepts a match against ANY.
137
+ */
138
+ extract(headers: Headers): { signature: string | string[]; ts?: string }
139
+ /** Replay tolerance in seconds, where the provider supplies a timestamp (e.g. Paddle ≈ 5). */
140
+ toleranceSec?: number
141
+ }
142
+ | {
143
+ kind: 'api_callback'
144
+ /**
145
+ * The validated path credential, read from the runtime env - the adapter self-describes its
146
+ * var, e.g. `(env) => env.GUMROAD_WEBHOOK_PATH` (mirrors hmac's `secret()`). There is no
147
+ * signature on the ack path for api_callback adapters, so the route timing-safe-compares the
148
+ * `:secret_path` URL segment against this value BEFORE enqueueing - it is the first-line
149
+ * credential that replaces HMAC. Returns `undefined` when unset → the route rejects
150
+ * (fail-closed). (ADR extending 0007.)
151
+ */
152
+ secretPath(env: CloudflareBindings): string | undefined
153
+ /**
154
+ * Fetch the authoritative entity from the provider's API. Runs as a durable, retriable Workflow
155
+ * step (NOT on the ack path - it is outbound I/O that would break the <100ms ack). The inbound
156
+ * ping body is NEVER trusted; the grant is mapped from this returned entity. `null` → terminal
157
+ * reject (a forged/unknown id 404s here).
158
+ */
159
+ fetchEntity(
160
+ raw: RawRequest,
161
+ env: CloudflareBindings,
162
+ ): Promise<VerifiedEntity | null>
163
+ }
164
+ | {
165
+ kind: 'shared_secret_header'
166
+ /**
167
+ * Name of the request header carrying the shared secret the provider echoes on every webhook
168
+ * (e.g. Telegram's `X-Telegram-Bot-Api-Secret-Token`, set once via `setWebhook`'s
169
+ * `secret_token`). The engine reads THIS header and timing-safe compares it against `secret`.
170
+ */
171
+ header: string
172
+ /**
173
+ * The expected secret, read from the runtime env - self-describing about which var holds it,
174
+ * exactly like hmac's `secret()` (e.g. `(env) => env.TELEGRAM_WEBHOOK_SECRET`). Returns
175
+ * `undefined` when unset → the engine rejects (fail-closed). The secret authenticates the
176
+ * TRANSPORT: once the header matches, the inbound body is authentic and the grant reads it
177
+ * directly - there is no signature to check (nothing is signed) and no entity to re-fetch (the
178
+ * update IS the authoritative record). The third taxonomy kind, additive over `hmac` /
179
+ * `api_callback`. (ADR extending 0007/0033.)
180
+ */
181
+ secret(env: CloudflareBindings): string | undefined
182
+ }
183
+
184
+ export interface PaymentAdapter {
185
+ /** Adapter id used in the route `/wh/:adapter/...` and the Workflow instance id. */
186
+ name: string
187
+ verification: VerificationStrategy
188
+ /**
189
+ * Normalize the raw request into an event, or `null` (hmac/shared_secret_header → route 400;
190
+ * api_callback → terminal access.failed "unhandled"). The optional `entity` is the API-fetched,
191
+ * trusted entity, supplied ONLY on the api_callback path (fetched inside the Workflow). hmac and
192
+ * shared_secret_header adapters ignore it and keep working unchanged. Under api_callback, map the
193
+ * grant from `entity` - never from `raw` (the ping is untrusted).
194
+ */
195
+ parse(raw: RawRequest, entity?: VerifiedEntity): NormalizedEvent | null
196
+ /**
197
+ * OPTIONAL interactive-handshake hook. When present, the router delegates a verified request to it
198
+ * BEFORE `parse` (only ever AFTER verification has passed - it can never bypass auth): a returned
199
+ * `Response` IS the ack (the router returns it, no enqueue), `null` falls through to the normal
200
+ * `parse → enqueue` path. It exists for providers whose webhook carries handshake steps as well as
201
+ * terminal events - e.g. Telegram's `pre_checkout_query`, which the bot must answer (its own bounded
202
+ * outbound call) before the terminal `successful_payment` arrives and flows through `parse`. Every
203
+ * existing adapter omits it and is unaffected. (ADR extending 0033.)
204
+ */
205
+ handle?(raw: RawRequest, env: CloudflareBindings): Promise<Response | null>
206
+ }
207
+
208
+ /**
209
+ * The raw, UNVERIFIED ping enqueued for an `api_callback` adapter. The Workflow fetches the
210
+ * authoritative entity (never trusting this body) and parses the event from it. Carried instead of
211
+ * a `NormalizedEvent` because, for api_callback, there is nothing to parse on the ack path - the
212
+ * event isn't known until the entity is fetched in a durable step.
213
+ */
214
+ export interface ApiCallbackPing {
215
+ /** The ping body byte-exact (the adapter derives its lookup id from this / `form`). */
216
+ bodyText: string
217
+ /** Parsed form fields (form-urlencoded ping); rebuilt into `URLSearchParams` in the Workflow. */
218
+ form: Record<string, string>
219
+ }
220
+
221
+ /**
222
+ * Params handed to `AccessWorkflow` on enqueue (internal - not part of the adapter contract).
223
+ * Exactly one of `event` / `ping` is set:
224
+ * - `event` - the hmac path: `parse()` already ran on the (fast, local) ack path.
225
+ * - `ping` - the api_callback path: the Workflow fetches the entity + parses (outbound I/O kept
226
+ * off the ack path). Grant vs revoke is then derived from the resolved `event.event_type`.
227
+ */
228
+ export interface AccessWorkflowParams {
229
+ adapter: string
230
+ event?: NormalizedEvent
231
+ ping?: ApiCallbackPing
232
+ /**
233
+ * Set by claim completion. Forces `username` grant mode - the product's configured mode is
234
+ * `claim`, which would otherwise loop back into another claim - and makes the grant emit
235
+ * `claim.completed` on success. The handle is validated at the claim POST before enqueue.
236
+ */
237
+ from_claim?: boolean
238
+ }
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ // GitHub username grammar: ≤39 chars, alphanumeric + single internal hyphens,
5
+ // no leading/trailing/consecutive hyphens. A malformed handle is treated as "no username" → claim
6
+ // fallback (so it never burns the org's 50/24h invitation quota). Same regex runs inline on the
7
+ // claim POST. Format validation is necessary-but-not-sufficient: a well-formed handle for a
8
+ // non-existent account still surfaces as a 404 from the membership call → access.failed.
9
+ const GITHUB_USERNAME = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/
10
+
11
+ export function isValidGithubUsername(
12
+ value: string | null | undefined,
13
+ ): value is string {
14
+ return typeof value === 'string' && GITHUB_USERNAME.test(value)
15
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,173 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import type {
5
+ PaymentAdapter,
6
+ RawRequest,
7
+ VerificationStrategy,
8
+ VerifiedEntity,
9
+ } from './types'
10
+
11
+ /**
12
+ * Verification engine. Core declares the strategy on each adapter and
13
+ * executes it here - adapters never do their own crypto. Runs on the fast-ack request path, so it
14
+ * does no work beyond the signature check (hmac) or the single entity fetch (api_callback, which is
15
+ * itself the verification). On failure the route rejects BEFORE enqueueing.
16
+ */
17
+ export type VerifyResult =
18
+ | { ok: true; entity?: VerifiedEntity }
19
+ | { ok: false; reason: string }
20
+
21
+ type HmacStrategy = Extract<VerificationStrategy, { kind: 'hmac' }>
22
+ type ApiCallbackStrategy = Extract<
23
+ VerificationStrategy,
24
+ { kind: 'api_callback' }
25
+ >
26
+ type SharedSecretHeaderStrategy = Extract<
27
+ VerificationStrategy,
28
+ { kind: 'shared_secret_header' }
29
+ >
30
+
31
+ const encoder = new TextEncoder()
32
+
33
+ async function hmacHex(
34
+ algo: 'SHA-256' | 'SHA-512',
35
+ secret: string,
36
+ message: string,
37
+ ): Promise<string> {
38
+ const key = await crypto.subtle.importKey(
39
+ 'raw',
40
+ encoder.encode(secret),
41
+ { name: 'HMAC', hash: algo },
42
+ false,
43
+ ['sign'],
44
+ )
45
+ const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(message))
46
+ const bytes = new Uint8Array(mac)
47
+ let hex = ''
48
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, '0')
49
+ return hex
50
+ }
51
+
52
+ /**
53
+ * Constant-time hex compare. The length check is acceptable: a digest's length is fixed by its
54
+ * algorithm, so a length mismatch only ever means an invalid signature, not a secret-dependent
55
+ * branch. The XOR loop over equal-length strings does not short-circuit. (invariant: timing-safe)
56
+ */
57
+ export function timingSafeEqualHex(a: string, b: string): boolean {
58
+ if (a.length !== b.length) return false
59
+ let diff = 0
60
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
61
+ return diff === 0
62
+ }
63
+
64
+ /**
65
+ * Constant-time UTF-8 string compare with NO early length-leak. Used for the api_callback
66
+ * `:secret_path` credential check - unlike `timingSafeEqualHex` (which returns early on a length
67
+ * mismatch, acceptable for fixed-length digests but a length oracle for a variable-length secret),
68
+ * this folds the length difference into the accumulator and always iterates over the EXPECTED
69
+ * secret's byte length. So neither the candidate's content nor its length short-circuits: a wrong
70
+ * length still fails (via the length XOR) without a distinguishable early return, and the loop count
71
+ * depends only on the deployment-fixed `expected`, never on attacker input.
72
+ */
73
+ export function timingSafeEqualString(
74
+ candidate: string,
75
+ expected: string,
76
+ ): boolean {
77
+ const a = encoder.encode(candidate)
78
+ const b = encoder.encode(expected)
79
+ let diff = a.length ^ b.length
80
+ for (let i = 0; i < b.length; i++) {
81
+ diff |= b[i] ^ (i < a.length ? a[i] : 0)
82
+ }
83
+ return diff === 0
84
+ }
85
+
86
+ export async function verifyHmac(
87
+ strategy: HmacStrategy,
88
+ raw: RawRequest,
89
+ env: CloudflareBindings,
90
+ nowMs: number = Date.now(),
91
+ ): Promise<VerifyResult> {
92
+ const secret = strategy.secret(env)
93
+ if (!secret) return { ok: false, reason: 'missing signing secret' }
94
+
95
+ const { signature, ts } = strategy.extract(raw.headers)
96
+ const candidates = (
97
+ Array.isArray(signature) ? signature : [signature]
98
+ ).filter(Boolean)
99
+ if (candidates.length === 0) return { ok: false, reason: 'missing signature' }
100
+
101
+ // Replay window, only where the provider supplies a timestamp (e.g. Paddle ≈ 5s).
102
+ if (strategy.toleranceSec !== undefined) {
103
+ if (!ts) return { ok: false, reason: 'missing timestamp' }
104
+ const tsSec = Number(ts)
105
+ if (!Number.isFinite(tsSec))
106
+ return { ok: false, reason: 'invalid timestamp' }
107
+ if (Math.abs(nowMs / 1000 - tsSec) > strategy.toleranceSec) {
108
+ return { ok: false, reason: 'timestamp outside tolerance' }
109
+ }
110
+ }
111
+
112
+ const expected = await hmacHex(strategy.algo, secret, strategy.canonical(raw))
113
+ // Match against ANY candidate (e.g. Stripe sends one v1 per active secret during rotation).
114
+ const matched = candidates.some((candidate) =>
115
+ timingSafeEqualHex(expected, candidate.toLowerCase()),
116
+ )
117
+ if (!matched) return { ok: false, reason: 'signature mismatch' }
118
+ return { ok: true }
119
+ }
120
+
121
+ export async function verifyApiCallback(
122
+ strategy: ApiCallbackStrategy,
123
+ raw: RawRequest,
124
+ env: CloudflareBindings,
125
+ ): Promise<VerifyResult> {
126
+ // The inbound payload is never trusted: the grant decision reads only this fetched entity.
127
+ // (The route's :secret_path segment is the first-line filter; enforced per-adapter in a later release.)
128
+ const entity = await strategy.fetchEntity(raw, env)
129
+ if (!entity) return { ok: false, reason: 'entity fetch returned null' }
130
+ return { ok: true, entity }
131
+ }
132
+
133
+ /**
134
+ * Verify a `shared_secret_header` request: the provider echoes a shared secret in a fixed header
135
+ * (e.g. Telegram's `X-Telegram-Bot-Api-Secret-Token`). Constant-time compare that header against the
136
+ * configured secret - fail-closed when the secret is unset OR the header is missing/mismatched (401),
137
+ * the same posture as the other two kinds. The secret authenticates the TRANSPORT: no body is signed
138
+ * and no entity is re-fetched, so this does NO body parse and NO outbound I/O - once the header
139
+ * matches, the inbound body is authentic and the grant reads it directly (like hmac, unlike
140
+ * api_callback). Synchronous, but returns a `VerifyResult` for a uniform `verifyRequest` surface.
141
+ */
142
+ export function verifySharedSecretHeader(
143
+ strategy: SharedSecretHeaderStrategy,
144
+ raw: RawRequest,
145
+ env: CloudflareBindings,
146
+ ): VerifyResult {
147
+ const expected = strategy.secret(env)
148
+ if (!expected) return { ok: false, reason: 'missing shared secret' }
149
+ const provided = raw.headers.get(strategy.header)
150
+ // No header → fail-closed. (Compare anyway would also fail, but skip the work and be explicit.)
151
+ if (provided === null) return { ok: false, reason: 'missing secret header' }
152
+ // Constant-time, no early length-leak - same compare used for the api_callback secret path.
153
+ if (!timingSafeEqualString(provided, expected))
154
+ return { ok: false, reason: 'secret header mismatch' }
155
+ return { ok: true }
156
+ }
157
+
158
+ /** Execute an adapter's declared verification strategy. */
159
+ export function verifyRequest(
160
+ adapter: PaymentAdapter,
161
+ raw: RawRequest,
162
+ env: CloudflareBindings,
163
+ ): Promise<VerifyResult> {
164
+ const strategy = adapter.verification
165
+ switch (strategy.kind) {
166
+ case 'hmac':
167
+ return verifyHmac(strategy, raw, env)
168
+ case 'api_callback':
169
+ return verifyApiCallback(strategy, raw, env)
170
+ case 'shared_secret_header':
171
+ return Promise.resolve(verifySharedSecretHeader(strategy, raw, env))
172
+ }
173
+ }