@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/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,99 @@
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
+ export function evaluateAccess(rule: AccessRule, ctx: { authenticated: boolean; entitlements: Entitlements | null }): PolicyDecision {
35
+ const req = rule.requirement;
36
+ switch (req.mode) {
37
+ case 'bypass':
38
+ case 'public':
39
+ return { allow: true };
40
+ case 'authenticated':
41
+ return ctx.authenticated ? { allow: true } : deny('unauthenticated', rule);
42
+ case 'entitlement': {
43
+ if (!ctx.authenticated) return deny('unauthenticated', rule);
44
+ const checker = providerEntitlementCheckers[req.provider];
45
+ const ok = checker ? checker(req.condition, ctx.entitlements) : false;
46
+ return ok ? { allow: true } : deny('not_entitled', rule);
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Static registry for the path-based access policy, initialized once at startup (mirrors the Plan
53
+ * registry pattern). The default rule (for unmatched paths) falls back to `authenticated`.
54
+ */
55
+ export class AccessPolicy {
56
+ private static config: AccessPolicyResolved | null = null;
57
+
58
+ static init(config: AccessPolicyConfig | undefined): void {
59
+ const parsed = AccessPolicySchema.parse(config ?? {});
60
+
61
+ // Validate that every entitlement requirement targets a provider that supports entitlements.
62
+ const requirements: Requirement[] = [...parsed.rules.map((r) => r.requirement), ...(parsed.default ? [parsed.default] : [])];
63
+ for (const req of requirements) {
64
+ if (req.mode === 'entitlement' && !providerSupportsEntitlements(req.provider)) {
65
+ throw new Error(
66
+ `Access policy references an entitlement condition for provider '${req.provider}', which does not support entitlement conditions`,
67
+ );
68
+ }
69
+ }
70
+
71
+ AccessPolicy.config = parsed;
72
+ }
73
+
74
+ static isInitialized(): boolean {
75
+ return AccessPolicy.config !== null;
76
+ }
77
+
78
+ /** Reset state — intended for tests that re-init with different configs. */
79
+ static reset(): void {
80
+ AccessPolicy.config = null;
81
+ }
82
+
83
+ /** Resolve the rule that applies to a path: first matching rule, else the default. */
84
+ static evaluate(path: string): AccessRule {
85
+ const cfg = AccessPolicy.config;
86
+ if (!cfg) throw new Error('AccessPolicy not initialized');
87
+
88
+ for (const rule of cfg.rules) {
89
+ if (matchPattern(rule.pattern, path)) return rule;
90
+ }
91
+
92
+ return {
93
+ pattern: '*',
94
+ requirement: cfg.default ?? { mode: 'authenticated' },
95
+ on_unauthorized: cfg.default_on_unauthorized,
96
+ upgrade_url: cfg.default_upgrade_url,
97
+ };
98
+ }
99
+ }
@@ -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
 
@@ -51,6 +51,14 @@ export class UserDO extends DurableObject {
51
51
  subject_id TEXT NOT NULL,
52
52
  PRIMARY KEY (provider, subject_id)
53
53
  );
54
+
55
+ CREATE TABLE IF NOT EXISTS entitlements_cache (
56
+ provider TEXT NOT NULL,
57
+ subject_id TEXT NOT NULL,
58
+ data TEXT,
59
+ checked_at INTEGER,
60
+ PRIMARY KEY (provider, subject_id)
61
+ );
54
62
  `);
55
63
 
56
64
  // Ensure the single row exists
@@ -186,6 +194,34 @@ export class UserDO extends DurableObject {
186
194
  return { success: true };
187
195
  }
188
196
 
197
+ /**
198
+ * Read the denormalized entitlements cache for a (provider, subject_id). This is the per-request
199
+ * hot-path read: the proxy already opens this UserDO each request, so no extra DO hop is needed.
200
+ * Returns null when there is no cached entry.
201
+ */
202
+ async getEntitlements(provider: string, subject_id: string): Promise<{ data: any; checked_at: number } | null> {
203
+ const result = this.sql.exec(
204
+ 'SELECT data, checked_at FROM entitlements_cache WHERE provider = ? AND subject_id = ?',
205
+ provider,
206
+ subject_id,
207
+ );
208
+ const row = result.next().value as any;
209
+ if (!row) return null;
210
+ return { data: row.data ? JSON.parse(row.data) : null, checked_at: row.checked_at };
211
+ }
212
+
213
+ /** Write-through update of the entitlements cache after a refresh (login / TTL / cron / webhook). */
214
+ async setEntitlements(provider: string, subject_id: string, data: Record<string, any>, checked_at: number): Promise<{ success: boolean }> {
215
+ this.sql.exec(
216
+ 'INSERT OR REPLACE INTO entitlements_cache (provider, subject_id, data, checked_at) VALUES (?, ?, ?, ?)',
217
+ provider,
218
+ subject_id,
219
+ JSON.stringify(data),
220
+ checked_at,
221
+ );
222
+ return { success: true };
223
+ }
224
+
189
225
  async listCredentials(): Promise<any[]> {
190
226
  const credentialsMapping = this.sql.exec('SELECT DISTINCT provider FROM user_credentials');
191
227
  const credentials = [];