@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.
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,82 @@ 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
+ ## Access policy & provider entitlements
136
+
137
+ 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.
138
+
139
+ ### Path-based access policy
140
+
141
+ Configure an ordered list of rules (first match wins) mapping a path pattern to a requirement, plus a `default` for unmatched paths. Requirement modes:
142
+
143
+ - **`bypass`** — raw pass-through: no credential check, no identity resolution, no headers, no power-strip injection.
144
+ - **`public`** — anyone; the session is resolved and identity/entitlement headers are forwarded when present.
145
+ - **`authenticated`** — any logged-in user.
146
+ - **`entitlement`** — a provider condition: Patreon `active_patron`, a specific `benefit` (perk) ID, or a `tier` ID.
147
+
148
+ 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).
149
+
150
+ The policy is passed to the factory as `accessPolicy` (see below). Example:
151
+
152
+ ```ts
153
+ const accessPolicy = {
154
+ rules: [
155
+ { pattern: '/', requirement: { mode: 'public' } },
156
+ {
157
+ pattern: '/special',
158
+ requirement: { mode: 'entitlement', provider: 'patreon', condition: { type: 'benefit', benefit_id: '<BENEFIT_ID>' } },
159
+ on_unauthorized: 'upgrade',
160
+ upgrade_url: 'https://www.patreon.com/yourpage',
161
+ },
162
+ ],
163
+ default: { mode: 'entitlement', provider: 'patreon', condition: { type: 'active_patron' } },
164
+ };
165
+ ```
166
+
167
+ ### Headers forwarded to the origin
168
+
169
+ 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`.
170
+
171
+ ### Keeping entitlements fresh
172
+
173
+ 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):
174
+
175
+ - **TTL** — lazily re-check on the request path when older than the TTL (`freshness.ttl: { ms }`, default 15 min), using the OAuth refresh token.
176
+ - **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.
177
+ - **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).
178
+
179
+ Set `providers.patreon.campaignId` to disambiguate when a user belongs to multiple campaigns.
180
+
181
+ ### Configuring via the factory
182
+
183
+ 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:
184
+
185
+ ```ts
186
+ // Defaults — unchanged:
187
+ export { default, UserDO, AccountDO, SystemDO, CredentialDO } from '@startup-api/cloudflare';
188
+ ```
189
+
190
+ ```ts
191
+ // Custom configuration:
192
+ import { createStartupAPI } from '@startup-api/cloudflare';
193
+
194
+ const api = createStartupAPI({
195
+ providers: {
196
+ patreon: {
197
+ scopes: 'identity.memberships',
198
+ campaignId: '<CAMPAIGN_ID>',
199
+ freshness: { ttl: true, cron: { schedule: '0 */6 * * *' }, webhook: true },
200
+ },
201
+ },
202
+ accessPolicy: { rules: [/* ... */], default: { mode: 'public' } },
203
+ });
204
+
205
+ export default api.default; // includes scheduled() because cron is enabled
206
+ export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
207
+ ```
208
+
209
+ (Remember to add `triggers.crons` to your wrangler config when enabling cron.)
210
+
108
211
  ## Contributing
109
212
 
110
213
  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.1.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 = '';
@@ -329,6 +341,7 @@ class PowerStrip extends HTMLElement {
329
341
 
330
342
  .provider-badge.google { color: #3c4043; }
331
343
  .provider-badge.twitch { color: #9146FF; }
344
+ .provider-badge.patreon { color: #FF424D; }
332
345
 
333
346
  .user-info {
334
347
  display: flex;
@@ -506,7 +519,17 @@ class PowerStrip extends HTMLElement {
506
519
  background-color: #7d2ee6;
507
520
  border-color: #7d2ee6;
508
521
  }
509
-
522
+
523
+ .auth-btn.patreon {
524
+ background-color: #FF424D;
525
+ color: white;
526
+ border-color: #FF424D;
527
+ }
528
+ .auth-btn.patreon:hover {
529
+ background-color: #e63a44;
530
+ border-color: #e63a44;
531
+ }
532
+
510
533
  /* Account Switcher Styling */
511
534
  .account-list {
512
535
  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
  }
@@ -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);
@@ -0,0 +1,39 @@
1
+ import type { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import type { ProviderOptions } from '../schemas/config';
3
+ import { OAuthProvider } from './OAuthProvider';
4
+ import { GoogleProvider } from './GoogleProvider';
5
+ import { TwitchProvider } from './TwitchProvider';
6
+ import { PatreonProvider } from './PatreonProvider';
7
+
8
+ export type ProviderConfigs = Record<string, ProviderOptions>;
9
+
10
+ /**
11
+ * Compute the base URL that provider callback/redirect URIs are built from, e.g.
12
+ * `https://host/users/auth`. Mirrors the logic in handleAuth so all call sites agree.
13
+ */
14
+ export function computeRedirectBase(env: StartupAPIEnv, origin: string, usersPath: string): string {
15
+ const baseUsersPath = usersPath.startsWith('/') ? usersPath : '/' + usersPath;
16
+ return new URL((baseUsersPath.endsWith('/') ? baseUsersPath : baseUsersPath + '/') + 'auth', origin).toString();
17
+ }
18
+
19
+ /**
20
+ * Build the list of active OAuth providers (those whose credentials are configured in env). Provider
21
+ * behavior (scopes, Patreon campaign) comes from the factory config passed as `providerConfigs`.
22
+ */
23
+ export function createProviders(env: StartupAPIEnv, redirectBase: string, providerConfigs: ProviderConfigs = {}): OAuthProvider[] {
24
+ return [
25
+ GoogleProvider.create(env, redirectBase, providerConfigs.google),
26
+ TwitchProvider.create(env, redirectBase, providerConfigs.twitch),
27
+ PatreonProvider.create(env, redirectBase, providerConfigs.patreon),
28
+ ].filter((p): p is OAuthProvider => p !== null);
29
+ }
30
+
31
+ /** Get a single active provider by name, or undefined if not configured. */
32
+ export function getProvider(
33
+ env: StartupAPIEnv,
34
+ redirectBase: string,
35
+ name: string,
36
+ providerConfigs: ProviderConfigs = {},
37
+ ): OAuthProvider | undefined {
38
+ return createProviders(env, redirectBase, providerConfigs).find((p) => p.name === name);
39
+ }