@startup-api/cloudflare 0.1.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 +45 -1
- package/package.json +1 -1
- package/public/users/power-strip.js +8 -1
- package/src/PowerStrip.ts +33 -9
- package/src/createStartupAPI.ts +52 -8
- package/src/policy/accessPolicy.ts +12 -4
- package/src/schemas/policy.ts +25 -1
package/README.md
CHANGED
|
@@ -132,6 +132,21 @@ const api = createStartupAPI({
|
|
|
132
132
|
3. **Proxying:** All other requests are proxied to the configured `ORIGIN_URL`
|
|
133
133
|
4. **Injection:** For `text/html` responses, the worker injects a `<script>` tag and a `<power-strip>` custom element before serving the content to the user
|
|
134
134
|
|
|
135
|
+
### Customizing the power strip
|
|
136
|
+
|
|
137
|
+
By default the worker injects its own `<power-strip>` pinned to the top-right corner of the page. If that overlaps your own menu or you simply want it somewhere else, **place a `<power-strip>` element in your own HTML**. When the worker sees one, it injects only `power-strip.js` (which defines the custom element) and leaves your element exactly where you put it — so you control placement and styling:
|
|
138
|
+
|
|
139
|
+
```html
|
|
140
|
+
<nav>
|
|
141
|
+
<!-- ...your links... -->
|
|
142
|
+
<power-strip></power-strip>
|
|
143
|
+
</nav>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- **`providers` is optional.** If you omit it, the worker fills in the active providers for you (e.g. `providers="google,twitch,patreon"`). Set it yourself to override which login buttons appear.
|
|
147
|
+
- **Prefer an explicit closing tag.** `<power-strip></power-strip>` and `<power-strip/>` are both detected, but per the HTML spec `<power-strip/>` is *not* truly self-closing — the browser treats it as an open tag and nests the following content inside it. Use a closing tag (or place the element last in its container) to avoid surprises.
|
|
148
|
+
- **Script-only opt-out.** Use `<power-strip hidden>` to load `power-strip.js` (and its JS API) without rendering a visible strip.
|
|
149
|
+
|
|
135
150
|
## Access policy & provider entitlements
|
|
136
151
|
|
|
137
152
|
StartupAPI can gate access to paths and forward the visitor's login/entitlement status to your origin so it can render gated UI. This is **provider-agnostic infrastructure**; only Patreon currently implements perk-level (benefit/tier) checks — Google and Twitch participate at the login levels only.
|
|
@@ -145,7 +160,36 @@ Configure an ordered list of rules (first match wins) mapping a path pattern to
|
|
|
145
160
|
- **`authenticated`** — any logged-in user.
|
|
146
161
|
- **`entitlement`** — a provider condition: Patreon `active_patron`, a specific `benefit` (perk) ID, or a `tier` ID.
|
|
147
162
|
|
|
148
|
-
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
|
+
```
|
|
191
|
+
|
|
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.)
|
|
149
193
|
|
|
150
194
|
The policy is passed to the factory as `accessPolicy` (see below). Example:
|
|
151
195
|
|
package/package.json
CHANGED
|
@@ -252,7 +252,14 @@ class PowerStrip extends HTMLElement {
|
|
|
252
252
|
display: block;
|
|
253
253
|
font-family: system-ui, -apple-system, sans-serif;
|
|
254
254
|
}
|
|
255
|
-
|
|
255
|
+
|
|
256
|
+
/* Honor the native [hidden] attribute so authors can load the script
|
|
257
|
+
without rendering a visible strip (<power-strip hidden>). The :host
|
|
258
|
+
rule above would otherwise override the UA [hidden] { display: none }. */
|
|
259
|
+
:host([hidden]) {
|
|
260
|
+
display: none !important;
|
|
261
|
+
}
|
|
262
|
+
|
|
256
263
|
@keyframes fadeIn {
|
|
257
264
|
from { opacity: 0; }
|
|
258
265
|
to { opacity: 1; }
|
package/src/PowerStrip.ts
CHANGED
|
@@ -2,18 +2,42 @@ export async function injectPowerStrip(response: Response, usersPath: string, pr
|
|
|
2
2
|
const contentType = response.headers.get('Content-Type');
|
|
3
3
|
|
|
4
4
|
if (contentType && contentType.includes('text/html')) {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const providersAttr = providers.join(',');
|
|
6
|
+
|
|
7
|
+
// Track whether the page author placed their own <power-strip> element.
|
|
8
|
+
// If they did, we respect their placement/styling and only load the script.
|
|
9
|
+
let hasUserPowerStrip = false;
|
|
10
|
+
|
|
7
11
|
return new HTMLRewriter()
|
|
12
|
+
.on('power-strip', {
|
|
13
|
+
element(element) {
|
|
14
|
+
hasUserPowerStrip = true;
|
|
15
|
+
|
|
16
|
+
// Fill in the active providers for the author so their element works
|
|
17
|
+
// out of the box, unless they explicitly chose their own list.
|
|
18
|
+
if (!element.hasAttribute('providers')) {
|
|
19
|
+
element.setAttribute('providers', providersAttr);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
})
|
|
8
23
|
.on('body', {
|
|
9
24
|
element(element) {
|
|
10
|
-
element.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
25
|
+
// The script is always needed to define the <power-strip> custom element.
|
|
26
|
+
// It is loaded from the USERS_PATH, which is intercepted by this worker.
|
|
27
|
+
element.prepend(`<script src="${usersPath}power-strip.js" async></script>`, { html: true });
|
|
28
|
+
|
|
29
|
+
// Defer the component decision until the end of <body>, by which point
|
|
30
|
+
// the streaming parser has seen any author-supplied <power-strip>.
|
|
31
|
+
element.onEndTag((end) => {
|
|
32
|
+
if (!hasUserPowerStrip) {
|
|
33
|
+
end.before(
|
|
34
|
+
`<power-strip providers="${providersAttr}" style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem;">` +
|
|
35
|
+
'<svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem;"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>' +
|
|
36
|
+
'</power-strip>',
|
|
37
|
+
{ html: true },
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
17
41
|
},
|
|
18
42
|
})
|
|
19
43
|
.transform(response);
|
package/src/createStartupAPI.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { CredentialDO } from './storage/CredentialDO';
|
|
|
7
7
|
import { CookieManager } from './CookieManager';
|
|
8
8
|
import { initPlans } from './billing/plansConfig';
|
|
9
9
|
import { Plan } from './billing/Plan';
|
|
10
|
-
import { getActiveProviders, parseCookies, getUserFromSession } from './handlers/utils';
|
|
10
|
+
import { getActiveProviders, parseCookies, getUserFromSession, isAdmin } from './handlers/utils';
|
|
11
11
|
import { handleAdmin } from './handlers/admin';
|
|
12
12
|
import {
|
|
13
13
|
handleMe,
|
|
@@ -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
|
}
|
|
@@ -259,6 +294,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
|
259
294
|
|
|
260
295
|
const user = await getUserFromSession(request, env, cookieManager);
|
|
261
296
|
const authenticated = !!user;
|
|
297
|
+
const userIsAdmin = user ? isAdmin(user, env) : false;
|
|
262
298
|
let entitlements: Entitlements | null = null;
|
|
263
299
|
let loginProvider: string | undefined;
|
|
264
300
|
|
|
@@ -294,10 +330,18 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
|
294
330
|
newRequest.headers.set(key, value);
|
|
295
331
|
}
|
|
296
332
|
|
|
297
|
-
// Enforce the requirement.
|
|
298
|
-
const decision = evaluateAccess(rule, { authenticated, entitlements });
|
|
333
|
+
// Enforce the requirement. Admins bypass the gate (identity/headers above still apply).
|
|
334
|
+
const decision = evaluateAccess(rule, { authenticated, entitlements, isAdmin: userIsAdmin });
|
|
299
335
|
if (!decision.allow) {
|
|
300
|
-
return denyResponse(decision, {
|
|
336
|
+
return denyResponse(decision, {
|
|
337
|
+
usersPath,
|
|
338
|
+
returnUrl,
|
|
339
|
+
activeProviders: getActiveProviders(env),
|
|
340
|
+
authenticated,
|
|
341
|
+
request,
|
|
342
|
+
env,
|
|
343
|
+
url,
|
|
344
|
+
});
|
|
301
345
|
}
|
|
302
346
|
|
|
303
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,18 +20,25 @@ 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
|
/**
|
|
30
30
|
* Decide whether a request satisfies a resolved rule, given the auth state and resolved entitlements.
|
|
31
31
|
* `bypass`/`public` always allow; `authenticated` needs a logged-in user; `entitlement` dispatches the
|
|
32
32
|
* condition through the provider checker registry.
|
|
33
|
+
*
|
|
34
|
+
* Admin users (`ctx.isAdmin`) bypass every requirement: identity has already been resolved and headers
|
|
35
|
+
* forwarded by the time we get here, but the gate itself is skipped so admins can reach any gated path.
|
|
33
36
|
*/
|
|
34
|
-
export function evaluateAccess(
|
|
37
|
+
export function evaluateAccess(
|
|
38
|
+
rule: AccessRule,
|
|
39
|
+
ctx: { authenticated: boolean; entitlements: Entitlements | null; isAdmin?: boolean },
|
|
40
|
+
): PolicyDecision {
|
|
41
|
+
if (ctx.isAdmin) return { allow: true };
|
|
35
42
|
const req = rule.requirement;
|
|
36
43
|
switch (req.mode) {
|
|
37
44
|
case 'bypass':
|
|
@@ -94,6 +101,7 @@ export class AccessPolicy {
|
|
|
94
101
|
requirement: cfg.default ?? { mode: 'authenticated' },
|
|
95
102
|
on_unauthorized: cfg.default_on_unauthorized,
|
|
96
103
|
upgrade_url: cfg.default_upgrade_url,
|
|
104
|
+
gate: cfg.default_gate,
|
|
97
105
|
};
|
|
98
106
|
}
|
|
99
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>;
|