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
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 })
|