@startup-api/cloudflare 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/auth/index.ts CHANGED
@@ -4,6 +4,7 @@ import { CookieManager } from '../CookieManager';
4
4
  import { refreshEntitlements } from '../entitlements/service';
5
5
  import { computeRedirectBase, createProviders } from './providers';
6
6
  import type { ProviderConfigs } from './providers';
7
+ import type { AuthContext, ExchangeResult, OAuthProvider } from './OAuthProvider';
7
8
 
8
9
  export async function handleAuth(
9
10
  request: Request,
@@ -25,21 +26,22 @@ export async function handleAuth(
25
26
  // Instantiate active providers
26
27
  const activeProviders = createProviders(env, redirectBase, providerConfigs);
27
28
 
29
+ const ctx: AuthContext = { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager };
30
+
31
+ // Provider-specific auxiliary routes (e.g. the atproto client-metadata document).
32
+ for (const provider of activeProviders) {
33
+ const res = await provider.handleExtraRoute(ctx);
34
+ if (res) return res;
35
+ }
36
+
28
37
  // Handle Auth Start
29
38
  for (const provider of activeProviders) {
30
39
  if (provider.isMatch(path, authPath)) {
31
- const returnUrl = url.searchParams.get('return_url');
32
- const stateObj = {
33
- nonce: Math.random().toString(36).substring(2),
34
- return_url: returnUrl,
35
- };
36
- // Use robust base64 encoding for state
37
- const state = btoa(unescape(encodeURIComponent(JSON.stringify(stateObj))))
38
- .replace(/\+/g, '-')
39
- .replace(/\//g, '_')
40
- .replace(/=+$/, '');
41
- const authUrl = provider.getAuthUrl(state);
42
- return Response.redirect(authUrl, 302);
40
+ try {
41
+ return await provider.authorize(ctx);
42
+ } catch (e) {
43
+ return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
44
+ }
43
45
  }
44
46
  }
45
47
 
@@ -47,207 +49,205 @@ export async function handleAuth(
47
49
  for (const provider of activeProviders) {
48
50
  if (provider.isCallback(path, authPath)) {
49
51
  console.log(`[Auth] Callback received for ${provider.name}`);
50
- const code = url.searchParams.get('code');
51
- if (!code) return new Response('Missing code', { status: 400 });
52
-
53
- const stateBase64 = url.searchParams.get('state');
54
- let returnUrl: string | null = null;
55
- if (stateBase64) {
56
- try {
57
- // Robust base64 decoding
58
- const base64 = stateBase64.replace(/-/g, '+').replace(/_/g, '/');
59
- const stateJson = decodeURIComponent(escape(atob(base64)));
60
- const stateObj = JSON.parse(stateJson);
61
- returnUrl = stateObj.return_url;
62
- } catch (e) {
63
- console.error('Failed to parse state', e);
64
- }
52
+ try {
53
+ const result = await provider.exchange(ctx);
54
+ return await finishLogin(provider, result, ctx);
55
+ } catch (e) {
56
+ const status = (e as { status?: number })?.status ?? 500;
57
+ return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status });
65
58
  }
59
+ }
60
+ }
66
61
 
67
- try {
68
- const token = await provider.getToken(code);
69
- const profile = await provider.getUserProfile(token.access_token);
70
-
71
- const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
72
-
73
- // 1. Try to resolve existing user by credential
74
- const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name));
75
- const resolveData = await credentialStub.get(profile.id);
76
-
77
- let userIdStr: string | null = null;
78
- let staleSessionId: string | null = null;
79
-
80
- if (resolveData) {
81
- userIdStr = resolveData.user_id;
82
- } else {
83
- // 2. Not found, check if user is already logged in (to link account)
84
- const cookieHeader = request.headers.get('Cookie');
85
- if (cookieHeader) {
86
- const cookies = cookieHeader.split(';').reduce(
87
- (acc, cookie) => {
88
- const [key, value] = cookie.split('=').map((c) => c.trim());
89
- if (key && value) acc[key] = value;
90
- return acc;
91
- },
92
- {} as Record<string, string>,
93
- );
94
- const sessionCookieEncrypted = cookies['session_id'];
95
- if (sessionCookieEncrypted) {
96
- const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
97
- if (sessionCookie && sessionCookie.includes(':')) {
98
- const parts = sessionCookie.split(':');
99
- staleSessionId = parts[0];
100
- userIdStr = parts[1];
101
- }
102
- }
103
- }
104
- }
62
+ return new Response('Auth route not found', { status: 404 });
63
+ }
105
64
 
106
- if (userIdStr) {
107
- // Verify user still exists (has a profile)
108
- const userStub = env.USER.get(env.USER.idFromString(userIdStr));
109
- const profileData = await userStub.getProfile();
110
- if (Object.keys(profileData).length === 0) {
111
- // User was deleted!
112
- if (staleSessionId) {
113
- try {
114
- await userStub.deleteSession(staleSessionId);
115
- } catch (_e) {
116
- // ignore
117
- }
118
- }
119
- userIdStr = null;
120
- }
65
+ /**
66
+ * Shared post-exchange login finalization, provider-agnostic: resolve or create the user, link the
67
+ * credential, fetch login-time entitlements, ensure a personal account exists, mint a session and set
68
+ * the session cookie. `result.setCookies` lets a provider emit additional cookies (e.g. clearing
69
+ * transient flow state).
70
+ */
71
+ async function finishLogin(provider: OAuthProvider, result: ExchangeResult, ctx: AuthContext): Promise<Response> {
72
+ const { env, request, usersPath, origin, cookieManager } = ctx;
73
+ const { token, profile, returnUrl } = result;
74
+
75
+ const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global'));
76
+
77
+ // 1. Try to resolve existing user by credential
78
+ const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name));
79
+ const resolveData = await credentialStub.get(profile.id);
80
+
81
+ let userIdStr: string | null = null;
82
+ let staleSessionId: string | null = null;
83
+
84
+ if (resolveData) {
85
+ userIdStr = resolveData.user_id;
86
+ } else {
87
+ // 2. Not found, check if user is already logged in (to link account)
88
+ const cookieHeader = request.headers.get('Cookie');
89
+ if (cookieHeader) {
90
+ const cookies = cookieHeader.split(';').reduce(
91
+ (acc, cookie) => {
92
+ const [key, value] = cookie.split('=').map((c) => c.trim());
93
+ if (key && value) acc[key] = value;
94
+ return acc;
95
+ },
96
+ {} as Record<string, string>,
97
+ );
98
+ const sessionCookieEncrypted = cookies['session_id'];
99
+ if (sessionCookieEncrypted) {
100
+ const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
101
+ if (sessionCookie && sessionCookie.includes(':')) {
102
+ const parts = sessionCookie.split(':');
103
+ staleSessionId = parts[0];
104
+ userIdStr = parts[1];
121
105
  }
106
+ }
107
+ }
108
+ }
122
109
 
123
- const isNewUser = !userIdStr;
124
- const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId();
125
- const userStub = env.USER.get(id);
126
- userIdStr = id.toString();
127
-
128
- // Fetch and Store Avatar (Only for new users)
129
- if (isNewUser && profile.picture) {
130
- try {
131
- const picRes = await fetch(profile.picture);
132
- if (picRes.ok) {
133
- const picBlob = await picRes.arrayBuffer();
134
- await userStub.storeImage('avatar', picBlob, picRes.headers.get('Content-Type') || 'image/jpeg');
135
- // Update profile.picture to point to our worker
136
- profile.picture = usersPath + 'me/avatar';
137
- }
138
- } catch (e) {
139
- console.error('Failed to fetch avatar', e);
140
- }
110
+ if (userIdStr) {
111
+ // Verify user still exists (has a profile)
112
+ const userStub = env.USER.get(env.USER.idFromString(userIdStr));
113
+ const profileData = await userStub.getProfile();
114
+ if (Object.keys(profileData).length === 0) {
115
+ // User was deleted!
116
+ if (staleSessionId) {
117
+ try {
118
+ await userStub.deleteSession(staleSessionId);
119
+ } catch (_e) {
120
+ // ignore
141
121
  }
122
+ }
123
+ userIdStr = null;
124
+ }
125
+ }
142
126
 
143
- // Register credential in provider-specific CredentialDO
144
- await credentialStub.put({
145
- user_id: userIdStr,
127
+ const isNewUser = !userIdStr;
128
+ const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId();
129
+ const userStub = env.USER.get(id);
130
+ userIdStr = id.toString();
131
+
132
+ // Fetch and Store Avatar (Only for new users)
133
+ if (isNewUser && profile.picture) {
134
+ try {
135
+ const picRes = await fetch(profile.picture);
136
+ if (picRes.ok) {
137
+ const picBlob = await picRes.arrayBuffer();
138
+ await userStub.storeImage('avatar', picBlob, picRes.headers.get('Content-Type') || 'image/jpeg');
139
+ // Update profile.picture to point to our worker
140
+ profile.picture = usersPath + 'me/avatar';
141
+ }
142
+ } catch (e) {
143
+ console.error('Failed to fetch avatar', e);
144
+ }
145
+ }
146
+
147
+ // Register credential in provider-specific CredentialDO
148
+ await credentialStub.put({
149
+ user_id: userIdStr,
150
+ subject_id: profile.id,
151
+ access_token: token.access_token,
152
+ refresh_token: token.refresh_token,
153
+ expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
154
+ scope: token.scope,
155
+ profile_data: profile,
156
+ });
157
+
158
+ // Register credential mapping in UserDO
159
+ await userStub.addCredential(provider.name, profile.id);
160
+
161
+ // Login-time entitlement fetch: providers that support entitlements (e.g. Patreon) get an
162
+ // initial entitlement snapshot now, so gating works even when no freshness mechanism is
163
+ // configured. Best-effort — never block or fail login on an entitlement error.
164
+ if (provider.supportsEntitlements()) {
165
+ try {
166
+ await refreshEntitlements(
167
+ env,
168
+ provider,
169
+ {
146
170
  subject_id: profile.id,
171
+ user_id: userIdStr,
147
172
  access_token: token.access_token,
148
173
  refresh_token: token.refresh_token,
149
174
  expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
150
- scope: token.scope,
175
+ scope: typeof token.scope === 'string' ? token.scope : undefined,
151
176
  profile_data: profile,
152
- });
153
-
154
- // Register credential mapping in UserDO
155
- await userStub.addCredential(provider.name, profile.id);
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
- }
177
+ },
178
+ 'oauth',
179
+ );
180
+ } catch (e) {
181
+ console.error('[auth] Login-time entitlement fetch failed', e);
182
+ }
183
+ }
180
184
 
181
- // Register User in SystemDO index (Only for new users)
182
- if (isNewUser) {
183
- await userStub.updateProfile(profile);
184
- await systemStub.registerUser({
185
- id: userIdStr,
186
- name: profile.name || userIdStr,
187
- email: profile.email,
188
- provider: provider.name,
189
- });
190
- }
185
+ // Register User in SystemDO index (Only for new users)
186
+ if (isNewUser) {
187
+ await userStub.updateProfile(profile);
188
+ await systemStub.registerUser({
189
+ id: userIdStr,
190
+ name: profile.name || userIdStr,
191
+ email: profile.email,
192
+ provider: provider.name,
193
+ });
194
+ }
191
195
 
192
- // Ensure user has at least one account
193
- const memberships = await userStub.getMemberships();
194
-
195
- if (memberships.length === 0) {
196
- // Create a personal account
197
- const accountId = env.ACCOUNT.newUniqueId();
198
- const accountStub = env.ACCOUNT.get(accountId);
199
- const accountIdStr = accountId.toString();
200
-
201
- // Initialize account info
202
- await accountStub.updateInfo({
203
- name: `${profile.name || userIdStr}'s Account`,
204
- personal: true,
205
- });
206
-
207
- // Register Account in SystemDO
208
- await systemStub.registerAccount({
209
- id: accountIdStr,
210
- name: `${profile.name || profile.id}'s Account`,
211
- status: 'active',
212
- plan: 'free',
213
- });
214
-
215
- // Add user as ADMIN to the account
216
- await accountStub.addMember(id.toString(), 1);
217
-
218
- // Add membership to user
219
- await userStub.addMembership(accountIdStr, 1, true);
220
- }
196
+ // Ensure user has at least one account
197
+ const memberships = await userStub.getMemberships();
198
+
199
+ if (memberships.length === 0) {
200
+ // Create a personal account
201
+ const accountId = env.ACCOUNT.newUniqueId();
202
+ const accountStub = env.ACCOUNT.get(accountId);
203
+ const accountIdStr = accountId.toString();
204
+
205
+ // Initialize account info
206
+ await accountStub.updateInfo({
207
+ name: `${profile.name || userIdStr}'s Account`,
208
+ personal: true,
209
+ });
210
+
211
+ // Register Account in SystemDO
212
+ await systemStub.registerAccount({
213
+ id: accountIdStr,
214
+ name: `${profile.name || profile.id}'s Account`,
215
+ status: 'active',
216
+ plan: 'free',
217
+ });
218
+
219
+ // Add user as ADMIN to the account
220
+ await accountStub.addMember(id.toString(), 1);
221
+
222
+ // Add membership to user
223
+ await userStub.addMembership(accountIdStr, 1, true);
224
+ }
221
225
 
222
- // Create Session
223
- const session = await userStub.createSession({ provider: provider.name });
224
-
225
- // Set cookie and redirect
226
- const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`);
227
- const headers = new Headers();
228
- headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`);
229
-
230
- let redirectUrl = !isNewUser ? usersPath + 'profile.html' : '/';
231
- if (returnUrl) {
232
- try {
233
- const parsedReturn = new URL(returnUrl, origin);
234
- if (parsedReturn.origin === origin) {
235
- redirectUrl = parsedReturn.toString();
236
- }
237
- } catch (_e) {
238
- if (returnUrl.startsWith('/')) {
239
- redirectUrl = returnUrl;
240
- }
241
- }
242
- }
226
+ // Create Session
227
+ const session = await userStub.createSession({ provider: provider.name });
243
228
 
244
- headers.set('Location', redirectUrl);
245
- return new Response(null, { status: 302, headers });
246
- } catch (e) {
247
- return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
229
+ // Set cookie and redirect
230
+ const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`);
231
+ const headers = new Headers();
232
+ headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`);
233
+ for (const cookie of result.setCookies ?? []) {
234
+ headers.append('Set-Cookie', cookie);
235
+ }
236
+
237
+ let redirectUrl = !isNewUser ? usersPath + 'profile.html' : '/';
238
+ if (returnUrl) {
239
+ try {
240
+ const parsedReturn = new URL(returnUrl, origin);
241
+ if (parsedReturn.origin === origin) {
242
+ redirectUrl = parsedReturn.toString();
243
+ }
244
+ } catch (_e) {
245
+ if (returnUrl.startsWith('/')) {
246
+ redirectUrl = returnUrl;
248
247
  }
249
248
  }
250
249
  }
251
250
 
252
- return new Response('Auth route not found', { status: 404 });
251
+ headers.set('Location', redirectUrl);
252
+ return new Response(null, { status: 302, headers });
253
253
  }
@@ -4,6 +4,7 @@ import { OAuthProvider } from './OAuthProvider';
4
4
  import { GoogleProvider } from './GoogleProvider';
5
5
  import { TwitchProvider } from './TwitchProvider';
6
6
  import { PatreonProvider } from './PatreonProvider';
7
+ import { AtprotoProvider } from './AtprotoProvider';
7
8
 
8
9
  export type ProviderConfigs = Record<string, ProviderOptions>;
9
10
 
@@ -25,6 +26,7 @@ export function createProviders(env: StartupAPIEnv, redirectBase: string, provid
25
26
  GoogleProvider.create(env, redirectBase, providerConfigs.google),
26
27
  TwitchProvider.create(env, redirectBase, providerConfigs.twitch),
27
28
  PatreonProvider.create(env, redirectBase, providerConfigs.patreon),
29
+ AtprotoProvider.create(env, redirectBase, providerConfigs.atproto),
28
30
  ].filter((p): p is OAuthProvider => p !== null);
29
31
  }
30
32
 
@@ -167,7 +167,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
167
167
  const isAccounts = subPath === 'accounts.html' || subPath === 'accounts';
168
168
 
169
169
  if (isProfile || isAccounts) {
170
- return handleSSR(request, env, url, usersPath, cookieManager);
170
+ return handleSSR(request, env, url, usersPath, cookieManager, providerConfigs);
171
171
  }
172
172
  }
173
173
 
@@ -262,7 +262,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
262
262
 
263
263
  // Admin Routes
264
264
  if (url.pathname.startsWith(usersPath + 'admin/')) {
265
- return handleAdmin(request, env, usersPath, cookieManager);
265
+ return handleAdmin(request, env, usersPath, cookieManager, providerConfigs);
266
266
  }
267
267
 
268
268
  // Intercept requests to usersPath and serve them from the public/users directory.
@@ -336,7 +336,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
336
336
  return denyResponse(decision, {
337
337
  usersPath,
338
338
  returnUrl,
339
- activeProviders: getActiveProviders(env),
339
+ activeProviders: getActiveProviders(env, providerConfigs),
340
340
  authenticated,
341
341
  request,
342
342
  env,
@@ -345,7 +345,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
345
345
  }
346
346
 
347
347
  const response = await originFetch(newRequest);
348
- const providers = getActiveProviders(env);
348
+ const providers = getActiveProviders(env, providerConfigs);
349
349
  return injectPowerStrip(response, usersPath, providers);
350
350
  }
351
351
 
@@ -1,6 +1,7 @@
1
1
  import { StartupAPIEnv } from '../StartupAPIEnv';
2
2
  import { CookieManager } from '../CookieManager';
3
3
  import { getUserFromSession, checkAndClearStaleSession, isAdmin, parseCookies, getActiveProviders } from './utils';
4
+ import type { ProviderConfigs } from '../auth/providers';
4
5
  import { Plan } from '../billing/Plan';
5
6
  import { UserProfileSchema } from '../schemas/user';
6
7
  import { SystemAccountSchema, MemberSchema } from '../schemas/account';
@@ -11,6 +12,7 @@ export async function handleAdmin(
11
12
  env: StartupAPIEnv,
12
13
  usersPath: string,
13
14
  cookieManager: CookieManager,
15
+ providerConfigs: ProviderConfigs = {},
14
16
  ): Promise<Response> {
15
17
  const user = await getUserFromSession(request, env, cookieManager);
16
18
  if (!user || !isAdmin(user, env)) {
@@ -31,7 +33,7 @@ export async function handleAdmin(
31
33
  html = html.replace(/\{\{ssr:([a-z0-9_]+)\}\}/g, (match, key) => {
32
34
  const replacements: Record<string, string> = {
33
35
  plans_json: JSON.stringify(Plan.getAll()).replace(/"/g, '&quot;'),
34
- providers: getActiveProviders(env).join(','),
36
+ providers: getActiveProviders(env, providerConfigs).join(','),
35
37
  };
36
38
  return replacements[key] !== undefined ? replacements[key] : match;
37
39
  });
@@ -1,6 +1,7 @@
1
1
  import { StartupAPIEnv } from '../StartupAPIEnv';
2
2
  import { CookieManager } from '../CookieManager';
3
3
  import { getUserFromSession, checkAndClearStaleSession, isAdmin, getActiveProviders } from './utils';
4
+ import type { ProviderConfigs } from '../auth/providers';
4
5
  import { Plan } from '../billing/Plan';
5
6
 
6
7
  export async function handleSSR(
@@ -9,6 +10,7 @@ export async function handleSSR(
9
10
  url: URL,
10
11
  usersPath: string,
11
12
  cookieManager: CookieManager,
13
+ providerConfigs: ProviderConfigs = {},
12
14
  ): Promise<Response> {
13
15
  const user = await getUserFromSession(request, env, cookieManager);
14
16
  if (!user) {
@@ -90,7 +92,7 @@ export async function handleSSR(
90
92
  // Prepare SSR values
91
93
  const replacements: Record<string, string> = {
92
94
  plans_json: JSON.stringify(Plan.getAll()).replace(/"/g, '&quot;'),
93
- providers: getActiveProviders(env).join(','),
95
+ providers: getActiveProviders(env, providerConfigs).join(','),
94
96
  profile_json: JSON.stringify(data).replace(/"/g, '&quot;'),
95
97
  credentials_json: JSON.stringify(credentials).replace(/"/g, '&quot;'),
96
98
  profile_name: data.profile.name || 'Anonymous',
@@ -105,7 +107,7 @@ export async function handleSSR(
105
107
  : '',
106
108
  nav_account_display: account && (account.role === 1 || data.is_admin) ? 'display: block;' : 'display: none;',
107
109
  credentials_list_html: renderCredentialsList(credentials, data.credential?.provider),
108
- link_credentials_html: renderLinkCredentialsList(getActiveProviders(env), url.href),
110
+ link_credentials_html: renderLinkCredentialsList(getActiveProviders(env, providerConfigs), url.href),
109
111
  };
110
112
 
111
113
  if (account) {
@@ -212,6 +214,8 @@ function getProviderIcon(provider: string): string {
212
214
  return '<svg viewBox="0 0 24 24" width="24" height="24" class="twitch-icon"><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"/></svg>';
213
215
  } else if (provider === 'patreon') {
214
216
  return '<svg viewBox="0 0 24 24" width="24" height="24" class="patreon-icon"><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"/></svg>';
217
+ } else if (provider === 'atproto') {
218
+ return '<svg viewBox="0 0 24 24" width="24" height="24" class="atproto-icon"><path d="M12 10.5C10.9 8.4 8.2 6.3 6.3 6c-1.5-.2-1.8.7-1.5 2 .2 1 1.5 5 2.3 6 .9 1.2 2 1.4 3 1.2-1.7.3-3.2 1-1.2 3 .9.9 1.6.3 2.1-.6.5-1 .8-2.1 1-2.6.2.5.5 1.6 1 2.6.5.9 1.2 1.5 2.1.6 2-2 .5-2.7-1.2-3 1 .2 2.1 0 3-1.2.8-1 2.1-5 2.3-6 .3-1.3 0-2.2-1.5-2-1.9.3-4.6 2.4-5.7 4.5z" fill="#0085FF"/></svg>';
215
219
  }
216
220
  return '';
217
221
  }
@@ -1,7 +1,9 @@
1
1
  import { StartupAPIEnv } from '../StartupAPIEnv';
2
2
  import { CookieManager } from '../CookieManager';
3
+ import { isAtprotoEnabled } from '../auth/AtprotoProvider';
4
+ import type { ProviderConfigs } from '../auth/providers';
3
5
 
4
- export function getActiveProviders(env: StartupAPIEnv): string[] {
6
+ export function getActiveProviders(env: StartupAPIEnv, providerConfigs: ProviderConfigs = {}): string[] {
5
7
  const providers: string[] = [];
6
8
  if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
7
9
  providers.push('google');
@@ -12,6 +14,10 @@ export function getActiveProviders(env: StartupAPIEnv): string[] {
12
14
  if (env.PATREON_CLIENT_ID && env.PATREON_CLIENT_SECRET) {
13
15
  providers.push('patreon');
14
16
  }
17
+ // atproto has no env credentials; it is enabled purely via factory config.
18
+ if (isAtprotoEnabled(providerConfigs.atproto)) {
19
+ providers.push('atproto');
20
+ }
15
21
  return providers;
16
22
  }
17
23
 
@@ -28,6 +28,12 @@ export const ProviderOptionsSchema = z.object({
28
28
  scopes: z.union([z.string(), z.array(z.string())]).optional(),
29
29
  /** Patreon only: restrict entitlements to a single campaign id. */
30
30
  campaignId: z.string().optional(),
31
+ /** atproto only: display name advertised in the client-metadata document. Default: "StartupAPI". */
32
+ clientName: z.string().optional(),
33
+ /** atproto only: override the PLC directory used to resolve did:plc identities. Default: https://plc.directory. */
34
+ plcUrl: z.string().optional(),
35
+ /** atproto only: override the DNS-over-HTTPS resolver used for handle resolution. */
36
+ dohUrl: z.string().optional(),
31
37
  freshness: ProviderFreshnessSchema.optional(),
32
38
  });
33
39
 
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable */
2
- // Generated by Wrangler by running `wrangler types` (hash: 38595a6a948093665f11cb1bc4cd28e8)
2
+ // Generated by Wrangler by running `wrangler types` (hash: 21e6c5c76a7bbde8583fcbcc839c03e7)
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 {
@@ -15,7 +15,6 @@ declare namespace Cloudflare {
15
15
  ORIGIN_URL: "https://startup-api-demo-origin.sergeychernyshev.workers.dev/";
16
16
  TWITCH_CLIENT_ID: "";
17
17
  TWITCH_CLIENT_SECRET: "";
18
- GITHUB_PROJECT_ID: string;
19
18
  USER: DurableObjectNamespace<import("./src/index").UserDO>;
20
19
  ACCOUNT: DurableObjectNamespace<import("./src/index").AccountDO>;
21
20
  SYSTEM: DurableObjectNamespace<import("./src/index").SystemDO>;
@@ -36,7 +35,6 @@ declare namespace Cloudflare {
36
35
  PATREON_CLIENT_ID: "";
37
36
  PATREON_CLIENT_SECRET: "";
38
37
  PATREON_WEBHOOK_SECRET: "";
39
- GITHUB_PROJECT_ID: string;
40
38
  USER: DurableObjectNamespace<import("./src/index").UserDO>;
41
39
  ACCOUNT: DurableObjectNamespace<import("./src/index").AccountDO>;
42
40
  SYSTEM: DurableObjectNamespace<import("./src/index").SystemDO>;
@@ -56,14 +54,12 @@ declare namespace Cloudflare {
56
54
  PATREON_CLIENT_ID: "patreon-id";
57
55
  PATREON_CLIENT_SECRET: "patreon-secret";
58
56
  PATREON_WEBHOOK_SECRET: "whsec-test";
59
- GITHUB_PROJECT_ID: string;
60
57
  USER: DurableObjectNamespace<import("./src/index").UserDO>;
61
58
  ACCOUNT: DurableObjectNamespace<import("./src/index").AccountDO>;
62
59
  SYSTEM: DurableObjectNamespace<import("./src/index").SystemDO>;
63
60
  CREDENTIAL: DurableObjectNamespace<import("./src/index").CredentialDO>;
64
61
  }
65
62
  interface Env {
66
- GITHUB_PROJECT_ID: string;
67
63
  IMAGE_STORAGE: R2Bucket;
68
64
  ASSETS: Fetcher;
69
65
  ENVIRONMENT?: "preview" | "" | "test";