@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.
- package/README.md +28 -1
- package/package.json +1 -1
- package/public/users/accounts.html +4 -7
- package/public/users/admin/index.html +73 -19
- package/public/users/power-strip.js +279 -42
- package/public/users/profile.html +5 -8
- package/public/users/style.css +149 -59
- package/src/PowerStrip.ts +1 -1
- package/src/createStartupAPI.ts +48 -5
- package/src/policy/accessPolicy.ts +4 -3
- package/src/schemas/policy.ts +25 -1
package/public/users/style.css
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
81
|
-
color:
|
|
167
|
+
background: var(--hover-bg);
|
|
168
|
+
color: var(--accent-text);
|
|
82
169
|
}
|
|
83
170
|
|
|
84
171
|
.nav-link.active {
|
|
85
|
-
color:
|
|
172
|
+
color: var(--accent-text);
|
|
86
173
|
font-weight: 600;
|
|
87
|
-
border-left: 3px solid
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
154
|
-
color:
|
|
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:
|
|
160
|
-
color:
|
|
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:
|
|
258
|
+
background: var(--accent-hover);
|
|
170
259
|
}
|
|
171
260
|
|
|
172
261
|
button.secondary-btn {
|
|
173
|
-
background:
|
|
174
|
-
color:
|
|
175
|
-
border: 1px solid
|
|
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:
|
|
268
|
+
background: var(--surface-muted);
|
|
180
269
|
}
|
|
181
270
|
|
|
182
271
|
button:disabled {
|
|
183
|
-
background:
|
|
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:
|
|
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:
|
|
214
|
-
border: 1px solid
|
|
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:
|
|
309
|
+
background: var(--danger-soft-bg);
|
|
221
310
|
}
|
|
222
311
|
|
|
223
312
|
.remove-btn:disabled {
|
|
224
|
-
background:
|
|
225
|
-
border-color:
|
|
226
|
-
color:
|
|
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:
|
|
234
|
-
color:
|
|
235
|
-
border: 1px solid
|
|
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:
|
|
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:
|
|
255
|
-
color:
|
|
256
|
-
border: 2px solid
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
312
|
-
background-color:
|
|
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:
|
|
318
|
-
color:
|
|
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
|
|
353
|
-
color:
|
|
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:
|
|
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
|
|
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:
|
|
490
|
+
background: var(--surface-alt);
|
|
402
491
|
display: flex;
|
|
403
492
|
align-items: center;
|
|
404
493
|
justify-content: center;
|
|
405
|
-
color:
|
|
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:
|
|
440
|
-
color:
|
|
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:
|
|
446
|
-
color:
|
|
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
|
|
541
|
+
border: 1px solid var(--border);
|
|
453
542
|
font-size: 0.85rem;
|
|
454
|
-
background:
|
|
543
|
+
background: var(--surface);
|
|
544
|
+
color: var(--text);
|
|
455
545
|
}
|
|
456
546
|
|
|
457
547
|
.role-select:disabled {
|
|
458
|
-
background:
|
|
459
|
-
color:
|
|
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:
|
|
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:
|
|
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;
|
|
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 },
|
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>;
|