diff --git a/README.md b/README.md index e134730..7cc1563 100644 --- a/README.md +++ b/README.md @@ -261,14 +261,30 @@ For non-`bypass` paths the worker forwards `X-StartupAPI-Authenticated`, `X-Star ### Keeping entitlements fresh -Entitlements are fetched once at login. Each provider can additionally opt into freshness mechanisms in its factory config (all off by default — if none are enabled, entitlements are only checked at login): +Entitlements are fetched at login and then kept fresh per-provider via three mechanisms. **Durations** below are a named-unit object (`{ days: 1 }`, `{ minutes: 15 }` — units are summed) or a plain number of milliseconds. -- **TTL** — lazily re-check on the request path when older than the TTL (`freshness.ttl: { ms }`, default 15 min), using the OAuth refresh token. -- **Cron** — a scheduled re-sync of all of a provider's credentials (`freshness.cron: { schedule }`). The `scheduled()` handler is only present when at least one provider enables cron; you must also add a matching `triggers.crons` to your wrangler config. -- **Webhook** (Patreon only) — set `freshness.webhook: true`, provide `PATREON_WEBHOOK_SECRET` (a secret, in env), and point a Patreon webhook at `/users/webhooks/patreon` (signature verified with HMAC-MD5). +- **TTL** (`entitlementTtl`) — lazily re-check on the request path when the cache is older than the duration, using the OAuth refresh token. **On by default** for entitlement providers: default **1 day for Patreon** (memberships change slowly; this backstops missed webhooks) and 15 min for other providers. Pass a Duration to tune it (`entitlementTtl: { hours: 6 }`), or `entitlementTtl: false` to disable (e.g. rely on the webhook only). +- **Cron** (`entitlementCron: { schedule }`) — a scheduled re-sync of all of a provider's credentials. Off by default. The `scheduled()` handler is only present when at least one provider enables cron; you must also add a matching `triggers.crons` to your wrangler config. +- **Webhook** (Patreon only, `entitlementWebhook: true`) — off by default. Provide `PATREON_WEBHOOK_SECRET` (a secret, in env) and point a Patreon webhook at `/users/webhooks/patreon` (signature verified with HMAC-MD5). Set `providers.patreon.campaignId` to disambiguate when a user belongs to multiple campaigns. +### Login session lifetime + +Login sessions are **separate** from entitlement freshness: how long a user stays logged in (authentication) is independent of how often their paid status is re-checked (authorization). A user can stay logged in for weeks while their entitlements are refreshed every few minutes via TTL/webhook — and a churned subscriber still loses access within the entitlement window regardless of session validity. + +- **Default lifetime is 30 days** with a **rolling window**: on activity, once less than half the window remains, the session expiry is pushed forward and the `session_id` cookie's `Max-Age` is refreshed. Active users effectively never have to re-login; idle users expire after the window. +- The `session_id` cookie is a **persistent cookie** (`Max-Age`), so it survives browser restarts. +- Configure the window via the factory (`session.ttl` is a Duration): + +```ts +const api = createStartupAPI({ + session: { ttl: { days: 30 } }, // default; lower/raise as needed (or a number of ms) +}); +``` + +> **Breaking in 0.5.0:** entitlement freshness moved from the `freshness: { ttl, cron, webhook }` block to flat per-provider keys `entitlementTtl` / `entitlementCron` / `entitlementWebhook`; `entitlementTtl` is now **on by default** (1 day for Patreon) and takes a Duration or `false`. The default session lifetime went from a fixed 24h (no renewal) to a **rolling 30 days**, and `session_id` is now a **persistent cookie**. + ### Configuring via the factory Environment variables hold only credentials/secrets and the per-deployment values (`ORIGIN_URL`, `AUTH_ORIGIN`, `USERS_PATH`, `ADMIN_IDS`, `ENVIRONMENT`). Everything else — provider scopes, Patreon campaign id, the access policy, and entitlement freshness — is passed to `createStartupAPI(config)`. The plain re-export still works with defaults: @@ -287,9 +303,13 @@ const api = createStartupAPI({ patreon: { scopes: 'identity.memberships', campaignId: '', - freshness: { ttl: true, cron: { schedule: '0 */6 * * *' }, webhook: true }, + // entitlementTtl is on by default (1 day for Patreon); tune with a Duration or set false to disable. + entitlementTtl: { hours: 12 }, + entitlementCron: { schedule: '0 */6 * * *' }, + entitlementWebhook: true, }, }, + session: { ttl: { days: 30 } }, // optional; login session lifetime (default 30d, rolling) accessPolicy: { rules: [ /* ... */ diff --git a/package.json b/package.json index 6b13852..612807a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@startup-api/cloudflare", - "version": "0.4.5", + "version": "0.5.0", "license": "Apache-2.0", "publishConfig": { "access": "public" diff --git a/src/auth/OAuthProvider.ts b/src/auth/OAuthProvider.ts index 82e2115..f11a3c2 100644 --- a/src/auth/OAuthProvider.ts +++ b/src/auth/OAuthProvider.ts @@ -38,6 +38,8 @@ export interface AuthContext { /** Effective origin (AUTH_ORIGIN override or request origin). */ origin: string; cookieManager: CookieManager; + /** Login session lifetime in ms, resolved from factory config. */ + sessionTtlMs: number; } /** diff --git a/src/auth/index.ts b/src/auth/index.ts index 78acace..99b5986 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,6 +1,8 @@ import type { StartupAPIEnv } from '../StartupAPIEnv'; import { CookieManager } from '../CookieManager'; +import { DEFAULT_SESSION_TTL_MS } from '../schemas/config'; +import { sessionSetCookie } from '../handlers/utils'; import { refreshEntitlements } from '../entitlements/service'; import { renderAuthError } from './errorPage'; import { computeRedirectBase, createProviders } from './providers'; @@ -14,6 +16,7 @@ export async function handleAuth( usersPath: string, cookieManager: CookieManager, providerConfigs: ProviderConfigs = {}, + sessionTtlMs: number = DEFAULT_SESSION_TTL_MS, ): Promise { const path = url.pathname; const origin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin; @@ -27,7 +30,7 @@ export async function handleAuth( // Instantiate active providers const activeProviders = createProviders(env, redirectBase, providerConfigs); - const ctx: AuthContext = { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager }; + const ctx: AuthContext = { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager, sessionTtlMs }; // Provider-specific auxiliary routes (e.g. the atproto client-metadata document). for (const provider of activeProviders) { @@ -70,7 +73,7 @@ export async function handleAuth( * transient flow state). */ async function finishLogin(provider: OAuthProvider, result: ExchangeResult, ctx: AuthContext): Promise { - const { env, request, usersPath, origin, cookieManager } = ctx; + const { env, request, usersPath, origin, cookieManager, sessionTtlMs } = ctx; const { token, profile, returnUrl } = result; const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); @@ -225,12 +228,12 @@ async function finishLogin(provider: OAuthProvider, result: ExchangeResult, ctx: } // Create Session - const session = await userStub.createSession({ provider: provider.name }); + const session = await userStub.createSession({ provider: provider.name }, sessionTtlMs); - // Set cookie and redirect + // Set cookie and redirect (persistent cookie so the session survives browser restarts) const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`); const headers = new Headers(); - headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`); + headers.set('Set-Cookie', sessionSetCookie(encryptedSession, Math.floor(sessionTtlMs / 1000))); for (const cookie of result.setCookies ?? []) { headers.append('Set-Cookie', cookie); } diff --git a/src/createStartupAPI.ts b/src/createStartupAPI.ts index 36b851b..e991148 100644 --- a/src/createStartupAPI.ts +++ b/src/createStartupAPI.ts @@ -7,7 +7,7 @@ import { CredentialDO } from './storage/CredentialDO'; import { CookieManager } from './CookieManager'; import { initPlans } from './billing/plansConfig'; import { Plan } from './billing/Plan'; -import { getActiveProviders, parseCookies, getUserFromSession, isAdmin } from './handlers/utils'; +import { getActiveProviders, parseCookies, getUserFromSession, isAdmin, applySessionRenewal, sessionSetCookie } from './handlers/utils'; import { handleAdmin } from './handlers/admin'; import { handleMe, @@ -22,7 +22,7 @@ import { handleLogout } from './handlers/auth'; import { handleSSR } from './handlers/ssr'; import type { StartupAPIEnv } from './StartupAPIEnv'; -import { StartupAPIConfigSchema } from './schemas/config'; +import { StartupAPIConfigSchema, DEFAULT_SESSION_TTL_MS, durationToMs } from './schemas/config'; import type { StartupAPIConfig, ProviderOptions, ResolvedFreshness } from './schemas/config'; import type { AccessPolicyConfig, PageSource } from './schemas/policy'; import { AccessPolicy, evaluateAccess } from './policy/accessPolicy'; @@ -36,6 +36,15 @@ import { handlePatreonWebhook } from './webhooks/patreon'; const DEFAULT_USERS_PATH = '/users/'; const DEFAULT_CRON_SCHEDULE = '0 */6 * * *'; const DEFAULT_ENTITLEMENT_TTL_MS = 15 * 60 * 1000; +// Patreon memberships change slowly (pledges are monthly) and real-time changes are already covered by +// the webhook, so a per-request re-check every 15 min is wasteful. Default Patreon's TTL to 1 day; it +// acts as a backstop for missed webhooks rather than the primary freshness mechanism. +const DEFAULT_PATREON_ENTITLEMENT_TTL_MS = 24 * 60 * 60 * 1000; + +/** Provider-specific default entitlement TTL, used when `entitlementTtl` is on but no interval is given. */ +function defaultEntitlementTtlMs(providerName?: string): number { + return providerName === 'patreon' ? DEFAULT_PATREON_ENTITLEMENT_TTL_MS : DEFAULT_ENTITLEMENT_TTL_MS; +} // The factory's request handler is a local `const fetch`, which would shadow the global fetch inside // its body. Use this alias to proxy to the origin via the *current* global fetch at call time (so test @@ -43,24 +52,38 @@ const DEFAULT_ENTITLEMENT_TTL_MS = 15 * 60 * 1000; const originFetch = (...args: Parameters): Promise => globalThis.fetch(...args); function isCronEnabled(options: ProviderOptions): boolean { - const cron = options.freshness?.cron; + const cron = options.entitlementCron; return cron === true || (typeof cron === 'object' && cron !== null); } -/** Resolve a provider's freshness config into concrete flags/values. */ -function resolveFreshness(options: ProviderOptions | undefined): ResolvedFreshness { - const f = options?.freshness ?? {}; - const ttlEnabled = f.ttl === true || (typeof f.ttl === 'object' && f.ttl !== null); - const ttlMs = typeof f.ttl === 'object' && f.ttl?.ms ? f.ttl.ms : DEFAULT_ENTITLEMENT_TTL_MS; +/** Resolve a provider's entitlement freshness config into concrete flags/values. */ +function resolveFreshness(options: ProviderOptions | undefined, providerName?: string): ResolvedFreshness { + // entitlementTtl is ON by default; only an explicit `false` disables it. A provided Duration sets the + // interval, otherwise fall back to the provider default (1 day for Patreon, 15 min otherwise). + const ttlRaw = options?.entitlementTtl; + const ttlEnabled = ttlRaw !== false; + const ttlFromConfig = ttlRaw !== undefined && ttlRaw !== false ? durationToMs(ttlRaw) : 0; + const ttlMs = ttlFromConfig > 0 ? ttlFromConfig : defaultEntitlementTtlMs(providerName); + const cron = options?.entitlementCron; const cronEnabled = isCronEnabled(options ?? {}); - const cronSchedule = typeof f.cron === 'object' && f.cron?.schedule ? f.cron.schedule : DEFAULT_CRON_SCHEDULE; + const cronSchedule = typeof cron === 'object' && cron?.schedule ? cron.schedule : DEFAULT_CRON_SCHEDULE; return { ttl: { enabled: ttlEnabled, ms: ttlMs }, cron: { enabled: cronEnabled, schedule: cronSchedule }, - webhook: { enabled: f.webhook === true }, + webhook: { enabled: options?.entitlementWebhook === true }, }; } +/** Resolve the login session lifetime (rolling window, ms) from factory config, else the 30-day default. */ +function resolveSessionTtlMs(session: StartupAPIConfig['session']): number { + const ttl = session?.ttl; + if (ttl !== undefined) { + const ms = durationToMs(ttl); + if (ms > 0) return ms; + } + return DEFAULT_SESSION_TTL_MS; +} + /** Resolve the access policy from factory config, else a backward-compatible all-public default. */ function resolveAccessPolicy(configPolicy: AccessPolicyConfig | undefined): AccessPolicyConfig { // No policy configured → preserve legacy behavior: allow everything, still forward identity headers. @@ -130,11 +153,12 @@ function denyResponse( export function createStartupAPI(config: StartupAPIConfig = {}) { const parsed = StartupAPIConfigSchema.parse(config); const providerConfigs = parsed.providers ?? {}; + const sessionTtlMs = resolveSessionTtlMs(parsed.session); const cronProviders = Object.entries(providerConfigs) .filter(([, options]) => isCronEnabled(options)) .map(([name]) => name); const anyCron = cronProviders.length > 0; - const patreonWebhookEnabled = providerConfigs.patreon?.freshness?.webhook === true; + const patreonWebhookEnabled = providerConfigs.patreon?.entitlementWebhook === true; const fetch = async (request: Request, env: StartupAPIEnv, ctx: ExecutionContext): Promise => { if (!Plan.isInitialized()) { @@ -178,7 +202,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { // Handle OAuth Routes if (url.pathname.startsWith(usersPath + 'auth/')) { - return handleAuth(request, env, url, usersPath, cookieManager, providerConfigs); + return handleAuth(request, env, url, usersPath, cookieManager, providerConfigs, sessionTtlMs); } if (url.pathname === usersPath + 'me/avatar') { @@ -221,7 +245,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { const headers = new Headers(); const newSessionIdEncrypted = await cookieManager.encrypt(backupSession); - headers.set('Set-Cookie', `session_id=${newSessionIdEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`); + headers.set('Set-Cookie', sessionSetCookie(newSessionIdEncrypted, Math.floor(sessionTtlMs / 1000))); headers.append('Set-Cookie', `backup_session_id=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`); return Response.json({ success: true }, { headers }); @@ -262,7 +286,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { // Admin Routes if (url.pathname.startsWith(usersPath + 'admin/')) { - return handleAdmin(request, env, usersPath, cookieManager, providerConfigs); + return handleAdmin(request, env, usersPath, cookieManager, providerConfigs, sessionTtlMs); } // Intercept requests to usersPath and serve them from the public/users directory. @@ -292,7 +316,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { return originFetch(newRequest); } - const user = await getUserFromSession(request, env, cookieManager); + const user = await getUserFromSession(request, env, cookieManager, sessionTtlMs); const authenticated = !!user; const userIsAdmin = user ? isAdmin(user, env) : false; let entitlements: Entitlements | null = null; @@ -311,7 +335,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { if (loginProvider && subjectId) { const provider = getProvider(env, computeRedirectBase(env, requestOrigin, usersPath), loginProvider, providerConfigs); if (provider && provider.supportsEntitlements()) { - const fr = resolveFreshness(providerConfigs[loginProvider]); + const fr = resolveFreshness(providerConfigs[loginProvider], loginProvider); entitlements = await loadEntitlements({ env, provider, @@ -346,7 +370,9 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { const response = await originFetch(newRequest); const providers = getActiveProviders(env, providerConfigs); - return injectPowerStrip(response, usersPath, providers); + // Refresh the persistent session cookie's Max-Age when the DO extended the session (sliding renewal). + const decorated = await injectPowerStrip(response, usersPath, providers); + return applySessionRenewal(decorated, user?.renew); } // do not modify the request as it will loop through the same worker again diff --git a/src/handlers/admin.ts b/src/handlers/admin.ts index 01c4212..4d496c2 100644 --- a/src/handlers/admin.ts +++ b/src/handlers/admin.ts @@ -1,7 +1,8 @@ import { StartupAPIEnv } from '../StartupAPIEnv'; import { CookieManager } from '../CookieManager'; -import { getUserFromSession, checkAndClearStaleSession, isAdmin, parseCookies, getActiveProviders } from './utils'; +import { getUserFromSession, checkAndClearStaleSession, isAdmin, parseCookies, getActiveProviders, sessionSetCookie } from './utils'; import type { ProviderConfigs } from '../auth/providers'; +import { DEFAULT_SESSION_TTL_MS } from '../schemas/config'; import { Plan } from '../billing/Plan'; import { UserProfileSchema } from '../schemas/user'; import { SystemAccountSchema, MemberSchema } from '../schemas/account'; @@ -13,6 +14,7 @@ export async function handleAdmin( usersPath: string, cookieManager: CookieManager, providerConfigs: ProviderConfigs = {}, + sessionTtlMs: number = DEFAULT_SESSION_TTL_MS, ): Promise { const user = await getUserFromSession(request, env, cookieManager); if (!user || !isAdmin(user, env)) { @@ -113,7 +115,7 @@ export async function handleAdmin( const userDOId = env.USER.idFromString(user_id); const userStub = env.USER.get(userDOId); - const session = await userStub.createSession({ provider: 'admin-impersonation', impersonator: user.id }); + const session = await userStub.createSession({ provider: 'admin-impersonation', impersonator: user.id }, sessionTtlMs); const cookieHeader = request.headers.get('Cookie'); const cookies = parseCookies(cookieHeader || ''); @@ -121,7 +123,7 @@ export async function handleAdmin( const headers = new Headers(); const newSessionIdEncrypted = await cookieManager.encrypt(`${session.sessionId}:${user_id}`); - headers.set('Set-Cookie', `session_id=${newSessionIdEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`); + headers.set('Set-Cookie', sessionSetCookie(newSessionIdEncrypted, Math.floor(sessionTtlMs / 1000))); if (currentSessionEncrypted) { const backupSession = await cookieManager.decrypt(currentSessionEncrypted); if (backupSession) { diff --git a/src/handlers/utils.ts b/src/handlers/utils.ts index bf9da73..4960737 100644 --- a/src/handlers/utils.ts +++ b/src/handlers/utils.ts @@ -53,7 +53,20 @@ export function parseCookies(cookieHeader: string): Record { ); } -export async function getUserFromSession(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { +/** + * Build the `Set-Cookie` string for the encrypted `session_id`. A positive `ttlSeconds` makes it a + * persistent cookie (survives browser restarts); pass 0 via the clear paths to expire it. + */ +export function sessionSetCookie(value: string, ttlSeconds: number): string { + return `session_id=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${ttlSeconds}`; +} + +export async function getUserFromSession( + request: Request, + env: StartupAPIEnv, + cookieManager: CookieManager, + ttlMs?: number, +): Promise { const cookieHeader = request.headers.get('Cookie'); if (!cookieHeader) return null; @@ -68,14 +81,36 @@ export async function getUserFromSession(request: Request, env: StartupAPIEnv, c try { const id = env.USER.idFromString(doId); const userStub = env.USER.get(id); - const result = await userStub.validateSession(sessionId); - if (result.valid) return { id: doId, sessionId, profile: result.profile, credential: result.credential }; + const result = await userStub.validateSession(sessionId, ttlMs); + if (result.valid) { + const user: any = { id: doId, sessionId, profile: result.profile, credential: result.credential }; + // If the DO extended the session, surface enough to re-issue the persistent cookie on the response. + if (result.renewedExpiresAt && ttlMs) { + user.renew = { encryptedValue: sessionCookieEncrypted, ttlMs }; + } + return user; + } } catch (_e) { // ignore } return null; } +/** + * Re-issue the `session_id` cookie with a fresh `Max-Age` when the DO extended the session (sliding + * renewal). The encrypted value is unchanged — only the cookie's lifetime is refreshed. Mirrors the + * response-cloning shape of `checkAndClearStaleSession`. + */ +export function applySessionRenewal(response: Response, renew: { encryptedValue: string; ttlMs: number } | undefined): Response { + if (!renew) return response; + const headers = new Headers(response.headers); + headers.append('Set-Cookie', sessionSetCookie(renew.encryptedValue, Math.floor(renew.ttlMs / 1000))); + if (response.status === 301 || response.status === 302) { + return new Response(null, { status: response.status, headers }); + } + return new Response(response.body, { status: response.status, headers }); +} + export async function checkAndClearStaleSession( request: Request, env: StartupAPIEnv, @@ -105,7 +140,7 @@ export async function checkAndClearStaleSession( await userStub.deleteSession(sessionId); const headers = new Headers(originalResponse.headers); - headers.set('Set-Cookie', 'session_id=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'); + headers.set('Set-Cookie', sessionSetCookie('', 0)); // If it was a redirect, we just update the headers if (originalResponse.status === 301 || originalResponse.status === 302) { diff --git a/src/schemas/config.ts b/src/schemas/config.ts index 87235e2..fa12668 100644 --- a/src/schemas/config.ts +++ b/src/schemas/config.ts @@ -4,22 +4,40 @@ import { AccessPolicySchema } from './policy'; /** * Zod schema + types for the StartupAPI configuration factory (see src/createStartupAPI.ts). * - * Only non-secret behavior lives here — provider enablement, per-provider freshness toggles, access - * policy and plans. Credentials/secrets stay in env. Every field is optional and falls back to + * Only non-secret behavior lives here — provider enablement, entitlement freshness, session lifetime, + * access policy and plans. Credentials/secrets stay in env. Every field is optional and falls back to * env-derived defaults, so `createStartupAPI()` with no config behaves like the previous package. */ -const TtlFreshnessSchema = z.union([z.boolean(), z.object({ ms: z.number().positive().optional() })]); -const CronFreshnessSchema = z.union([z.boolean(), z.object({ schedule: z.string().optional() })]); +/** Default login session lifetime (rolling window) when no `session.ttl` is configured. */ +export const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days -export const ProviderFreshnessSchema = z.object({ - /** Lazily re-check entitlements on the request hot path when older than the TTL. Off by default. */ - ttl: TtlFreshnessSchema.optional(), - /** Periodically re-sync entitlements via a scheduled() handler. Off by default. */ - cron: CronFreshnessSchema.optional(), - /** Update entitlements from provider webhooks (Patreon only). Off by default. */ - webhook: z.boolean().optional(), -}); +/** + * A human-friendly duration. Either a named-unit object (`{ days: 30 }`, `{ minutes: 15 }`, units are + * summed) or a plain number of milliseconds (`86400000`). Use `durationToMs()` to normalize. + */ +export const DurationSchema = z.union([ + z.number().nonnegative(), + z + .object({ + days: z.number().nonnegative().optional(), + hours: z.number().nonnegative().optional(), + minutes: z.number().nonnegative().optional(), + seconds: z.number().nonnegative().optional(), + ms: z.number().nonnegative().optional(), + }) + .strict(), +]); +export type Duration = z.infer; + +/** Normalize a Duration to milliseconds. A bare number is treated as ms; object units are summed. */ +export function durationToMs(d: Duration): number { + if (typeof d === 'number') return d; + return (d.days ?? 0) * 86400000 + (d.hours ?? 0) * 3600000 + (d.minutes ?? 0) * 60000 + (d.seconds ?? 0) * 1000 + (d.ms ?? 0); +} + +/** Periodic re-sync of entitlements via a scheduled() handler. `true`/`{ schedule }` on, off by default. */ +const EntitlementCronSchema = z.union([z.boolean(), z.object({ schedule: z.string().optional() })]); export const ProviderOptionsSchema = z.object({ /** Force-enable/disable the provider. Default: enabled iff its credentials are present in env. */ @@ -34,21 +52,39 @@ export const ProviderOptionsSchema = z.object({ plcUrl: z.string().optional(), /** atproto only: override the DNS-over-HTTPS resolver used for handle resolution. */ dohUrl: z.string().optional(), - freshness: ProviderFreshnessSchema.optional(), + /** + * Lazily re-check entitlements on the request hot path when the cache is older than this duration. + * ON by default for entitlement providers — default 1 day for Patreon, 15 min otherwise. Pass a + * Duration to tune the interval, or `false` to disable (e.g. rely on the webhook only). + */ + entitlementTtl: z.union([DurationSchema, z.literal(false)]).optional(), + /** Update entitlements from provider webhooks (Patreon only). Off by default. */ + entitlementWebhook: z.boolean().optional(), + /** Periodically re-sync all of this provider's entitlements via a scheduled() handler. Off by default. */ + entitlementCron: EntitlementCronSchema.optional(), +}); + +export const SessionConfigSchema = z.object({ + /** + * Login session lifetime as a Duration (default 30 days). The session is a rolling window — renewed + * on activity once less than half remains. Independent of entitlement freshness. + */ + ttl: DurationSchema.optional(), }); export const StartupAPIConfigSchema = z.object({ providers: z.record(z.string(), ProviderOptionsSchema).optional(), accessPolicy: AccessPolicySchema.optional(), + session: SessionConfigSchema.optional(), // Plans are validated by the billing layer; accept an array passthrough here. plans: z.array(z.any()).optional(), }); -export type ProviderFreshness = z.infer; export type ProviderOptions = z.infer; +export type SessionConfig = z.infer; export type StartupAPIConfig = z.input; -/** Normalized per-provider freshness after resolving boolean/object forms and env fallbacks. */ +/** Normalized per-provider entitlement freshness after resolving the config forms and defaults. */ export interface ResolvedFreshness { ttl: { enabled: boolean; ms: number }; cron: { enabled: boolean; schedule: string }; diff --git a/src/storage/UserDO.ts b/src/storage/UserDO.ts index d867289..1ac1de6 100644 --- a/src/storage/UserDO.ts +++ b/src/storage/UserDO.ts @@ -3,6 +3,11 @@ import { StartupAPIEnv } from '../StartupAPIEnv'; import { UserProfileSchema } from '../schemas/user'; import type { UserProfile } from '../schemas/user'; +/** Safety fallback if a caller does not pass an explicit TTL. Real callers pass the factory-resolved value. */ +const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; +/** Renew (extend) a session once less than this fraction of its TTL window remains. */ +const SESSION_RENEW_THRESHOLD = 0.5; + /** * A Durable Object representing a User. * This class handles the storage and management of user profiles, @@ -73,7 +78,8 @@ export class UserDO extends DurableObject { */ async validateSession( sessionId: string, - ): Promise<{ valid: boolean; profile?: UserProfile; credential?: Record; error?: string }> { + ttlMs?: number, + ): Promise<{ valid: boolean; profile?: UserProfile; credential?: Record; error?: string; renewedExpiresAt?: number }> { try { // Check session const sessionResult = this.sql.exec('SELECT * FROM sessions WHERE id = ?', sessionId); @@ -83,10 +89,19 @@ export class UserDO extends DurableObject { return { valid: false }; } - if (session.expires_at < Date.now()) { + const now = Date.now(); + if (session.expires_at < now) { return { valid: false, error: 'Expired' }; } + // Sliding renewal: once less than half the window remains, push the expiry forward. A bump + // resets "remaining" to the full window, so this writes at most once per ~half-TTL of activity. + let renewedExpiresAt: number | undefined; + if (ttlMs && now > session.expires_at - ttlMs * SESSION_RENEW_THRESHOLD) { + renewedExpiresAt = now + ttlMs; + this.sql.exec('UPDATE sessions SET expires_at = ? WHERE id = ?', renewedExpiresAt, sessionId); + } + // Get profile data from local 'profile' table const profile = await this.getProfile(); @@ -115,7 +130,7 @@ export class UserDO extends DurableObject { // Ensure the ID is set profile.id = this.ctx.id.toString(); - return { valid: true, profile, credential }; + return { valid: true, profile, credential, renewedExpiresAt }; } catch (_e) { return { valid: false }; } @@ -261,16 +276,17 @@ export class UserDO extends DurableObject { /** * Creates a new login session for the user. - * Generates a random session ID and sets a 24-hour expiration. + * Generates a random session ID and sets an expiration `ttlMs` from now. * * @param meta - Optional metadata to store with the session. + * @param ttlMs - Session lifetime in milliseconds. Defaults to a 24h safety fallback; callers pass the resolved value. * @returns A Promise resolving to a JSON response with the session ID and expiration time. */ - async createSession(meta?: Record): Promise<{ sessionId: string; expiresAt: number }> { + async createSession(meta?: Record, ttlMs: number = DEFAULT_SESSION_TTL_MS): Promise<{ sessionId: string; expiresAt: number }> { // Basic session creation const sessionId = crypto.randomUUID(); const now = Date.now(); - const expiresAt = now + 24 * 60 * 60 * 1000; // 24 hours + const expiresAt = now + ttlMs; this.sql.exec( 'INSERT INTO sessions (id, created_at, expires_at, meta) VALUES (?, ?, ?, ?)', diff --git a/test/entitlement_integration.spec.ts b/test/entitlement_integration.spec.ts index bde9cb4..9b5364b 100644 --- a/test/entitlement_integration.spec.ts +++ b/test/entitlement_integration.spec.ts @@ -133,6 +133,50 @@ describe('Entitlement headers + access policy', () => { } }); + it('defaults Patreon entitlement TTL to 1 day, on by default (a ~1h-old cache is not re-fetched)', async () => { + // No freshness config at all — entitlementTtl is ON by default at 1 day for Patreon. A cache checked + // 1h ago is still fresh, so the request path must NOT hit www.patreon.com to refresh. + const api = createStartupAPI({ accessPolicy: POLICY }); + + const userId = env.USER.newUniqueId(); + const userStub = env.USER.get(userId); + const userIdStr = userId.toString(); + const subjectId = 'patreon-' + userIdStr.slice(0, 10); + await userStub.addMembership(env.ACCOUNT.newUniqueId().toString(), 1, true); + await userStub.addCredential('patreon', subjectId); + const { sessionId } = await userStub.createSession({ provider: 'patreon' }); + + const checkedAt = Date.now() - 60 * 60 * 1000; // 1 hour ago + await userStub.setEntitlements( + 'patreon', + subjectId, + { + provider: 'patreon', + checked_at: checkedAt, + source: 'oauth', + patreon: { patron_status: 'active_patron', is_active_patron: true, entitled_tier_ids: ['t1'], entitled_benefit_ids: ['benefit-vip'], pledge_amount_cents: 500 }, + }, + checkedAt, + ); + const cookie = await cookieManager.encrypt(`${sessionId}:${userIdStr}`); + + const calls: string[] = []; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + calls.push(url); + return new Response('origin', { status: 200, headers: { 'Content-Type': 'text/html' } }) as any; + }); + try { + const ctx = createExecutionContext(); + const res = await api.fetch(new Request('http://example.com/special', { headers: { Cookie: `session_id=${cookie}` } }), env, ctx); + await waitOnExecutionContext(ctx); + expect(res.status).toBe(200); // still entitled from the fresh cache + expect(calls.some((u) => u.includes('patreon.com'))).toBe(false); // no refresh under the 1-day default + } finally { + fetchSpy.mockRestore(); + } + }); + it('bypass path forwards with NO X-StartupAPI headers and no power-strip injection, even when logged in', async () => { const cookie = await createPatreonUser(['benefit-vip']); const fetchSpy = spyOrigin(); diff --git a/test/factory.spec.ts b/test/factory.spec.ts index c85fe9e..f8e4e0f 100644 --- a/test/factory.spec.ts +++ b/test/factory.spec.ts @@ -1,8 +1,26 @@ import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { createStartupAPI } from '../src/createStartupAPI'; +import { CookieManager } from '../src/CookieManager'; +import { durationToMs } from '../src/schemas/config'; import { hmacMd5Hex } from '../src/webhooks/md5hmac'; +describe('durationToMs', () => { + it('treats a bare number as milliseconds', () => { + expect(durationToMs(86400000)).toBe(86400000); + expect(durationToMs(0)).toBe(0); + }); + + it('sums named units', () => { + expect(durationToMs({ days: 30 })).toBe(30 * 24 * 60 * 60 * 1000); + expect(durationToMs({ minutes: 15 })).toBe(15 * 60 * 1000); + expect(durationToMs({ hours: 1, minutes: 30 })).toBe(90 * 60 * 1000); + expect(durationToMs({ days: 1, hours: 2, minutes: 3, seconds: 4, ms: 5 })).toBe( + 1 * 86400000 + 2 * 3600000 + 3 * 60000 + 4 * 1000 + 5, + ); + }); +}); + describe('createStartupAPI factory', () => { it('returns the Worker handler and all Durable Object classes', () => { const api = createStartupAPI(); @@ -20,7 +38,7 @@ describe('createStartupAPI factory', () => { }); it('attaches scheduled() when a provider enables cron', () => { - const api = createStartupAPI({ providers: { patreon: { freshness: { cron: { schedule: '0 */6 * * *' } } } } }); + const api = createStartupAPI({ providers: { patreon: { entitlementCron: { schedule: '0 */6 * * *' } } } }); expect(typeof api.scheduled).toBe('function'); expect(typeof api.default.scheduled).toBe('function'); }); @@ -40,11 +58,48 @@ describe('createStartupAPI factory', () => { AccessPolicy.reset(); }); + describe('session lifetime config', () => { + const cookieManager = new CookieManager(env.SESSION_SECRET); + + // Drive a configured instance directly and return the renewal Set-Cookie (if any) from the proxy path. + async function proxyRenewalCookie(config: any, remainingMs: number): Promise { + const api = createStartupAPI(config); + const userId = env.USER.newUniqueId(); + const userStub = env.USER.get(userId); + // Seed a session whose remaining life is `remainingMs`. + const { sessionId } = await userStub.createSession({ provider: 'test' }, remainingMs); + const cookie = await cookieManager.encrypt(`${sessionId}:${userId.toString()}`); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('OK', { status: 200 })); + try { + const ctx = createExecutionContext(); + const res = await api.fetch(new Request('http://example.com/page', { headers: { Cookie: `session_id=${cookie}` } }), env, ctx); + await waitOnExecutionContext(ctx); + return res.headers.get('Set-Cookie'); + } finally { + fetchSpy.mockRestore(); + } + } + + it('renews with a custom session ttl (named units) reflected in Max-Age', async () => { + // Remaining 1h is below the 7d/2 threshold → renew with the custom 7-day Max-Age. + const setCookie = await proxyRenewalCookie({ session: { ttl: { days: 7 } } }, 60 * 60 * 1000); + expect(setCookie).toContain('session_id='); + expect(setCookie).toContain(`Max-Age=${7 * 24 * 60 * 60}`); // 604800 + }); + + it('defaults to a 30-day session when unconfigured', async () => { + // Default ttl 30d; remaining 1h is below 15d threshold → renew with the 30-day Max-Age. + const setCookie = await proxyRenewalCookie({}, 60 * 60 * 1000); + expect(setCookie).toContain('Max-Age=2592000'); // 30 days + }); + }); + describe('Patreon webhook mounting', () => { const webhookBody = JSON.stringify({ data: { relationships: { user: { data: { id: 'sub-unknown' } } } } }); it('verifies a valid signature and returns 200 when webhook is enabled', async () => { - const api = createStartupAPI({ providers: { patreon: { freshness: { webhook: true } } } }); + const api = createStartupAPI({ providers: { patreon: { entitlementWebhook: true } } }); const sig = hmacMd5Hex(env.PATREON_WEBHOOK_SECRET!, webhookBody); const ctx = createExecutionContext(); const res = await api.fetch( @@ -57,7 +112,7 @@ describe('createStartupAPI factory', () => { }); it('rejects an invalid signature with 401', async () => { - const api = createStartupAPI({ providers: { patreon: { freshness: { webhook: true } } } }); + const api = createStartupAPI({ providers: { patreon: { entitlementWebhook: true } } }); const ctx = createExecutionContext(); const res = await api.fetch( new Request('http://example.com/users/webhooks/patreon', { method: 'POST', body: webhookBody, headers: { 'X-Patreon-Signature': 'deadbeef' } }), diff --git a/test/headers.spec.ts b/test/headers.spec.ts index 47efd74..425f7dd 100644 --- a/test/headers.spec.ts +++ b/test/headers.spec.ts @@ -50,6 +50,63 @@ describe('Custom Headers Tests', () => { } }); + it('should re-issue a persistent session cookie when the session slides (renewal)', async () => { + const userId = env.USER.newUniqueId(); + const userStub = env.USER.get(userId); + const userIdStr = userId.toString(); + + // DO default ttl is 24h; SELF runs createStartupAPI() with the 30-day default, so 24h remaining + // is below the 15-day (half of 30d) renewal threshold → the proxy response must refresh the cookie. + const { sessionId } = await userStub.createSession(); + const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${userIdStr}`); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes('example.com/renew-page')) return new Response('OK', { status: 200 }); + return new Response('Not Found', { status: 404 }); + }); + + try { + const res = await SELF.fetch('http://example.com/renew-page', { + headers: { Cookie: `session_id=${encryptedCookie}` }, + }); + expect(res.status).toBe(200); + + const setCookie = res.headers.get('Set-Cookie'); + expect(setCookie).toContain(`session_id=${encryptedCookie}`); + expect(setCookie).toContain('Max-Age=2592000'); // 30 days in seconds + expect(setCookie).toContain('HttpOnly'); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('should NOT re-issue the session cookie when the session is well within its window', async () => { + const userId = env.USER.newUniqueId(); + const userStub = env.USER.get(userId); + const userIdStr = userId.toString(); + + // Fresh 30-day session: remaining ~= 30d, far above the 15-day threshold → no renewal cookie. + const { sessionId } = await userStub.createSession({ provider: 'test' }, 30 * 24 * 60 * 60 * 1000); + const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${userIdStr}`); + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes('example.com/fresh-page')) return new Response('OK', { status: 200 }); + return new Response('Not Found', { status: 404 }); + }); + + try { + const res = await SELF.fetch('http://example.com/fresh-page', { + headers: { Cookie: `session_id=${encryptedCookie}` }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('Set-Cookie')).toBeNull(); + } finally { + fetchSpy.mockRestore(); + } + }); + it('should NOT send X-StartupAPI headers if no session is present', async () => { // Mock global fetch const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { diff --git a/test/userdo.spec.ts b/test/userdo.spec.ts index 774f1e2..bd0f4b0 100644 --- a/test/userdo.spec.ts +++ b/test/userdo.spec.ts @@ -29,6 +29,49 @@ describe('UserDO Durable Object', () => { expect(data).toHaveProperty('expiresAt'); }); + it('should honor a custom session ttl', async () => { + const id = env.USER.newUniqueId(); + const stub = env.USER.get(id); + + const ttlMs = 7 * 24 * 60 * 60 * 1000; // 7 days + const before = Date.now(); + const data: any = await stub.createSession({ provider: 'test' }, ttlMs); + const after = Date.now(); + + // expiresAt should be ~ttlMs from now (allow scheduling slack). + expect(data.expiresAt).toBeGreaterThanOrEqual(before + ttlMs); + expect(data.expiresAt).toBeLessThanOrEqual(after + ttlMs + 1000); + }); + + it('should slide the session expiry when past the renewal threshold', async () => { + const id = env.USER.newUniqueId(); + const stub = env.USER.get(id); + + // Short window so we start already inside the renewal half-life: created with a tiny ttl, then + // validated with a much larger ttl. Since remaining (<=100ms) < largeTtl/2, it must renew. + const { sessionId, expiresAt } = (await stub.createSession({ provider: 'test' }, 100)) as any; + + const largeTtl = 30 * 24 * 60 * 60 * 1000; + const result: any = await stub.validateSession(sessionId, largeTtl); + + expect(result.valid).toBe(true); + expect(result.renewedExpiresAt).toBeDefined(); + expect(result.renewedExpiresAt).toBeGreaterThan(expiresAt); + }); + + it('should NOT slide the session expiry when well within the window', async () => { + const id = env.USER.newUniqueId(); + const stub = env.USER.get(id); + + // Fresh session with the same ttl used for validation: remaining ~= ttl, far above ttl/2 → no renew. + const ttlMs = 30 * 24 * 60 * 60 * 1000; + const { sessionId } = (await stub.createSession({ provider: 'test' }, ttlMs)) as any; + + const result: any = await stub.validateSession(sessionId, ttlMs); + expect(result.valid).toBe(true); + expect(result.renewedExpiresAt).toBeUndefined(); + }); + it('should delete session', async () => { const id = env.USER.newUniqueId(); const stub = env.USER.get(id);