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/config.ts ADDED
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import type { ProductConfig, ProductTeamMap } from './types'
5
+
6
+ /**
7
+ * Light runtime guard that a config-authored `productTeamMap` carries the reserved `defaults` key.
8
+ * The typed `RepoAccessConfig` already enforces this at compile time; this catches a
9
+ * hand-authored config that was cast/loosened, failing loudly at startup rather than silently
10
+ * granting nothing. (Replaces the old JSON-string `parseProductTeamMap`.)
11
+ */
12
+ export function assertProductTeamMap(map: ProductTeamMap): ProductTeamMap {
13
+ if (
14
+ typeof map !== 'object' ||
15
+ map === null ||
16
+ typeof map.defaults !== 'object' ||
17
+ map.defaults === null
18
+ ) {
19
+ throw new Error('productTeamMap must be an object with a `defaults` key')
20
+ }
21
+ return map
22
+ }
23
+
24
+ /**
25
+ * Resolve a product's config: `map[adapter]?.[product_id] ?? map.defaults` (whole-object
26
+ * fallback). `defaults` is a reserved key - an adapter literally named "defaults" cannot shadow
27
+ * the fallback.
28
+ */
29
+ export function resolveProductConfig(
30
+ map: ProductTeamMap,
31
+ adapter: string,
32
+ productId: string,
33
+ ): ProductConfig {
34
+ const perAdapter =
35
+ adapter === 'defaults'
36
+ ? undefined
37
+ : (map[adapter] as Record<string, ProductConfig> | undefined)
38
+ return perAdapter?.[productId] ?? map.defaults
39
+ }
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import { Hono } from 'hono'
5
+ import type {
6
+ AccessWorkflowParams,
7
+ ApiCallbackPing,
8
+ PaymentAdapter,
9
+ RepoAccessConfig,
10
+ } from './types'
11
+ import { captureRawRequest } from './raw-request'
12
+ import { apiCallbackInstanceId, workflowInstanceId } from './workflow-id'
13
+ import { timingSafeEqualString, verifyRequest } from './verify'
14
+ import { makeClaimGet, makeClaimPost } from './claim'
15
+ import { defaultClaimTemplate, type ClaimTemplate } from './claim-template'
16
+
17
+ export interface CreateWorkerOptions {
18
+ adapters: PaymentAdapter[]
19
+ /**
20
+ * Deployment config as a typed object - `githubOrg`, `productTeamMap`, branding,
21
+ * outbound `eventWebhook`. Non-secret config no longer comes from `wrangler vars`; secrets stay in
22
+ * the env. The Workflow path receives the SAME object via `createAccessWorkflow(config, adapters)`.
23
+ */
24
+ config: RepoAccessConfig
25
+ /**
26
+ * Claim-page HTML template. The open extension point: a downstream worker supplies its
27
+ * own `ClaimTemplate` to restyle every claim state without forking the controller. Defaults to
28
+ * core's `defaultClaimTemplate`.
29
+ */
30
+ claimTemplate?: ClaimTemplate
31
+ }
32
+
33
+ /**
34
+ * Composition root. Core exports this; the example entry composes `[stripe]`, Pro
35
+ * composes `[stripe, paddle, …]`. The router code is identical across core/pro - only the adapter
36
+ * list differs. No provider-specific branches live here.
37
+ */
38
+ export function createWorker({
39
+ adapters,
40
+ config,
41
+ claimTemplate = defaultClaimTemplate,
42
+ }: CreateWorkerOptions) {
43
+ const adaptersByName = new Map(
44
+ adapters.map((adapter) => [adapter.name, adapter]),
45
+ )
46
+ const app = new Hono<{ Bindings: CloudflareBindings }>()
47
+
48
+ // Liveness only - never leak config/secrets. (route map: hono skill)
49
+ app.get('/health', (c) => c.json({ status: 'ok' }))
50
+
51
+ // Claim flow. GET renders the JSON projection or the injected HTML template
52
+ // (defaultClaimTemplate unless overridden) after KV token validation; POST validates the
53
+ // handle inline → single-flights via ClaimGuard → enqueues a grant in `username` mode under
54
+ // the id `{adapter}-claim_completed-{transaction_id}-{handle}`. The route does NOT delete the token -
55
+ // the workflow terminal step consumes it on success / retains it on user-not-found. See
56
+ // `claim.tsx` (controller) + `claim-template.tsx` (view contract).
57
+ app.get('/claim/:token', makeClaimGet(claimTemplate, config))
58
+ app.post('/claim/:token', makeClaimPost(claimTemplate, config))
59
+
60
+ /**
61
+ * Inbound payment webhooks. The request path does ONLY: resolve adapter → capture raw body →
62
+ * [verify | secret-path check] → enqueue the Workflow (deterministic id) → ack. No GitHub calls,
63
+ * no outbound events, all side effects run inside the Workflow. Target < 100 ms.
64
+ *
65
+ * Three verification kinds, branched here:
66
+ * - hmac: timing-safe signature check → `parse(raw)` → enqueue the parsed `event`.
67
+ * - shared_secret_header: timing-safe compare a fixed header against the adapter's configured
68
+ * secret (fail-closed). Like hmac, the body is then authentic and parsed on the ack path - no
69
+ * fetch. Shares the hmac code path below (verifyRequest dispatches by kind).
70
+ * - api_callback: there is no signature. The `:secret_path` segment IS the credential
71
+ * (timing-safe compared against the adapter's configured path, fail-closed). The authoritative
72
+ * entity fetch is outbound I/O, so it is deferred to a durable Workflow step - the ack path
73
+ * enqueues only the RAW ping (no fetch, no parse here).
74
+ */
75
+ app.post('/wh/:adapter/:secret_path', async (c) => {
76
+ const adapter = adaptersByName.get(c.req.param('adapter'))
77
+ if (!adapter) return c.notFound() // unknown adapter → 404
78
+
79
+ // Read the raw body byte-exact BEFORE any parse (HMAC depends on it).
80
+ const raw = await captureRawRequest(c.req.raw)
81
+
82
+ if (adapter.verification.kind === 'api_callback') {
83
+ // First-line credential (replaces HMAC for api_callback): timing-safe compare the path segment
84
+ // against the adapter's configured secret path. Unset secret OR mismatch → 401, fail-closed,
85
+ // BEFORE any fetch/parse/enqueue. No outbound I/O on the ack path.
86
+ const expected = adapter.verification.secretPath(c.env)
87
+ const provided = c.req.param('secret_path')
88
+ if (!expected || !timingSafeEqualString(provided, expected)) {
89
+ console.log(
90
+ JSON.stringify({
91
+ level: 'warn',
92
+ msg: 'webhook secret-path rejected',
93
+ adapter: adapter.name,
94
+ }),
95
+ )
96
+ return c.text('unauthorized', 401)
97
+ }
98
+
99
+ // The event isn't known until the entity is fetched (in the Workflow), so the id hashes the raw
100
+ // ping body: identical retried pings dedupe; distinct events (different bodies) get distinct ids.
101
+ const id = await apiCallbackInstanceId(adapter.name, raw.bodyText)
102
+ const ping: ApiCallbackPing = {
103
+ bodyText: raw.bodyText,
104
+ form: Object.fromEntries(raw.bodyForm ?? []),
105
+ }
106
+ const params: AccessWorkflowParams = { adapter: adapter.name, ping }
107
+ await c.env.ACCESS_WORKFLOW.createBatch([{ id, params }])
108
+ return c.text('ok', 200)
109
+ }
110
+
111
+ // hmac | shared_secret_header - execute the adapter's declared check (timing-safe compare +
112
+ // optional tolerance, or a timing-safe header-secret compare). Reject BEFORE enqueue on failure.
113
+ const verification = await verifyRequest(adapter, raw, c.env)
114
+ if (!verification.ok) {
115
+ // Structured warn on verification failure - never log signatures/secrets/body.
116
+ // Visibility into rejected webhooks without leaking anything sensitive.
117
+ console.log(
118
+ JSON.stringify({
119
+ level: 'warn',
120
+ msg: 'webhook verification failed',
121
+ adapter: adapter.name,
122
+ }),
123
+ )
124
+ return c.text('invalid signature', 401)
125
+ }
126
+
127
+ // Optional interactive-handshake hook. Runs ONLY after verify passed (so it can never bypass
128
+ // auth), BEFORE parse → enqueue. A returned Response IS the ack (e.g. answering a Telegram
129
+ // pre_checkout_query); `null` falls through to the normal parse path. No-op for adapters that
130
+ // omit it - the hmac/api_callback paths stay byte-identical. The hook's own outbound (the ack
131
+ // call back to the provider) is the adapter's bounded concern.
132
+ if (adapter.handle) {
133
+ const handled = await adapter.handle(raw, c.env)
134
+ if (handled) return handled
135
+ }
136
+
137
+ const event = adapter.parse(raw)
138
+ if (!event) return c.text('unprocessable entity', 400) // malformed/unrecognized → 400
139
+
140
+ // Deterministic Workflow id = the idempotency key (`-`-joined, see workflow-id.ts -
141
+ // the runtime rejects the `:` separator). This IS the dedupe mechanism - no KV bookkeeping.
142
+ // `createBatch` is idempotent: a duplicate id (within the instance retention window) is silently
143
+ // skipped (NOT thrown), so we still ack 200. (Workflows API, verified against docs + runtime)
144
+ const id = await workflowInstanceId(
145
+ adapter.name,
146
+ event.event_type,
147
+ event.transaction_id,
148
+ )
149
+ const params: AccessWorkflowParams = { adapter: adapter.name, event }
150
+ await c.env.ACCESS_WORKFLOW.createBatch([{ id, params }])
151
+
152
+ return c.text('ok', 200)
153
+ })
154
+
155
+ return app
156
+ }
package/src/events.ts ADDED
@@ -0,0 +1,195 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import type { NormalizedEvent, RepoAccessConfig } from './types'
5
+ import { validateWebhookUrl } from './ssrf'
6
+
7
+ // Outbound event envelopes. The envelope shape + a structured-log sink shipped first; the signed-HTTP
8
+ // delivery sink was added behind the SAME EventSink interface (so callers don't change).
9
+ // Delivery runs in a Workflow step AFTER the GitHub side effect and must never block or fail the
10
+ // grant: transient failures throw (the durable engine retries), and the emit step swallows the
11
+ // final exhaustion (see workflow emitEvent()).
12
+
13
+ export type OutboundEventType =
14
+ | 'access.granted'
15
+ | 'access.failed'
16
+ | 'access.revoked'
17
+ | 'claim.pending'
18
+ | 'claim.completed'
19
+
20
+ export interface EventEnvelope {
21
+ event_id: string
22
+ event_type: OutboundEventType
23
+ timestamp: string
24
+ product_id: string
25
+ transaction_id: string
26
+ buyer_email: string | null
27
+ org: string
28
+ // Per-event fields: github_username (string|null), teams (string[]), status/reason/claim_url/
29
+ // trigger (string). A flat scalar/array union - not a recursive JSON type - keeps the envelope
30
+ // Serializable cheaply across the `step.do` boundary that emits it (avoids TS2589).
31
+ [extra: string]: EnvelopeField
32
+ }
33
+
34
+ /** Value type for an envelope's per-event extra fields (see the index signature above). */
35
+ export type EnvelopeField = string | string[] | null
36
+
37
+ /** Build the base envelope + per-type fields. event_id is THIS delivery's id (not the txn). */
38
+ export function buildEnvelope(
39
+ org: string,
40
+ type: OutboundEventType,
41
+ event: NormalizedEvent,
42
+ extra: Record<string, EnvelopeField> = {},
43
+ ): EventEnvelope {
44
+ return {
45
+ event_id: crypto.randomUUID(),
46
+ event_type: type,
47
+ timestamp: new Date().toISOString(),
48
+ product_id: event.product_id,
49
+ transaction_id: event.transaction_id,
50
+ buyer_email: event.buyer_email,
51
+ org,
52
+ ...extra,
53
+ }
54
+ }
55
+
56
+ /** A delivery sink. The default logs; a signed-HTTP + SSRF-guarded sink is also provided. */
57
+ export type EventSink = (envelope: EventEnvelope) => void | Promise<void>
58
+
59
+ // The envelope legitimately carries buyer_email (PII) and, on claim.pending, claim_url (a single-use
60
+ // bearer token) for delivery to the seller - but the LOG fallback must contain neither.
61
+ // buyer_email is redacted; claim_url is dropped entirely (the full URL still reaches the
62
+ // outbound HTTP sink via deliver(); only this log line omits it).
63
+ export const logSink: EventSink = (envelope) => {
64
+ const { buyer_email, claim_url: _claim_url, ...rest } = envelope
65
+ console.log(
66
+ JSON.stringify({
67
+ level: 'info',
68
+ msg: 'event',
69
+ ...rest,
70
+ buyer_email: buyer_email ? '[redacted]' : null,
71
+ }),
72
+ )
73
+ }
74
+
75
+ // --- signed HTTP delivery ------------------------------------
76
+
77
+ const DELIVERY_TIMEOUT_MS = 10_000
78
+
79
+ const encoder = new TextEncoder()
80
+
81
+ /**
82
+ * HMAC-SHA256 over the canonical `${ts}.${body}` string, hex. Deliberately mirrors our inbound
83
+ * Stripe-style scheme (verify.ts) so a seller verifies our deliveries with the same recipe they
84
+ * already use for inbound webhooks.
85
+ */
86
+ async function signDelivery(
87
+ secret: string,
88
+ ts: string,
89
+ body: string,
90
+ ): Promise<string> {
91
+ const key = await crypto.subtle.importKey(
92
+ 'raw',
93
+ encoder.encode(secret),
94
+ { name: 'HMAC', hash: 'SHA-256' },
95
+ false,
96
+ ['sign'],
97
+ )
98
+ const mac = await crypto.subtle.sign(
99
+ 'HMAC',
100
+ key,
101
+ encoder.encode(`${ts}.${body}`),
102
+ )
103
+ return [...new Uint8Array(mac)]
104
+ .map((b) => b.toString(16).padStart(2, '0'))
105
+ .join('')
106
+ }
107
+
108
+ function safeHost(raw: string): string {
109
+ try {
110
+ return new URL(raw).host
111
+ } catch {
112
+ return '(unparseable)'
113
+ }
114
+ }
115
+
116
+ /**
117
+ * POST the signed envelope to the configured delivery URL. Returns (no-op) for config conditions
118
+ * that a retry can't fix - destination unset, secret missing (fail-closed: never send unsigned), or
119
+ * the SSRF guard rejecting the URL. THROWS on a transient delivery failure (non-2xx, timeout,
120
+ * network) so the durable Workflow engine retries the emit step. The destination +
121
+ * allowlist come from `config.eventWebhook`; the signing secret stays in env.
122
+ */
123
+ async function deliver(
124
+ env: CloudflareBindings,
125
+ config: RepoAccessConfig,
126
+ envelope: EventEnvelope,
127
+ ): Promise<void> {
128
+ const url = config.eventWebhook?.url
129
+ if (!url) return // optional destination unset → nothing to deliver
130
+
131
+ const secret = env.EVENT_WEBHOOK_SECRET // optional secret → string | undefined (see worker-env.d.ts)
132
+ if (!secret) {
133
+ // Fail-closed, consistent with inbound: a configured URL with no secret must NOT send unsigned.
134
+ console.log(
135
+ JSON.stringify({
136
+ level: 'error',
137
+ msg: 'event delivery misconfigured: eventWebhook.url set but EVENT_WEBHOOK_SECRET missing',
138
+ }),
139
+ )
140
+ return
141
+ }
142
+
143
+ const check = validateWebhookUrl(url, {
144
+ allowlist: config.eventWebhook?.allowlist,
145
+ })
146
+ if (!check.ok) {
147
+ console.log(
148
+ JSON.stringify({
149
+ level: 'error',
150
+ msg: 'event delivery blocked by SSRF guard',
151
+ reason: check.reason,
152
+ host: safeHost(url),
153
+ }),
154
+ )
155
+ return
156
+ }
157
+
158
+ const body = JSON.stringify(envelope)
159
+ const ts = String(Math.floor(Date.now() / 1000))
160
+ const signature = await signDelivery(secret, ts, body)
161
+
162
+ const controller = new AbortController()
163
+ const timer = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS)
164
+ try {
165
+ const res = await fetch(check.url.toString(), {
166
+ method: 'POST',
167
+ body,
168
+ headers: {
169
+ 'content-type': 'application/json',
170
+ 'x-repoaccess-signature': `sha256=${signature}`,
171
+ 'x-repoaccess-timestamp': ts,
172
+ },
173
+ redirect: 'manual', // never follow a redirect to an internal target
174
+ signal: controller.signal,
175
+ })
176
+ // redirect:'manual' surfaces a 3xx as-is; any non-2xx (incl. a redirect) is a delivery failure.
177
+ if (!res.ok) throw new Error(`event delivery: HTTP ${res.status}`)
178
+ } finally {
179
+ clearTimeout(timer)
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Production sink: structured log (always, redacted) + signed HTTP delivery. Swapped in by
185
+ * `AccessWorkflow.run`; `executeAccessWorkflow` still defaults to `logSink` for tests/local.
186
+ */
187
+ export function createEventSink(
188
+ env: CloudflareBindings,
189
+ config: RepoAccessConfig,
190
+ ): EventSink {
191
+ return async (envelope) => {
192
+ logSink(envelope)
193
+ await deliver(env, config, envelope)
194
+ }
195
+ }
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import { validateWebhookUrl, type SsrfOptions } from './ssrf'
5
+ import type { VerifiedEntity } from './types'
6
+
7
+ /**
8
+ * Guarded outbound fetch for `api_callback` entity verification - the core guardrail that closes the
9
+ * ADR-0036 security-gate Medium. An adapter's `fetchEntity` builds the provider URL (with the
10
+ * untrusted lookup id `encodeURIComponent`'d - that part the adapter must still do) and calls this
11
+ * helper instead of bare `fetch`, so every entity fetch gets the same protections the outbound event
12
+ * sink already has (`src/events.ts`): https-only, SSRF guard (`src/ssrf.ts` - reject private/reserved
13
+ * IP-literal hosts), `redirect: 'manual'` (never follow a 3xx to an internal target), and a
14
+ * per-attempt `AbortController` timeout.
15
+ *
16
+ * Return/throw semantics match `resolveApiCallbackEvent`'s retry model (`src/workflow.ts`):
17
+ * - **2xx** → parsed JSON entity.
18
+ * - **404** → `null` (a *definitive* not-found - a forged/unknown id → the adapter returns `null` →
19
+ * terminal `access.failed`, with no retry storm).
20
+ * - **everything else** → THROW: other non-2xx (incl. a 3xx surfaced by `redirect: 'manual'`, and
21
+ * auth errors like 401/403), network errors, the timeout abort, and SSRF/non-https rejects. The
22
+ * durable fetch-entity step retries a throw; on exhaustion it becomes `access.failed`. Throwing
23
+ * (not returning `null`) on these keeps an ambiguous/transient failure from masquerading as a
24
+ * clean "forged id".
25
+ *
26
+ * Never follows redirects; never reads a non-2xx body beyond its status. The auth token belongs in
27
+ * `opts.headers` (`Authorization: Bearer …`), keeping it out of the URL and logs.
28
+ */
29
+
30
+ const DEFAULT_TIMEOUT_MS = 10_000
31
+
32
+ export interface FetchEntityOptions {
33
+ /** Request headers - put the provider auth token here (e.g. `Authorization: Bearer <token>`). */
34
+ headers?: Record<string, string>
35
+ /** Per-attempt timeout (default 10s). */
36
+ timeoutMs?: number
37
+ /** SSRF options (e.g. a host allowlist); https-only is enforced regardless. */
38
+ ssrf?: SsrfOptions
39
+ }
40
+
41
+ function is2xx(status: number): boolean {
42
+ return status >= 200 && status < 300
43
+ }
44
+
45
+ export async function fetchVerifiedEntity(
46
+ url: string,
47
+ opts: FetchEntityOptions = {},
48
+ // Reserved for future env-derived fetch policy (e.g. an SSRF allowlist / http opt-in from config);
49
+ // kept in the signature so adapters call a stable `fetchVerifiedEntity(url, opts, env)` shape.
50
+ _env?: CloudflareBindings,
51
+ ): Promise<VerifiedEntity | null> {
52
+ const check = validateWebhookUrl(url, opts.ssrf) // https-only + private/reserved-IP reject
53
+ if (!check.ok) {
54
+ // Security reject → throw (the step retries, then access.failed) rather than a silent null.
55
+ throw new Error(`fetchVerifiedEntity blocked: ${check.reason}`)
56
+ }
57
+
58
+ const controller = new AbortController()
59
+ const timer = setTimeout(
60
+ () => controller.abort(),
61
+ opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
62
+ )
63
+ try {
64
+ const res = await fetch(check.url.toString(), {
65
+ method: 'GET',
66
+ headers: opts.headers,
67
+ redirect: 'manual', // never follow a redirect to an internal target
68
+ signal: controller.signal,
69
+ })
70
+ if (res.status === 404) return null // definitive not-found / forged id → terminal
71
+ if (!is2xx(res.status)) {
72
+ // Other non-2xx (incl. a manual-redirect 3xx and 401/403/5xx) → transient/ambiguous → retry.
73
+ throw new Error(`fetchVerifiedEntity: HTTP ${res.status}`)
74
+ }
75
+ return (await res.json()) as VerifiedEntity
76
+ } finally {
77
+ clearTimeout(timer)
78
+ }
79
+ }
package/src/github.ts ADDED
@@ -0,0 +1,140 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ // Minimal GitHub REST client over `fetch` - no Octokit/SDK, no Node APIs (Workers-only). Auth is a
5
+ // fine-grained PAT (Members: R&W) from env.GITHUB_TOKEN (a secret); the org is passed in by the
6
+ // caller from `config.githubOrg`. Teams are addressed by SLUG (the product→team map
7
+ // carries slugs).
8
+
9
+ import type { Json } from './types'
10
+
11
+ const GITHUB_API = 'https://api.github.com'
12
+ const USER_AGENT = 'repoaccess-worker' // GitHub rejects requests without a User-Agent.
13
+ const API_VERSION = '2022-11-28'
14
+
15
+ /** Serializable result (returned from inside step.do - must not contain a live Response). */
16
+ export interface GithubResult {
17
+ status: number
18
+ json: Json
19
+ retryAfterSec: number | null
20
+ rateLimitRemaining: number | null
21
+ rateLimitResetSec: number | null
22
+ }
23
+
24
+ function header(value: string | null): number | null {
25
+ if (value === null || value === '') return null
26
+ const n = Number(value)
27
+ return Number.isFinite(n) ? n : null
28
+ }
29
+
30
+ async function githubRequest(
31
+ env: CloudflareBindings,
32
+ method: string,
33
+ path: string,
34
+ body?: unknown,
35
+ ): Promise<GithubResult> {
36
+ const init: RequestInit = {
37
+ method,
38
+ headers: {
39
+ Authorization: `Bearer ${env.GITHUB_TOKEN}`,
40
+ Accept: 'application/vnd.github+json',
41
+ 'X-GitHub-Api-Version': API_VERSION,
42
+ 'User-Agent': USER_AGENT,
43
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
44
+ },
45
+ body: body !== undefined ? JSON.stringify(body) : undefined,
46
+ }
47
+ const res = await fetch(`${GITHUB_API}${path}`, init)
48
+ const text = await res.text()
49
+ let json: Json = null
50
+ if (text) {
51
+ try {
52
+ json = JSON.parse(text)
53
+ } catch {
54
+ json = text
55
+ }
56
+ }
57
+ return {
58
+ status: res.status,
59
+ json,
60
+ retryAfterSec: header(res.headers.get('retry-after')),
61
+ rateLimitRemaining: header(res.headers.get('x-ratelimit-remaining')),
62
+ rateLimitResetSec: header(res.headers.get('x-ratelimit-reset')),
63
+ }
64
+ }
65
+
66
+ // Path segments are URL-encoded as defense-in-depth (usernames are validated upstream and
67
+ // slugs come from trusted config, but never interpolate raw user input into a URL path).
68
+ const enc = encodeURIComponent
69
+
70
+ export const github = {
71
+ getTeamMembership: (
72
+ env: CloudflareBindings,
73
+ org: string,
74
+ slug: string,
75
+ username: string,
76
+ ) =>
77
+ githubRequest(
78
+ env,
79
+ 'GET',
80
+ `/orgs/${enc(org)}/teams/${enc(slug)}/memberships/${enc(username)}`,
81
+ ),
82
+
83
+ addTeamMembership: (
84
+ env: CloudflareBindings,
85
+ org: string,
86
+ slug: string,
87
+ username: string,
88
+ ) =>
89
+ githubRequest(
90
+ env,
91
+ 'PUT',
92
+ `/orgs/${enc(org)}/teams/${enc(slug)}/memberships/${enc(username)}`,
93
+ ),
94
+
95
+ removeTeamMembership: (
96
+ env: CloudflareBindings,
97
+ org: string,
98
+ slug: string,
99
+ username: string,
100
+ ) =>
101
+ githubRequest(
102
+ env,
103
+ 'DELETE',
104
+ `/orgs/${enc(org)}/teams/${enc(slug)}/memberships/${enc(username)}`,
105
+ ),
106
+
107
+ listInvitations: (env: CloudflareBindings, org: string) =>
108
+ githubRequest(env, 'GET', `/orgs/${enc(org)}/invitations?per_page=100`),
109
+
110
+ cancelInvitation: (
111
+ env: CloudflareBindings,
112
+ org: string,
113
+ invitationId: number,
114
+ ) =>
115
+ githubRequest(
116
+ env,
117
+ 'DELETE',
118
+ `/orgs/${enc(org)}/invitations/${invitationId}`,
119
+ ),
120
+
121
+ removeOrgMembership: (
122
+ env: CloudflareBindings,
123
+ org: string,
124
+ username: string,
125
+ ) =>
126
+ githubRequest(
127
+ env,
128
+ 'DELETE',
129
+ `/orgs/${enc(org)}/memberships/${enc(username)}`,
130
+ ),
131
+ }
132
+
133
+ /** 429, or 403 carrying a rate-limit signal (primary or secondary limit). */
134
+ export function isRateLimited(result: GithubResult): boolean {
135
+ if (result.status === 429) return true
136
+ if (result.status === 403) {
137
+ return result.rateLimitRemaining === 0 || result.retryAfterSec !== null
138
+ }
139
+ return false
140
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import { createWorker } from './create-worker'
5
+ import { createAccessWorkflow } from './workflow'
6
+ import { stripe } from './adapters/stripe'
7
+ import { production as config } from './repoaccess.config'
8
+
9
+ /**
10
+ * Core PRODUCTION worker entry - selected by wrangler's per-env `main` override
11
+ * (`[env.production].main`). Identical composition to `src/index.ts`, but bound to the
12
+ * `production` config profile. This file is a deploy entry only (never the npm barrel), so it
13
+ * re-exports just the binding classes wrangler must resolve from `main`.
14
+ */
15
+ export { ClaimGuard } from './claim-guard'
16
+ // Same adapter list to both factories (ack path + Workflow path) - see src/index.ts.
17
+ const adapters = [stripe]
18
+ export class AccessWorkflow extends createAccessWorkflow(config, adapters) {}
19
+ export default createWorker({ adapters, config })
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Gary Stupak
3
+
4
+ import { createWorker } from './create-worker'
5
+ import { createAccessWorkflow } from './workflow'
6
+ import { stripe } from './adapters/stripe'
7
+ import { sandbox as config } from './repoaccess.config'
8
+
9
+ // --- public surface (the npm barrel; `exports['.']`) -------------------------
10
+ // Re-export everything a downstream worker composes core from via the package dependency.
11
+ export { createWorker } from './create-worker'
12
+ export type { CreateWorkerOptions } from './create-worker'
13
+ // Config-as-code: the factory that binds the Workflow class to a typed config, plus the
14
+ // config contract types a deployer authors in their own `repoaccess.config.ts`.
15
+ export { createAccessWorkflow } from './workflow'
16
+ // The claim-page template contract - the open extension point a downstream worker implements to
17
+ // restyle the claim flow; core ships the default template.
18
+ export { defaultClaimTemplate } from './claim-template'
19
+ export type { Branding, ClaimView, ClaimTemplate } from './claim-template'
20
+ // Guarded outbound fetch for api_callback adapters' `fetchEntity` (https-only + SSRF + redirect:manual
21
+ // + timeout). Use it instead of bare `fetch` so every entity verification gets the same protections.
22
+ export { fetchVerifiedEntity } from './fetch-entity'
23
+ export type { FetchEntityOptions } from './fetch-entity'
24
+ // The adapter contract - the authoritative shapes an external adapter implements (see AGENTS.md).
25
+ export type {
26
+ PaymentAdapter,
27
+ VerificationStrategy,
28
+ NormalizedEvent,
29
+ RawRequest,
30
+ VerifiedEntity,
31
+ GrantMode,
32
+ RevokePolicy,
33
+ ProductConfig,
34
+ ProductTeamMap,
35
+ RepoAccessConfig,
36
+ EventWebhookConfig,
37
+ } from './types'
38
+ // The claim single-flight Durable Object - exported so the CLAIM_GUARD binding resolves.
39
+ export { ClaimGuard } from './claim-guard'
40
+
41
+ // --- core example/sandbox worker entry (core's own `main`) -------------------
42
+ /**
43
+ * Core example entry (sandbox/default env). Composes the free-core adapter set - just Stripe - with
44
+ * the neutral `sandbox` config. Pro composes `[stripe, paddle, …]` from its own
45
+ * entry; the router code is identical, only the adapter list + config differ.
46
+ *
47
+ * `AccessWorkflow` MUST be exported from `main` (wrangler `class_name`) so the ACCESS_WORKFLOW binding
48
+ * resolves. `extends createAccessWorkflow(config, adapters)` makes it a class (value AND type) bound
49
+ * to config - the runtime constructs it, so config can't be injected any other way. The adapter list
50
+ * is passed to BOTH `createWorker` (ack path) and `createAccessWorkflow` (Workflow path) so an
51
+ * api_callback adapter can run its entity fetch + parse in-step; keep the two lists identical.
52
+ */
53
+ const adapters = [stripe]
54
+ export class AccessWorkflow extends createAccessWorkflow(config, adapters) {}
55
+ export default createWorker({ adapters, config })