Skip to content

Commit f842876

Browse files
Add config-driven 'gate' page handling to access policy (#460) (#101)
When a request is denied, the framework can now serve an explainer page in place (no redirect) instead of only redirecting to a single upgrade_url. The page varies by login state: - anonymous variant for logged-out visitors - unentitled variant for logged-in visitors who fail the requirement (falls back to anonymous when omitted) Each variant's body is sourced from either the ASSETS binding (a local file) or a path proxied from ORIGIN_URL, via the new PageSource union. Status defaults to 200 (explainer-page UX) and is overridable. All additions are optional, so existing forbidden/upgrade/login behavior is unchanged. Bumps the package 0.2.0 -> 0.3.0. Closes #460 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9741499 commit f842876

7 files changed

Lines changed: 281 additions & 13 deletions

File tree

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,34 @@ Configure an ordered list of rules (first match wins) mapping a path pattern to
160160
- **`authenticated`** — any logged-in user.
161161
- **`entitlement`** — a provider condition: Patreon `active_patron`, a specific `benefit` (perk) ID, or a `tier` ID.
162162

163-
Patterns are exact (`/special`), prefix (`/app/*`), or `/` (homepage only). Each rule's `on_unauthorized` is `login` (redirect to sign in), `forbidden` (403), or `upgrade` (redirect to `upgrade_url`, e.g. a Patreon join page). When no policy is configured at all, every path is treated as `public` (backward compatible).
163+
Patterns are exact (`/special`), prefix (`/app/*`), or `/` (homepage only). Each rule's `on_unauthorized` is `login` (redirect to sign in), `forbidden` (403), `upgrade` (redirect to `upgrade_url`, e.g. a Patreon join page), or `gate` (serve an explainer page **in place**, with no redirect — see below). When no policy is configured at all, every path is treated as `public` (backward compatible).
164+
165+
#### Serving a gate page in place (`on_unauthorized: 'gate'`)
166+
167+
Instead of redirecting, a denied request can serve an explainer page **at the requested URL** (no redirect, status `200` by default). The page shown depends on login state, so anonymous and logged-in-but-unentitled visitors can see different copy:
168+
169+
- **`anonymous`** (required) — shown to visitors who are **not** logged in (e.g. a "become a patron + log in" page).
170+
- **`unentitled`** (optional) — shown to logged-in visitors who fail the requirement (e.g. a "pledge/upgrade" page). Falls back to `anonymous` when omitted.
171+
- **`status`** (optional) — HTTP status for the served page; defaults to `200` to preserve typical explainer-page UX (set `403` if you prefer).
172+
173+
Each variant is a `PageSource` whose body comes from **either** the `ASSETS` binding **or** a path proxied from `ORIGIN_URL` — exactly one of:
174+
175+
- **`{ asset: '/early-access' }`** — a local file from `ASSETS` (resolved like other assets, `/early-access``early-access.html`).
176+
- **`{ origin: '/early-access' }`** — a path proxied from `ORIGIN_URL`. The path must be reachable directly on the origin (the raw site, not behind this worker).
177+
178+
The gate config is set per rule via `gate`, or on the policy default via `default_gate`. The served gate page is produced inside the deny path, so it is not re-subjected to the access policy and no power-strip is injected — the page is expected to carry its own login CTA. Because nothing redirects, there is no loop risk.
179+
180+
```ts
181+
const accessPolicy = {
182+
default: { mode: 'entitlement', provider: 'patreon', condition: { type: 'benefit', benefit_id: '<BENEFIT_ID>' } },
183+
default_on_unauthorized: 'gate',
184+
default_gate: {
185+
anonymous: { origin: '/early-access' }, // or { asset: '/early-access' }
186+
unentitled: { origin: '/pledge-needed' }, // or { asset: '/pledge-needed' }
187+
// status: 403, // optional; defaults to 200
188+
},
189+
};
190+
```
164191

165192
Admin users (those listed in `ADMIN_IDS`) bypass every `authenticated`/`entitlement` requirement and can reach any gated path. Their identity is still resolved and the usual identity/entitlement headers are forwarded to the origin — only the gate itself is skipped. (`bypass` paths remain a raw pass-through for everyone, with no identity resolution.)
166193

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@startup-api/cloudflare",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"license": "Apache-2.0",
55
"publishConfig": {
66
"access": "public"

src/createStartupAPI.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { handleSSR } from './handlers/ssr';
2424
import type { StartupAPIEnv } from './StartupAPIEnv';
2525
import { StartupAPIConfigSchema } from './schemas/config';
2626
import type { StartupAPIConfig, ProviderOptions, ResolvedFreshness } from './schemas/config';
27-
import type { AccessPolicyConfig } from './schemas/policy';
27+
import type { AccessPolicyConfig, PageSource } from './schemas/policy';
2828
import { AccessPolicy, evaluateAccess } from './policy/accessPolicy';
2929
import type { PolicyDecision } from './policy/accessPolicy';
3030
import { loadEntitlements, entitlementHeaders } from './entitlements/service';
@@ -67,11 +67,46 @@ function resolveAccessPolicy(configPolicy: AccessPolicyConfig | undefined): Acce
6767
return configPolicy ?? { default: { mode: 'public' } };
6868
}
6969

70-
/** Build a deny response (login redirect / 403 / upgrade redirect) for an unmet access requirement. */
70+
/**
71+
* Serve a gate page body in place (no redirect), sourced from either the ASSETS binding (a local file)
72+
* or a path proxied from ORIGIN_URL. The configured status is re-stamped onto the response so e.g. a
73+
* 200 asset can be served as a 403 gate.
74+
*/
75+
async function serveGatePage(
76+
source: PageSource,
77+
status: number,
78+
request: Request,
79+
env: StartupAPIEnv,
80+
reqUrl: URL,
81+
): Promise<Response> {
82+
let res: Response;
83+
if ('asset' in source) {
84+
// Serve a local file from ASSETS, mirroring the existing user-asset path.
85+
const assetReq = new Request(new URL(source.asset, reqUrl).toString(), { method: 'GET' });
86+
assetReq.headers.set('x-skip-worker', 'true');
87+
res = await env.ASSETS.fetch(assetReq);
88+
} else {
89+
// Proxy a path from ORIGIN_URL (swap host, set Host), like the main origin proxy.
90+
const target = new URL(source.origin, new URL(env.ORIGIN_URL));
91+
const proxied = new Request(target.toString(), request);
92+
proxied.headers.set('Host', target.host);
93+
res = await originFetch(proxied);
94+
}
95+
// Re-stamp the status (e.g. a 200 asset can be served as the configured gate status).
96+
return new Response(res.body, { status, headers: res.headers });
97+
}
98+
99+
/** Build a deny response (login redirect / 403 / upgrade redirect / in-place gate page) for an unmet access requirement. */
71100
function denyResponse(
72101
decision: Extract<PolicyDecision, { allow: false }>,
73-
ctx: { usersPath: string; returnUrl: string; activeProviders: string[] },
74-
): Response {
102+
ctx: { usersPath: string; returnUrl: string; activeProviders: string[]; authenticated: boolean; request: Request; env: StartupAPIEnv; url: URL },
103+
): Response | Promise<Response> {
104+
if (decision.action === 'gate' && decision.gate) {
105+
// Serve an explainer page in place: anonymous variant for logged-out visitors, unentitled variant
106+
// (falling back to anonymous) for logged-in visitors who fail the requirement. No redirect.
107+
const source = ctx.authenticated ? (decision.gate.unentitled ?? decision.gate.anonymous) : decision.gate.anonymous;
108+
return serveGatePage(source, decision.gate.status ?? 200, ctx.request, ctx.env, ctx.url);
109+
}
75110
if (decision.action === 'forbidden') {
76111
return new Response('Forbidden', { status: 403 });
77112
}
@@ -298,7 +333,15 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
298333
// Enforce the requirement. Admins bypass the gate (identity/headers above still apply).
299334
const decision = evaluateAccess(rule, { authenticated, entitlements, isAdmin: userIsAdmin });
300335
if (!decision.allow) {
301-
return denyResponse(decision, { usersPath, returnUrl, activeProviders: getActiveProviders(env) });
336+
return denyResponse(decision, {
337+
usersPath,
338+
returnUrl,
339+
activeProviders: getActiveProviders(env),
340+
authenticated,
341+
request,
342+
env,
343+
url,
344+
});
302345
}
303346

304347
const response = await originFetch(newRequest);

src/policy/accessPolicy.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AccessPolicySchema } from '../schemas/policy';
2-
import type { AccessPolicyConfig, AccessPolicyResolved, AccessRule, Requirement, UnauthorizedAction } from '../schemas/policy';
2+
import type { AccessPolicyConfig, AccessPolicyResolved, AccessRule, Gate, Requirement, UnauthorizedAction } from '../schemas/policy';
33
import type { Entitlements } from '../entitlements/types';
44
import { providerEntitlementCheckers, providerSupportsEntitlements } from './entitlementCheckers';
55

@@ -20,10 +20,10 @@ export function matchPattern(pattern: string, path: string): boolean {
2020

2121
export type PolicyDecision =
2222
| { allow: true }
23-
| { allow: false; reason: 'unauthenticated' | 'not_entitled'; action: UnauthorizedAction; upgrade_url?: string };
23+
| { allow: false; reason: 'unauthenticated' | 'not_entitled'; action: UnauthorizedAction; upgrade_url?: string; gate?: Gate };
2424

2525
function deny(reason: 'unauthenticated' | 'not_entitled', rule: AccessRule): PolicyDecision {
26-
return { allow: false, reason, action: rule.on_unauthorized, upgrade_url: rule.upgrade_url };
26+
return { allow: false, reason, action: rule.on_unauthorized, upgrade_url: rule.upgrade_url, gate: rule.gate };
2727
}
2828

2929
/**
@@ -101,6 +101,7 @@ export class AccessPolicy {
101101
requirement: cfg.default ?? { mode: 'authenticated' },
102102
on_unauthorized: cfg.default_on_unauthorized,
103103
upgrade_url: cfg.default_upgrade_url,
104+
gate: cfg.default_gate,
104105
};
105106
}
106107
}

src/schemas/policy.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,27 @@ export const RequirementSchema = z.discriminatedUnion('mode', [
3434
}),
3535
]);
3636

37-
export const UnauthorizedActionSchema = z.enum(['login', 'forbidden', 'upgrade']);
37+
/** Where a gate page body comes from. Exactly one of asset/origin. */
38+
export const PageSourceSchema = z.union([
39+
// Served from the ASSETS binding; path is resolved like other assets (`/foo` -> foo.html).
40+
z.object({ asset: z.string() }),
41+
// Proxied from this path on ORIGIN_URL.
42+
z.object({ origin: z.string() }),
43+
]);
44+
export type PageSource = z.infer<typeof PageSourceSchema>;
45+
46+
/** Page(s) served in place when a requirement is not met (on_unauthorized: 'gate'). */
47+
export const GateSchema = z.object({
48+
/** Shown to visitors who are NOT logged in. Required. */
49+
anonymous: PageSourceSchema,
50+
/** Shown to logged-in visitors who fail the requirement. Falls back to `anonymous` if omitted. */
51+
unentitled: PageSourceSchema.optional(),
52+
/** HTTP status for the served page. Default 200 (preserves typical explainer-page UX). */
53+
status: z.number().int().optional(),
54+
});
55+
export type Gate = z.infer<typeof GateSchema>;
56+
57+
export const UnauthorizedActionSchema = z.enum(['login', 'forbidden', 'upgrade', 'gate']);
3858

3959
export const RuleSchema = z.object({
4060
/** Path pattern: exact (`/special`), prefix (`/special/*`), or `/` for the homepage only. */
@@ -44,6 +64,8 @@ export const RuleSchema = z.object({
4464
on_unauthorized: UnauthorizedActionSchema.default('login'),
4565
/** Redirect target for the 'upgrade' action (e.g. a Patreon join page). */
4666
upgrade_url: z.string().optional(),
67+
/** Page(s) served in place for the 'gate' action. */
68+
gate: GateSchema.optional(),
4769
});
4870

4971
export const AccessPolicySchema = z.object({
@@ -52,6 +74,8 @@ export const AccessPolicySchema = z.object({
5274
default: RequirementSchema.optional(),
5375
default_on_unauthorized: UnauthorizedActionSchema.default('login'),
5476
default_upgrade_url: z.string().optional(),
77+
/** Page(s) served in place for the 'gate' action on paths that match no rule. */
78+
default_gate: GateSchema.optional(),
5579
});
5680

5781
export type EntitlementCondition = z.infer<typeof EntitlementConditionSchema>;

test/gate.spec.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test';
2+
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
3+
import { CookieManager } from '../src/CookieManager';
4+
import { createStartupAPI } from '../src/createStartupAPI';
5+
import { AccessPolicy } from '../src/policy/accessPolicy';
6+
import type { Gate } from '../src/schemas/policy';
7+
8+
const cookieManager = new CookieManager(env.SESSION_SECRET);
9+
10+
// Reset the global policy so each configured instance re-initializes, and leave it uninitialized
11+
// afterwards so other test files re-init from their own config.
12+
beforeEach(() => AccessPolicy.reset());
13+
afterAll(() => AccessPolicy.reset());
14+
15+
// A path gated behind the "benefit-vip" Patreon perk. Denials are served as in-place gate pages.
16+
function gatePolicy(gate: Gate, on_unauthorized: 'gate' | 'upgrade' | 'login' = 'gate', extra: Record<string, unknown> = {}) {
17+
return {
18+
rules: [
19+
{
20+
pattern: '/gated',
21+
requirement: { mode: 'entitlement' as const, provider: 'patreon', condition: { type: 'benefit' as const, benefit_id: 'benefit-vip' } },
22+
on_unauthorized,
23+
...(on_unauthorized === 'gate' ? { gate } : {}),
24+
...extra,
25+
},
26+
],
27+
default: { mode: 'public' as const },
28+
};
29+
}
30+
31+
function fetchWith(policy: any, path: string, cookie?: string, assetsMock?: (req: Request) => Promise<Response>) {
32+
const api = createStartupAPI({ accessPolicy: policy });
33+
const ctx = createExecutionContext();
34+
const headers: Record<string, string> = cookie ? { Cookie: `session_id=${cookie}` } : {};
35+
// Override ASSETS only when a mock is provided (asset-sourced gate pages).
36+
const testEnv = assetsMock ? { ...env, ASSETS: { fetch: vi.fn(assetsMock) } } : env;
37+
return api.fetch(new Request('http://example.com' + path, { headers }), testEnv as any, ctx).then(async (res) => {
38+
await waitOnExecutionContext(ctx);
39+
return res;
40+
});
41+
}
42+
43+
// Logged-in Patreon user with the given benefits, stored entitlements seeded so no network is needed.
44+
async function createPatreonUser(benefits: string[], active = true, userId = env.USER.newUniqueId()) {
45+
const userStub = env.USER.get(userId);
46+
const userIdStr = userId.toString();
47+
const subjectId = 'patreon-' + userIdStr.slice(0, 10);
48+
49+
await userStub.addMembership(env.ACCOUNT.newUniqueId().toString(), 1, true);
50+
await userStub.addCredential('patreon', subjectId);
51+
const { sessionId } = await userStub.createSession({ provider: 'patreon' });
52+
53+
const entitlements = {
54+
provider: 'patreon',
55+
checked_at: Date.now(),
56+
source: 'oauth',
57+
patreon: {
58+
patron_status: active ? 'active_patron' : 'former_patron',
59+
is_active_patron: active,
60+
entitled_tier_ids: ['t1'],
61+
entitled_benefit_ids: benefits,
62+
pledge_amount_cents: 500,
63+
},
64+
};
65+
await userStub.setEntitlements('patreon', subjectId, entitlements, entitlements.checked_at);
66+
67+
return cookieManager.encrypt(`${sessionId}:${userIdStr}`);
68+
}
69+
70+
// Spy globalThis.fetch (origin proxy). Returns a distinct body per origin path so we can assert which
71+
// page was served.
72+
function spyOrigin() {
73+
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
74+
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
75+
const path = new URL(url).pathname;
76+
return new Response(`origin:${path}`, { status: 200, headers: { 'Content-Type': 'text/html' } }) as any;
77+
});
78+
}
79+
80+
// ASSETS mock that returns a distinct body per asset path.
81+
async function assetMock(req: Request): Promise<Response> {
82+
const path = new URL(req.url).pathname;
83+
return new Response(`asset:${path}`, { status: 200, headers: { 'Content-Type': 'text/html' } });
84+
}
85+
86+
describe('gate action — serves an explainer page in place', () => {
87+
it('anonymous visitor → asset variant', async () => {
88+
const gate: Gate = { anonymous: { asset: '/early-access' } };
89+
const res = await fetchWith(gatePolicy(gate), '/gated', undefined, assetMock);
90+
expect(res.status).toBe(200);
91+
expect(await res.text()).toBe('asset:/early-access');
92+
});
93+
94+
it('anonymous visitor → origin variant (no redirect)', async () => {
95+
const gate: Gate = { anonymous: { origin: '/early-access' } };
96+
const fetchSpy = spyOrigin();
97+
try {
98+
const res = await fetchWith(gatePolicy(gate), '/gated');
99+
expect(res.status).toBe(200);
100+
expect(await res.text()).toBe('origin:/early-access');
101+
// It is served in place — no redirect.
102+
expect(res.headers.has('Location')).toBe(false);
103+
} finally {
104+
fetchSpy.mockRestore();
105+
}
106+
});
107+
108+
it('unentitled (logged-in, lacks benefit) → asset variant', async () => {
109+
const gate: Gate = { anonymous: { asset: '/early-access' }, unentitled: { asset: '/pledge-needed' } };
110+
const cookie = await createPatreonUser(['some-other-benefit']);
111+
const res = await fetchWith(gatePolicy(gate), '/gated', cookie, assetMock);
112+
expect(res.status).toBe(200);
113+
expect(await res.text()).toBe('asset:/pledge-needed');
114+
});
115+
116+
it('unentitled (logged-in, lacks benefit) → origin variant', async () => {
117+
const gate: Gate = { anonymous: { origin: '/early-access' }, unentitled: { origin: '/pledge-needed' } };
118+
const cookie = await createPatreonUser(['some-other-benefit']);
119+
const fetchSpy = spyOrigin();
120+
try {
121+
const res = await fetchWith(gatePolicy(gate), '/gated', cookie);
122+
expect(res.status).toBe(200);
123+
expect(await res.text()).toBe('origin:/pledge-needed');
124+
} finally {
125+
fetchSpy.mockRestore();
126+
}
127+
});
128+
129+
it('unentitled falls back to anonymous when no unentitled variant is configured', async () => {
130+
const gate: Gate = { anonymous: { asset: '/early-access' } };
131+
const cookie = await createPatreonUser(['some-other-benefit']);
132+
const res = await fetchWith(gatePolicy(gate), '/gated', cookie, assetMock);
133+
expect(res.status).toBe(200);
134+
expect(await res.text()).toBe('asset:/early-access');
135+
});
136+
137+
it('re-stamps a custom status onto the served page', async () => {
138+
const gate: Gate = { anonymous: { asset: '/early-access' }, status: 403 };
139+
const res = await fetchWith(gatePolicy(gate), '/gated', undefined, assetMock);
140+
expect(res.status).toBe(403);
141+
expect(await res.text()).toBe('asset:/early-access');
142+
});
143+
144+
it('allows the gated path (no gate page) when the user has the required benefit', async () => {
145+
const gate: Gate = { anonymous: { asset: '/early-access' } };
146+
const cookie = await createPatreonUser(['benefit-vip']);
147+
const fetchSpy = spyOrigin();
148+
try {
149+
const res = await fetchWith(gatePolicy(gate), '/gated', cookie);
150+
expect(res.status).toBe(200);
151+
// Served from origin (the real page), not the gate asset.
152+
expect(await res.text()).toContain('origin:/gated');
153+
} finally {
154+
fetchSpy.mockRestore();
155+
}
156+
});
157+
});
158+
159+
describe('gate action — back-compat: omitting gate leaves upgrade/login unchanged', () => {
160+
it('upgrade still redirects (302) to upgrade_url', async () => {
161+
const policy = gatePolicy({ anonymous: { asset: '/x' } }, 'upgrade', { upgrade_url: 'https://patreon.com/join' });
162+
const res = await fetchWith(policy, '/gated');
163+
expect(res.status).toBe(302);
164+
expect(res.headers.get('Location')).toBe('https://patreon.com/join');
165+
});
166+
167+
it('login still redirects (302) to authenticate', async () => {
168+
const policy = gatePolicy({ anonymous: { asset: '/x' } }, 'login');
169+
const res = await fetchWith(policy, '/gated');
170+
expect(res.status).toBe(302);
171+
expect(res.headers.get('Location')).toContain('return_url=');
172+
});
173+
});

0 commit comments

Comments
 (0)