@startup-api/cloudflare 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Pure-TS MD5 and HMAC-MD5.
3
+ *
4
+ * Patreon signs webhook payloads with HMAC-MD5 of the raw request body keyed by the webhook secret,
5
+ * hex-encoded in the `X-Patreon-Signature` header. WebCrypto's SubtleCrypto does not support MD5, so
6
+ * we implement it by hand. This is used ONLY for verifying inbound webhook signatures — MD5 is not
7
+ * used for any security primitive that depends on collision resistance.
8
+ */
9
+
10
+ // Per-round shift amounts.
11
+ const S = [
12
+ 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23,
13
+ 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
14
+ ];
15
+
16
+ // Binary integer parts of the sines of integers (constants) — the standard MD5 T table.
17
+ const K = new Uint32Array([
18
+ 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1,
19
+ 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453,
20
+ 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942,
21
+ 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
22
+ 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d,
23
+ 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
24
+ ]);
25
+
26
+ function rotl(x: number, c: number): number {
27
+ return ((x << c) | (x >>> (32 - c))) >>> 0;
28
+ }
29
+
30
+ export function md5(message: Uint8Array): Uint8Array {
31
+ const origLenBits = message.length * 8;
32
+ const paddedLen = ((((message.length + 8) >>> 6) + 1) << 6) >>> 0; // multiple of 64 with room for the length
33
+ const bytes = new Uint8Array(paddedLen);
34
+ bytes.set(message);
35
+ bytes[message.length] = 0x80;
36
+
37
+ const dv = new DataView(bytes.buffer);
38
+ dv.setUint32(paddedLen - 8, origLenBits >>> 0, true);
39
+ dv.setUint32(paddedLen - 4, Math.floor(origLenBits / 2 ** 32) >>> 0, true);
40
+
41
+ let a0 = 0x67452301;
42
+ let b0 = 0xefcdab89;
43
+ let c0 = 0x98badcfe;
44
+ let d0 = 0x10325476;
45
+
46
+ const M = new Uint32Array(16);
47
+ for (let chunk = 0; chunk < paddedLen; chunk += 64) {
48
+ for (let i = 0; i < 16; i++) M[i] = dv.getUint32(chunk + i * 4, true);
49
+
50
+ let A = a0;
51
+ let B = b0;
52
+ let C = c0;
53
+ let D = d0;
54
+
55
+ for (let i = 0; i < 64; i++) {
56
+ let F: number;
57
+ let g: number;
58
+ if (i < 16) {
59
+ F = (B & C) | (~B & D);
60
+ g = i;
61
+ } else if (i < 32) {
62
+ F = (D & B) | (~D & C);
63
+ g = (5 * i + 1) % 16;
64
+ } else if (i < 48) {
65
+ F = B ^ C ^ D;
66
+ g = (3 * i + 5) % 16;
67
+ } else {
68
+ F = C ^ (B | ~D);
69
+ g = (7 * i) % 16;
70
+ }
71
+ F = (F + A + K[i] + M[g]) >>> 0;
72
+ A = D;
73
+ D = C;
74
+ C = B;
75
+ B = (B + rotl(F, S[i])) >>> 0;
76
+ }
77
+
78
+ a0 = (a0 + A) >>> 0;
79
+ b0 = (b0 + B) >>> 0;
80
+ c0 = (c0 + C) >>> 0;
81
+ d0 = (d0 + D) >>> 0;
82
+ }
83
+
84
+ const out = new Uint8Array(16);
85
+ const odv = new DataView(out.buffer);
86
+ odv.setUint32(0, a0, true);
87
+ odv.setUint32(4, b0, true);
88
+ odv.setUint32(8, c0, true);
89
+ odv.setUint32(12, d0, true);
90
+ return out;
91
+ }
92
+
93
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
94
+ const out = new Uint8Array(a.length + b.length);
95
+ out.set(a);
96
+ out.set(b, a.length);
97
+ return out;
98
+ }
99
+
100
+ export function hmacMd5(key: Uint8Array, message: Uint8Array): Uint8Array {
101
+ const blockSize = 64;
102
+ let k = key;
103
+ if (k.length > blockSize) k = md5(k);
104
+ if (k.length < blockSize) {
105
+ const padded = new Uint8Array(blockSize);
106
+ padded.set(k);
107
+ k = padded;
108
+ }
109
+
110
+ const oKeyPad = new Uint8Array(blockSize);
111
+ const iKeyPad = new Uint8Array(blockSize);
112
+ for (let i = 0; i < blockSize; i++) {
113
+ oKeyPad[i] = k[i] ^ 0x5c;
114
+ iKeyPad[i] = k[i] ^ 0x36;
115
+ }
116
+
117
+ return md5(concat(oKeyPad, md5(concat(iKeyPad, message))));
118
+ }
119
+
120
+ function toHex(bytes: Uint8Array): string {
121
+ let s = '';
122
+ for (const b of bytes) s += b.toString(16).padStart(2, '0');
123
+ return s;
124
+ }
125
+
126
+ /** Hex-encoded HMAC-MD5 of `body` keyed by `secret` (both UTF-8). */
127
+ export function hmacMd5Hex(secret: string, body: string): string {
128
+ const enc = new TextEncoder();
129
+ return toHex(hmacMd5(enc.encode(secret), enc.encode(body)));
130
+ }
131
+
132
+ /** Length-and-content constant-time string comparison (hex digests). */
133
+ export function timingSafeEqual(a: string, b: string): boolean {
134
+ if (a.length !== b.length) return false;
135
+ let result = 0;
136
+ for (let i = 0; i < a.length; i++) {
137
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
138
+ }
139
+ return result === 0;
140
+ }
@@ -0,0 +1,61 @@
1
+ import type { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import { hmacMd5Hex, timingSafeEqual } from './md5hmac';
3
+ import { computeRedirectBase, getProvider } from '../auth/providers';
4
+ import type { ProviderConfigs } from '../auth/providers';
5
+ import { refreshEntitlements } from '../entitlements/service';
6
+
7
+ /**
8
+ * Handle a Patreon webhook (members:pledge:create|update|delete and similar). Verifies the
9
+ * `X-Patreon-Signature` HMAC-MD5 over the raw body, then re-fetches the affected patron's entitlements
10
+ * so gating reflects the change. Responds 200 quickly and does the refresh in the background.
11
+ *
12
+ * Mounted only when the Patreon provider has `webhook` enabled in the factory config; if the webhook
13
+ * secret is not configured, returns 404 (route effectively disabled).
14
+ */
15
+ export async function handlePatreonWebhook(
16
+ request: Request,
17
+ env: StartupAPIEnv,
18
+ ctx?: ExecutionContext,
19
+ providerConfigs: ProviderConfigs = {},
20
+ ): Promise<Response> {
21
+ const secret = env.PATREON_WEBHOOK_SECRET;
22
+ if (!secret) return new Response('Webhook not configured', { status: 404 });
23
+ if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 });
24
+
25
+ const signature = request.headers.get('X-Patreon-Signature') || '';
26
+ const body = await request.text();
27
+ const expected = hmacMd5Hex(secret, body);
28
+ if (!signature || !timingSafeEqual(signature.toLowerCase(), expected)) {
29
+ return new Response('Invalid signature', { status: 401 });
30
+ }
31
+
32
+ let payload: any;
33
+ try {
34
+ payload = JSON.parse(body);
35
+ } catch {
36
+ return new Response('Bad payload', { status: 400 });
37
+ }
38
+
39
+ // The webhook 'data' is a JSON:API member; the patron's Patreon user id is our credential subject_id.
40
+ const subjectId = payload?.data?.relationships?.user?.data?.id;
41
+ if (!subjectId) return new Response('OK', { status: 200 }); // ack — nothing to do
42
+
43
+ const work = (async () => {
44
+ try {
45
+ const credStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName('patreon'));
46
+ const cred = await credStub.get(String(subjectId));
47
+ if (!cred) return;
48
+ const redirectBase = computeRedirectBase(env, env.AUTH_ORIGIN || 'https://localhost', '/users/');
49
+ const provider = getProvider(env, redirectBase, 'patreon', providerConfigs);
50
+ if (!provider) return;
51
+ await refreshEntitlements(env, provider, { ...cred, user_id: cred.user_id }, 'webhook');
52
+ } catch (e) {
53
+ console.error('[webhook] patreon entitlement refresh failed', e);
54
+ }
55
+ })();
56
+
57
+ if (ctx) ctx.waitUntil(work);
58
+ else await work;
59
+
60
+ return new Response('OK', { status: 200 });
61
+ }
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable */
2
- // Generated by Wrangler by running `wrangler types` (hash: c70f035350bd314cfa3f52dffa2be560)
2
+ // Generated by Wrangler by running `wrangler types` (hash: 38595a6a948093665f11cb1bc4cd28e8)
3
3
  // Runtime types generated with workerd@1.20260120.0 2025-09-27 global_fetch_strictly_public
4
4
  declare namespace Cloudflare {
5
5
  interface GlobalProps {
@@ -33,6 +33,9 @@ declare namespace Cloudflare {
33
33
  TWITCH_CLIENT_SECRET: "";
34
34
  GOOGLE_CLIENT_ID: "";
35
35
  GOOGLE_CLIENT_SECRET: "";
36
+ PATREON_CLIENT_ID: "";
37
+ PATREON_CLIENT_SECRET: "";
38
+ PATREON_WEBHOOK_SECRET: "";
36
39
  GITHUB_PROJECT_ID: string;
37
40
  USER: DurableObjectNamespace<import("./src/index").UserDO>;
38
41
  ACCOUNT: DurableObjectNamespace<import("./src/index").AccountDO>;
@@ -50,6 +53,9 @@ declare namespace Cloudflare {
50
53
  GOOGLE_CLIENT_SECRET: "google-secret";
51
54
  TWITCH_CLIENT_ID: "twitch-id";
52
55
  TWITCH_CLIENT_SECRET: "twitch-secret";
56
+ PATREON_CLIENT_ID: "patreon-id";
57
+ PATREON_CLIENT_SECRET: "patreon-secret";
58
+ PATREON_WEBHOOK_SECRET: "whsec-test";
53
59
  GITHUB_PROJECT_ID: string;
54
60
  USER: DurableObjectNamespace<import("./src/index").UserDO>;
55
61
  ACCOUNT: DurableObjectNamespace<import("./src/index").AccountDO>;
@@ -73,6 +79,9 @@ declare namespace Cloudflare {
73
79
  AUTH_ORIGIN?: "";
74
80
  GOOGLE_CLIENT_ID?: "" | "google-id";
75
81
  GOOGLE_CLIENT_SECRET?: "" | "google-secret";
82
+ PATREON_CLIENT_ID?: "" | "patreon-id";
83
+ PATREON_CLIENT_SECRET?: "" | "patreon-secret";
84
+ PATREON_WEBHOOK_SECRET?: "" | "whsec-test";
76
85
  }
77
86
  }
78
87
  interface Env extends Cloudflare.Env {}