@fabrika/proxy-core 0.8.1 → 0.9.0-cloudflare.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/package.json +19 -8
- package/src/authorize.ts +77 -21
- package/src/brand.ts +121 -0
- package/src/cache.ts +0 -0
- package/src/constants.ts +9 -0
- package/src/handoff.ts +8 -0
- package/src/index.ts +4 -0
- package/src/manifest.ts +142 -10
- package/src/page-style.ts +505 -0
- package/src/pages.ts +150 -0
- package/src/service.ts +49 -48
- package/types/src/authorize.d.ts +253 -0
- package/types/src/brand.d.ts +104 -0
- package/types/src/cache.d.ts +53 -0
- package/types/src/constants.d.ts +48 -0
- package/types/src/credentials.d.ts +10 -0
- package/types/src/handoff.d.ts +20 -0
- package/types/src/headers.d.ts +112 -0
- package/types/src/iam.d.ts +115 -0
- package/types/src/index.d.ts +18 -0
- package/types/src/log.d.ts +29 -0
- package/types/src/manifest.d.ts +9222 -0
- package/types/src/oauth-resource.d.ts +52 -0
- package/types/src/oauth.d.ts +12 -0
- package/types/src/page-style.d.ts +40 -0
- package/types/src/pages.d.ts +62 -0
- package/types/src/service.d.ts +47 -0
- package/types/src/verifier.d.ts +22 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import type { BrandImage, InstallationBrand } from './brand'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one stylesheet of every page fabrika renders outside the console and an application's own interface: IAM's
|
|
5
|
+
* sign-in, account, invitation and consent pages, Control's GitHub handoff, and the proxy's error pages (`./pages`).
|
|
6
|
+
* Each page is the console's welcome (`packages/dashboard/src/styles.css`, `.welcome`) set for a page that stands alone:
|
|
7
|
+
* one panel in the middle of the window, the page's own words at the left, the fabrika mark drawn large on the
|
|
8
|
+
* blueprint grid at the right, and fabrika named once, quietly, in a footer under it. Unlike the console's welcome the
|
|
9
|
+
* panel follows the scheme: a plate on the floor in light, the welcome's dark panel in dark. Buttons are the console's:
|
|
10
|
+
* outlined, and the brand blue fills only the page's one constructive step, the first button of a form.
|
|
11
|
+
* `pageBody` writes the frame, so every page has the same one. `page-style.test.ts` fails when a token here drifts from
|
|
12
|
+
* the console's.
|
|
13
|
+
*
|
|
14
|
+
* It imports nothing at runtime and loads nothing: every page that carries it admits only its own inline style, so the
|
|
15
|
+
* console's IBM Plex is used where the machine has it and a system face otherwise. The values the console mixes with
|
|
16
|
+
* `color-mix()` are written out here, and `light-dark()` is a media query, because these pages reach every browser
|
|
17
|
+
* that follows a sign-in link, not only the console's.
|
|
18
|
+
*
|
|
19
|
+
* The proxy's Caddy configuration serves it inside a `static_response` body, where Caddy expands `{name}`
|
|
20
|
+
* placeholders. Every block therefore opens with `{` and a newline, never `{` and a name.
|
|
21
|
+
*/
|
|
22
|
+
export const PAGE_STYLE = `
|
|
23
|
+
:root {
|
|
24
|
+
color-scheme: light dark;
|
|
25
|
+
--floor: #f6f7f9;
|
|
26
|
+
--panel: #ffffff;
|
|
27
|
+
--panel-rule: rgba(14, 20, 32, 0.1);
|
|
28
|
+
--art-rule: rgba(14, 20, 32, 0.08);
|
|
29
|
+
--logo: #2544c2;
|
|
30
|
+
--recess: #f1f3f6;
|
|
31
|
+
--rule: rgba(14, 20, 32, 0.1);
|
|
32
|
+
--rule-strong: rgba(14, 20, 32, 0.18);
|
|
33
|
+
--control: #ffffff;
|
|
34
|
+
--control-rule: rgba(14, 20, 32, 0.16);
|
|
35
|
+
--control-hover: #f2f4f7;
|
|
36
|
+
--ink: #0e1117;
|
|
37
|
+
--ink-2: #343d4a;
|
|
38
|
+
--ink-3: #4b5563;
|
|
39
|
+
--ink-4: #596372;
|
|
40
|
+
--brand: #2544c2;
|
|
41
|
+
--brand-hover: #1e389f;
|
|
42
|
+
--brand-on: #ffffff;
|
|
43
|
+
--blueprint: #2544c2;
|
|
44
|
+
--blueprint-hover: #1e369b;
|
|
45
|
+
--blueprint-wash: rgba(37, 68, 194, 0.07);
|
|
46
|
+
--rule-focus: rgba(37, 68, 194, 0.28);
|
|
47
|
+
--lamp-stop: #bf2f2f;
|
|
48
|
+
--wash-stop: rgba(191, 47, 47, 0.09);
|
|
49
|
+
--r-1: 4px;
|
|
50
|
+
--r-2: 6px;
|
|
51
|
+
--font-ui: 'IBM Plex Sans Variable', 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
52
|
+
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@media (prefers-color-scheme: dark) {
|
|
56
|
+
:root {
|
|
57
|
+
--floor: #080a0d;
|
|
58
|
+
--panel: #080a0d;
|
|
59
|
+
--panel-rule: rgba(255, 255, 255, 0.08);
|
|
60
|
+
--art-rule: rgba(255, 255, 255, 0.08);
|
|
61
|
+
--logo: #e6e9ee;
|
|
62
|
+
--recess: #0a0d11;
|
|
63
|
+
--rule: rgba(255, 255, 255, 0.1);
|
|
64
|
+
--rule-strong: rgba(255, 255, 255, 0.18);
|
|
65
|
+
--control: #11151a;
|
|
66
|
+
--control-rule: rgba(255, 255, 255, 0.15);
|
|
67
|
+
--control-hover: #1b2027;
|
|
68
|
+
--ink: #f1f3f6;
|
|
69
|
+
--ink-2: #b0b2b8;
|
|
70
|
+
--ink-3: #85878e;
|
|
71
|
+
--ink-4: #5c5e64;
|
|
72
|
+
--blueprint: #9dabe4;
|
|
73
|
+
--blueprint-hover: #bec7ed;
|
|
74
|
+
--blueprint-wash: rgba(37, 68, 194, 0.16);
|
|
75
|
+
--rule-focus: rgba(37, 68, 194, 0.45);
|
|
76
|
+
--lamp-stop: #f4736f;
|
|
77
|
+
--wash-stop: rgba(244, 115, 111, 0.14);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
*, *::before, *::after {
|
|
82
|
+
box-sizing: border-box;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
[hidden] {
|
|
86
|
+
display: none !important;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
html {
|
|
90
|
+
-webkit-text-size-adjust: 100%;
|
|
91
|
+
text-size-adjust: 100%;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* The panel and its footer, together in the middle of the window; a page taller than the window grows down. */
|
|
95
|
+
body {
|
|
96
|
+
display: grid;
|
|
97
|
+
align-content: center;
|
|
98
|
+
min-height: 100vh;
|
|
99
|
+
min-height: 100dvh;
|
|
100
|
+
margin: 0;
|
|
101
|
+
padding: 3rem 1rem;
|
|
102
|
+
background: var(--floor);
|
|
103
|
+
color: var(--ink);
|
|
104
|
+
font: 400 0.875rem/1.5 var(--font-ui);
|
|
105
|
+
font-variant-numeric: tabular-nums;
|
|
106
|
+
-webkit-font-smoothing: antialiased;
|
|
107
|
+
-moz-osx-font-smoothing: grayscale;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
::selection {
|
|
111
|
+
background: var(--blueprint-wash);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
main {
|
|
115
|
+
display: grid;
|
|
116
|
+
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
|
117
|
+
gap: 2.5rem;
|
|
118
|
+
width: 100%;
|
|
119
|
+
max-width: 60rem;
|
|
120
|
+
margin: 0 auto;
|
|
121
|
+
padding: 2.75rem;
|
|
122
|
+
background: var(--panel);
|
|
123
|
+
border: 1px solid var(--panel-rule);
|
|
124
|
+
border-radius: var(--r-2);
|
|
125
|
+
color: var(--ink-2);
|
|
126
|
+
overflow-wrap: anywhere;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.copy {
|
|
130
|
+
min-width: 0;
|
|
131
|
+
align-self: center;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.copy > :last-child {
|
|
135
|
+
margin-bottom: 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* The mark on the grid of the console's welcome, in the brand blue on both panels. */
|
|
139
|
+
.art {
|
|
140
|
+
display: grid;
|
|
141
|
+
place-items: center;
|
|
142
|
+
min-height: 15rem;
|
|
143
|
+
color: var(--brand);
|
|
144
|
+
background-image:
|
|
145
|
+
linear-gradient(var(--art-rule) 1px, transparent 1px),
|
|
146
|
+
linear-gradient(90deg, var(--art-rule) 1px, transparent 1px);
|
|
147
|
+
background-size: 24px 24px;
|
|
148
|
+
background-position: center;
|
|
149
|
+
border-radius: var(--r-1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.art svg {
|
|
153
|
+
display: block;
|
|
154
|
+
width: min(70%, 10rem);
|
|
155
|
+
height: auto;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* The client's logo (ADR-0066), fitted inside the grid whatever its proportions: a wordmark runs wide, a symbol tall. */
|
|
159
|
+
.art img {
|
|
160
|
+
display: block;
|
|
161
|
+
max-width: 80%;
|
|
162
|
+
max-height: 8rem;
|
|
163
|
+
width: auto;
|
|
164
|
+
height: auto;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.made-with {
|
|
168
|
+
display: flex;
|
|
169
|
+
align-items: center;
|
|
170
|
+
justify-content: space-between;
|
|
171
|
+
gap: 1rem;
|
|
172
|
+
width: 100%;
|
|
173
|
+
max-width: 60rem;
|
|
174
|
+
margin: 1rem auto 0;
|
|
175
|
+
padding-inline: 0.25rem;
|
|
176
|
+
color: var(--ink-3);
|
|
177
|
+
font-size: 0.75rem;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.runs-on {
|
|
181
|
+
display: inline-flex;
|
|
182
|
+
align-items: center;
|
|
183
|
+
gap: 0.3125rem;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.runs-on svg {
|
|
187
|
+
display: block;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.eyebrow {
|
|
191
|
+
margin: 0 0 0.75rem;
|
|
192
|
+
color: var(--ink-3);
|
|
193
|
+
font-size: 0.8125rem;
|
|
194
|
+
font-weight: 500;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
h1 {
|
|
198
|
+
margin: 0 0 1rem;
|
|
199
|
+
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
|
200
|
+
font-weight: 700;
|
|
201
|
+
line-height: 1.05;
|
|
202
|
+
letter-spacing: -0.04em;
|
|
203
|
+
color: var(--ink);
|
|
204
|
+
text-wrap: balance;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
p {
|
|
208
|
+
max-width: 34rem;
|
|
209
|
+
margin: 0 0 0.75rem;
|
|
210
|
+
color: var(--ink-2);
|
|
211
|
+
font-size: 0.9375rem;
|
|
212
|
+
line-height: 1.6;
|
|
213
|
+
text-wrap: pretty;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
strong {
|
|
217
|
+
color: var(--ink);
|
|
218
|
+
font-weight: 600;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
a {
|
|
222
|
+
color: var(--blueprint);
|
|
223
|
+
text-decoration: none;
|
|
224
|
+
border-radius: 2px;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
a:hover {
|
|
228
|
+
color: var(--blueprint-hover);
|
|
229
|
+
text-decoration: underline;
|
|
230
|
+
text-underline-offset: 2px;
|
|
231
|
+
text-decoration-thickness: 1px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
:focus-visible {
|
|
235
|
+
outline: 2px solid var(--blueprint);
|
|
236
|
+
outline-offset: 2px;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
code {
|
|
240
|
+
padding: 0.1em 0.35em;
|
|
241
|
+
font-family: var(--font-mono);
|
|
242
|
+
font-size: 0.92em;
|
|
243
|
+
background: var(--recess);
|
|
244
|
+
border-radius: var(--r-1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
ul {
|
|
248
|
+
margin: 0 0 0.75rem;
|
|
249
|
+
padding: 0;
|
|
250
|
+
list-style: none;
|
|
251
|
+
border: 1px solid var(--rule);
|
|
252
|
+
border-radius: var(--r-2);
|
|
253
|
+
font-size: 0.875rem;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
li {
|
|
257
|
+
padding: 0.5rem 0.75rem;
|
|
258
|
+
color: var(--ink-2);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
li + li {
|
|
262
|
+
border-top: 1px solid var(--rule);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
hr {
|
|
266
|
+
margin: 1.25rem 0;
|
|
267
|
+
border: 0;
|
|
268
|
+
border-top: 1px solid var(--rule);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
form {
|
|
272
|
+
display: grid;
|
|
273
|
+
gap: 1rem;
|
|
274
|
+
margin: 1rem 0 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
form + p {
|
|
278
|
+
margin: 1rem 0 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
label {
|
|
282
|
+
display: grid;
|
|
283
|
+
gap: 0.375rem;
|
|
284
|
+
color: var(--ink-2);
|
|
285
|
+
font-size: 0.8125rem;
|
|
286
|
+
font-weight: 600;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
input {
|
|
290
|
+
width: 100%;
|
|
291
|
+
min-width: 0;
|
|
292
|
+
min-height: 2.25rem;
|
|
293
|
+
margin: 0;
|
|
294
|
+
padding: 0.375rem 0.625rem;
|
|
295
|
+
font: inherit;
|
|
296
|
+
font-size: 0.875rem;
|
|
297
|
+
font-weight: 400;
|
|
298
|
+
color: var(--ink);
|
|
299
|
+
background: var(--control);
|
|
300
|
+
border: 1px solid var(--control-rule);
|
|
301
|
+
border-radius: var(--r-1);
|
|
302
|
+
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
input::placeholder {
|
|
306
|
+
color: var(--ink-4);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
input:hover {
|
|
310
|
+
border-color: var(--rule-strong);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
input:focus {
|
|
314
|
+
outline: none;
|
|
315
|
+
border-color: var(--blueprint);
|
|
316
|
+
box-shadow: 0 0 0 3px var(--rule-focus);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
button,
|
|
320
|
+
.button {
|
|
321
|
+
display: inline-flex;
|
|
322
|
+
align-items: center;
|
|
323
|
+
justify-content: center;
|
|
324
|
+
justify-self: start;
|
|
325
|
+
gap: 0.375rem;
|
|
326
|
+
min-height: 2.25rem;
|
|
327
|
+
margin: 0;
|
|
328
|
+
padding: 0.375rem 0.875rem;
|
|
329
|
+
font: inherit;
|
|
330
|
+
font-size: 0.8125rem;
|
|
331
|
+
font-weight: 500;
|
|
332
|
+
white-space: nowrap;
|
|
333
|
+
text-decoration: none;
|
|
334
|
+
color: var(--ink);
|
|
335
|
+
background: var(--control);
|
|
336
|
+
border: 1px solid var(--control-rule);
|
|
337
|
+
border-radius: var(--r-1);
|
|
338
|
+
cursor: pointer;
|
|
339
|
+
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
button:hover,
|
|
343
|
+
.button:hover {
|
|
344
|
+
color: var(--ink);
|
|
345
|
+
text-decoration: none;
|
|
346
|
+
background: var(--control-hover);
|
|
347
|
+
border-color: var(--rule-strong);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
form > button:first-of-type {
|
|
351
|
+
color: var(--brand-on);
|
|
352
|
+
background: var(--brand);
|
|
353
|
+
border-color: var(--brand);
|
|
354
|
+
font-weight: 600;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
form > button:first-of-type:hover {
|
|
358
|
+
color: var(--brand-on);
|
|
359
|
+
background: var(--brand-hover);
|
|
360
|
+
border-color: var(--brand-hover);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.password {
|
|
364
|
+
display: flex;
|
|
365
|
+
background: var(--control);
|
|
366
|
+
border: 1px solid var(--control-rule);
|
|
367
|
+
border-radius: var(--r-1);
|
|
368
|
+
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.password:hover {
|
|
372
|
+
border-color: var(--rule-strong);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.password:focus-within {
|
|
376
|
+
border-color: var(--blueprint);
|
|
377
|
+
box-shadow: 0 0 0 3px var(--rule-focus);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.password input {
|
|
381
|
+
flex: 1;
|
|
382
|
+
min-height: calc(2.25rem - 2px);
|
|
383
|
+
background: transparent;
|
|
384
|
+
border: 0;
|
|
385
|
+
box-shadow: none;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.password button {
|
|
389
|
+
flex: none;
|
|
390
|
+
min-height: 0;
|
|
391
|
+
margin: 0.25rem;
|
|
392
|
+
padding: 0 0.625rem;
|
|
393
|
+
font-size: 0.75rem;
|
|
394
|
+
color: var(--blueprint);
|
|
395
|
+
background: transparent;
|
|
396
|
+
border: 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.password button:hover {
|
|
400
|
+
color: var(--blueprint);
|
|
401
|
+
background: var(--blueprint-wash);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.error {
|
|
405
|
+
display: flex;
|
|
406
|
+
gap: 0.625rem;
|
|
407
|
+
padding: 0.75rem 1rem;
|
|
408
|
+
color: var(--ink);
|
|
409
|
+
background: var(--wash-stop);
|
|
410
|
+
border-radius: var(--r-2);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.error::before {
|
|
414
|
+
content: '';
|
|
415
|
+
flex: none;
|
|
416
|
+
width: 0.5rem;
|
|
417
|
+
height: 0.5rem;
|
|
418
|
+
margin-top: 0.55em;
|
|
419
|
+
border-radius: 50%;
|
|
420
|
+
background: var(--lamp-stop);
|
|
421
|
+
box-shadow: 0 0 0 3px var(--wash-stop);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* On a phone the art becomes a band above the copy. */
|
|
425
|
+
@media (max-width: 40rem) {
|
|
426
|
+
body {
|
|
427
|
+
padding: 1.5rem 1rem;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
main {
|
|
431
|
+
grid-template-columns: minmax(0, 1fr);
|
|
432
|
+
gap: 1.5rem;
|
|
433
|
+
padding: 1.5rem;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.art {
|
|
437
|
+
order: -1;
|
|
438
|
+
min-height: 7rem;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.art svg {
|
|
442
|
+
width: 4.5rem;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.art img {
|
|
446
|
+
max-height: 4.5rem;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* A smaller input makes a phone zoom the page in when it is focused. */
|
|
450
|
+
input {
|
|
451
|
+
font-size: 1rem;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
@media (prefers-reduced-motion: reduce) {
|
|
456
|
+
* {
|
|
457
|
+
transition: none !important;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
`
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* What the grid shows: the installation's client logo where it has one (ADR-0066), the designer's mark otherwise. The
|
|
464
|
+
* dark variant takes over on a dark page; without one, the light logo stands on the dark panel as it is.
|
|
465
|
+
*/
|
|
466
|
+
export function pageArt(brand: InstallationBrand | null): string {
|
|
467
|
+
if (brand === null) return brandMark(160)
|
|
468
|
+
const dark = brand.dark === undefined ? '' : `<source media="(prefers-color-scheme: dark)" srcset="${dataUri(brand.dark)}">`
|
|
469
|
+
return `<picture>${dark}<img src="${dataUri(brand.light)}" alt=""></picture>`
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** `brandImageDataUri` of `./brand`, written here so this module stays without a runtime import. Base64 holds no `{`. */
|
|
473
|
+
function dataUri(image: BrandImage): string {
|
|
474
|
+
return `data:${image.type};base64,${image.base64}`
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The designer's mark, as the console's `BrandMark` draws it: `height` tall and a hair narrower. */
|
|
478
|
+
export function brandMark(height: number): string {
|
|
479
|
+
return `<svg width="${
|
|
480
|
+
Math.round((height * 94) / 98)
|
|
481
|
+
}" height="${height}" viewBox="0 0 94 98" fill="currentColor" aria-hidden="true" focusable="false"><path d="M0 31 53 0 94 22 94 98 54 98 54 31 25 48 25 98 0 98Z"/></svg>`
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** What the footer under the panel says: who runs the page, and the status the page answers with, if it has one. */
|
|
485
|
+
export interface PageFooter {
|
|
486
|
+
/** The words before the mark and the name: "Runs on" in English. */
|
|
487
|
+
readonly runsOn: string
|
|
488
|
+
readonly status?: string
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The body every page in this stylesheet has: the panel with the page's `content` at the left and the art on the grid
|
|
493
|
+
* at the right (`pageArt`), and the footer under it, which names fabrika whatever the art shows. Every value is markup
|
|
494
|
+
* the caller has already escaped; the brand is checked where it was written and again where it was read.
|
|
495
|
+
*/
|
|
496
|
+
export function pageBody(content: string, footer: PageFooter, brand: InstallationBrand | null = null): string {
|
|
497
|
+
const status = footer.status === undefined ? '' : `<span class="status">${footer.status}</span>`
|
|
498
|
+
return `<main>
|
|
499
|
+
<div class="copy">
|
|
500
|
+
${content}
|
|
501
|
+
</div>
|
|
502
|
+
<div class="art" aria-hidden="true">${pageArt(brand)}</div>
|
|
503
|
+
</main>
|
|
504
|
+
<footer class="made-with"><span class="runs-on">${footer.runsOn} ${brandMark(12)} fabrika</span>${status}</footer>`
|
|
505
|
+
}
|
package/src/pages.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { InstallationBrand } from './brand'
|
|
2
|
+
import { PAGE_STYLE, pageBody } from './page-style'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The pages a person reads when the proxy, not the application, answers their browser: a refusal, an unknown host, an
|
|
6
|
+
* application that does not answer. Every enforcement point renders them from here, the auth service, the generated
|
|
7
|
+
* Caddy configuration and the Cloudflare proxy Worker, so a refusal looks the same on both providers.
|
|
8
|
+
*
|
|
9
|
+
* A page is only for a top-level navigation (`isPageNavigation`). An XHR, a `fetch`, a key-carrying program or a client
|
|
10
|
+
* that sends no `Sec-Fetch-Mode` gets the short text it always got, which is what each of them parses. The text of a
|
|
11
|
+
* page is fixed per status and names no deny reason: the reasons stay coarse and stay in the log.
|
|
12
|
+
*
|
|
13
|
+
* They look like IAM's pages because they carry the same stylesheet and frame (`./page-style`). No script and no external
|
|
14
|
+
* reference: a page is served on the application's own origin under its header policy, whose CSP admits inline styles
|
|
15
|
+
* and nothing else that a page could load from elsewhere. A download endpoint's answers never get a page, because the
|
|
16
|
+
* file treatment makes every one of them an attachment.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The browser's own description of what issued the request, written by the browser and not by the page: `Sec-` is a
|
|
21
|
+
* forbidden header prefix, so a page's `fetch` cannot set it. Read from the FORWARDED headers like everything else a
|
|
22
|
+
* decision depends on; caddy 2.10.2's `forward_auth` subrequest carries the original `Sec-Fetch-*` verbatim.
|
|
23
|
+
*/
|
|
24
|
+
export const FETCH_MODE_HEADER = 'Sec-Fetch-Mode'
|
|
25
|
+
|
|
26
|
+
/** The statuses the proxy answers on its own. */
|
|
27
|
+
export type ErrorPageStatus = 400 | 401 | 403 | 404 | 502 | 503 | 504
|
|
28
|
+
|
|
29
|
+
/** The console's refresh glyph (`packages/dashboard/src/components/Icon.tsx`), drawn as its `Icon` draws it. */
|
|
30
|
+
const REFRESH =
|
|
31
|
+
`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.4 12a8.4 8.4 0 1 1-2.6-6.05"/><path d="M20.4 3.85v4.3h-4.3"/></svg>`
|
|
32
|
+
|
|
33
|
+
/** A same-origin link under a page's text, drawn as the console's outlined button; an empty `href` reloads the page. */
|
|
34
|
+
export interface PageAction {
|
|
35
|
+
readonly href: string
|
|
36
|
+
readonly label: string
|
|
37
|
+
/** Draws the console's refresh glyph before the label, as its Retry does. */
|
|
38
|
+
readonly retry?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PageContent {
|
|
42
|
+
readonly title: string
|
|
43
|
+
readonly detail: string
|
|
44
|
+
readonly action?: PageAction
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Only an explicit `navigate`. Unlike the login bounce, an absent header is not read as a browser here: a client that
|
|
49
|
+
* sends none (curl, a script, an old SDK) has always read the text, and a page instead of it helps nobody.
|
|
50
|
+
*/
|
|
51
|
+
export function isPageNavigation(headers: Headers): boolean {
|
|
52
|
+
return headers.get(FETCH_MODE_HEADER) === 'navigate'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const START_PAGE: PageAction = { href: '/', label: 'Go to the start page' }
|
|
56
|
+
const TRY_AGAIN: PageAction = { href: '', label: 'Try again', retry: true }
|
|
57
|
+
|
|
58
|
+
const STATUS_PAGES: Record<ErrorPageStatus, PageContent> = {
|
|
59
|
+
400: {
|
|
60
|
+
title: 'This request could not be read',
|
|
61
|
+
detail: 'The address or the request is malformed, so it was not passed on to the application.',
|
|
62
|
+
action: START_PAGE,
|
|
63
|
+
},
|
|
64
|
+
401: {
|
|
65
|
+
title: 'This page needs an access key',
|
|
66
|
+
detail: 'This address answers programs that present a key. Opening it in a browser does not sign you in.',
|
|
67
|
+
action: START_PAGE,
|
|
68
|
+
},
|
|
69
|
+
// The proxy also refuses a path the application does not serve with a 403, so the text cannot tell the two apart.
|
|
70
|
+
403: {
|
|
71
|
+
title: 'You cannot open this page',
|
|
72
|
+
detail: 'The page does not exist, or your account has no access to it. If you expected to see it, ask whoever runs this application.',
|
|
73
|
+
action: START_PAGE,
|
|
74
|
+
},
|
|
75
|
+
404: {
|
|
76
|
+
title: 'Nothing is served here',
|
|
77
|
+
detail: 'No application answers at this address. Check that it is spelled correctly.',
|
|
78
|
+
},
|
|
79
|
+
502: {
|
|
80
|
+
title: 'The application is not responding',
|
|
81
|
+
detail: 'It may be starting or restarting. Try again in a moment.',
|
|
82
|
+
action: TRY_AGAIN,
|
|
83
|
+
},
|
|
84
|
+
503: {
|
|
85
|
+
title: 'This page is unavailable for a moment',
|
|
86
|
+
detail: 'A service it depends on could not be reached. Try again in a moment.',
|
|
87
|
+
action: TRY_AGAIN,
|
|
88
|
+
},
|
|
89
|
+
504: {
|
|
90
|
+
title: 'The application is not responding',
|
|
91
|
+
detail: 'It did not answer in time. Try again in a moment.',
|
|
92
|
+
action: TRY_AGAIN,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ErrorPageOptions {
|
|
97
|
+
/**
|
|
98
|
+
* The code printed on the page, the status itself by default; the Caddy configuration passes
|
|
99
|
+
* `{http.error.status_code}`, a placeholder Caddy fills in for the status it answers with.
|
|
100
|
+
*/
|
|
101
|
+
readonly shownStatus?: string
|
|
102
|
+
/** The installation's client logo (ADR-0066), from the manifest; the fabrika mark without one. */
|
|
103
|
+
readonly brand?: InstallationBrand | null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The page of a status. */
|
|
107
|
+
export function errorPage(status: ErrorPageStatus, options: ErrorPageOptions = {}): string {
|
|
108
|
+
return renderPage(STATUS_PAGES[status], options.shownStatus ?? String(status), options.brand ?? null)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The answer of a status: its page when `asPage`, `text` as plain text otherwise. The caller applies the header policy. */
|
|
112
|
+
export function errorAnswer(status: ErrorPageStatus, text: string, asPage: boolean, brand: InstallationBrand | null = null): Response {
|
|
113
|
+
return asPage ? pageResponse(status, errorPage(status, { brand })) : textAnswer(status, text)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A rendered page as an answer; the caller applies the header policy. */
|
|
117
|
+
export function pageResponse(status: number, body: string): Response {
|
|
118
|
+
return new Response(body, { status, headers: { 'content-type': PAGE_CONTENT_TYPE, 'cache-control': 'no-store' } })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const PAGE_CONTENT_TYPE = 'text/html; charset=utf-8'
|
|
122
|
+
|
|
123
|
+
function textAnswer(status: number, body: string): Response {
|
|
124
|
+
return new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The page in the shared stylesheet and frame (`./page-style`): a heading, one sentence and an outlined button in the
|
|
129
|
+
* panel, the status in the footer for whoever reports it. Every string that reaches the markup is a constant of this
|
|
130
|
+
* module or a status code; nothing of the request is printed, so nothing is escaped.
|
|
131
|
+
*/
|
|
132
|
+
export function renderPage(content: PageContent, shownStatus: string, brand: InstallationBrand | null = null): string {
|
|
133
|
+
const action = content.action === undefined
|
|
134
|
+
? ''
|
|
135
|
+
: `\n<a class="button" href="${content.action.href}">${content.action.retry === true ? REFRESH : ''}${content.action.label}</a>`
|
|
136
|
+
return `<!doctype html>
|
|
137
|
+
<html lang="en">
|
|
138
|
+
<head>
|
|
139
|
+
<meta charset="utf-8">
|
|
140
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
141
|
+
<meta name="robots" content="noindex">
|
|
142
|
+
<title>${content.title}</title>
|
|
143
|
+
<style>${PAGE_STYLE}</style>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
${pageBody(`<h1>${content.title}</h1>\n<p>${content.detail}</p>${action}`, { runsOn: 'Runs on', status: `Status ${shownStatus}` }, brand)}
|
|
147
|
+
</body>
|
|
148
|
+
</html>
|
|
149
|
+
`
|
|
150
|
+
}
|