@startup-api/cloudflare 0.1.0 → 0.2.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 +17 -0
- package/package.json +1 -1
- package/public/users/power-strip.js +8 -1
- package/src/PowerStrip.ts +33 -9
- package/src/createStartupAPI.ts +4 -3
- package/src/policy/accessPolicy.ts +8 -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.
|
|
@@ -147,6 +162,8 @@ Configure an ordered list of rules (first match wins) mapping a path pattern to
|
|
|
147
162
|
|
|
148
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).
|
|
149
164
|
|
|
165
|
+
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
|
+
|
|
150
167
|
The policy is passed to the factory as `accessPolicy` (see below). Example:
|
|
151
168
|
|
|
152
169
|
```ts
|
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,
|
|
@@ -259,6 +259,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
|
259
259
|
|
|
260
260
|
const user = await getUserFromSession(request, env, cookieManager);
|
|
261
261
|
const authenticated = !!user;
|
|
262
|
+
const userIsAdmin = user ? isAdmin(user, env) : false;
|
|
262
263
|
let entitlements: Entitlements | null = null;
|
|
263
264
|
let loginProvider: string | undefined;
|
|
264
265
|
|
|
@@ -294,8 +295,8 @@ export function createStartupAPI(config: StartupAPIConfig = {}) {
|
|
|
294
295
|
newRequest.headers.set(key, value);
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
// Enforce the requirement.
|
|
298
|
-
const decision = evaluateAccess(rule, { authenticated, entitlements });
|
|
298
|
+
// Enforce the requirement. Admins bypass the gate (identity/headers above still apply).
|
|
299
|
+
const decision = evaluateAccess(rule, { authenticated, entitlements, isAdmin: userIsAdmin });
|
|
299
300
|
if (!decision.allow) {
|
|
300
301
|
return denyResponse(decision, { usersPath, returnUrl, activeProviders: getActiveProviders(env) });
|
|
301
302
|
}
|
|
@@ -30,8 +30,15 @@ function deny(reason: 'unauthenticated' | 'not_entitled', rule: AccessRule): Pol
|
|
|
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':
|