@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 +120 -0
- package/package.json +1 -1
- package/public/users/power-strip.js +32 -2
- package/public/users/profile.html +4 -0
- package/src/PowerStrip.ts +33 -9
- package/src/StartupAPIEnv.ts +3 -0
- package/src/auth/GoogleProvider.ts +10 -3
- package/src/auth/OAuthProvider.ts +48 -1
- package/src/auth/PatreonProvider.ts +124 -0
- package/src/auth/TwitchProvider.ts +10 -3
- package/src/auth/index.ts +31 -9
- package/src/auth/providers.ts +39 -0
- package/src/createStartupAPI.ts +322 -0
- package/src/entitlements/cron.ts +37 -0
- package/src/entitlements/patreon.ts +85 -0
- package/src/entitlements/service.ts +98 -0
- package/src/entitlements/tokenManager.ts +67 -0
- package/src/entitlements/types.ts +32 -0
- package/src/handlers/ssr.ts +2 -0
- package/src/handlers/utils.ts +3 -0
- package/src/index.ts +22 -190
- package/src/policy/accessPolicy.ts +106 -0
- package/src/policy/entitlementCheckers.ts +36 -0
- package/src/schemas/config.ts +50 -0
- package/src/schemas/entitlement.ts +24 -0
- package/src/schemas/policy.ts +62 -0
- package/src/storage/CredentialDO.ts +50 -4
- package/src/storage/UserDO.ts +36 -0
- package/src/webhooks/md5hmac.ts +140 -0
- package/src/webhooks/patreon.ts +61 -0
- package/worker-configuration.d.ts +10 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { handleAuth } from './auth/index';
|
|
2
|
+
import { injectPowerStrip } from './PowerStrip';
|
|
3
|
+
import { UserDO } from './storage/UserDO';
|
|
4
|
+
import { AccountDO } from './storage/AccountDO';
|
|
5
|
+
import { SystemDO } from './storage/SystemDO';
|
|
6
|
+
import { CredentialDO } from './storage/CredentialDO';
|
|
7
|
+
import { CookieManager } from './CookieManager';
|
|
8
|
+
import { initPlans } from './billing/plansConfig';
|
|
9
|
+
import { Plan } from './billing/Plan';
|
|
10
|
+
import { getActiveProviders, parseCookies, getUserFromSession, isAdmin } from './handlers/utils';
|
|
11
|
+
import { handleAdmin } from './handlers/admin';
|
|
12
|
+
import {
|
|
13
|
+
handleMe,
|
|
14
|
+
handleUpdateProfile,
|
|
15
|
+
handleListCredentials,
|
|
16
|
+
handleDeleteCredential,
|
|
17
|
+
handleMeImage,
|
|
18
|
+
handleUserImage,
|
|
19
|
+
} from './handlers/user';
|
|
20
|
+
import { handleMyAccounts, handleSwitchAccount, handleAccountDetails, handleAccountImage, handleAccountMembers } from './handlers/account';
|
|
21
|
+
import { handleLogout } from './handlers/auth';
|
|
22
|
+
import { handleSSR } from './handlers/ssr';
|
|
23
|
+
|
|
24
|
+
import type { StartupAPIEnv } from './StartupAPIEnv';
|
|
25
|
+
import { StartupAPIConfigSchema } from './schemas/config';
|
|
26
|
+
import type { StartupAPIConfig, ProviderOptions, ResolvedFreshness } from './schemas/config';
|
|
27
|
+
import type { AccessPolicyConfig } from './schemas/policy';
|
|
28
|
+
import { AccessPolicy, evaluateAccess } from './policy/accessPolicy';
|
|
29
|
+
import type { PolicyDecision } from './policy/accessPolicy';
|
|
30
|
+
import { loadEntitlements, entitlementHeaders } from './entitlements/service';
|
|
31
|
+
import type { Entitlements } from './entitlements/types';
|
|
32
|
+
import { computeRedirectBase, getProvider } from './auth/providers';
|
|
33
|
+
import { runEntitlementResync } from './entitlements/cron';
|
|
34
|
+
import { handlePatreonWebhook } from './webhooks/patreon';
|
|
35
|
+
|
|
36
|
+
const DEFAULT_USERS_PATH = '/users/';
|
|
37
|
+
const DEFAULT_CRON_SCHEDULE = '0 */6 * * *';
|
|
38
|
+
const DEFAULT_ENTITLEMENT_TTL_MS = 15 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
// The factory's request handler is a local `const fetch`, which would shadow the global fetch inside
|
|
41
|
+
// its body. Use this alias to proxy to the origin via the *current* global fetch at call time (so test
|
|
42
|
+
// spies on globalThis.fetch are honored).
|
|
43
|
+
const originFetch = (...args: Parameters<typeof fetch>): Promise<Response> => globalThis.fetch(...args);
|
|
44
|
+
|
|
45
|
+
function isCronEnabled(options: ProviderOptions): boolean {
|
|
46
|
+
const cron = options.freshness?.cron;
|
|
47
|
+
return cron === true || (typeof cron === 'object' && cron !== null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Resolve a provider's freshness config into concrete flags/values. */
|
|
51
|
+
function resolveFreshness(options: ProviderOptions | undefined): ResolvedFreshness {
|
|
52
|
+
const f = options?.freshness ?? {};
|
|
53
|
+
const ttlEnabled = f.ttl === true || (typeof f.ttl === 'object' && f.ttl !== null);
|
|
54
|
+
const ttlMs = typeof f.ttl === 'object' && f.ttl?.ms ? f.ttl.ms : DEFAULT_ENTITLEMENT_TTL_MS;
|
|
55
|
+
const cronEnabled = isCronEnabled(options ?? {});
|
|
56
|
+
const cronSchedule = typeof f.cron === 'object' && f.cron?.schedule ? f.cron.schedule : DEFAULT_CRON_SCHEDULE;
|
|
57
|
+
return {
|
|
58
|
+
ttl: { enabled: ttlEnabled, ms: ttlMs },
|
|
59
|
+
cron: { enabled: cronEnabled, schedule: cronSchedule },
|
|
60
|
+
webhook: { enabled: f.webhook === true },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve the access policy from factory config, else a backward-compatible all-public default. */
|
|
65
|
+
function resolveAccessPolicy(configPolicy: AccessPolicyConfig | undefined): AccessPolicyConfig {
|
|
66
|
+
// No policy configured → preserve legacy behavior: allow everything, still forward identity headers.
|
|
67
|
+
return configPolicy ?? { default: { mode: 'public' } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build a deny response (login redirect / 403 / upgrade redirect) for an unmet access requirement. */
|
|
71
|
+
function denyResponse(
|
|
72
|
+
decision: Extract<PolicyDecision, { allow: false }>,
|
|
73
|
+
ctx: { usersPath: string; returnUrl: string; activeProviders: string[] },
|
|
74
|
+
): Response {
|
|
75
|
+
if (decision.action === 'forbidden') {
|
|
76
|
+
return new Response('Forbidden', { status: 403 });
|
|
77
|
+
}
|
|
78
|
+
if (decision.action === 'upgrade' && decision.upgrade_url) {
|
|
79
|
+
return new Response(null, { status: 302, headers: { Location: decision.upgrade_url } });
|
|
80
|
+
}
|
|
81
|
+
// 'login' (default): send the user to authenticate, preserving where they were going.
|
|
82
|
+
const ret = encodeURIComponent(ctx.returnUrl);
|
|
83
|
+
const target =
|
|
84
|
+
ctx.activeProviders.length === 1 ? `${ctx.usersPath}auth/${ctx.activeProviders[0]}?return_url=${ret}` : `/?return_url=${ret}`;
|
|
85
|
+
return new Response(null, { status: 302, headers: { Location: target } });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a configured StartupAPI instance: the Worker handler ({ fetch, scheduled? }) plus the Durable
|
|
90
|
+
* Object classes for re-export. `scheduled` is attached ONLY when at least one provider enables cron.
|
|
91
|
+
*
|
|
92
|
+
* All config is optional and falls back to env-derived defaults, so `createStartupAPI()` behaves like
|
|
93
|
+
* the previous package (login-only entitlements, no scheduled handler, all-public access policy).
|
|
94
|
+
*/
|
|
95
|
+
export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
96
|
+
const parsed = StartupAPIConfigSchema.parse(config);
|
|
97
|
+
const providerConfigs = parsed.providers ?? {};
|
|
98
|
+
const cronProviders = Object.entries(providerConfigs)
|
|
99
|
+
.filter(([, options]) => isCronEnabled(options))
|
|
100
|
+
.map(([name]) => name);
|
|
101
|
+
const anyCron = cronProviders.length > 0;
|
|
102
|
+
const patreonWebhookEnabled = providerConfigs.patreon?.freshness?.webhook === true;
|
|
103
|
+
|
|
104
|
+
const fetch = async (request: Request, env: StartupAPIEnv, ctx: ExecutionContext): Promise<Response> => {
|
|
105
|
+
if (!Plan.isInitialized()) {
|
|
106
|
+
if (parsed.plans) Plan.init(parsed.plans as any);
|
|
107
|
+
else initPlans();
|
|
108
|
+
}
|
|
109
|
+
if (!AccessPolicy.isInitialized()) {
|
|
110
|
+
AccessPolicy.init(resolveAccessPolicy(parsed.accessPolicy));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Prevent infinite loops when serving assets
|
|
114
|
+
if (request.headers.has('x-skip-worker')) {
|
|
115
|
+
return env.ASSETS.fetch(request);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!env.ORIGIN_URL || !env.SESSION_SECRET) {
|
|
119
|
+
return env.ASSETS.fetch(request);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const url = new URL(request.url);
|
|
123
|
+
const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH;
|
|
124
|
+
|
|
125
|
+
const cookieManager = new CookieManager(env.SESSION_SECRET);
|
|
126
|
+
|
|
127
|
+
// SSR Routes
|
|
128
|
+
const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/';
|
|
129
|
+
if (url.pathname.startsWith(usersPathNormalized)) {
|
|
130
|
+
const subPath = url.pathname.slice(usersPathNormalized.length);
|
|
131
|
+
const isProfile = subPath === 'profile.html' || subPath === 'profile';
|
|
132
|
+
const isAccounts = subPath === 'accounts.html' || subPath === 'accounts';
|
|
133
|
+
|
|
134
|
+
if (isProfile || isAccounts) {
|
|
135
|
+
return handleSSR(request, env, url, usersPath, cookieManager);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Patreon webhook (only mounted when configured)
|
|
140
|
+
if (patreonWebhookEnabled && url.pathname === usersPath + 'webhooks/patreon') {
|
|
141
|
+
return handlePatreonWebhook(request, env, ctx, providerConfigs);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Handle OAuth Routes
|
|
145
|
+
if (url.pathname.startsWith(usersPath + 'auth/')) {
|
|
146
|
+
return handleAuth(request, env, url, usersPath, cookieManager, providerConfigs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (url.pathname === usersPath + 'me/avatar') {
|
|
150
|
+
return handleMeImage(request, env, 'avatar', cookieManager);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Handle API Routes
|
|
154
|
+
if (url.pathname.startsWith(usersPath + 'api/')) {
|
|
155
|
+
const apiPath = url.pathname.replace(usersPath + 'api/', '/');
|
|
156
|
+
|
|
157
|
+
if (apiPath === '/me') {
|
|
158
|
+
return handleMe(request, env, cookieManager);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (apiPath === '/me/profile' && request.method === 'POST') {
|
|
162
|
+
return handleUpdateProfile(request, env, cookieManager);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (apiPath === '/me/credentials') {
|
|
166
|
+
if (request.method === 'GET') {
|
|
167
|
+
return handleListCredentials(request, env, cookieManager);
|
|
168
|
+
} else if (request.method === 'DELETE') {
|
|
169
|
+
return handleDeleteCredential(request, env, cookieManager);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (apiPath === '/stop-impersonation' && request.method === 'POST') {
|
|
174
|
+
const cookieHeader = request.headers.get('Cookie');
|
|
175
|
+
const cookies = parseCookies(cookieHeader || '');
|
|
176
|
+
const backupSessionEncrypted = cookies['backup_session_id'];
|
|
177
|
+
|
|
178
|
+
if (!backupSessionEncrypted) {
|
|
179
|
+
return new Response('No impersonation session found', { status: 400 });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const backupSession = await cookieManager.decrypt(backupSessionEncrypted);
|
|
183
|
+
if (!backupSession) {
|
|
184
|
+
return new Response('Invalid backup session', { status: 400 });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const headers = new Headers();
|
|
188
|
+
const newSessionIdEncrypted = await cookieManager.encrypt(backupSession);
|
|
189
|
+
headers.set('Set-Cookie', `session_id=${newSessionIdEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
190
|
+
headers.append('Set-Cookie', `backup_session_id=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
|
|
191
|
+
|
|
192
|
+
return Response.json({ success: true }, { headers });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (apiPath === '/me/accounts') {
|
|
196
|
+
return handleMyAccounts(request, env, cookieManager);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (apiPath === '/me/accounts/switch' && request.method === 'POST') {
|
|
200
|
+
return handleSwitchAccount(request, env, cookieManager);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (apiPath.startsWith('/me/accounts/')) {
|
|
204
|
+
const parts = apiPath.split('/');
|
|
205
|
+
if (parts.length === 4) {
|
|
206
|
+
return handleAccountDetails(request, env, parts[3], cookieManager);
|
|
207
|
+
}
|
|
208
|
+
if (parts.length === 5 && parts[4] === 'avatar') {
|
|
209
|
+
return handleAccountImage(request, env, parts[3], 'avatar', cookieManager);
|
|
210
|
+
}
|
|
211
|
+
if (parts.length >= 5 && parts[4] === 'members') {
|
|
212
|
+
return handleAccountMembers(request, env, parts[3], parts.slice(5), cookieManager);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (apiPath.startsWith('/users/')) {
|
|
217
|
+
const parts = apiPath.split('/');
|
|
218
|
+
if (parts.length === 4 && parts[3] === 'avatar') {
|
|
219
|
+
return handleUserImage(request, env, parts[2], 'avatar', cookieManager);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (url.pathname === usersPath + 'logout') {
|
|
225
|
+
return handleLogout(request, env, url, usersPath, cookieManager);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Admin Routes
|
|
229
|
+
if (url.pathname.startsWith(usersPath + 'admin/')) {
|
|
230
|
+
return handleAdmin(request, env, usersPath, cookieManager);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Intercept requests to usersPath and serve them from the public/users directory.
|
|
234
|
+
if (url.pathname.startsWith(usersPath)) {
|
|
235
|
+
url.pathname = url.pathname.replace(usersPath, '/users/');
|
|
236
|
+
const newRequest = new Request(url.toString(), request);
|
|
237
|
+
newRequest.headers.set('x-skip-worker', 'true');
|
|
238
|
+
return env.ASSETS.fetch(newRequest);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (env.ORIGIN_URL) {
|
|
242
|
+
// Evaluate the access policy BEFORE any identity work, so bypass paths are a raw proxy.
|
|
243
|
+
const rule = AccessPolicy.evaluate(url.pathname);
|
|
244
|
+
const requestOrigin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin;
|
|
245
|
+
const returnUrl = url.pathname + url.search;
|
|
246
|
+
|
|
247
|
+
const originUrl = new URL(env.ORIGIN_URL);
|
|
248
|
+
url.protocol = originUrl.protocol;
|
|
249
|
+
url.host = originUrl.host;
|
|
250
|
+
url.port = originUrl.port;
|
|
251
|
+
|
|
252
|
+
const newRequest = new Request(url.toString(), request);
|
|
253
|
+
newRequest.headers.set('Host', url.host);
|
|
254
|
+
|
|
255
|
+
if (rule.requirement.mode === 'bypass') {
|
|
256
|
+
// Pure pass-through: no identity resolution, no headers, no power-strip injection.
|
|
257
|
+
return originFetch(newRequest);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const user = await getUserFromSession(request, env, cookieManager);
|
|
261
|
+
const authenticated = !!user;
|
|
262
|
+
const userIsAdmin = user ? isAdmin(user, env) : false;
|
|
263
|
+
let entitlements: Entitlements | null = null;
|
|
264
|
+
let loginProvider: string | undefined;
|
|
265
|
+
|
|
266
|
+
if (user) {
|
|
267
|
+
newRequest.headers.set('X-StartupAPI-User-Id', user.id);
|
|
268
|
+
const userStub = env.USER.get(env.USER.idFromString(user.id));
|
|
269
|
+
const currentAccount = await userStub.getCurrentAccount();
|
|
270
|
+
if (currentAccount) {
|
|
271
|
+
newRequest.headers.set('X-StartupAPI-Account-Id', currentAccount.account_id);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
loginProvider = user.credential?.provider;
|
|
275
|
+
const subjectId = user.credential?.subject_id;
|
|
276
|
+
if (loginProvider && subjectId) {
|
|
277
|
+
const provider = getProvider(env, computeRedirectBase(env, requestOrigin, usersPath), loginProvider, providerConfigs);
|
|
278
|
+
if (provider && provider.supportsEntitlements()) {
|
|
279
|
+
const fr = resolveFreshness(providerConfigs[loginProvider]);
|
|
280
|
+
entitlements = await loadEntitlements({
|
|
281
|
+
env,
|
|
282
|
+
provider,
|
|
283
|
+
userStub,
|
|
284
|
+
userId: user.id,
|
|
285
|
+
subjectId,
|
|
286
|
+
ttlEnabled: fr.ttl.enabled,
|
|
287
|
+
ttlMs: fr.ttl.ms,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Forward login + entitlement info to the origin (skipped for bypass above).
|
|
294
|
+
for (const [key, value] of Object.entries(entitlementHeaders(authenticated, loginProvider, entitlements))) {
|
|
295
|
+
newRequest.headers.set(key, value);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Enforce the requirement. Admins bypass the gate (identity/headers above still apply).
|
|
299
|
+
const decision = evaluateAccess(rule, { authenticated, entitlements, isAdmin: userIsAdmin });
|
|
300
|
+
if (!decision.allow) {
|
|
301
|
+
return denyResponse(decision, { usersPath, returnUrl, activeProviders: getActiveProviders(env) });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const response = await originFetch(newRequest);
|
|
305
|
+
const providers = getActiveProviders(env);
|
|
306
|
+
return injectPowerStrip(response, usersPath, providers);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// do not modify the request as it will loop through the same worker again
|
|
310
|
+
return env.ASSETS.fetch(request);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const handler: ExportedHandler<StartupAPIEnv> = { fetch };
|
|
314
|
+
|
|
315
|
+
if (anyCron) {
|
|
316
|
+
handler.scheduled = async (_event: ScheduledController, env: StartupAPIEnv, ctx: ExecutionContext): Promise<void> => {
|
|
317
|
+
ctx.waitUntil(runEntitlementResync(env, cronProviders, providerConfigs));
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return { default: handler, fetch: handler.fetch!, scheduled: handler.scheduled, UserDO, AccountDO, SystemDO, CredentialDO };
|
|
322
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
import { computeRedirectBase, getProvider } from '../auth/providers';
|
|
3
|
+
import type { ProviderConfigs } from '../auth/providers';
|
|
4
|
+
import { refreshEntitlements } from './service';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Periodically re-sync entitlements for the given providers (those that enabled cron). Pages through
|
|
8
|
+
* each provider's CredentialDO with keyset pagination and refreshes every credential; per-credential
|
|
9
|
+
* failures are logged and skipped so one revoked token doesn't abort the batch.
|
|
10
|
+
*
|
|
11
|
+
* Scale note: all credentials for a provider live in a single Durable Object, so throughput is bounded
|
|
12
|
+
* by that one SQLite instance. Fine to thousands; very large bases would need work-splitting.
|
|
13
|
+
*/
|
|
14
|
+
export async function runEntitlementResync(env: StartupAPIEnv, providerNames: string[], providerConfigs: ProviderConfigs = {}): Promise<void> {
|
|
15
|
+
// redirectBase is irrelevant for refresh/fetch-entitlement calls (they use tokens + client creds),
|
|
16
|
+
// so a placeholder origin is fine here where there is no inbound request.
|
|
17
|
+
const redirectBase = computeRedirectBase(env, env.AUTH_ORIGIN || 'https://localhost', '/users/');
|
|
18
|
+
|
|
19
|
+
for (const name of providerNames) {
|
|
20
|
+
const provider = getProvider(env, redirectBase, name, providerConfigs);
|
|
21
|
+
if (!provider || !provider.supportsEntitlements()) continue;
|
|
22
|
+
|
|
23
|
+
const credStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(name));
|
|
24
|
+
let cursor: string | null = null;
|
|
25
|
+
do {
|
|
26
|
+
const page: { rows: any[]; cursor: string | null } = await credStub.listAll(500, cursor ?? undefined);
|
|
27
|
+
for (const cred of page.rows) {
|
|
28
|
+
try {
|
|
29
|
+
await refreshEntitlements(env, provider, { ...cred, user_id: cred.user_id }, 'cron');
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.error(`[cron] entitlement resync failed for ${name}/${cred.subject_id}`, e);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
cursor = page.cursor;
|
|
35
|
+
} while (cursor);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { PatreonEntitlement } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a Patreon v2 `identity` response (JSON:API) into our flat {@link PatreonEntitlement}.
|
|
5
|
+
*
|
|
6
|
+
* The relevant request is:
|
|
7
|
+
* GET /api/oauth2/v2/identity
|
|
8
|
+
* ?include=memberships,memberships.currently_entitled_tiers,memberships.currently_entitled_tiers.benefits
|
|
9
|
+
* &fields[member]=patron_status,currently_entitled_amount_cents
|
|
10
|
+
* &fields[tier]=title&fields[benefit]=title
|
|
11
|
+
*
|
|
12
|
+
* The response carries the user in `data`, with `data.relationships.memberships.data` listing member
|
|
13
|
+
* refs, and the full member/tier/benefit objects in the top-level `included` array. We resolve refs
|
|
14
|
+
* through `included`, walking member → currently_entitled_tiers → benefits.
|
|
15
|
+
*
|
|
16
|
+
* When `campaignId` is provided, only the membership for that campaign is considered (a user may be a
|
|
17
|
+
* patron of several campaigns through the same Patreon account); otherwise all memberships aggregate.
|
|
18
|
+
*/
|
|
19
|
+
export function parsePatreonIdentity(json: any, campaignId?: string): PatreonEntitlement {
|
|
20
|
+
const empty: PatreonEntitlement = {
|
|
21
|
+
patron_status: null,
|
|
22
|
+
is_active_patron: false,
|
|
23
|
+
entitled_tier_ids: [],
|
|
24
|
+
entitled_benefit_ids: [],
|
|
25
|
+
pledge_amount_cents: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (!json || typeof json !== 'object' || !json.data) return empty;
|
|
29
|
+
|
|
30
|
+
// Index every included resource by `${type}:${id}` for O(1) ref resolution.
|
|
31
|
+
const included = new Map<string, any>();
|
|
32
|
+
for (const item of Array.isArray(json.included) ? json.included : []) {
|
|
33
|
+
if (item && item.type && item.id != null) {
|
|
34
|
+
included.set(`${item.type}:${item.id}`, item);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const resolve = (ref: any) => (ref && ref.type && ref.id != null ? included.get(`${ref.type}:${ref.id}`) : undefined);
|
|
38
|
+
|
|
39
|
+
const membershipRefs: any[] = json.data?.relationships?.memberships?.data ?? [];
|
|
40
|
+
|
|
41
|
+
const tierIds = new Set<string>();
|
|
42
|
+
const benefitIds = new Set<string>();
|
|
43
|
+
let patronStatus: PatreonEntitlement['patron_status'] = null;
|
|
44
|
+
let pledgeAmount: number | null = null;
|
|
45
|
+
|
|
46
|
+
for (const memberRef of membershipRefs) {
|
|
47
|
+
const member = resolve(memberRef);
|
|
48
|
+
if (!member) continue;
|
|
49
|
+
|
|
50
|
+
// Filter to a specific campaign when configured.
|
|
51
|
+
if (campaignId) {
|
|
52
|
+
const memberCampaignId = member.relationships?.campaign?.data?.id;
|
|
53
|
+
if (memberCampaignId && String(memberCampaignId) !== String(campaignId)) continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const status = member.attributes?.patron_status ?? null;
|
|
57
|
+
// Prefer an active membership's status/amount; otherwise keep the first non-null seen.
|
|
58
|
+
if (status === 'active_patron' || patronStatus === null) {
|
|
59
|
+
patronStatus = status;
|
|
60
|
+
}
|
|
61
|
+
const amount = member.attributes?.currently_entitled_amount_cents;
|
|
62
|
+
if (typeof amount === 'number' && (pledgeAmount === null || status === 'active_patron')) {
|
|
63
|
+
pledgeAmount = amount;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const tierRefs: any[] = member.relationships?.currently_entitled_tiers?.data ?? [];
|
|
67
|
+
for (const tierRef of tierRefs) {
|
|
68
|
+
if (tierRef?.id == null) continue;
|
|
69
|
+
tierIds.add(String(tierRef.id));
|
|
70
|
+
const tier = resolve(tierRef);
|
|
71
|
+
const benefitRefs: any[] = tier?.relationships?.benefits?.data ?? [];
|
|
72
|
+
for (const benefitRef of benefitRefs) {
|
|
73
|
+
if (benefitRef?.id != null) benefitIds.add(String(benefitRef.id));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
patron_status: patronStatus,
|
|
80
|
+
is_active_patron: patronStatus === 'active_patron',
|
|
81
|
+
entitled_tier_ids: [...tierIds],
|
|
82
|
+
entitled_benefit_ids: [...benefitIds],
|
|
83
|
+
pledge_amount_cents: pledgeAmount,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
import type { OAuthProvider } from '../auth/OAuthProvider';
|
|
3
|
+
import type { Entitlements, EntitlementSource } from './types';
|
|
4
|
+
import { getValidAccessToken } from './tokenManager';
|
|
5
|
+
import type { StoredCredential } from './tokenManager';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fetch fresh entitlements for a credential and persist them to both the source of truth
|
|
9
|
+
* (CredentialDO) and the hot-path cache (UserDO). Returns null when the provider has no entitlements,
|
|
10
|
+
* the token can't be refreshed, or the provider call fails. Shared by login, lazy TTL, cron, webhook.
|
|
11
|
+
*/
|
|
12
|
+
export async function refreshEntitlements(
|
|
13
|
+
env: StartupAPIEnv,
|
|
14
|
+
provider: OAuthProvider,
|
|
15
|
+
credential: StoredCredential,
|
|
16
|
+
source: EntitlementSource,
|
|
17
|
+
): Promise<Entitlements | null> {
|
|
18
|
+
if (!provider.supportsEntitlements()) return null;
|
|
19
|
+
|
|
20
|
+
const token = await getValidAccessToken(env, provider, credential);
|
|
21
|
+
if (!token) return null;
|
|
22
|
+
|
|
23
|
+
const partial = await provider.fetchEntitlements(token);
|
|
24
|
+
if (!partial) return null;
|
|
25
|
+
|
|
26
|
+
const entitlements: Entitlements = {
|
|
27
|
+
provider: provider.name,
|
|
28
|
+
checked_at: Date.now(),
|
|
29
|
+
source,
|
|
30
|
+
...partial,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Source of truth + write-through hot-path cache.
|
|
34
|
+
const credStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name));
|
|
35
|
+
await credStub.putEntitlements(credential.subject_id, entitlements as unknown as Record<string, any>);
|
|
36
|
+
const userStub = env.USER.get(env.USER.idFromString(credential.user_id));
|
|
37
|
+
await userStub.setEntitlements(provider.name, credential.subject_id, entitlements as unknown as Record<string, any>, entitlements.checked_at);
|
|
38
|
+
|
|
39
|
+
return entitlements;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the entitlements that apply to the current request. Reads the UserDO hot-path cache first;
|
|
44
|
+
* if the entry is missing or (when TTL is enabled) older than `ttlMs`, refreshes from the provider and
|
|
45
|
+
* falls back to the cached value on failure. Returns null when the provider produces no entitlements.
|
|
46
|
+
*/
|
|
47
|
+
export async function loadEntitlements(opts: {
|
|
48
|
+
env: StartupAPIEnv;
|
|
49
|
+
provider: OAuthProvider;
|
|
50
|
+
// DurableObjectStub for the current user's UserDO (already opened by the proxy).
|
|
51
|
+
userStub: { getEntitlements: (provider: string, subjectId: string) => Promise<{ data: any; checked_at: number } | null> };
|
|
52
|
+
userId: string;
|
|
53
|
+
subjectId: string;
|
|
54
|
+
ttlEnabled: boolean;
|
|
55
|
+
ttlMs: number;
|
|
56
|
+
}): Promise<Entitlements | null> {
|
|
57
|
+
const { env, provider, userStub, userId, subjectId, ttlEnabled, ttlMs } = opts;
|
|
58
|
+
if (!provider.supportsEntitlements()) return null;
|
|
59
|
+
|
|
60
|
+
const cached = await userStub.getEntitlements(provider.name, subjectId);
|
|
61
|
+
const isFresh = cached && (!ttlEnabled || Date.now() - cached.checked_at < ttlMs);
|
|
62
|
+
if (isFresh) return cached!.data as Entitlements;
|
|
63
|
+
|
|
64
|
+
// Missing or stale → refresh (lazy TTL). Fall back to the cached value if the refresh fails.
|
|
65
|
+
try {
|
|
66
|
+
const credStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name));
|
|
67
|
+
const cred = await credStub.get(subjectId);
|
|
68
|
+
if (!cred) return (cached?.data as Entitlements) ?? null;
|
|
69
|
+
const refreshed = await refreshEntitlements(env, provider, { ...cred, user_id: cred.user_id ?? userId }, 'oauth');
|
|
70
|
+
return refreshed ?? ((cached?.data as Entitlements) ?? null);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error('[entitlements] loadEntitlements refresh failed', e);
|
|
73
|
+
return (cached?.data as Entitlements) ?? null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the entitlement-related headers forwarded to the origin app. Always includes the
|
|
79
|
+
* authenticated flag; adds the login provider and a compact JSON entitlement summary when present,
|
|
80
|
+
* plus provider-namespaced convenience headers for Patreon.
|
|
81
|
+
*/
|
|
82
|
+
export function entitlementHeaders(authenticated: boolean, provider?: string, entitlements?: Entitlements | null): Record<string, string> {
|
|
83
|
+
const headers: Record<string, string> = { 'X-StartupAPI-Authenticated': String(authenticated) };
|
|
84
|
+
if (provider) headers['X-StartupAPI-Login-Provider'] = provider;
|
|
85
|
+
if (entitlements) {
|
|
86
|
+
headers['X-StartupAPI-Entitlements'] = JSON.stringify(entitlements);
|
|
87
|
+
if (entitlements.patreon) {
|
|
88
|
+
headers['X-StartupAPI-Patreon-Active'] = String(entitlements.patreon.is_active_patron);
|
|
89
|
+
if (entitlements.patreon.entitled_tier_ids.length) {
|
|
90
|
+
headers['X-StartupAPI-Patreon-Tiers'] = entitlements.patreon.entitled_tier_ids.join(',');
|
|
91
|
+
}
|
|
92
|
+
if (entitlements.patreon.entitled_benefit_ids.length) {
|
|
93
|
+
headers['X-StartupAPI-Patreon-Benefits'] = entitlements.patreon.entitled_benefit_ids.join(',');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return headers;
|
|
98
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { StartupAPIEnv } from '../StartupAPIEnv';
|
|
2
|
+
import type { OAuthProvider } from '../auth/OAuthProvider';
|
|
3
|
+
|
|
4
|
+
/** Refresh the access token this many ms before its actual expiry, to avoid edge-of-expiry failures. */
|
|
5
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
export interface StoredCredential {
|
|
8
|
+
subject_id: string;
|
|
9
|
+
user_id: string;
|
|
10
|
+
access_token?: string | null;
|
|
11
|
+
refresh_token?: string | null;
|
|
12
|
+
expires_at?: number | null;
|
|
13
|
+
scope?: string | null;
|
|
14
|
+
profile_data?: Record<string, any> | null;
|
|
15
|
+
created_at?: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return a valid access token for a stored credential, refreshing it via the provider's refresh token
|
|
20
|
+
* when it has expired (or is about to). On a successful refresh the new token set is persisted back to
|
|
21
|
+
* the provider's CredentialDO. Returns null when there is no usable token and refresh is impossible or
|
|
22
|
+
* fails (e.g. a revoked refresh token) — callers should treat that as "entitlements unavailable" rather
|
|
23
|
+
* than crash the request.
|
|
24
|
+
*
|
|
25
|
+
* This is the single chokepoint shared by lazy TTL refresh (hot path) and the cron re-sync.
|
|
26
|
+
*/
|
|
27
|
+
export async function getValidAccessToken(
|
|
28
|
+
env: StartupAPIEnv,
|
|
29
|
+
provider: OAuthProvider,
|
|
30
|
+
credential: StoredCredential,
|
|
31
|
+
): Promise<string | null> {
|
|
32
|
+
const notExpired = !credential.expires_at || Date.now() < credential.expires_at - EXPIRY_SKEW_MS;
|
|
33
|
+
if (credential.access_token && notExpired) {
|
|
34
|
+
return credential.access_token;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!credential.refresh_token) {
|
|
38
|
+
// No way to refresh; fall back to the existing token (may be stale) or give up.
|
|
39
|
+
return credential.access_token ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const refreshed = await provider.refreshToken(credential.refresh_token);
|
|
44
|
+
if (!refreshed || !refreshed.access_token) {
|
|
45
|
+
return credential.access_token ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const expiresAt = refreshed.expires_in ? Date.now() + refreshed.expires_in * 1000 : undefined;
|
|
49
|
+
|
|
50
|
+
const stub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name));
|
|
51
|
+
await stub.put({
|
|
52
|
+
subject_id: credential.subject_id,
|
|
53
|
+
user_id: credential.user_id,
|
|
54
|
+
access_token: refreshed.access_token,
|
|
55
|
+
refresh_token: refreshed.refresh_token ?? credential.refresh_token,
|
|
56
|
+
expires_at: expiresAt,
|
|
57
|
+
scope: refreshed.scope ?? credential.scope ?? undefined,
|
|
58
|
+
profile_data: credential.profile_data ?? undefined,
|
|
59
|
+
created_at: credential.created_at ?? undefined,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return refreshed.access_token;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error(`[entitlements] Token refresh failed for ${provider.name}`, e);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|