@startup-api/cloudflare 0.2.0 → 0.3.1

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.
@@ -1,3 +1,89 @@
1
+ /* Color tokens. Every color below is keyed off these variables so the whole
2
+ page can be re-themed by flipping a single set of values — matching how the
3
+ landing page (index.html) and the <power-strip> component theme themselves.
4
+ The light palette is the default; the dark overrides are driven by the
5
+ user's OS color-scheme preference, with a [data-theme] escape hatch so a
6
+ page (or the power strip) can force a theme at runtime. */
7
+ :root {
8
+ --bg: #f9f9f9;
9
+ --surface: #fff;
10
+ --surface-muted: #f8f9fa;
11
+ --surface-alt: #f1f3f4;
12
+ --hover-bg: #f0f0f0;
13
+ --text: #333;
14
+ --text-secondary: #555;
15
+ --text-faint: #666;
16
+ --text-muted: #717171;
17
+ --border: #ddd;
18
+ --border-light: #eee;
19
+ --accent: #ffcc00;
20
+ --accent-hover: #e6b800;
21
+ --accent-text: #826700;
22
+ --accent-soft-bg: #fff7d6;
23
+ --on-accent: #202124;
24
+ --muted-badge-text: #5f6368;
25
+ --danger: #d93025;
26
+ --danger-hover: #ea4335;
27
+ --danger-soft-bg: #fce8e6;
28
+ --disabled-bg: #ccc;
29
+ --avatar-remove-bg: #727579;
30
+ --avatar-remove-icon: #fff;
31
+ }
32
+
33
+ @media (prefers-color-scheme: dark) {
34
+ :root:not([data-theme='light']) {
35
+ --bg: #1a1a1a;
36
+ --surface: #2d2d2d;
37
+ --surface-muted: #333;
38
+ --surface-alt: #3c4043;
39
+ --hover-bg: #3c4043;
40
+ --text: #e0e0e0;
41
+ --text-secondary: #c0c0c0;
42
+ --text-faint: #bdc1c6;
43
+ --text-muted: #9aa0a6;
44
+ --border: #5f6368;
45
+ --border-light: #444;
46
+ --accent: #ffcc00;
47
+ --accent-hover: #e6b800;
48
+ --accent-text: #ffcc00;
49
+ --accent-soft-bg: #3a3320;
50
+ --on-accent: #202124;
51
+ --muted-badge-text: #a8adb2;
52
+ --danger: #f28b82;
53
+ --danger-hover: #ee675c;
54
+ --danger-soft-bg: #3c2a28;
55
+ --disabled-bg: #5f6368;
56
+ --avatar-remove-bg: #5f6368;
57
+ --avatar-remove-icon: #e0e0e0;
58
+ }
59
+ }
60
+
61
+ [data-theme='dark'] {
62
+ --bg: #1a1a1a;
63
+ --surface: #2d2d2d;
64
+ --surface-muted: #333;
65
+ --surface-alt: #3c4043;
66
+ --hover-bg: #3c4043;
67
+ --text: #e0e0e0;
68
+ --text-secondary: #c0c0c0;
69
+ --text-faint: #bdc1c6;
70
+ --text-muted: #9aa0a6;
71
+ --border: #5f6368;
72
+ --border-light: #444;
73
+ --accent: #ffcc00;
74
+ --accent-hover: #e6b800;
75
+ --accent-text: #ffcc00;
76
+ --accent-soft-bg: #3a3320;
77
+ --on-accent: #202124;
78
+ --muted-badge-text: #a8adb2;
79
+ --danger: #f28b82;
80
+ --danger-hover: #ee675c;
81
+ --danger-soft-bg: #3c2a28;
82
+ --disabled-bg: #5f6368;
83
+ --avatar-remove-bg: #5f6368;
84
+ --avatar-remove-icon: #e0e0e0;
85
+ }
86
+
1
87
  * {
2
88
  box-sizing: border-box;
3
89
  }
@@ -9,7 +95,8 @@ body {
9
95
  sans-serif;
10
96
  padding: 2rem;
11
97
  margin: 0 auto;
12
- background: #f9f9f9;
98
+ background: var(--bg);
99
+ color: var(--text);
13
100
  }
14
101
 
15
102
  .main-layout,
@@ -68,7 +155,7 @@ body {
68
155
  .nav-link {
69
156
  display: block;
70
157
  padding: 0.75rem 1rem;
71
- color: #555;
158
+ color: var(--text-secondary);
72
159
  text-decoration: none;
73
160
  border-radius: 6px;
74
161
  transition: all 0.2s;
@@ -77,20 +164,20 @@ body {
77
164
  }
78
165
 
79
166
  .nav-link:hover {
80
- background: #f0f0f0;
81
- color: #1a73e8;
167
+ background: var(--hover-bg);
168
+ color: var(--accent-text);
82
169
  }
83
170
 
84
171
  .nav-link.active {
85
- color: #1a73e8;
172
+ color: var(--accent-text);
86
173
  font-weight: 600;
87
- border-left: 3px solid #1a73e8;
174
+ border-left: 3px solid var(--accent-text);
88
175
  border-radius: 0;
89
176
  padding-left: calc(1rem - 3px);
90
177
  }
91
178
 
92
179
  h1.page-subtitle {
93
- color: #666;
180
+ color: var(--text-faint);
94
181
  margin-bottom: 0.25rem;
95
182
  font-size: 1.1rem;
96
183
  text-transform: uppercase;
@@ -101,7 +188,7 @@ h1.page-subtitle {
101
188
  .page-title {
102
189
  font-size: 2.5rem;
103
190
  font-weight: bold;
104
- color: #333;
191
+ color: var(--text);
105
192
  margin-bottom: 0.5rem;
106
193
  white-space: nowrap;
107
194
  overflow: hidden;
@@ -111,7 +198,7 @@ h1.page-subtitle {
111
198
 
112
199
  .subtitle {
113
200
  font-size: 0.75rem;
114
- color: #888;
201
+ color: var(--text-muted);
115
202
  margin-bottom: 2rem;
116
203
  font-family: monospace;
117
204
  display: flex;
@@ -121,7 +208,7 @@ h1.page-subtitle {
121
208
  }
122
209
 
123
210
  section {
124
- background: white;
211
+ background: var(--surface);
125
212
  padding: 1.5rem;
126
213
  border-radius: 8px;
127
214
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
@@ -136,28 +223,30 @@ section {
136
223
  display: block;
137
224
  margin-bottom: 0.5rem;
138
225
  font-weight: 500;
139
- color: #555;
226
+ color: var(--text-secondary);
140
227
  }
141
228
 
142
229
  .form-group input,
143
230
  .form-group select {
144
231
  width: 100%;
145
232
  padding: 0.75rem;
146
- border: 1px solid #ddd;
233
+ border: 1px solid var(--border);
147
234
  border-radius: 4px;
148
235
  box-sizing: border-box;
149
236
  font-size: 1rem;
237
+ background: var(--surface);
238
+ color: var(--text);
150
239
  }
151
240
 
152
241
  .form-group input:disabled {
153
- background: #f0f0f0;
154
- color: #888;
242
+ background: var(--surface-muted);
243
+ color: var(--text-muted);
155
244
  }
156
245
 
157
246
  button {
158
247
  padding: 0.75rem 1.5rem;
159
- background: #1a73e8;
160
- color: white;
248
+ background: var(--accent);
249
+ color: var(--on-accent);
161
250
  border: none;
162
251
  border-radius: 4px;
163
252
  cursor: pointer;
@@ -166,21 +255,21 @@ button {
166
255
  }
167
256
 
168
257
  button:hover {
169
- background: #1557b0;
258
+ background: var(--accent-hover);
170
259
  }
171
260
 
172
261
  button.secondary-btn {
173
- background: #fff;
174
- color: #1a73e8;
175
- border: 1px solid #1a73e8;
262
+ background: var(--surface);
263
+ color: var(--accent-text);
264
+ border: 1px solid var(--accent-text);
176
265
  }
177
266
 
178
267
  button.secondary-btn:hover {
179
- background: #f8f9fa;
268
+ background: var(--surface-muted);
180
269
  }
181
270
 
182
271
  button:disabled {
183
- background: #ccc;
272
+ background: var(--disabled-bg);
184
273
  cursor: not-allowed;
185
274
  }
186
275
 
@@ -200,7 +289,7 @@ button:disabled {
200
289
  .back-link {
201
290
  display: inline-block;
202
291
  margin-bottom: 1rem;
203
- color: #1a73e8;
292
+ color: var(--accent-text);
204
293
  text-decoration: none;
205
294
  }
206
295
 
@@ -210,29 +299,29 @@ button:disabled {
210
299
 
211
300
  .remove-btn {
212
301
  background: transparent;
213
- color: #d93025;
214
- border: 1px solid #d93025;
302
+ color: var(--danger);
303
+ border: 1px solid var(--danger);
215
304
  padding: 0.4rem 0.8rem;
216
305
  font-size: 0.85rem;
217
306
  }
218
307
 
219
308
  .remove-btn:hover {
220
- background: #fce8e6;
309
+ background: var(--danger-soft-bg);
221
310
  }
222
311
 
223
312
  .remove-btn:disabled {
224
- background: #fafafa;
225
- border-color: #eee;
226
- color: #999;
313
+ background: var(--surface-muted);
314
+ border-color: var(--border-light);
315
+ color: var(--text-muted);
227
316
  cursor: not-allowed;
228
317
  }
229
318
 
230
319
  .btn-link {
231
320
  display: inline-block;
232
321
  padding: 0.75rem 1rem;
233
- background: white;
234
- color: #1a73e8;
235
- border: 1px solid #1a73e8;
322
+ background: var(--surface);
323
+ color: var(--accent-text);
324
+ border: 1px solid var(--accent-text);
236
325
  border-radius: 4px;
237
326
  text-decoration: none;
238
327
  font-weight: 500;
@@ -241,7 +330,7 @@ button:disabled {
241
330
  }
242
331
 
243
332
  .btn-link:hover {
244
- background: #f8f9fa;
333
+ background: var(--surface-muted);
245
334
  }
246
335
 
247
336
  .remove-image-btn {
@@ -251,9 +340,9 @@ button:disabled {
251
340
  width: 20px;
252
341
  height: 20px;
253
342
  border-radius: 50%;
254
- background: #bdc1c6;
255
- color: white;
256
- border: 2px solid white;
343
+ background: var(--avatar-remove-bg);
344
+ color: var(--avatar-remove-icon);
345
+ border: 2px solid var(--surface);
257
346
  cursor: pointer;
258
347
  display: flex;
259
348
  align-items: center;
@@ -284,7 +373,7 @@ button:disabled {
284
373
  height: 100px;
285
374
  border-radius: 50%;
286
375
  object-fit: cover;
287
- border: 3px solid white;
376
+ border: 3px solid var(--surface);
288
377
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
289
378
  }
290
379
 
@@ -293,7 +382,7 @@ button:disabled {
293
382
  height: 100px;
294
383
  border-radius: 8px;
295
384
  object-fit: cover;
296
- border: 3px solid white;
385
+ border: 3px solid var(--surface);
297
386
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
298
387
  }
299
388
 
@@ -302,20 +391,20 @@ button:disabled {
302
391
  justify-content: space-between;
303
392
  align-items: center;
304
393
  padding: 1rem;
305
- border: 1px solid #eee;
394
+ border: 1px solid var(--border-light);
306
395
  border-radius: 8px;
307
396
  margin-bottom: 0.75rem;
308
397
  }
309
398
 
310
399
  .credential-item.active {
311
- border-color: #1a73e8;
312
- background-color: #e8f0fe;
400
+ border-color: var(--accent-text);
401
+ background-color: var(--accent-soft-bg);
313
402
  }
314
403
 
315
404
  .current-badge {
316
405
  font-size: 0.75rem;
317
- background: #1a73e8;
318
- color: white;
406
+ background: var(--accent);
407
+ color: var(--on-accent);
319
408
  padding: 0.125rem 0.375rem;
320
409
  border-radius: 0.75rem;
321
410
  margin-left: 0.5rem;
@@ -349,13 +438,13 @@ button:disabled {
349
438
  text-decoration: none;
350
439
  font-weight: 500;
351
440
  font-size: 0.875rem;
352
- border: 1px solid #ddd;
353
- color: #333;
441
+ border: 1px solid var(--border);
442
+ color: var(--text);
354
443
  transition: background 0.2s;
355
444
  }
356
445
 
357
446
  .link-account-btn.google:hover {
358
- background: #f8f9fa;
447
+ background: var(--surface-muted);
359
448
  }
360
449
 
361
450
  .link-account-btn.twitch {
@@ -378,7 +467,7 @@ button:disabled {
378
467
  justify-content: space-between;
379
468
  align-items: center;
380
469
  padding: 1rem;
381
- border-bottom: 1px solid #eee;
470
+ border-bottom: 1px solid var(--border-light);
382
471
  }
383
472
 
384
473
  .member-item:last-child {
@@ -398,11 +487,11 @@ button:disabled {
398
487
  border-radius: 50%;
399
488
  object-fit: cover;
400
489
  flex-shrink: 0;
401
- background: #f1f3f4;
490
+ background: var(--surface-alt);
402
491
  display: flex;
403
492
  align-items: center;
404
493
  justify-content: center;
405
- color: #5f6368;
494
+ color: var(--muted-badge-text);
406
495
  }
407
496
 
408
497
  .member-avatar svg {
@@ -436,27 +525,28 @@ button:disabled {
436
525
  font-size: 0.75rem;
437
526
  padding: 0.25rem 0.5rem;
438
527
  border-radius: 1rem;
439
- background: #f1f3f4;
440
- color: #5f6368;
528
+ background: var(--surface-alt);
529
+ color: var(--muted-badge-text);
441
530
  font-weight: 500;
442
531
  }
443
532
 
444
533
  .role-badge.admin {
445
- background: #e8f0fe;
446
- color: #1a73e8;
534
+ background: var(--accent-soft-bg);
535
+ color: var(--accent-text);
447
536
  }
448
537
 
449
538
  .role-select {
450
539
  padding: 0.25rem 0.5rem;
451
540
  border-radius: 4px;
452
- border: 1px solid #ddd;
541
+ border: 1px solid var(--border);
453
542
  font-size: 0.85rem;
454
- background: #fff;
543
+ background: var(--surface);
544
+ color: var(--text);
455
545
  }
456
546
 
457
547
  .role-select:disabled {
458
- background: #f1f3f4;
459
- color: #5f6368;
548
+ background: var(--surface-alt);
549
+ color: var(--muted-badge-text);
460
550
  border-color: transparent;
461
551
  appearance: none;
462
552
  -webkit-appearance: none;
@@ -475,7 +565,7 @@ button:disabled {
475
565
  border: none;
476
566
  padding: 0.25rem;
477
567
  cursor: pointer;
478
- color: #1a73e8;
568
+ color: var(--accent-text);
479
569
  display: flex;
480
570
  align-items: center;
481
571
  justify-content: center;
@@ -484,7 +574,7 @@ button:disabled {
484
574
  }
485
575
 
486
576
  .copy-btn:hover {
487
- background: #f0f0f0;
577
+ background: var(--hover-bg);
488
578
  }
489
579
 
490
580
  .copy-btn svg {
package/src/PowerStrip.ts CHANGED
@@ -31,7 +31,7 @@ export async function injectPowerStrip(response: Response, usersPath: string, pr
31
31
  element.onEndTag((end) => {
32
32
  if (!hasUserPowerStrip) {
33
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;">` +
34
+ `<power-strip providers="${providersAttr}" style="position: absolute; top: 0; right: 0; z-index: 9999; border-radius: 0 0 0 0.3rem;">` +
35
35
  '<svg viewBox="0 0 24 24" style="width: 1rem; height: 1rem;"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>' +
36
36
  '</power-strip>',
37
37
  { html: true },
@@ -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
- /** 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. */
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, { 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
+ });
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
  }
@@ -34,7 +34,27 @@ export const RequirementSchema = z.discriminatedUnion('mode', [
34
34
  }),
35
35
  ]);
36
36
 
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']);
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>;