@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startup-api/cloudflare",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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
- // Inject a script tag and a custom element into the proxied HTML pages.
6
- // The script is loaded from the USERS_PATH, which is intercepted by this worker.
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.prepend(
11
- `<script src="${usersPath}power-strip.js" async></script>` +
12
- `<power-strip providers="${providers.join(',')}" style="position: absolute; top: 0; right: 0; z-index: 9999; padding: 0.1rem; border-radius: 0 0 0 0.3rem;">` +
13
- '<svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem;"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>' +
14
- '</power-strip>',
15
- { html: true },
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);
@@ -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(rule: AccessRule, ctx: { authenticated: boolean; entitlements: Entitlements | null }): PolicyDecision {
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':