@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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Provider-agnostic entitlement model.
3
+ *
4
+ * Entitlements describe what a logged-in user is currently entitled to with a given OAuth provider —
5
+ * for example, an active Patreon membership and the perks (benefits) it grants. The shape is generic;
6
+ * each provider that supports entitlements populates its own sub-object. Only Patreon does today.
7
+ */
8
+
9
+ export type EntitlementSource = 'oauth' | 'webhook' | 'cron';
10
+
11
+ export interface Entitlements {
12
+ /** The provider these entitlements were resolved from (e.g. 'patreon'). */
13
+ provider: string;
14
+ /** When these entitlements were last successfully refreshed (epoch ms) — the TTL anchor. */
15
+ checked_at: number;
16
+ /** How the latest refresh was triggered. */
17
+ source: EntitlementSource;
18
+ /** Patreon-specific entitlement details (present only for the Patreon provider). */
19
+ patreon?: PatreonEntitlement;
20
+ }
21
+
22
+ export interface PatreonEntitlement {
23
+ patron_status: 'active_patron' | 'declined_patron' | 'former_patron' | null;
24
+ /** Convenience flag derived from patron_status === 'active_patron'. */
25
+ is_active_patron: boolean;
26
+ /** IDs of tiers the user is currently entitled to. */
27
+ entitled_tier_ids: string[];
28
+ /** IDs of benefits (perks) granted by the currently-entitled tiers (deduped). */
29
+ entitled_benefit_ids: string[];
30
+ /** Currently-entitled amount in cents, if known. */
31
+ pledge_amount_cents: number | null;
32
+ }
@@ -210,6 +210,8 @@ function getProviderIcon(provider: string): string {
210
210
  return '<svg viewBox="0 0 24 24" width="24" height="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.66l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>';
211
211
  } else if (provider === 'twitch') {
212
212
  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
+ } else if (provider === 'patreon') {
214
+ 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>';
213
215
  }
214
216
  return '';
215
217
  }
@@ -9,6 +9,9 @@ export function getActiveProviders(env: StartupAPIEnv): string[] {
9
9
  if (env.TWITCH_CLIENT_ID && env.TWITCH_CLIENT_SECRET) {
10
10
  providers.push('twitch');
11
11
  }
12
+ if (env.PATREON_CLIENT_ID && env.PATREON_CLIENT_SECRET) {
13
+ providers.push('patreon');
14
+ }
12
15
  return providers;
13
16
  }
14
17
 
package/src/index.ts CHANGED
@@ -1,190 +1,22 @@
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 { getActiveProviders, parseCookies, getUserFromSession } from './handlers/utils';
10
- import { handleAdmin } from './handlers/admin';
11
- import {
12
- handleMe,
13
- handleUpdateProfile,
14
- handleListCredentials,
15
- handleDeleteCredential,
16
- handleMeImage,
17
- handleUserImage,
18
- } from './handlers/user';
19
- import { handleMyAccounts, handleSwitchAccount, handleAccountDetails, handleAccountImage, handleAccountMembers } from './handlers/account';
20
- import { handleLogout } from './handlers/auth';
21
- import { handleSSR } from './handlers/ssr';
22
- import { Plan } from './billing/Plan';
23
-
24
- const DEFAULT_USERS_PATH = '/users/';
25
-
26
- export { UserDO, AccountDO, SystemDO, CredentialDO };
27
-
28
- import type { StartupAPIEnv } from './StartupAPIEnv';
29
-
30
- export default {
31
- /**
32
- * Main Worker fetch handler.
33
- */
34
- async fetch(request: Request, env: StartupAPIEnv): Promise<Response> {
35
- if (!Plan.isInitialized()) {
36
- initPlans();
37
- }
38
-
39
- // Prevent infinite loops when serving assets
40
- if (request.headers.has('x-skip-worker')) {
41
- return env.ASSETS.fetch(request);
42
- }
43
-
44
- if (!env.ORIGIN_URL || !env.SESSION_SECRET) {
45
- return env.ASSETS.fetch(request);
46
- }
47
-
48
- const url = new URL(request.url);
49
- const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH;
50
-
51
- const cookieManager = new CookieManager(env.SESSION_SECRET);
52
-
53
- // SSR Routes
54
- const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/';
55
- if (url.pathname.startsWith(usersPathNormalized)) {
56
- const subPath = url.pathname.slice(usersPathNormalized.length);
57
- const isProfile = subPath === 'profile.html' || subPath === 'profile';
58
- const isAccounts = subPath === 'accounts.html' || subPath === 'accounts';
59
-
60
- if (isProfile || isAccounts) {
61
- return handleSSR(request, env, url, usersPath, cookieManager);
62
- }
63
- }
64
-
65
- // Handle OAuth Routes
66
- if (url.pathname.startsWith(usersPath + 'auth/')) {
67
- return handleAuth(request, env, url, usersPath, cookieManager);
68
- }
69
-
70
- if (url.pathname === usersPath + 'me/avatar') {
71
- return handleMeImage(request, env, 'avatar', cookieManager);
72
- }
73
-
74
- // Handle API Routes
75
- if (url.pathname.startsWith(usersPath + 'api/')) {
76
- const apiPath = url.pathname.replace(usersPath + 'api/', '/');
77
-
78
- if (apiPath === '/me') {
79
- return handleMe(request, env, cookieManager);
80
- }
81
-
82
- if (apiPath === '/me/profile' && request.method === 'POST') {
83
- return handleUpdateProfile(request, env, cookieManager);
84
- }
85
-
86
- if (apiPath === '/me/credentials') {
87
- if (request.method === 'GET') {
88
- return handleListCredentials(request, env, cookieManager);
89
- } else if (request.method === 'DELETE') {
90
- return handleDeleteCredential(request, env, cookieManager);
91
- }
92
- }
93
-
94
- if (apiPath === '/stop-impersonation' && request.method === 'POST') {
95
- const cookieHeader = request.headers.get('Cookie');
96
- const cookies = parseCookies(cookieHeader || '');
97
- const backupSessionEncrypted = cookies['backup_session_id'];
98
-
99
- if (!backupSessionEncrypted) {
100
- return new Response('No impersonation session found', { status: 400 });
101
- }
102
-
103
- const backupSession = await cookieManager.decrypt(backupSessionEncrypted);
104
- if (!backupSession) {
105
- return new Response('Invalid backup session', { status: 400 });
106
- }
107
-
108
- const headers = new Headers();
109
- const newSessionIdEncrypted = await cookieManager.encrypt(backupSession);
110
- headers.set('Set-Cookie', `session_id=${newSessionIdEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`);
111
- headers.append('Set-Cookie', `backup_session_id=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
112
-
113
- return Response.json({ success: true }, { headers });
114
- }
115
-
116
- if (apiPath === '/me/accounts') {
117
- return handleMyAccounts(request, env, cookieManager);
118
- }
119
-
120
- if (apiPath === '/me/accounts/switch' && request.method === 'POST') {
121
- return handleSwitchAccount(request, env, cookieManager);
122
- }
123
-
124
- if (apiPath.startsWith('/me/accounts/')) {
125
- const parts = apiPath.split('/');
126
- if (parts.length === 4) {
127
- return handleAccountDetails(request, env, parts[3], cookieManager);
128
- }
129
- if (parts.length === 5 && parts[4] === 'avatar') {
130
- return handleAccountImage(request, env, parts[3], 'avatar', cookieManager);
131
- }
132
- if (parts.length >= 5 && parts[4] === 'members') {
133
- return handleAccountMembers(request, env, parts[3], parts.slice(5), cookieManager);
134
- }
135
- }
136
-
137
- if (apiPath.startsWith('/users/')) {
138
- const parts = apiPath.split('/');
139
- if (parts.length === 4 && parts[3] === 'avatar') {
140
- return handleUserImage(request, env, parts[2], 'avatar', cookieManager);
141
- }
142
- }
143
- }
144
-
145
- if (url.pathname === usersPath + 'logout') {
146
- return handleLogout(request, env, url, usersPath, cookieManager);
147
- }
148
-
149
- // Admin Routes
150
- if (url.pathname.startsWith(usersPath + 'admin/')) {
151
- return handleAdmin(request, env, usersPath, cookieManager);
152
- }
153
-
154
- // Intercept requests to usersPath and serve them from the public/users directory.
155
- if (url.pathname.startsWith(usersPath)) {
156
- url.pathname = url.pathname.replace(usersPath, '/users/');
157
- const newRequest = new Request(url.toString(), request);
158
- newRequest.headers.set('x-skip-worker', 'true');
159
- return env.ASSETS.fetch(newRequest);
160
- }
161
-
162
- if (env.ORIGIN_URL) {
163
- const originUrl = new URL(env.ORIGIN_URL);
164
- url.protocol = originUrl.protocol;
165
- url.host = originUrl.host;
166
- url.port = originUrl.port;
167
-
168
- const newRequest = new Request(url.toString(), request);
169
- newRequest.headers.set('Host', url.host);
170
-
171
- const user = await getUserFromSession(request, env, cookieManager);
172
- if (user) {
173
- newRequest.headers.set('X-StartupAPI-User-Id', user.id);
174
- const userStub = env.USER.get(env.USER.idFromString(user.id));
175
- const currentAccount = await userStub.getCurrentAccount();
176
- if (currentAccount) {
177
- newRequest.headers.set('X-StartupAPI-Account-Id', currentAccount.account_id);
178
- }
179
- }
180
-
181
- const response = await fetch(newRequest);
182
- const providers = getActiveProviders(env);
183
-
184
- return injectPowerStrip(response, usersPath, providers);
185
- }
186
-
187
- // do not modify the request as it will loop through the same worker again
188
- return env.ASSETS.fetch(request);
189
- },
190
- } satisfies ExportedHandler<StartupAPIEnv>;
1
+ import { createStartupAPI } from './createStartupAPI';
2
+
3
+ /**
4
+ * Default StartupAPI instance, built from environment configuration only (no code config). This keeps
5
+ * the long-standing usage working unchanged:
6
+ *
7
+ * export { default, UserDO, AccountDO, SystemDO, CredentialDO } from '@startup-api/cloudflare';
8
+ *
9
+ * To customize behavior (provider freshness, access policy, plans), import `createStartupAPI` and pass
10
+ * a config object instead of re-exporting the default.
11
+ */
12
+ const instance = createStartupAPI();
13
+
14
+ export const UserDO = instance.UserDO;
15
+ export const AccountDO = instance.AccountDO;
16
+ export const SystemDO = instance.SystemDO;
17
+ export const CredentialDO = instance.CredentialDO;
18
+
19
+ export { createStartupAPI };
20
+ export type { StartupAPIConfig } from './schemas/config';
21
+
22
+ export default instance.default;
@@ -0,0 +1,106 @@
1
+ import { AccessPolicySchema } from '../schemas/policy';
2
+ import type { AccessPolicyConfig, AccessPolicyResolved, AccessRule, Requirement, UnauthorizedAction } from '../schemas/policy';
3
+ import type { Entitlements } from '../entitlements/types';
4
+ import { providerEntitlementCheckers, providerSupportsEntitlements } from './entitlementCheckers';
5
+
6
+ /**
7
+ * Match a single path pattern against a request path.
8
+ * - `/` → the homepage only
9
+ * - `/foo/*` → `/foo` and anything under `/foo/`
10
+ * - `/foo` → exact match
11
+ */
12
+ export function matchPattern(pattern: string, path: string): boolean {
13
+ if (pattern === '/') return path === '/';
14
+ if (pattern.endsWith('/*')) {
15
+ const base = pattern.slice(0, -2);
16
+ return path === base || path.startsWith(base + '/');
17
+ }
18
+ return path === pattern;
19
+ }
20
+
21
+ export type PolicyDecision =
22
+ | { allow: true }
23
+ | { allow: false; reason: 'unauthenticated' | 'not_entitled'; action: UnauthorizedAction; upgrade_url?: string };
24
+
25
+ function deny(reason: 'unauthenticated' | 'not_entitled', rule: AccessRule): PolicyDecision {
26
+ return { allow: false, reason, action: rule.on_unauthorized, upgrade_url: rule.upgrade_url };
27
+ }
28
+
29
+ /**
30
+ * Decide whether a request satisfies a resolved rule, given the auth state and resolved entitlements.
31
+ * `bypass`/`public` always allow; `authenticated` needs a logged-in user; `entitlement` dispatches the
32
+ * condition through the provider checker registry.
33
+ *
34
+ * Admin users (`ctx.isAdmin`) bypass every requirement: identity has already been resolved and headers
35
+ * forwarded by the time we get here, but the gate itself is skipped so admins can reach any gated path.
36
+ */
37
+ export function evaluateAccess(
38
+ rule: AccessRule,
39
+ ctx: { authenticated: boolean; entitlements: Entitlements | null; isAdmin?: boolean },
40
+ ): PolicyDecision {
41
+ if (ctx.isAdmin) return { allow: true };
42
+ const req = rule.requirement;
43
+ switch (req.mode) {
44
+ case 'bypass':
45
+ case 'public':
46
+ return { allow: true };
47
+ case 'authenticated':
48
+ return ctx.authenticated ? { allow: true } : deny('unauthenticated', rule);
49
+ case 'entitlement': {
50
+ if (!ctx.authenticated) return deny('unauthenticated', rule);
51
+ const checker = providerEntitlementCheckers[req.provider];
52
+ const ok = checker ? checker(req.condition, ctx.entitlements) : false;
53
+ return ok ? { allow: true } : deny('not_entitled', rule);
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Static registry for the path-based access policy, initialized once at startup (mirrors the Plan
60
+ * registry pattern). The default rule (for unmatched paths) falls back to `authenticated`.
61
+ */
62
+ export class AccessPolicy {
63
+ private static config: AccessPolicyResolved | null = null;
64
+
65
+ static init(config: AccessPolicyConfig | undefined): void {
66
+ const parsed = AccessPolicySchema.parse(config ?? {});
67
+
68
+ // Validate that every entitlement requirement targets a provider that supports entitlements.
69
+ const requirements: Requirement[] = [...parsed.rules.map((r) => r.requirement), ...(parsed.default ? [parsed.default] : [])];
70
+ for (const req of requirements) {
71
+ if (req.mode === 'entitlement' && !providerSupportsEntitlements(req.provider)) {
72
+ throw new Error(
73
+ `Access policy references an entitlement condition for provider '${req.provider}', which does not support entitlement conditions`,
74
+ );
75
+ }
76
+ }
77
+
78
+ AccessPolicy.config = parsed;
79
+ }
80
+
81
+ static isInitialized(): boolean {
82
+ return AccessPolicy.config !== null;
83
+ }
84
+
85
+ /** Reset state — intended for tests that re-init with different configs. */
86
+ static reset(): void {
87
+ AccessPolicy.config = null;
88
+ }
89
+
90
+ /** Resolve the rule that applies to a path: first matching rule, else the default. */
91
+ static evaluate(path: string): AccessRule {
92
+ const cfg = AccessPolicy.config;
93
+ if (!cfg) throw new Error('AccessPolicy not initialized');
94
+
95
+ for (const rule of cfg.rules) {
96
+ if (matchPattern(rule.pattern, path)) return rule;
97
+ }
98
+
99
+ return {
100
+ pattern: '*',
101
+ requirement: cfg.default ?? { mode: 'authenticated' },
102
+ on_unauthorized: cfg.default_on_unauthorized,
103
+ upgrade_url: cfg.default_upgrade_url,
104
+ };
105
+ }
106
+ }
@@ -0,0 +1,36 @@
1
+ import type { Entitlements } from '../entitlements/types';
2
+ import type { EntitlementCondition } from '../schemas/policy';
3
+
4
+ /**
5
+ * Provider entitlement checker registry — the ONLY place provider-specific access logic lives.
6
+ *
7
+ * Each entry maps a provider name to a function that decides whether a given entitlement condition is
8
+ * satisfied by the user's resolved entitlements. The access policy engine dispatches through this map,
9
+ * so adding perk-level checks for a new provider is purely additive (register a checker + implement
10
+ * the provider's `fetchEntitlements`) with no engine changes.
11
+ *
12
+ * Only Patreon participates today; Google/Twitch register nothing and therefore cannot be the target
13
+ * of an entitlement requirement (the policy rejects that at init time).
14
+ */
15
+ export type EntitlementChecker = (condition: EntitlementCondition, entitlements: Entitlements | null) => boolean;
16
+
17
+ export const providerEntitlementCheckers: Record<string, EntitlementChecker> = {
18
+ patreon(condition, entitlements) {
19
+ const patreon = entitlements?.patreon;
20
+ if (!patreon) return false;
21
+ switch (condition.type) {
22
+ case 'active_patron':
23
+ return patreon.is_active_patron;
24
+ case 'benefit':
25
+ return patreon.entitled_benefit_ids.includes(condition.benefit_id);
26
+ case 'tier':
27
+ return patreon.entitled_tier_ids.includes(condition.tier_id);
28
+ default:
29
+ return false;
30
+ }
31
+ },
32
+ };
33
+
34
+ export function providerSupportsEntitlements(provider: string): boolean {
35
+ return Object.prototype.hasOwnProperty.call(providerEntitlementCheckers, provider);
36
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ import { AccessPolicySchema } from './policy';
3
+
4
+ /**
5
+ * Zod schema + types for the StartupAPI configuration factory (see src/createStartupAPI.ts).
6
+ *
7
+ * Only non-secret behavior lives here — provider enablement, per-provider freshness toggles, access
8
+ * policy and plans. Credentials/secrets stay in env. Every field is optional and falls back to
9
+ * env-derived defaults, so `createStartupAPI()` with no config behaves like the previous package.
10
+ */
11
+
12
+ const TtlFreshnessSchema = z.union([z.boolean(), z.object({ ms: z.number().positive().optional() })]);
13
+ const CronFreshnessSchema = z.union([z.boolean(), z.object({ schedule: z.string().optional() })]);
14
+
15
+ export const ProviderFreshnessSchema = z.object({
16
+ /** Lazily re-check entitlements on the request hot path when older than the TTL. Off by default. */
17
+ ttl: TtlFreshnessSchema.optional(),
18
+ /** Periodically re-sync entitlements via a scheduled() handler. Off by default. */
19
+ cron: CronFreshnessSchema.optional(),
20
+ /** Update entitlements from provider webhooks (Patreon only). Off by default. */
21
+ webhook: z.boolean().optional(),
22
+ });
23
+
24
+ export const ProviderOptionsSchema = z.object({
25
+ /** Force-enable/disable the provider. Default: enabled iff its credentials are present in env. */
26
+ enabled: z.boolean().optional(),
27
+ /** Extra OAuth scopes to request, on top of the provider's required base scopes. */
28
+ scopes: z.union([z.string(), z.array(z.string())]).optional(),
29
+ /** Patreon only: restrict entitlements to a single campaign id. */
30
+ campaignId: z.string().optional(),
31
+ freshness: ProviderFreshnessSchema.optional(),
32
+ });
33
+
34
+ export const StartupAPIConfigSchema = z.object({
35
+ providers: z.record(z.string(), ProviderOptionsSchema).optional(),
36
+ accessPolicy: AccessPolicySchema.optional(),
37
+ // Plans are validated by the billing layer; accept an array passthrough here.
38
+ plans: z.array(z.any()).optional(),
39
+ });
40
+
41
+ export type ProviderFreshness = z.infer<typeof ProviderFreshnessSchema>;
42
+ export type ProviderOptions = z.infer<typeof ProviderOptionsSchema>;
43
+ export type StartupAPIConfig = z.input<typeof StartupAPIConfigSchema>;
44
+
45
+ /** Normalized per-provider freshness after resolving boolean/object forms and env fallbacks. */
46
+ export interface ResolvedFreshness {
47
+ ttl: { enabled: boolean; ms: number };
48
+ cron: { enabled: boolean; schedule: string };
49
+ webhook: { enabled: boolean };
50
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Zod schemas for the provider-agnostic entitlement model (see src/entitlements/types.ts).
5
+ * Used to validate entitlement blobs on read/write from storage.
6
+ */
7
+
8
+ export const PatreonEntitlementSchema = z.object({
9
+ patron_status: z.enum(['active_patron', 'declined_patron', 'former_patron']).nullable(),
10
+ is_active_patron: z.boolean(),
11
+ entitled_tier_ids: z.array(z.string()),
12
+ entitled_benefit_ids: z.array(z.string()),
13
+ pledge_amount_cents: z.number().nullable(),
14
+ });
15
+
16
+ export const EntitlementsSchema = z.object({
17
+ provider: z.string(),
18
+ checked_at: z.number(),
19
+ source: z.enum(['oauth', 'webhook', 'cron']),
20
+ patreon: PatreonEntitlementSchema.optional(),
21
+ });
22
+
23
+ export type EntitlementsInput = z.input<typeof EntitlementsSchema>;
24
+ export type EntitlementsOutput = z.output<typeof EntitlementsSchema>;
@@ -0,0 +1,62 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Zod schemas for the path-based access policy (see src/policy/accessPolicy.ts).
5
+ *
6
+ * A policy is an ordered list of rules mapping a URL path pattern to an access requirement, plus a
7
+ * default requirement for unmatched paths. The requirement modes are provider-agnostic except for
8
+ * `entitlement`, whose `condition` is provider-scoped and validated against the provider entitlement
9
+ * checker registry at policy-init time.
10
+ */
11
+
12
+ /**
13
+ * Provider entitlement conditions. Currently only Patreon implements these; the engine rejects an
14
+ * entitlement requirement whose provider has no registered checker.
15
+ */
16
+ export const EntitlementConditionSchema = z.discriminatedUnion('type', [
17
+ z.object({ type: z.literal('active_patron') }),
18
+ z.object({ type: z.literal('benefit'), benefit_id: z.string() }),
19
+ z.object({ type: z.literal('tier'), tier_id: z.string() }),
20
+ ]);
21
+
22
+ export const RequirementSchema = z.discriminatedUnion('mode', [
23
+ // Raw pass-through: no credential check, no identity resolution, no headers, no injection.
24
+ z.object({ mode: z.literal('bypass') }),
25
+ // Anyone; resolve session if present and forward identity/entitlement headers.
26
+ z.object({ mode: z.literal('public') }),
27
+ // Any logged-in user.
28
+ z.object({ mode: z.literal('authenticated') }),
29
+ // Provider-specific entitlement (e.g. Patreon active patron / benefit / tier).
30
+ z.object({
31
+ mode: z.literal('entitlement'),
32
+ provider: z.string(),
33
+ condition: EntitlementConditionSchema,
34
+ }),
35
+ ]);
36
+
37
+ export const UnauthorizedActionSchema = z.enum(['login', 'forbidden', 'upgrade']);
38
+
39
+ export const RuleSchema = z.object({
40
+ /** Path pattern: exact (`/special`), prefix (`/special/*`), or `/` for the homepage only. */
41
+ pattern: z.string(),
42
+ requirement: RequirementSchema,
43
+ /** What to do when the requirement is not met. Defaults to 'login'. */
44
+ on_unauthorized: UnauthorizedActionSchema.default('login'),
45
+ /** Redirect target for the 'upgrade' action (e.g. a Patreon join page). */
46
+ upgrade_url: z.string().optional(),
47
+ });
48
+
49
+ export const AccessPolicySchema = z.object({
50
+ rules: z.array(RuleSchema).default([]),
51
+ /** Requirement applied to paths that match no rule. Defaults to 'authenticated' when omitted. */
52
+ default: RequirementSchema.optional(),
53
+ default_on_unauthorized: UnauthorizedActionSchema.default('login'),
54
+ default_upgrade_url: z.string().optional(),
55
+ });
56
+
57
+ export type EntitlementCondition = z.infer<typeof EntitlementConditionSchema>;
58
+ export type Requirement = z.infer<typeof RequirementSchema>;
59
+ export type UnauthorizedAction = z.infer<typeof UnauthorizedActionSchema>;
60
+ export type AccessRule = z.infer<typeof RuleSchema>;
61
+ export type AccessPolicyConfig = z.input<typeof AccessPolicySchema>;
62
+ export type AccessPolicyResolved = z.output<typeof AccessPolicySchema>;
@@ -2,6 +2,7 @@ import { DurableObject } from 'cloudflare:workers';
2
2
  import { StartupAPIEnv } from '../StartupAPIEnv';
3
3
  import { OAuthCredentialSchema } from '../schemas/credential';
4
4
  import type { OAuthCredential, OAuthCredentialOutput } from '../schemas/credential';
5
+ import { EntitlementsSchema } from '../schemas/entitlement';
5
6
 
6
7
  /**
7
8
  * A Durable Object representing all OAuth credentials for a specific provider.
@@ -28,6 +29,23 @@ export class CredentialDO extends DurableObject {
28
29
  );
29
30
  CREATE INDEX IF NOT EXISTS idx_user_id ON credentials(user_id);
30
31
  `);
32
+
33
+ // Entitlement columns were added after the initial schema. SQLite has no
34
+ // "ADD COLUMN IF NOT EXISTS", so guard each ALTER and ignore the duplicate-column error.
35
+ for (const column of ['entitlements TEXT', 'entitlements_checked_at INTEGER']) {
36
+ try {
37
+ this.sql.exec(`ALTER TABLE credentials ADD COLUMN ${column}`);
38
+ } catch (_e) {
39
+ // Column already exists.
40
+ }
41
+ }
42
+ }
43
+
44
+ /** Parse the JSON columns (profile_data, entitlements) of a credential row in place. */
45
+ private hydrate(row: any): any {
46
+ row.profile_data = row.profile_data ? JSON.parse(row.profile_data) : null;
47
+ row.entitlements = row.entitlements ? JSON.parse(row.entitlements) : null;
48
+ return row;
31
49
  }
32
50
 
33
51
  async get(subjectId: string): Promise<any | null> {
@@ -35,20 +53,48 @@ export class CredentialDO extends DurableObject {
35
53
  const row = result.next().value as any;
36
54
  if (!row) return null;
37
55
 
38
- row.profile_data = JSON.parse(row.profile_data);
39
- return row;
56
+ return this.hydrate(row);
40
57
  }
41
58
 
42
59
  async list(userId: string): Promise<any[]> {
43
60
  const result = this.sql.exec('SELECT * FROM credentials WHERE user_id = ?', userId);
44
61
  const credentials = [];
45
62
  for (const row of result) {
46
- (row as any).profile_data = JSON.parse((row as any).profile_data);
47
- credentials.push(row);
63
+ credentials.push(this.hydrate(row as any));
48
64
  }
49
65
  return credentials;
50
66
  }
51
67
 
68
+ /**
69
+ * Enumerate all credentials for this provider using keyset pagination on the subject_id PK.
70
+ * Used by the scheduled cron re-sync. Returns the next cursor (last subject_id) or null when done.
71
+ */
72
+ async listAll(limit = 500, after?: string): Promise<{ rows: any[]; cursor: string | null }> {
73
+ const result = after
74
+ ? this.sql.exec('SELECT * FROM credentials WHERE subject_id > ? ORDER BY subject_id LIMIT ?', after, limit)
75
+ : this.sql.exec('SELECT * FROM credentials ORDER BY subject_id LIMIT ?', limit);
76
+
77
+ const rows: any[] = [];
78
+ for (const row of result) {
79
+ rows.push(this.hydrate(row as any));
80
+ }
81
+ const cursor = rows.length === limit ? (rows[rows.length - 1].subject_id as string) : null;
82
+ return { rows, cursor };
83
+ }
84
+
85
+ /** Persist the entitlements blob for a credential (source of truth). */
86
+ async putEntitlements(subjectId: string, entitlements: Record<string, any>): Promise<{ success: boolean }> {
87
+ const validated = EntitlementsSchema.parse(entitlements);
88
+ this.sql.exec(
89
+ 'UPDATE credentials SET entitlements = ?, entitlements_checked_at = ?, updated_at = ? WHERE subject_id = ?',
90
+ JSON.stringify(validated),
91
+ validated.checked_at,
92
+ Date.now(),
93
+ subjectId,
94
+ );
95
+ return { success: true };
96
+ }
97
+
52
98
  async put(data: OAuthCredential): Promise<{ success: boolean }> {
53
99
  console.log('[auth] Parsing Cred', data);
54
100