@startup-api/cloudflare 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/package.json +1 -1
- package/src/createStartupAPI.ts +48 -5
- package/src/policy/accessPolicy.ts +4 -3
- package/src/schemas/policy.ts +25 -1
package/README.md
CHANGED
|
@@ -160,7 +160,34 @@ Configure an ordered list of rules (first match wins) mapping a path pattern to
|
|
|
160
160
|
- **`authenticated`** — any logged-in user.
|
|
161
161
|
- **`entitlement`** — a provider condition: Patreon `active_patron`, a specific `benefit` (perk) ID, or a `tier` ID.
|
|
162
162
|
|
|
163
|
-
Patterns are exact (`/special`), prefix (`/app/*`), or `/` (homepage only). Each rule's `on_unauthorized` is `login` (redirect to sign in), `forbidden` (403),
|
|
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
|
+
```
|
|
164
191
|
|
|
165
192
|
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.)
|
|
166
193
|
|
package/package.json
CHANGED
package/src/createStartupAPI.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { handleSSR } from './handlers/ssr';
|
|
|
24
24
|
import type { StartupAPIEnv } from './StartupAPIEnv';
|
|
25
25
|
import { StartupAPIConfigSchema } from './schemas/config';
|
|
26
26
|
import type { StartupAPIConfig, ProviderOptions, ResolvedFreshness } from './schemas/config';
|
|
27
|
-
import type { AccessPolicyConfig } from './schemas/policy';
|
|
27
|
+
import type { AccessPolicyConfig, PageSource } from './schemas/policy';
|
|
28
28
|
import { AccessPolicy, evaluateAccess } from './policy/accessPolicy';
|
|
29
29
|
import type { PolicyDecision } from './policy/accessPolicy';
|
|
30
30
|
import { loadEntitlements, entitlementHeaders } from './entitlements/service';
|
|
@@ -67,11 +67,46 @@ function resolveAccessPolicy(configPolicy: AccessPolicyConfig | undefined): Acce
|
|
|
67
67
|
return configPolicy ?? { default: { mode: 'public' } };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
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. */
|
|
71
100
|
function denyResponse(
|
|
72
101
|
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
|
+
}
|
|
75
110
|
if (decision.action === 'forbidden') {
|
|
76
111
|
return new Response('Forbidden', { status: 403 });
|
|
77
112
|
}
|
|
@@ -298,7 +333,15 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
|
298
333
|
// Enforce the requirement. Admins bypass the gate (identity/headers above still apply).
|
|
299
334
|
const decision = evaluateAccess(rule, { authenticated, entitlements, isAdmin: userIsAdmin });
|
|
300
335
|
if (!decision.allow) {
|
|
301
|
-
return denyResponse(decision, {
|
|
336
|
+
return denyResponse(decision, {
|
|
337
|
+
usersPath,
|
|
338
|
+
returnUrl,
|
|
339
|
+
activeProviders: getActiveProviders(env),
|
|
340
|
+
authenticated,
|
|
341
|
+
request,
|
|
342
|
+
env,
|
|
343
|
+
url,
|
|
344
|
+
});
|
|
302
345
|
}
|
|
303
346
|
|
|
304
347
|
const response = await originFetch(newRequest);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
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';
|
|
3
3
|
import type { Entitlements } from '../entitlements/types';
|
|
4
4
|
import { providerEntitlementCheckers, providerSupportsEntitlements } from './entitlementCheckers';
|
|
5
5
|
|
|
@@ -20,10 +20,10 @@ export function matchPattern(pattern: string, path: string): boolean {
|
|
|
20
20
|
|
|
21
21
|
export type PolicyDecision =
|
|
22
22
|
| { 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 };
|
|
24
24
|
|
|
25
25
|
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 };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
@@ -101,6 +101,7 @@ export class AccessPolicy {
|
|
|
101
101
|
requirement: cfg.default ?? { mode: 'authenticated' },
|
|
102
102
|
on_unauthorized: cfg.default_on_unauthorized,
|
|
103
103
|
upgrade_url: cfg.default_upgrade_url,
|
|
104
|
+
gate: cfg.default_gate,
|
|
104
105
|
};
|
|
105
106
|
}
|
|
106
107
|
}
|
package/src/schemas/policy.ts
CHANGED
|
@@ -34,7 +34,27 @@ export const RequirementSchema = z.discriminatedUnion('mode', [
|
|
|
34
34
|
}),
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
|
-
|
|
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']);
|
|
38
58
|
|
|
39
59
|
export const RuleSchema = z.object({
|
|
40
60
|
/** Path pattern: exact (`/special`), prefix (`/special/*`), or `/` for the homepage only. */
|
|
@@ -44,6 +64,8 @@ export const RuleSchema = z.object({
|
|
|
44
64
|
on_unauthorized: UnauthorizedActionSchema.default('login'),
|
|
45
65
|
/** Redirect target for the 'upgrade' action (e.g. a Patreon join page). */
|
|
46
66
|
upgrade_url: z.string().optional(),
|
|
67
|
+
/** Page(s) served in place for the 'gate' action. */
|
|
68
|
+
gate: GateSchema.optional(),
|
|
47
69
|
});
|
|
48
70
|
|
|
49
71
|
export const AccessPolicySchema = z.object({
|
|
@@ -52,6 +74,8 @@ export const AccessPolicySchema = z.object({
|
|
|
52
74
|
default: RequirementSchema.optional(),
|
|
53
75
|
default_on_unauthorized: UnauthorizedActionSchema.default('login'),
|
|
54
76
|
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(),
|
|
55
79
|
});
|
|
56
80
|
|
|
57
81
|
export type EntitlementCondition = z.infer<typeof EntitlementConditionSchema>;
|