@startup-api/cloudflare 0.0.1 → 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/README.md CHANGED
@@ -67,6 +67,11 @@ Use this option if you want to deploy from your local machine.
67
67
  | `GOOGLE_CLIENT_SECRET` | No | N/A | Google OAuth2 Client Secret |
68
68
  | `TWITCH_CLIENT_ID` | No | N/A | Twitch OAuth2 Client ID |
69
69
  | `TWITCH_CLIENT_SECRET` | No | N/A | Twitch OAuth2 Client Secret |
70
+ | `PATREON_CLIENT_ID` | No | N/A | Patreon OAuth2 Client ID |
71
+ | `PATREON_CLIENT_SECRET`| No | N/A | Patreon OAuth2 Client Secret |
72
+ | `PATREON_WEBHOOK_SECRET`| No | N/A | Secret for verifying Patreon webhook signatures |
73
+
74
+ > Environment variables hold only credentials/secrets (OAuth client IDs and all secrets) plus the per‑deployment values `ORIGIN_URL`, `AUTH_ORIGIN`, `USERS_PATH`, `ADMIN_IDS`, and `ENVIRONMENT`. **All other configuration — OAuth scopes, Patreon campaign id, the access policy, entitlement freshness — is passed to the `createStartupAPI` factory** (see [Access policy & provider entitlements](#access-policy--provider-entitlements)).
70
75
 
71
76
  ### Setting up OAuth
72
77
 
@@ -88,6 +93,28 @@ Use this option if you want to deploy from your local machine.
88
93
  4. Select **Website** as the category
89
94
  5. Copy the **Client ID** and generate a **Client Secret** to add them to your Worker's environment variables
90
95
 
96
+ #### Patreon
97
+
98
+ 1. Go to the [Patreon Developer Portal](https://www.patreon.com/portal/registration/register-clients)
99
+ 2. Click **Create Client** and fill in your app details
100
+ 3. Add your authorized redirect URI: `https://<your-worker-url>/users/auth/patreon/callback`
101
+ 4. Copy the **Client ID** and **Client Secret** and add them to your Worker's environment variables
102
+
103
+ #### Requesting additional scopes
104
+
105
+ Each provider requests the minimal scopes needed to sign a user in and read their basic profile. To request more (for example, to read a user's Patreon memberships), set the provider's `scopes` in the factory config (a string or array). The extra scopes are merged with the required base scopes:
106
+
107
+ ```ts
108
+ import { createStartupAPI } from '@startup-api/cloudflare';
109
+
110
+ const api = createStartupAPI({
111
+ providers: {
112
+ // Adds `identity.memberships` on top of the base `identity identity[email]`
113
+ patreon: { scopes: 'identity.memberships' },
114
+ },
115
+ });
116
+ ```
117
+
91
118
  ### Example `wrangler.jsonc` snippet:
92
119
 
93
120
  ```json
@@ -105,6 +132,99 @@ Use this option if you want to deploy from your local machine.
105
132
  3. **Proxying:** All other requests are proxied to the configured `ORIGIN_URL`
106
133
  4. **Injection:** For `text/html` responses, the worker injects a `<script>` tag and a `<power-strip>` custom element before serving the content to the user
107
134
 
135
+ ### Customizing the power strip
136
+
137
+ By default the worker injects its own `<power-strip>` pinned to the top-right corner of the page. If that overlaps your own menu or you simply want it somewhere else, **place a `<power-strip>` element in your own HTML**. When the worker sees one, it injects only `power-strip.js` (which defines the custom element) and leaves your element exactly where you put it — so you control placement and styling:
138
+
139
+ ```html
140
+ <nav>
141
+ <!-- ...your links... -->
142
+ <power-strip></power-strip>
143
+ </nav>
144
+ ```
145
+
146
+ - **`providers` is optional.** If you omit it, the worker fills in the active providers for you (e.g. `providers="google,twitch,patreon"`). Set it yourself to override which login buttons appear.
147
+ - **Prefer an explicit closing tag.** `<power-strip></power-strip>` and `<power-strip/>` are both detected, but per the HTML spec `<power-strip/>` is *not* truly self-closing — the browser treats it as an open tag and nests the following content inside it. Use a closing tag (or place the element last in its container) to avoid surprises.
148
+ - **Script-only opt-out.** Use `<power-strip hidden>` to load `power-strip.js` (and its JS API) without rendering a visible strip.
149
+
150
+ ## Access policy & provider entitlements
151
+
152
+ StartupAPI can gate access to paths and forward the visitor's login/entitlement status to your origin so it can render gated UI. This is **provider-agnostic infrastructure**; only Patreon currently implements perk-level (benefit/tier) checks — Google and Twitch participate at the login levels only.
153
+
154
+ ### Path-based access policy
155
+
156
+ Configure an ordered list of rules (first match wins) mapping a path pattern to a requirement, plus a `default` for unmatched paths. Requirement modes:
157
+
158
+ - **`bypass`** — raw pass-through: no credential check, no identity resolution, no headers, no power-strip injection.
159
+ - **`public`** — anyone; the session is resolved and identity/entitlement headers are forwarded when present.
160
+ - **`authenticated`** — any logged-in user.
161
+ - **`entitlement`** — a provider condition: Patreon `active_patron`, a specific `benefit` (perk) ID, or a `tier` ID.
162
+
163
+ Patterns are exact (`/special`), prefix (`/app/*`), or `/` (homepage only). Each rule's `on_unauthorized` is `login` (redirect to sign in), `forbidden` (403), or `upgrade` (redirect to `upgrade_url`, e.g. a Patreon join page). When no policy is configured at all, every path is treated as `public` (backward compatible).
164
+
165
+ Admin users (those listed in `ADMIN_IDS`) bypass every `authenticated`/`entitlement` requirement and can reach any gated path. Their identity is still resolved and the usual identity/entitlement headers are forwarded to the origin — only the gate itself is skipped. (`bypass` paths remain a raw pass-through for everyone, with no identity resolution.)
166
+
167
+ The policy is passed to the factory as `accessPolicy` (see below). Example:
168
+
169
+ ```ts
170
+ const accessPolicy = {
171
+ rules: [
172
+ { pattern: '/', requirement: { mode: 'public' } },
173
+ {
174
+ pattern: '/special',
175
+ requirement: { mode: 'entitlement', provider: 'patreon', condition: { type: 'benefit', benefit_id: '<BENEFIT_ID>' } },
176
+ on_unauthorized: 'upgrade',
177
+ upgrade_url: 'https://www.patreon.com/yourpage',
178
+ },
179
+ ],
180
+ default: { mode: 'entitlement', provider: 'patreon', condition: { type: 'active_patron' } },
181
+ };
182
+ ```
183
+
184
+ ### Headers forwarded to the origin
185
+
186
+ For non-`bypass` paths the worker forwards `X-StartupAPI-Authenticated`, `X-StartupAPI-Login-Provider`, a compact `X-StartupAPI-Entitlements` JSON, and (for Patreon) `X-StartupAPI-Patreon-Active` / `-Tiers` / `-Benefits` alongside the existing `X-StartupAPI-User-Id` / `-Account-Id`.
187
+
188
+ ### Keeping entitlements fresh
189
+
190
+ Entitlements are fetched once at login. Each provider can additionally opt into freshness mechanisms in its factory config (all off by default — if none are enabled, entitlements are only checked at login):
191
+
192
+ - **TTL** — lazily re-check on the request path when older than the TTL (`freshness.ttl: { ms }`, default 15 min), using the OAuth refresh token.
193
+ - **Cron** — a scheduled re-sync of all of a provider's credentials (`freshness.cron: { schedule }`). The `scheduled()` handler is only present when at least one provider enables cron; you must also add a matching `triggers.crons` to your wrangler config.
194
+ - **Webhook** (Patreon only) — set `freshness.webhook: true`, provide `PATREON_WEBHOOK_SECRET` (a secret, in env), and point a Patreon webhook at `<your-worker-url>/users/webhooks/patreon` (signature verified with HMAC-MD5).
195
+
196
+ Set `providers.patreon.campaignId` to disambiguate when a user belongs to multiple campaigns.
197
+
198
+ ### Configuring via the factory
199
+
200
+ Environment variables hold only credentials/secrets and the per-deployment values (`ORIGIN_URL`, `AUTH_ORIGIN`, `USERS_PATH`, `ADMIN_IDS`, `ENVIRONMENT`). Everything else — provider scopes, Patreon campaign id, the access policy, and entitlement freshness — is passed to `createStartupAPI(config)`. The plain re-export still works with defaults:
201
+
202
+ ```ts
203
+ // Defaults — unchanged:
204
+ export { default, UserDO, AccountDO, SystemDO, CredentialDO } from '@startup-api/cloudflare';
205
+ ```
206
+
207
+ ```ts
208
+ // Custom configuration:
209
+ import { createStartupAPI } from '@startup-api/cloudflare';
210
+
211
+ const api = createStartupAPI({
212
+ providers: {
213
+ patreon: {
214
+ scopes: 'identity.memberships',
215
+ campaignId: '<CAMPAIGN_ID>',
216
+ freshness: { ttl: true, cron: { schedule: '0 */6 * * *' }, webhook: true },
217
+ },
218
+ },
219
+ accessPolicy: { rules: [/* ... */], default: { mode: 'public' } },
220
+ });
221
+
222
+ export default api.default; // includes scheduled() because cron is enabled
223
+ export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
224
+ ```
225
+
226
+ (Remember to add `triggers.crons` to your wrangler config when enabling cron.)
227
+
108
228
  ## Contributing
109
229
 
110
230
  Contributions are welcome! Please feel free to submit a Pull Request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startup-api/cloudflare",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -93,6 +93,8 @@ class PowerStrip extends HTMLElement {
93
93
  </svg>`;
94
94
  } else if (provider === 'twitch') {
95
95
  return `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#9146FF" stroke="white" stroke-width="1"/><path d="M7 6H6v10h2v3l3-3h3l4-4V6H7zm9 6l-2 2h-3l-2 2v-2H8V7h8v5z" fill="white"/><path d="M14 8.5h1.5v2H14V8.5zm-3 0h1.5v2H11v-2z" fill="white"/></svg>`;
96
+ } else if (provider === 'patreon') {
97
+ return `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#FF424D" stroke="white" stroke-width="1"/><circle cx="14" cy="11" r="3.5" fill="white"/><rect x="6.5" y="6.5" width="2" height="11" fill="white"/></svg>`;
96
98
  }
97
99
  return '';
98
100
  }
@@ -101,6 +103,7 @@ class PowerStrip extends HTMLElement {
101
103
  const returnUrl = encodeURIComponent(window.location.href);
102
104
  const googleLink = `${this.basePath}/auth/google?return_url=${returnUrl}`;
103
105
  const twitchLink = `${this.basePath}/auth/twitch?return_url=${returnUrl}`;
106
+ const patreonLink = `${this.basePath}/auth/patreon?return_url=${returnUrl}`;
104
107
  const logoutLink = `${this.basePath}/logout?return_url=${returnUrl}`;
105
108
 
106
109
  const providersStr = this.getAttribute('providers') || '';
@@ -128,6 +131,15 @@ class PowerStrip extends HTMLElement {
128
131
  Continue with Twitch
129
132
  </a>`;
130
133
  }
134
+ if (providers.includes('patreon')) {
135
+ authButtons += `
136
+ <a href="${patreonLink}" class="auth-btn patreon">
137
+ <svg viewBox="0 0 24 24">
138
+ <path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-3.97 0-7.21-3.22-7.21-7.18 0-3.97 3.24-7.21 7.21-7.21M2 21.6h3.5V2.41H2V21.6z" fill="currentColor"/>
139
+ </svg>
140
+ Continue with Patreon
141
+ </a>`;
142
+ }
131
143
 
132
144
  let content = '';
133
145
  let accountSwitcher = '';
@@ -240,7 +252,14 @@ class PowerStrip extends HTMLElement {
240
252
  display: block;
241
253
  font-family: system-ui, -apple-system, sans-serif;
242
254
  }
243
-
255
+
256
+ /* Honor the native [hidden] attribute so authors can load the script
257
+ without rendering a visible strip (<power-strip hidden>). The :host
258
+ rule above would otherwise override the UA [hidden] { display: none }. */
259
+ :host([hidden]) {
260
+ display: none !important;
261
+ }
262
+
244
263
  @keyframes fadeIn {
245
264
  from { opacity: 0; }
246
265
  to { opacity: 1; }
@@ -329,6 +348,7 @@ class PowerStrip extends HTMLElement {
329
348
 
330
349
  .provider-badge.google { color: #3c4043; }
331
350
  .provider-badge.twitch { color: #9146FF; }
351
+ .provider-badge.patreon { color: #FF424D; }
332
352
 
333
353
  .user-info {
334
354
  display: flex;
@@ -506,7 +526,17 @@ class PowerStrip extends HTMLElement {
506
526
  background-color: #7d2ee6;
507
527
  border-color: #7d2ee6;
508
528
  }
509
-
529
+
530
+ .auth-btn.patreon {
531
+ background-color: #FF424D;
532
+ color: white;
533
+ border-color: #FF424D;
534
+ }
535
+ .auth-btn.patreon:hover {
536
+ background-color: #e63a44;
537
+ border-color: #e63a44;
538
+ }
539
+
510
540
  /* Account Switcher Styling */
511
541
  .account-list {
512
542
  display: flex;
@@ -432,6 +432,10 @@
432
432
  return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #9146FF;">
433
433
  <path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z" fill="currentColor"/>
434
434
  </svg>`;
435
+ } else if (provider === 'patreon') {
436
+ return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #FF424D;">
437
+ <path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-3.97 0-7.21-3.22-7.21-7.18 0-3.97 3.24-7.21 7.21-7.21M2 21.6h3.5V2.41H2V21.6z" fill="currentColor"/>
438
+ </svg>`;
435
439
  }
436
440
  return '';
437
441
  }
package/src/PowerStrip.ts CHANGED
@@ -2,18 +2,42 @@ export async function injectPowerStrip(response: Response, usersPath: string, pr
2
2
  const contentType = response.headers.get('Content-Type');
3
3
 
4
4
  if (contentType && contentType.includes('text/html')) {
5
- // Inject a script tag and a custom element into the proxied HTML pages.
6
- // The script is loaded from the USERS_PATH, which is intercepted by this worker.
5
+ const providersAttr = providers.join(',');
6
+
7
+ // Track whether the page author placed their own <power-strip> element.
8
+ // If they did, we respect their placement/styling and only load the script.
9
+ let hasUserPowerStrip = false;
10
+
7
11
  return new HTMLRewriter()
12
+ .on('power-strip', {
13
+ element(element) {
14
+ hasUserPowerStrip = true;
15
+
16
+ // Fill in the active providers for the author so their element works
17
+ // out of the box, unless they explicitly chose their own list.
18
+ if (!element.hasAttribute('providers')) {
19
+ element.setAttribute('providers', providersAttr);
20
+ }
21
+ },
22
+ })
8
23
  .on('body', {
9
24
  element(element) {
10
- element.prepend(
11
- `<script src="${usersPath}power-strip.js" async></script>` +
12
- `<power-strip providers="${providers.join(',')}" style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem;">` +
13
- '<svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem;"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>' +
14
- '</power-strip>',
15
- { html: true },
16
- );
25
+ // The script is always needed to define the <power-strip> custom element.
26
+ // It is loaded from the USERS_PATH, which is intercepted by this worker.
27
+ element.prepend(`<script src="${usersPath}power-strip.js" async></script>`, { html: true });
28
+
29
+ // Defer the component decision until the end of <body>, by which point
30
+ // the streaming parser has seen any author-supplied <power-strip>.
31
+ element.onEndTag((end) => {
32
+ if (!hasUserPowerStrip) {
33
+ end.before(
34
+ `<power-strip providers="${providersAttr}" style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem;">` +
35
+ '<svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem;"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>' +
36
+ '</power-strip>',
37
+ { html: true },
38
+ );
39
+ }
40
+ });
17
41
  },
18
42
  })
19
43
  .transform(response);
@@ -6,6 +6,9 @@ export type StartupAPIEnv = {
6
6
  GOOGLE_CLIENT_SECRET: string;
7
7
  TWITCH_CLIENT_ID: string;
8
8
  TWITCH_CLIENT_SECRET: string;
9
+ PATREON_CLIENT_ID: string;
10
+ PATREON_CLIENT_SECRET: string;
11
+ PATREON_WEBHOOK_SECRET?: string;
9
12
  ADMIN_IDS: string;
10
13
  SESSION_SECRET: string;
11
14
  ENVIRONMENT?: string;
@@ -1,11 +1,18 @@
1
1
  import type { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import type { ProviderOptions } from '../schemas/config';
2
3
 
3
4
  import { OAuthProvider, OAuthTokenResponse, UserProfile } from './OAuthProvider';
4
5
 
5
6
  export class GoogleProvider extends OAuthProvider {
6
- static create(env: StartupAPIEnv, redirectBase: string): GoogleProvider | null {
7
+ static create(env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): GoogleProvider | null {
7
8
  if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) return null;
8
- return new GoogleProvider(env.GOOGLE_CLIENT_ID, env.GOOGLE_CLIENT_SECRET, redirectBase + '/google/callback', 'google');
9
+ return new GoogleProvider(
10
+ env.GOOGLE_CLIENT_ID,
11
+ env.GOOGLE_CLIENT_SECRET,
12
+ redirectBase + '/google/callback',
13
+ 'google',
14
+ options?.scopes,
15
+ );
9
16
  }
10
17
 
11
18
  getAuthUrl(state: string): string {
@@ -13,7 +20,7 @@ export class GoogleProvider extends OAuthProvider {
13
20
  client_id: this.clientId,
14
21
  redirect_uri: this.redirectUri,
15
22
  response_type: 'code',
16
- scope: 'openid email profile',
23
+ scope: this.buildScope(['openid', 'email', 'profile']),
17
24
  state: state,
18
25
  access_type: 'offline',
19
26
  prompt: 'consent',
@@ -15,17 +15,39 @@ export interface UserProfile {
15
15
  verified_email?: boolean;
16
16
  }
17
17
 
18
+ import type { Entitlements } from '../entitlements/types';
19
+
18
20
  export abstract class OAuthProvider {
19
21
  protected clientId: string;
20
22
  protected clientSecret: string;
21
23
  protected redirectUri: string;
22
24
  public name: string;
25
+ protected additionalScopes: string[];
23
26
 
24
- constructor(clientId: string, clientSecret: string, redirectUri: string, name: string) {
27
+ constructor(clientId: string, clientSecret: string, redirectUri: string, name: string, additionalScopes: string | string[] = []) {
25
28
  this.clientId = clientId.trim();
26
29
  this.clientSecret = clientSecret.trim();
27
30
  this.redirectUri = redirectUri.trim();
28
31
  this.name = name.trim();
32
+ // Accept a single scope string or a list; buildScope() trims and dedupes.
33
+ this.additionalScopes = Array.isArray(additionalScopes) ? additionalScopes : [additionalScopes];
34
+ }
35
+
36
+ /**
37
+ * Merge a provider's required base scopes with the configured additional scopes,
38
+ * preserving order and removing duplicates.
39
+ */
40
+ protected buildScope(defaultScopes: string[], separator = ' '): string {
41
+ const seen = new Set<string>();
42
+ const merged: string[] = [];
43
+ for (const scope of [...defaultScopes, ...this.additionalScopes]) {
44
+ const trimmed = scope.trim();
45
+ if (trimmed && !seen.has(trimmed)) {
46
+ seen.add(trimmed);
47
+ merged.push(trimmed);
48
+ }
49
+ }
50
+ return merged.join(separator);
29
51
  }
30
52
 
31
53
  isMatch(path: string, authBasePath: string): boolean {
@@ -41,6 +63,31 @@ export abstract class OAuthProvider {
41
63
  abstract getToken(code: string): Promise<OAuthTokenResponse>;
42
64
  abstract getUserProfile(token: string): Promise<UserProfile>;
43
65
 
66
+ /**
67
+ * Whether this provider produces entitlements (memberships / perks). Providers that gate access on
68
+ * provider-specific conditions (e.g. Patreon) override this to return true. Default: false.
69
+ */
70
+ supportsEntitlements(): boolean {
71
+ return false;
72
+ }
73
+
74
+ /**
75
+ * Fetch the user's current entitlements using a valid access token. Returns the provider-specific
76
+ * portion of the {@link Entitlements} shape (without `checked_at`/`source`, which the caller stamps).
77
+ * Default: null (provider has no entitlements).
78
+ */
79
+ async fetchEntitlements(_accessToken: string): Promise<Partial<Entitlements> | null> {
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Exchange a refresh token for a fresh access token. Providers that issue refresh tokens override
85
+ * this. Default: null (no refresh support).
86
+ */
87
+ async refreshToken(_refreshToken: string): Promise<OAuthTokenResponse | null> {
88
+ return null;
89
+ }
90
+
44
91
  protected async fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
45
92
  const response = await fetch(url, options);
46
93
  if (!response.ok) {
@@ -0,0 +1,124 @@
1
+ import type { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import type { ProviderOptions } from '../schemas/config';
3
+
4
+ import { OAuthProvider, OAuthTokenResponse, UserProfile } from './OAuthProvider';
5
+ import type { Entitlements } from '../entitlements/types';
6
+ import { parsePatreonIdentity } from '../entitlements/patreon';
7
+
8
+ export class PatreonProvider extends OAuthProvider {
9
+ private campaignId?: string;
10
+
11
+ static create(env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): PatreonProvider | null {
12
+ if (!env.PATREON_CLIENT_ID || !env.PATREON_CLIENT_SECRET) return null;
13
+ const provider = new PatreonProvider(
14
+ env.PATREON_CLIENT_ID,
15
+ env.PATREON_CLIENT_SECRET,
16
+ redirectBase + '/patreon/callback',
17
+ 'patreon',
18
+ options?.scopes,
19
+ );
20
+ provider.campaignId = options?.campaignId?.trim() || undefined;
21
+ return provider;
22
+ }
23
+
24
+ supportsEntitlements(): boolean {
25
+ return true;
26
+ }
27
+
28
+ getAuthUrl(state: string): string {
29
+ const params = new URLSearchParams({
30
+ client_id: this.clientId,
31
+ redirect_uri: this.redirectUri,
32
+ response_type: 'code',
33
+ scope: this.buildScope(['identity', 'identity[email]']),
34
+ state: state,
35
+ });
36
+ return `https://www.patreon.com/oauth2/authorize?${params.toString()}`;
37
+ }
38
+
39
+ getIcon(): string {
40
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
41
+ <circle cx="12" cy="12" r="11" fill="#FF424D" stroke="white" stroke-width="1"/>
42
+ <circle cx="14" cy="11" r="3.5" fill="white"/>
43
+ <rect x="6.5" y="6.5" width="2" height="11" fill="white"/>
44
+ </svg>`;
45
+ }
46
+
47
+ async getToken(code: string): Promise<OAuthTokenResponse> {
48
+ const params = new URLSearchParams({
49
+ code,
50
+ client_id: this.clientId,
51
+ client_secret: this.clientSecret,
52
+ redirect_uri: this.redirectUri,
53
+ grant_type: 'authorization_code',
54
+ });
55
+
56
+ return this.fetchJson<OAuthTokenResponse>('https://www.patreon.com/api/oauth2/token', {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Content-Type': 'application/x-www-form-urlencoded',
60
+ },
61
+ body: params.toString(),
62
+ });
63
+ }
64
+
65
+ async getUserProfile(accessToken: string): Promise<UserProfile> {
66
+ const params = new URLSearchParams({
67
+ 'fields[user]': 'email,full_name,image_url,is_email_verified',
68
+ });
69
+
70
+ const data = await this.fetchJson<{
71
+ data: {
72
+ id: string;
73
+ attributes: { email?: string; full_name?: string; image_url?: string; is_email_verified?: boolean };
74
+ };
75
+ }>(`https://www.patreon.com/api/oauth2/v2/identity?${params.toString()}`, {
76
+ headers: {
77
+ Authorization: `Bearer ${accessToken}`,
78
+ },
79
+ });
80
+
81
+ const user = data.data;
82
+ return {
83
+ id: user.id,
84
+ email: user.attributes.email,
85
+ name: user.attributes.full_name,
86
+ picture: user.attributes.image_url,
87
+ verified_email: user.attributes.is_email_verified,
88
+ };
89
+ }
90
+
91
+ async refreshToken(refreshToken: string): Promise<OAuthTokenResponse> {
92
+ const params = new URLSearchParams({
93
+ grant_type: 'refresh_token',
94
+ refresh_token: refreshToken,
95
+ client_id: this.clientId,
96
+ client_secret: this.clientSecret,
97
+ });
98
+
99
+ return this.fetchJson<OAuthTokenResponse>('https://www.patreon.com/api/oauth2/token', {
100
+ method: 'POST',
101
+ headers: {
102
+ 'Content-Type': 'application/x-www-form-urlencoded',
103
+ },
104
+ body: params.toString(),
105
+ });
106
+ }
107
+
108
+ async fetchEntitlements(accessToken: string): Promise<Partial<Entitlements>> {
109
+ const params = new URLSearchParams({
110
+ include: 'memberships,memberships.currently_entitled_tiers,memberships.currently_entitled_tiers.benefits',
111
+ 'fields[member]': 'patron_status,currently_entitled_amount_cents',
112
+ 'fields[tier]': 'title',
113
+ 'fields[benefit]': 'title',
114
+ });
115
+
116
+ const data = await this.fetchJson<any>(`https://www.patreon.com/api/oauth2/v2/identity?${params.toString()}`, {
117
+ headers: {
118
+ Authorization: `Bearer ${accessToken}`,
119
+ },
120
+ });
121
+
122
+ return { patreon: parsePatreonIdentity(data, this.campaignId) };
123
+ }
124
+ }
@@ -1,11 +1,18 @@
1
1
  import type { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import type { ProviderOptions } from '../schemas/config';
2
3
 
3
4
  import { OAuthProvider, OAuthTokenResponse, UserProfile } from './OAuthProvider';
4
5
 
5
6
  export class TwitchProvider extends OAuthProvider {
6
- static create(env: StartupAPIEnv, redirectBase: string): TwitchProvider | null {
7
+ static create(env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): TwitchProvider | null {
7
8
  if (!env.TWITCH_CLIENT_ID || !env.TWITCH_CLIENT_SECRET) return null;
8
- return new TwitchProvider(env.TWITCH_CLIENT_ID, env.TWITCH_CLIENT_SECRET, redirectBase + '/twitch/callback', 'twitch');
9
+ return new TwitchProvider(
10
+ env.TWITCH_CLIENT_ID,
11
+ env.TWITCH_CLIENT_SECRET,
12
+ redirectBase + '/twitch/callback',
13
+ 'twitch',
14
+ options?.scopes,
15
+ );
9
16
  }
10
17
 
11
18
  getAuthUrl(state: string): string {
@@ -13,7 +20,7 @@ export class TwitchProvider extends OAuthProvider {
13
20
  client_id: this.clientId,
14
21
  redirect_uri: this.redirectUri,
15
22
  response_type: 'code',
16
- scope: 'user:read:email',
23
+ scope: this.buildScope(['user:read:email']),
17
24
  state: state,
18
25
  });
19
26
  return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`;
package/src/auth/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { StartupAPIEnv } from '../StartupAPIEnv';
2
2
 
3
- import { GoogleProvider } from './GoogleProvider';
4
- import { TwitchProvider } from './TwitchProvider';
5
- import { OAuthProvider } from './OAuthProvider';
6
3
  import { CookieManager } from '../CookieManager';
4
+ import { refreshEntitlements } from '../entitlements/service';
5
+ import { computeRedirectBase, createProviders } from './providers';
6
+ import type { ProviderConfigs } from './providers';
7
7
 
8
8
  export async function handleAuth(
9
9
  request: Request,
@@ -11,21 +11,19 @@ export async function handleAuth(
11
11
  url: URL,
12
12
  usersPath: string,
13
13
  cookieManager: CookieManager,
14
+ providerConfigs: ProviderConfigs = {},
14
15
  ): Promise<Response> {
15
16
  const path = url.pathname;
16
17
  const origin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin;
17
18
 
18
19
  // Standardize redirectBase
19
- const baseUsersPath = usersPath.startsWith('/') ? usersPath : '/' + usersPath;
20
- const redirectBase = new URL((baseUsersPath.endsWith('/') ? baseUsersPath : baseUsersPath + '/') + 'auth', origin).toString();
20
+ const redirectBase = computeRedirectBase(env, origin, usersPath);
21
21
 
22
22
  // For internal matching, we still need authPath
23
23
  const authPath = new URL(redirectBase).pathname;
24
24
 
25
- // Instantiate providers
26
- const providers: (OAuthProvider | null)[] = [GoogleProvider.create(env, redirectBase), TwitchProvider.create(env, redirectBase)];
27
-
28
- const activeProviders = providers.filter((p): p is OAuthProvider => p !== null);
25
+ // Instantiate active providers
26
+ const activeProviders = createProviders(env, redirectBase, providerConfigs);
29
27
 
30
28
  // Handle Auth Start
31
29
  for (const provider of activeProviders) {
@@ -156,6 +154,30 @@ export async function handleAuth(
156
154
  // Register credential mapping in UserDO
157
155
  await userStub.addCredential(provider.name, profile.id);
158
156
 
157
+ // Login-time entitlement fetch: providers that support entitlements (e.g. Patreon) get an
158
+ // initial entitlement snapshot now, so gating works even when no freshness mechanism is
159
+ // configured. Best-effort — never block or fail login on an entitlement error.
160
+ if (provider.supportsEntitlements()) {
161
+ try {
162
+ await refreshEntitlements(
163
+ env,
164
+ provider,
165
+ {
166
+ subject_id: profile.id,
167
+ user_id: userIdStr,
168
+ access_token: token.access_token,
169
+ refresh_token: token.refresh_token,
170
+ expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
171
+ scope: typeof token.scope === 'string' ? token.scope : undefined,
172
+ profile_data: profile,
173
+ },
174
+ 'oauth',
175
+ );
176
+ } catch (e) {
177
+ console.error('[auth] Login-time entitlement fetch failed', e);
178
+ }
179
+ }
180
+
159
181
  // Register User in SystemDO index (Only for new users)
160
182
  if (isNewUser) {
161
183
  await userStub.updateProfile(profile);