@k2b/cloud 0.34.0 → 0.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -107,7 +107,7 @@
107
107
  "@tailwindcss/typography": "0.5.20",
108
108
  "@k2b/nessi": "0.18.0",
109
109
  "@k2b/ssr": "0.16.0",
110
- "@k2b/ui": "0.25.0",
110
+ "@k2b/ui": "0.26.0",
111
111
  "@k2b/stdlib": "0.29.0",
112
112
  "@k2b/sync": "7.0.0",
113
113
  "@nats-io/jetstream": "3.4.0",
@@ -68,12 +68,13 @@ const firstPage = (coverage: GroupCoverage) => coverage.members.pages()[0];
68
68
  * The members toggle beside the group name. Its label never changes, so
69
69
  * loading the members moves nothing in the row; the count leads the list.
70
70
  */
71
- export function GroupCoverageToggle(props: { coverage: GroupCoverage; groupName: string }) {
71
+ export function GroupCoverageToggle(props: { coverage: GroupCoverage; groupName: string; disabled?: boolean }) {
72
72
  const locale = useLocale();
73
73
  const t = () => accessMessages.resolve([locale()]).t;
74
74
  return (
75
75
  <button
76
76
  type="button"
77
+ disabled={props.disabled}
77
78
  aria-expanded={props.coverage.open()}
78
79
  aria-controls={props.coverage.panelId}
79
80
  // The group name tells several group rows apart.
@@ -1,14 +1,14 @@
1
1
  import { mutation } from "@k2b/stdlib/solid";
2
- import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
3
- import { createMemo, For, Show } from "solid-js";
4
- import { createStore } from "solid-js/store";
2
+ import { announce, IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
3
+ import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
4
+ import { createStore, reconcile } from "solid-js/store";
5
5
  import { CloudAvatar } from "../account/Avatar";
6
6
  import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
7
7
  import { groupDisplayName } from "../shared/account-display";
8
8
  import { createGroupCoverage, type GroupCoverage, GroupCoveragePanel, GroupCoverageToggle } from "./GroupCoverage";
9
9
  import { isManagerEntry } from "./managers";
10
10
  import { accessMessages } from "./messages";
11
- import PrincipalPicker from "./PrincipalPicker";
11
+ import PrincipalPicker, { principalKey } from "./PrincipalPicker";
12
12
  import { serviceAccountKindDisplay } from "./service-account-kind";
13
13
 
14
14
  // ─────────────────────────────────────────────────────────────────────────
@@ -180,6 +180,12 @@ export default function PermissionEditor(props: PermissionEditorProps) {
180
180
  // A store keeps each row's identity across level changes, so an expanded member list stays open. It holds
181
181
  // copies: store writes go into the objects themselves, and the caller's entries are not the editor's to change.
182
182
  const [entries, setEntries] = createStore<AccessEntry[]>(props.initialEntries.map((entry) => ({ ...entry })));
183
+ const [pending, setPending] = createSignal<string | null>(null);
184
+ const grantPending = () => pending() !== null;
185
+ let disposed = false;
186
+ onCleanup(() => {
187
+ disposed = true;
188
+ });
183
189
  const canEdit = () => props.canEdit !== false;
184
190
  const allowPublic = () => props.allowPublic === true;
185
191
  const allowAuthenticated = () => props.allowAuthenticated !== false;
@@ -196,18 +202,38 @@ export default function PermissionEditor(props: PermissionEditorProps) {
196
202
  }
197
203
  }
198
204
 
199
- const grantMut = mutation.create({
200
- mutation: async (data: {
201
- principal: Principal;
202
- permission: GrantableLevel;
203
- scope: string | undefined;
204
- display: { displayName: string; serviceAccountKind?: ServiceAccountKind };
205
- }) => props.grantAccess(data.principal, data.permission, data.display, data.scope),
206
- onSuccess: (newEntry) => {
207
- setEntries(entries.length, { ...newEntry });
208
- },
209
- onError: (err) => prompts.error(err.message),
210
- });
205
+ const grant = async (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => {
206
+ if (disposed || grantPending() || updateMut.loading() || revokeMut.loading()) return;
207
+ const key = principalKey(principal);
208
+ if (entries.some((entry) => principalKey(entry.principal) === key)) return;
209
+ const first = allowed(principal)[0];
210
+ if (!first) return;
211
+ const id = `pending:${key}`;
212
+ setPending(id);
213
+ setEntries(entries.length, {
214
+ id,
215
+ principal,
216
+ permission: first.level,
217
+ scope: first.scope,
218
+ createdAt: new Date().toISOString(),
219
+ ...display,
220
+ });
221
+ try {
222
+ const entry = await props.grantAccess(principal, first.level, display, first.scope);
223
+ if (disposed) return;
224
+ const index = entries.findIndex((entry) => entry.id === id);
225
+ if (index !== -1) setEntries(index, reconcile({ ...entry }));
226
+ announce(t().accessGranted({ name: display.displayName }));
227
+ } catch (err) {
228
+ if (!disposed) setEntries((current) => current.filter((entry) => entry.id !== id));
229
+ // Prompts are global, so a grant that fails after its dialog closed still says so.
230
+ if (!(typeof err === "object" && err !== null && "name" in err && err.name === "AbortError")) {
231
+ prompts.error(err instanceof Error ? err.message : String(err));
232
+ }
233
+ } finally {
234
+ if (!disposed) setPending(null);
235
+ }
236
+ };
211
237
 
212
238
  type LevelChange = { accessId: string; permission: GrantableLevel; scope: string | undefined };
213
239
  const updateMut = mutation.create<LevelChange, LevelChange>({
@@ -239,11 +265,12 @@ export default function PermissionEditor(props: PermissionEditorProps) {
239
265
  },
240
266
  onError: (err) => prompts.error(err.message),
241
267
  });
242
- const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
268
+ const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
243
269
  // The service refuses to remove the last manager; the row says so up front.
244
270
  const lastManagerId = createMemo(() => {
245
271
  // A grant on part of the resource never manages it.
246
- const managers = (props.effectiveEntries?.(entries) ?? entries).filter((entry) => !entry.scope && isManagerEntry(entry));
272
+ const granted = entries.filter((entry) => entry.id !== pending());
273
+ const managers = (props.effectiveEntries?.(granted) ?? granted).filter((entry) => !entry.scope && isManagerEntry(entry));
247
274
  return managers.length === 1 ? managers[0]!.id : null;
248
275
  });
249
276
 
@@ -260,6 +287,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
260
287
  entry={entry}
261
288
  coverage={coverage}
262
289
  canEdit={canEdit()}
290
+ pending={entry.id === pending()}
263
291
  disabled={busy()}
264
292
  lastManager={entry.id === lastManagerId()}
265
293
  allowed={allowed(entry.principal)}
@@ -288,11 +316,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
288
316
  allowPublic={allowPublic()}
289
317
  allowAuthenticated={allowAuthenticated()}
290
318
  allowServiceAccounts={props.allowServiceAccounts}
291
- disabled={busy()}
292
- onSelect={(principal, display) => {
293
- const first = allowed(principal)[0];
294
- if (first && !busy()) grantMut.mutate({ principal, permission: first.level, scope: first.scope, display });
295
- }}
319
+ readOnly={grantPending()}
320
+ disabled={updateMut.loading() || revokeMut.loading()}
321
+ onSelect={(principal, display) => void grant(principal, display)}
296
322
  />
297
323
  </Show>
298
324
  </div>
@@ -307,6 +333,7 @@ function AccessEntryRow(props: {
307
333
  entry: AccessEntry;
308
334
  canEdit: boolean;
309
335
  disabled: boolean;
336
+ pending?: boolean;
310
337
  /** The only entry that manages the resource: it keeps its level and cannot be removed. */
311
338
  lastManager: boolean;
312
339
  allowed: ResolvedLevel[];
@@ -326,14 +353,14 @@ function AccessEntryRow(props: {
326
353
  const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
327
354
  const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
328
355
  const isInteractive = () =>
329
- props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
356
+ props.canEdit && !props.disabled && !props.pending && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
330
357
 
331
358
  const badgeClass =
332
359
  "flex min-h-7 items-center gap-1 rounded-full border border-transparent bg-[var(--ui-surface-muted)] px-2.5 py-1 text-xs text-secondary";
333
360
 
334
361
  const badgeContent = (
335
362
  <>
336
- <i class={`ti ${display().icon}`} />
363
+ <i class={props.pending ? "ti ti-loader-2 animate-spin" : `ti ${display().icon}`} />
337
364
  <span>{display().label}</span>
338
365
  <Show when={isInteractive()}>
339
366
  <i class="ti ti-chevron-down text-[10px]" />
@@ -343,7 +370,7 @@ function AccessEntryRow(props: {
343
370
 
344
371
  return (
345
372
  <div>
346
- <div class="group/access-row flex items-center gap-2 py-1.5">
373
+ <div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={props.pending ? "true" : undefined}>
347
374
  <Show
348
375
  when={props.entry.principal.type === "user"}
349
376
  fallback={
@@ -381,7 +408,9 @@ function AccessEntryRow(props: {
381
408
  >
382
409
  {(label) => <span class="truncate text-xs text-dimmed">({label()})</span>}
383
410
  </Show>
384
- <Show when={props.coverage}>{(coverage) => <GroupCoverageToggle coverage={coverage()} groupName={displayName()} />}</Show>
411
+ <Show when={props.coverage}>
412
+ {(coverage) => <GroupCoverageToggle coverage={coverage()} groupName={displayName()} disabled={props.pending} />}
413
+ </Show>
385
414
  </div>
386
415
 
387
416
  {/* Permission badge — interactive single-value picker when editable,
@@ -422,7 +451,7 @@ function AccessEntryRow(props: {
422
451
  variant="ghost"
423
452
  size="xs"
424
453
  onClick={props.onRevoke}
425
- disabled={props.disabled || props.lastManager}
454
+ disabled={props.disabled || props.pending || props.lastManager}
426
455
  aria-label={t().remove({ name: displayName() })}
427
456
  // A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
428
457
  aria-description={props.lastManager ? removeLabel() : undefined}
@@ -33,6 +33,7 @@ export default function PrincipalPicker(props: {
33
33
  allowAuthenticated?: boolean;
34
34
  allowServiceAccounts?: boolean;
35
35
  disabled?: boolean;
36
+ readOnly?: boolean;
36
37
  /** `serviceAccountKind` is set for service accounts so a new row can show the same kind as the result. */
37
38
  onSelect: (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => void;
38
39
  }) {
@@ -100,6 +101,7 @@ export default function PrincipalPicker(props: {
100
101
  return (
101
102
  <Combobox
102
103
  disabled={props.disabled}
104
+ readOnly={props.readOnly}
103
105
  placeholder={
104
106
  props.allowAuthenticated !== false || props.allowPublic
105
107
  ? props.allowServiceAccounts
@@ -14,6 +14,7 @@ export const accessMessages = i18n.define({
14
14
  publicDescription: "Anyone with the link, including people who are not signed in",
15
15
  removeAccessConfirm: ({ name }: { name: string }) => `Remove access for ${name}?`,
16
16
  removeAccess: "Remove access",
17
+ accessGranted: ({ name }: { name: string }) => `Added ${name}`,
17
18
  userBoundServiceAccount: "User-bound service account",
18
19
  resourceBoundServiceAccount: "Resource-bound service account",
19
20
  standaloneServiceAccount: "Service account",
@@ -92,6 +93,7 @@ export const accessMessages = i18n.define({
92
93
  publicDescription: "Alle mit dem Link, auch ohne Anmeldung",
93
94
  removeAccessConfirm: ({ name }) => `Zugriff für ${name} entfernen?`,
94
95
  removeAccess: "Zugriff entfernen",
96
+ accessGranted: ({ name }) => `${name} hinzugefügt`,
95
97
  userBoundServiceAccount: "Benutzergebundenes Dienstkonto",
96
98
  resourceBoundServiceAccount: "Ressourcengebundenes Dienstkonto",
97
99
  standaloneServiceAccount: "Dienstkonto",
@@ -425,6 +425,7 @@ const SCHEMA_KEYS = new Set([
425
425
  "required",
426
426
  "items",
427
427
  "prefixItems",
428
+ "additionalItems",
428
429
  "additionalProperties",
429
430
  "anyOf",
430
431
  "oneOf",
@@ -435,29 +436,139 @@ const SCHEMA_KEYS = new Set([
435
436
  "definitions",
436
437
  ]);
437
438
 
438
- const reduceSchemaValue = (value: unknown, key?: string): unknown => {
439
- if (key === "const") return structuredClone(value);
439
+ type SchemaReductionContext = {
440
+ root: Record<string, unknown>;
441
+ mirrorNames: Map<string, string>;
442
+ mirrors: Record<string, Record<string, unknown>>;
443
+ };
444
+
445
+ /** A local reference into the input schema: the root (`#`) or one of its `$defs` or `definitions`. */
446
+ const localReference = (
447
+ reference: unknown,
448
+ root: Record<string, unknown>,
449
+ ): { identity: string; keyword: string; name: string; schema: unknown } | null => {
450
+ if (reference === "#") {
451
+ const { $defs: _defs, definitions: _definitions, ...schema } = root;
452
+ return { identity: "#", keyword: "$defs", name: "root", schema };
453
+ }
454
+ const match = typeof reference === "string" ? /^#\/(\$defs|definitions)\/([^/]+)$/.exec(reference) : null;
455
+ if (!match) return null;
456
+ const [, keyword, encodedName] = match;
457
+ const name = encodedName!.replace(/~1/g, "/").replace(/~0/g, "~");
458
+ const definitions = root[keyword!];
459
+ if (!definitions || typeof definitions !== "object" || !Object.hasOwn(definitions, name)) return null;
460
+ return { identity: `${keyword}/${name}`, keyword: keyword!, name, schema: Reflect.get(definitions, name) };
461
+ };
462
+
463
+ /** Whether a raw schema reaches a tuple; `visited` keeps each definition to one visit per question. */
464
+ const containsTuple = (value: unknown, context: SchemaReductionContext, key?: string, visited = new Set<string>()): boolean => {
465
+ if (key === "const" || key === "enum" || key === "default" || key === "required" || key === "type") return false;
466
+ if (Array.isArray(value)) return value.some((item) => containsTuple(item, context, undefined, visited));
467
+ if (!value || typeof value !== "object") return false;
468
+ if (key === "properties" || key === "$defs" || key === "definitions") {
469
+ return Object.values(value).some((schema) => containsTuple(schema, context, undefined, visited));
470
+ }
471
+ if (Object.hasOwn(value, "prefixItems") || Array.isArray(Reflect.get(value, "items"))) return true;
472
+ const reference = localReference(Reflect.get(value, "$ref"), context.root);
473
+ if (reference && !visited.has(reference.identity)) {
474
+ visited.add(reference.identity);
475
+ if (containsTuple(reference.schema, context, undefined, visited)) return true;
476
+ }
477
+ return Object.entries(value).some(([childKey, childValue]) => containsTuple(childValue, context, childKey, visited));
478
+ };
479
+
480
+ const reduceSchemaValue = (value: unknown, context: SchemaReductionContext, key?: string, merged = false): unknown => {
481
+ if (key === "const" || key === "default") return structuredClone(value);
440
482
  if (Array.isArray(value)) {
441
483
  if (key === "required" || key === "enum" || key === "type") return structuredClone(value);
442
- return value.map((item) => reduceSchemaValue(item));
484
+ return value.map((item) => reduceSchemaValue(item, context, undefined, merged));
443
485
  }
444
486
  if (!value || typeof value !== "object") return value;
487
+ if (key === "properties" || key === "$defs" || key === "definitions") {
488
+ return Object.fromEntries(Object.entries(value).map(([name, schema]) => [name, reduceSchemaValue(schema, context, undefined, merged)]));
489
+ }
445
490
 
446
491
  const output: Record<string, unknown> = {};
447
492
  for (const [childKey, childValue] of Object.entries(value)) {
448
- if (key === "properties" || key === "$defs" || key === "definitions") {
449
- output[childKey] = reduceSchemaValue(childValue);
450
- continue;
493
+ if (!SCHEMA_KEYS.has(childKey) || (merged && childKey === "default")) continue;
494
+ // Tuple widening can make surrounding union branches overlap, so their defaults belong to the app.
495
+ const unionMerged =
496
+ merged ||
497
+ ((childKey === "anyOf" || childKey === "oneOf") &&
498
+ Array.isArray(childValue) &&
499
+ childValue.some((branch) => containsTuple(branch, context)));
500
+ output[childKey] = reduceSchemaValue(childValue, context, childKey, unionMerged);
501
+ }
502
+ if (output.oneOf !== undefined) {
503
+ if (output.anyOf === undefined) output.anyOf = output.oneOf;
504
+ else {
505
+ output.allOf = [...(Array.isArray(output.allOf) ? output.allOf : []), { anyOf: output.anyOf }, { anyOf: output.oneOf }];
506
+ delete output.anyOf;
451
507
  }
452
- if (!SCHEMA_KEYS.has(childKey)) continue;
453
- output[childKey] = reduceSchemaValue(childValue, childKey);
508
+ delete output.oneOf;
509
+ }
510
+
511
+ const positions = Array.isArray(output.prefixItems) ? output.prefixItems : Array.isArray(output.items) ? output.items : null;
512
+ if (positions) {
513
+ const rest = Array.isArray(output.prefixItems) ? output.items : output.additionalItems;
514
+ // Extra items of an open tuple may be anything, so the AI side must not narrow them to the positions.
515
+ const schemas = rest === undefined || rest === true ? [] : rest === false ? positions : [...positions, rest];
516
+ // Position-erased branches must leave defaults to the app's authoritative tuple schema.
517
+ const mergedSchemas = schemas.some((schema) => JSON.stringify(schema) !== JSON.stringify(schemas[0]))
518
+ ? schemas.map((schema) => reduceSchemaValue(schema, context, undefined, true))
519
+ : schemas;
520
+ const distinct = mergedSchemas.filter(
521
+ (schema, index) => mergedSchemas.findIndex((candidate) => JSON.stringify(candidate) === JSON.stringify(schema)) === index,
522
+ );
523
+ output.items = distinct.length === 0 ? {} : distinct.length === 1 ? distinct[0] : { anyOf: distinct };
524
+ if (rest === false)
525
+ output.maxItems = Math.min(typeof output.maxItems === "number" ? output.maxItems : positions.length, positions.length);
526
+ }
527
+ delete output.prefixItems;
528
+ delete output.additionalItems;
529
+ if (output.items === false) output.maxItems = 0;
530
+ // Zod drops array bounds without an item schema, so "any item" stays an empty schema.
531
+ if (typeof output.items === "boolean") output.items = {};
532
+ const reference = merged ? localReference(output.$ref, context.root) : null;
533
+ if (reference) {
534
+ const { identity, keyword, name } = reference;
535
+ const definitions = context.root[keyword];
536
+ const mirrors = context.mirrors[keyword] ?? {};
537
+ context.mirrors[keyword] = mirrors;
538
+ let mirrorName = context.mirrorNames.get(identity);
539
+ if (mirrorName === undefined) {
540
+ mirrorName = `${name}.ai`;
541
+ for (
542
+ let suffix = 1;
543
+ (definitions && typeof definitions === "object" && Object.hasOwn(definitions, mirrorName)) || Object.hasOwn(mirrors, mirrorName);
544
+ suffix++
545
+ ) {
546
+ mirrorName = `${name}.ai${suffix}`;
547
+ }
548
+ // Reserve the mirror before descending so recursive refs never reach default-bearing originals.
549
+ context.mirrorNames.set(identity, mirrorName);
550
+ mirrors[mirrorName] = {};
551
+ mirrors[mirrorName] = reduceSchemaValue(reference.schema, context, undefined, true);
552
+ }
553
+ output.$ref = `#/${keyword}/${mirrorName.replace(/~/g, "~0").replace(/\//g, "~1")}`;
454
554
  }
455
555
  return output;
456
556
  };
457
557
 
458
- /** Keep the provider-useful shape while leaving authoritative validation in the target app. */
459
- export const reduceAiCapabilityInputSchema = (schema: Record<string, unknown>): Record<string, unknown> =>
460
- reduceSchemaValue(schema) as Record<string, unknown>;
558
+ /**
559
+ * Keep the provider-useful shape while leaving authoritative validation in the target app.
560
+ * Tuples reach the model as bounded arrays of their item schemas and `oneOf` as `anyOf`, because loosened branches may
561
+ * overlap; the app still validates exact tuple positions and exclusive unions.
562
+ */
563
+ export const reduceAiCapabilityInputSchema = (schema: Record<string, unknown>): Record<string, unknown> => {
564
+ const context: SchemaReductionContext = { root: schema, mirrorNames: new Map(), mirrors: {} };
565
+ const reduced = reduceSchemaValue(schema, context) as Record<string, unknown>;
566
+ for (const [keyword, mirrors] of Object.entries(context.mirrors)) {
567
+ const definitions = reduced[keyword];
568
+ reduced[keyword] = { ...(definitions && typeof definitions === "object" ? definitions : {}), ...mirrors };
569
+ }
570
+ return reduced;
571
+ };
461
572
 
462
573
  export const aiCapabilityInputSchema = (schema: Record<string, unknown>): z.ZodType =>
463
574
  z.fromJSONSchema(reduceAiCapabilityInputSchema(schema));
@@ -1,8 +1,8 @@
1
- import { isIP } from "node:net";
2
1
  import { type ToolContext, truncateMiddle } from "@k2b/nessi";
3
2
  import { z } from "zod";
4
3
  import type { RequestActor } from "../server";
5
4
  import { coreSettings } from "../services";
5
+ import { isUnsafeNetworkAddress, isUnsafeNetworkHostname, parseNetworkAddress } from "../shared/network-address";
6
6
  import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsWebsiteApprovals, aiWebsiteApprovalScope, findRememberedAiToolApproval } from "./approvals";
7
7
  import { aiConversations } from "./store";
8
8
  import { defineAiTool } from "./tools";
@@ -141,31 +141,6 @@ const truncate = (text: string, maxChars: number): { text: string; truncated: bo
141
141
  return { text: text.slice(0, Math.max(0, maxChars)).trimEnd(), truncated: true };
142
142
  };
143
143
 
144
- const isPrivateIpv4 = (hostname: string): boolean => {
145
- const parts = hostname.split(".").map((part) => Number(part));
146
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
147
- const [a, b] = parts as [number, number, number, number];
148
- if (a === 0 || a === 10 || a === 127) return true;
149
- if (a === 100 && b >= 64 && b <= 127) return true;
150
- if (a === 169 && b === 254) return true;
151
- if (a === 172 && b >= 16 && b <= 31) return true;
152
- if (a === 192 && (b === 0 || b === 168)) return true;
153
- if (a === 198 && (b === 18 || b === 19)) return true;
154
- return a >= 224;
155
- };
156
-
157
- const isPrivateIpv6 = (hostname: string): boolean => {
158
- const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();
159
- return (
160
- normalized === "::" ||
161
- normalized === "::1" ||
162
- normalized.startsWith("fc") ||
163
- normalized.startsWith("fd") ||
164
- normalized.startsWith("fe80:") ||
165
- normalized.startsWith("2001:db8:")
166
- );
167
- };
168
-
169
144
  export const assertPublicHttpUrl = (rawUrl: string): string => {
170
145
  let parsed: URL;
171
146
  try {
@@ -176,13 +151,11 @@ export const assertPublicHttpUrl = (rawUrl: string): string => {
176
151
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error("URL must use http or https.");
177
152
 
178
153
  const hostname = parsed.hostname.toLowerCase();
179
- if (!hostname || hostname === "localhost" || hostname.endsWith(".localhost")) {
154
+ if (isUnsafeNetworkHostname(hostname)) {
180
155
  throw new Error("URL host is not allowed.");
181
156
  }
182
157
 
183
- const ipKind = isIP(hostname.replace(/^\[|\]$/g, ""));
184
- if (ipKind === 4 && isPrivateIpv4(hostname)) throw new Error("URL must not point to a private IP address.");
185
- if (ipKind === 6 && isPrivateIpv6(hostname)) throw new Error("URL must not point to a private IP address.");
158
+ if (parseNetworkAddress(hostname) && isUnsafeNetworkAddress(hostname)) throw new Error("URL must not point to a private IP address.");
186
159
  return parsed.toString();
187
160
  };
188
161
 
@@ -9,11 +9,26 @@ import { type ToastHandle, toast } from "@k2b/ui";
9
9
  */
10
10
  export const AUTOMATIC_RELOAD_WINDOW_MS = 30_000;
11
11
 
12
+ let reloadPending = false;
13
+ /** One notice at a time: every deferred reload has the same remedy. */
14
+ let pageChanged: ToastHandle | undefined;
15
+
16
+ /**
17
+ * An open modal dialog holds the reload. A modeless window, such as global search moved aside, leaves the page
18
+ * usable and may stay open for long, so it does not: the page would otherwise stay stale without notice.
19
+ */
20
+ const modalOpen = () => document.querySelector('dialog[open]:not([aria-modal="false"])') !== null;
21
+
12
22
  const storageKey = (key: string) => `cloud.reload.${key}`;
13
23
 
14
24
  /**
15
25
  * Reloads the page for an automatic reason (a live event, a failed live
16
26
  * subscription, a changed view) at most once per `key` and window in this tab.
27
+ * While a modal dialog is open, it does not reload: once the last one closes,
28
+ * it shows a notice with a reload action. The person may have confirmed work
29
+ * in that dialog whose request a reload would cancel, so only they reload.
30
+ * The call returns `true` in both cases; a dialog that shows live data says
31
+ * itself that it is stale.
17
32
  *
18
33
  * Returns `false` without reloading when the same key already reloaded within
19
34
  * the window, or when `sessionStorage` is unavailable and a repeat therefore
@@ -22,6 +37,7 @@ const storageKey = (key: string) => `cloud.reload.${key}`;
22
37
  * need this guard.
23
38
  */
24
39
  export const reloadOnce = (key: string, options: { windowMs?: number } = {}): boolean => {
40
+ if (reloadPending) return true;
25
41
  const windowMs = options.windowMs ?? AUTOMATIC_RELOAD_WINDOW_MS;
26
42
  const now = Date.now();
27
43
  try {
@@ -32,7 +48,20 @@ export const reloadOnce = (key: string, options: { windowMs?: number } = {}): bo
32
48
  } catch {
33
49
  return false;
34
50
  }
35
- window.location.reload();
51
+ if (typeof document === "undefined" || !modalOpen()) {
52
+ window.location.reload();
53
+ return true;
54
+ }
55
+ reloadPending = true;
56
+ const observer = new MutationObserver(() => {
57
+ if (modalOpen()) return;
58
+ observer.disconnect();
59
+ reloadPending = false;
60
+ const t = reloadMessages.resolve([document.documentElement.lang || "en"]).t;
61
+ pageChanged?.dismiss();
62
+ pageChanged = toast(t.pageChanged, { duration: 0, action: { label: t.reload, onClick: () => window.location.reload() } });
63
+ });
64
+ observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["open", "aria-modal"] });
36
65
  return true;
37
66
  };
38
67
 
@@ -41,10 +70,12 @@ const reloadMessages = i18n.define({
41
70
  messages: {
42
71
  en: {
43
72
  codeUnavailable: "Part of this page could not load. Reload the page and try again.",
73
+ pageChanged: "This page changed. Reload it to see the latest version.",
44
74
  reload: "Reload",
45
75
  },
46
76
  de: {
47
77
  codeUnavailable: "Ein Teil dieser Seite konnte nicht geladen werden. Lade die Seite neu und versuche es erneut.",
78
+ pageChanged: "Diese Seite hat sich geändert. Lade sie neu, um den aktuellen Stand zu sehen.",
48
79
  reload: "Neu laden",
49
80
  },
50
81
  },
@@ -218,3 +218,11 @@ export type {
218
218
  MailPage,
219
219
  MailServiceError,
220
220
  } from "../contracts/outgoing-mail";
221
+
222
+ export {
223
+ NetworkTargetBlockedError,
224
+ pinnedNetworkLookup,
225
+ resolvePublicNetworkAddresses,
226
+ resolveAllowedNetworkAddresses,
227
+ } from "./network-security";
228
+ export type { NetworkLookup, PublicNetworkAddress } from "./network-security";
@@ -1,5 +1,14 @@
1
1
  import { lookup as dnsLookup } from "node:dns/promises";
2
- import { isUnsafeNetworkAddress, isUnsafeNetworkHostname, networkAddressFamily, normalizeNetworkHostname } from "../shared/network-address";
2
+ import type { LookupFunction } from "node:net";
3
+ import {
4
+ EMPTY_NETWORK_ALLOWLIST,
5
+ isAllowlistedNetworkAddress,
6
+ isUnsafeNetworkAddress,
7
+ isUnsafeNetworkHostname,
8
+ type NetworkAllowlist,
9
+ normalizeNetworkHostname,
10
+ parseNetworkAddress,
11
+ } from "../shared/network-address";
3
12
 
4
13
  export type PublicNetworkAddress = { address: string; family: 4 | 6 };
5
14
  export type NetworkLookup = (
@@ -7,25 +16,56 @@ export type NetworkLookup = (
7
16
  options: { all: true; verbatim: true },
8
17
  ) => Promise<Array<{ address: string; family: number }>>;
9
18
 
10
- export const resolvePublicNetworkAddresses = async (
19
+ export class NetworkTargetBlockedError extends Error {
20
+ constructor(message: string) {
21
+ super(message);
22
+ this.name = "NetworkTargetBlockedError";
23
+ }
24
+ }
25
+
26
+ export const resolveAllowedNetworkAddresses = async (
11
27
  rawHostname: string,
28
+ allowlist: NetworkAllowlist,
12
29
  lookup: NetworkLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
13
30
  ): Promise<PublicNetworkAddress[]> => {
14
31
  const hostname = normalizeNetworkHostname(rawHostname);
15
- if (!hostname || isUnsafeNetworkHostname(hostname)) throw new Error("Network target host is not allowed");
32
+ const trustedHost = allowlist.hosts.has(hostname);
33
+ if (!trustedHost && isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
16
34
 
17
- const literalFamily = networkAddressFamily(hostname);
18
- const addresses = literalFamily ? [{ address: hostname, family: literalFamily }] : await lookup(hostname, { all: true, verbatim: true });
35
+ const literal = parseNetworkAddress(rawHostname);
36
+ const addresses = literal ? [literal] : await lookup(hostname, { all: true, verbatim: true });
19
37
  if (addresses.length === 0) throw new Error("Network target could not be resolved");
20
38
 
21
39
  const normalized: PublicNetworkAddress[] = addresses.map((entry) => {
22
- const address = normalizeNetworkHostname(entry.address);
23
- if (entry.family !== 4 && entry.family !== 6) throw new Error("Network target returned an unsupported address family");
24
- if (networkAddressFamily(address) !== entry.family) throw new Error("Network target returned an invalid address");
25
- return { address, family: entry.family };
40
+ const literal = parseNetworkAddress(entry.address);
41
+ if (entry.family !== 4 && entry.family !== 6)
42
+ throw new NetworkTargetBlockedError("Network target returned an unsupported address family");
43
+ if (!literal || literal.family !== entry.family) throw new NetworkTargetBlockedError("Network target returned an invalid address");
44
+ return literal;
26
45
  });
27
- if (normalized.some((entry) => isUnsafeNetworkAddress(entry.address))) {
28
- throw new Error("Network target resolved to a private or reserved address");
46
+ if (
47
+ !trustedHost &&
48
+ normalized.some((entry) => isUnsafeNetworkAddress(entry.address) && !isAllowlistedNetworkAddress(allowlist, entry.address))
49
+ ) {
50
+ throw new NetworkTargetBlockedError("Network target resolved to a private or reserved address");
29
51
  }
30
52
  return normalized;
31
53
  };
54
+
55
+ export const resolvePublicNetworkAddresses = (rawHostname: string, lookup?: NetworkLookup): Promise<PublicNetworkAddress[]> =>
56
+ resolveAllowedNetworkAddresses(rawHostname, EMPTY_NETWORK_ALLOWLIST, lookup);
57
+
58
+ /** Socket lookup using only addresses already checked by the public resolver. */
59
+ export const pinnedNetworkLookup =
60
+ (addresses: readonly PublicNetworkAddress[]): LookupFunction =>
61
+ (_hostname, options, callback) => {
62
+ const family = options.family === 4 || options.family === 6 ? options.family : 0;
63
+ const candidates = family ? addresses.filter((entry) => entry.family === family) : [...addresses];
64
+ const selected = candidates[0];
65
+ if (!selected) {
66
+ callback(Object.assign(new Error("No validated address for requested IP family"), { code: "ENOTFOUND" }), "", family || undefined);
67
+ return;
68
+ }
69
+ if (options.all) callback(null, candidates);
70
+ else callback(null, selected.address, selected.family);
71
+ };
@@ -32,22 +32,12 @@ const UNSAFE_IPV4_RANGES: ReadonlyArray<readonly [string, number]> = [
32
32
  ["240.0.0.0", 4],
33
33
  ];
34
34
 
35
- const ipv6ToBigInt = (rawAddress: string): bigint | null => {
36
- let address = rawAddress.toLowerCase().split("%")[0] ?? "";
37
- if (address.includes(".")) {
38
- const separator = address.lastIndexOf(":");
39
- const ipv4 = ipv4ToNumber(address.slice(separator + 1));
40
- if (separator < 0 || ipv4 === null) return null;
41
- address = `${address.slice(0, separator)}:${(ipv4 >>> 16).toString(16)}:${(ipv4 & 0xffff).toString(16)}`;
42
- }
35
+ // Only canonical IPv6 from the WHATWG parser reaches this conversion.
36
+ const ipv6ToBigInt = (address: string): bigint => {
43
37
  const halves = address.split("::");
44
- if (halves.length > 2) return null;
45
38
  const left = halves[0] ? halves[0].split(":") : [];
46
39
  const right = halves[1] ? halves[1].split(":") : [];
47
- const missing = halves.length === 2 ? 8 - left.length - right.length : 0;
48
- if (missing < 0 || (halves.length === 1 && left.length !== 8)) return null;
49
- const groups = [...left, ...Array.from({ length: missing }, () => "0"), ...right];
50
- if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return null;
40
+ const groups = [...left, ...Array.from({ length: 8 - left.length - right.length }, () => "0"), ...right];
51
41
  return groups.reduce((value, group) => (value << 16n) | BigInt(`0x${group}`), 0n);
52
42
  };
53
43
 
@@ -57,51 +47,133 @@ const ipv6InRange = (address: bigint, base: bigint, bits: number): boolean => {
57
47
  };
58
48
 
59
49
  const UNSAFE_IPV6_RANGES: ReadonlyArray<readonly [bigint, number]> = [
60
- [ipv6ToBigInt("::")!, 96],
61
- [ipv6ToBigInt("64:ff9b::")!, 96],
62
- [ipv6ToBigInt("64:ff9b:1::")!, 48],
63
- [ipv6ToBigInt("100::")!, 64],
64
- [ipv6ToBigInt("2001::")!, 32],
65
- [ipv6ToBigInt("2001:2::")!, 48],
66
- [ipv6ToBigInt("2001:db8::")!, 32],
67
- [ipv6ToBigInt("2002::")!, 16],
68
- [ipv6ToBigInt("3fff::")!, 20],
69
- [ipv6ToBigInt("fc00::")!, 7],
70
- [ipv6ToBigInt("fe80::")!, 10],
71
- [ipv6ToBigInt("fec0::")!, 10],
72
- [ipv6ToBigInt("ff00::")!, 8],
50
+ [ipv6ToBigInt("2001::"), 23],
51
+ [ipv6ToBigInt("2001:db8::"), 32],
52
+ [ipv6ToBigInt("2002::"), 16],
53
+ [ipv6ToBigInt("3fff::"), 20],
73
54
  ];
74
55
 
75
- export const normalizeNetworkHostname = (hostname: string): string =>
76
- hostname
77
- .toLowerCase()
78
- .replace(/^\[|\]$/g, "")
79
- .replace(/\.$/, "");
56
+ export const normalizeNetworkHostname = (hostname: string): string => {
57
+ const lower = hostname.toLowerCase();
58
+ if (lower.startsWith("[") && lower.endsWith("]") && lower.includes(":")) return lower.slice(1, -1);
59
+ return lower.includes(":") ? lower : lower.replace(/\.$/, "");
60
+ };
61
+
62
+ // WHATWG's "ends in a number" rule: these hosts must parse as IPv4,
63
+ // rather than falling back to DNS (including invalid octal and overflow).
64
+ const endsInNumber = (hostname: string): boolean => {
65
+ const last = hostname.split(".").at(-1) ?? "";
66
+ return /^\d+$/.test(last) || /^0x[0-9a-f]*$/i.test(last);
67
+ };
80
68
 
81
- export const networkAddressFamily = (rawAddress: string): 4 | 6 | null => {
69
+ /** Parse all URL IP literal forms without DNS or server-only dependencies. */
70
+ export const parseNetworkAddress = (rawAddress: string): { address: string; family: 4 | 6 } | null => {
82
71
  const address = normalizeNetworkHostname(rawAddress);
83
- if (ipv4ToNumber(address) !== null) return 4;
84
- return ipv6ToBigInt(address) !== null ? 6 : null;
72
+ const ipv6 = address.includes(":");
73
+ if (!address || /[\s/@\\?#%\[\]]/.test(address) || (!ipv6 && !endsInNumber(address))) return null;
74
+ try {
75
+ // URL implements legacy IPv4 (decimal, octal, hex and shortened forms),
76
+ // as well as IPv6 compression and dotted tails. Reject zone IDs above.
77
+ const parsed = new URL(`http://${ipv6 ? `[${address}]` : address}/`).hostname;
78
+ if (ipv6) return { address: normalizeNetworkHostname(parsed), family: 6 };
79
+ return ipv4ToNumber(parsed) !== null ? { address: parsed, family: 4 } : null;
80
+ } catch {
81
+ return null;
82
+ }
85
83
  };
86
84
 
85
+ export const networkAddressFamily = (rawAddress: string): 4 | 6 | null => parseNetworkAddress(rawAddress)?.family ?? null;
86
+
87
87
  export const isUnsafeNetworkAddress = (rawAddress: string): boolean => {
88
- const address = normalizeNetworkHostname(rawAddress);
89
- const family = networkAddressFamily(address);
90
- if (family === 4) return UNSAFE_IPV4_RANGES.some(([base, bits]) => ipv4InRange(address, base, bits));
91
- if (family !== 6) return true;
92
- const value = ipv6ToBigInt(address);
93
- if (value === null) return true;
94
- if (value >> 32n === 0xffffn) {
88
+ const literal = parseNetworkAddress(rawAddress);
89
+ if (!literal) return true;
90
+ if (literal.family === 4) return UNSAFE_IPV4_RANGES.some(([base, bits]) => ipv4InRange(literal.address, base, bits));
91
+ const value = ipv6ToBigInt(literal.address);
92
+ if (value >> 32n === 0xffffn || ipv6InRange(value, ipv6ToBigInt("64:ff9b::"), 96)) {
95
93
  const mapped = Number(value & 0xffffffffn);
96
- const ipv4 = [mapped >>> 24, (mapped >>> 16) & 255, (mapped >>> 8) & 255, mapped & 255].join(".");
97
- return isUnsafeNetworkAddress(ipv4);
94
+ return isUnsafeNetworkAddress([mapped >>> 24, (mapped >>> 16) & 255, (mapped >>> 8) & 255, mapped & 255].join("."));
98
95
  }
99
- return UNSAFE_IPV6_RANGES.some(([base, bits]) => ipv6InRange(value, base, bits));
96
+ return !ipv6InRange(value, ipv6ToBigInt("2000::"), 3) || UNSAFE_IPV6_RANGES.some(([base, bits]) => ipv6InRange(value, base, bits));
100
97
  };
101
98
 
102
99
  export const isUnsafeNetworkHostname = (rawHostname: string): boolean => {
103
100
  const hostname = normalizeNetworkHostname(rawHostname);
104
101
  return (
105
- hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "metadata.google.internal" || hostname.endsWith(".internal")
102
+ !hostname ||
103
+ ["localhost", "localhost.localdomain", "metadata", "metadata.google.internal", "instance-data"].includes(hostname) ||
104
+ [".localhost", ".internal", ".local", ".home.arpa"].some((suffix) => hostname.endsWith(suffix)) ||
105
+ /[%\[\]]/.test(hostname) ||
106
+ ((endsInNumber(hostname) || hostname.includes(":")) && !parseNetworkAddress(hostname))
106
107
  );
107
108
  };
109
+
110
+ /** Operator-owned exact DNS names and canonical IP networks. Never populate from person input. */
111
+ export type NetworkAllowlist = {
112
+ readonly hosts: ReadonlySet<string>;
113
+ readonly networks: ReadonlyArray<Readonly<{ address: string; family: 4 | 6; bits: number }>>;
114
+ };
115
+
116
+ export const EMPTY_NETWORK_ALLOWLIST: NetworkAllowlist = { hosts: new Set(), networks: [] };
117
+
118
+ export const parseNetworkAllowlist = (entries: readonly string[]): NetworkAllowlist => {
119
+ const hosts = new Set<string>();
120
+ const networks: Array<{ address: string; family: 4 | 6; bits: number }> = [];
121
+ for (const entry of entries) {
122
+ const invalid = (reason = "expected an exact host name, IP address or CIDR") =>
123
+ new Error(`Invalid network allowlist entry "${entry}": ${reason}`);
124
+ const parts = entry.split("/");
125
+ const raw = parts[0] ?? "";
126
+ const literal = parseNetworkAddress(raw);
127
+ if (literal) {
128
+ // Allowlist entries use strict IP notation, even though URL destinations may use legacy IPv4.
129
+ if (literal.family === 4 && ipv4ToNumber(raw) === null) throw invalid();
130
+ if (literal.family === 6 && raw.includes(".") && ipv4ToNumber(raw.replace(/\]$/, "").split(":").at(-1) ?? "") === null)
131
+ throw invalid();
132
+ const width = literal.family === 4 ? 32 : 128;
133
+ if (parts.length > 2 || (parts.length === 2 && !/^(0|[1-9]\d*)$/.test(parts[1] ?? ""))) throw invalid();
134
+ const bits = parts.length === 2 ? Number(parts[1]) : width;
135
+ if (bits > width) throw invalid();
136
+ if (bits === 0) throw invalid("a /0 network allows every address; list the internal hosts or ranges instead");
137
+ const value = literal.family === 4 ? BigInt(ipv4ToNumber(literal.address)!) : ipv6ToBigInt(literal.address);
138
+ const shift = BigInt(width - bits);
139
+ const base = (value >> shift) << shift;
140
+ if (base !== value) {
141
+ const address =
142
+ literal.family === 4
143
+ ? [24n, 16n, 8n, 0n].map((shift) => Number((base >> shift) & 255n)).join(".")
144
+ : parseNetworkAddress(
145
+ Array.from({ length: 8 }, (_, index) => ((base >> BigInt((7 - index) * 16)) & 65535n).toString(16)).join(":"),
146
+ )!.address;
147
+ throw invalid(`host bits are set; use ${address}/${bits}`);
148
+ }
149
+ networks.push({ ...literal, bits });
150
+ continue;
151
+ }
152
+ const hostname = normalizeNetworkHostname(raw);
153
+ if (
154
+ parts.length !== 1 ||
155
+ endsInNumber(hostname) ||
156
+ raw.includes(":") ||
157
+ hostname.length > 253 ||
158
+ !hostname.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
159
+ )
160
+ throw invalid();
161
+ hosts.add(hostname);
162
+ }
163
+ return { hosts, networks };
164
+ };
165
+
166
+ export const isAllowlistedNetworkAddress = (allowlist: NetworkAllowlist, rawAddress: string): boolean => {
167
+ const literal = parseNetworkAddress(rawAddress);
168
+ if (!literal) return false;
169
+ if (literal.family === 6) {
170
+ const value = ipv6ToBigInt(literal.address);
171
+ // Only IPv4-mapped IPv6 reaches the embedded IPv4 host directly.
172
+ if (value >> 32n === 0xffffn) {
173
+ const ipv4 = [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 255n)).join(".");
174
+ if (isAllowlistedNetworkAddress(allowlist, ipv4)) return true;
175
+ }
176
+ return allowlist.networks.some((network) => network.family === 6 && ipv6InRange(value, ipv6ToBigInt(network.address), network.bits));
177
+ }
178
+ return allowlist.networks.some((network) => network.family === 4 && ipv4InRange(literal.address, network.address, network.bits));
179
+ };
@@ -8,6 +8,7 @@ import {
8
8
  MarkdownView,
9
9
  NoticeCard,
10
10
  Placeholder,
11
+ prefersReducedMotion,
11
12
  prompts,
12
13
  ScrollArea,
13
14
  TextInput,
@@ -186,7 +187,7 @@ const HelpShell = (props: {
186
187
  ).find((element) => element.id === hash);
187
188
  if (!heading) return false;
188
189
  setActiveSectionId(hash);
189
- heading.scrollIntoView({ block: "start", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
190
+ heading.scrollIntoView({ block: "start", behavior: prefersReducedMotion() ? "auto" : "smooth" });
190
191
  return true;
191
192
  };
192
193
 
@@ -255,8 +255,26 @@ html:has(.k2b-mobile-shell) body {
255
255
  --color-selection-fg: rgb(239 246 255);
256
256
  }
257
257
 
258
- @view-transition {
259
- navigation: auto;
258
+ /* @k2b/ui makes motion inside .k2b-ui instant under reduced motion; the
259
+ page owns its view transitions, so the same two sources stop them here. */
260
+ @media (prefers-reduced-motion: no-preference) {
261
+ @view-transition {
262
+ navigation: auto;
263
+ }
264
+ }
265
+
266
+ @media (prefers-reduced-motion: reduce) {
267
+ ::view-transition-group(*),
268
+ ::view-transition-old(*),
269
+ ::view-transition-new(*) {
270
+ animation: none !important;
271
+ }
272
+ }
273
+
274
+ html[data-k2b-motion="reduced"]::view-transition-group(*),
275
+ html[data-k2b-motion="reduced"]::view-transition-old(*),
276
+ html[data-k2b-motion="reduced"]::view-transition-new(*) {
277
+ animation: none !important;
260
278
  }
261
279
 
262
280
  ::selection {
@@ -275,15 +293,6 @@ solid-island {
275
293
  display: contents;
276
294
  }
277
295
 
278
- @media (prefers-reduced-motion: reduce) {
279
- *,
280
- *::before,
281
- *::after {
282
- scroll-behavior: auto !important;
283
- transition-duration: 0.01ms !important;
284
- }
285
- }
286
-
287
296
  /* Cloud has three shell modes. Phones, below md, get the header and no rail.
288
297
  From md, signed-in people get the rail instead of the header: up to 2xl the
289
298
  app area meets the window's top, right and bottom edges, and from 2xl it is