@k2b/cloud 0.34.0 → 0.35.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.35.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": {
@@ -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
 
@@ -1,5 +1,6 @@
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 { isUnsafeNetworkAddress, isUnsafeNetworkHostname, normalizeNetworkHostname, parseNetworkAddress } from "../shared/network-address";
3
4
 
4
5
  export type PublicNetworkAddress = { address: string; family: 4 | 6 };
5
6
  export type NetworkLookup = (
@@ -7,25 +8,48 @@ export type NetworkLookup = (
7
8
  options: { all: true; verbatim: true },
8
9
  ) => Promise<Array<{ address: string; family: number }>>;
9
10
 
11
+ export class NetworkTargetBlockedError extends Error {
12
+ constructor(message: string) {
13
+ super(message);
14
+ this.name = "NetworkTargetBlockedError";
15
+ }
16
+ }
17
+
10
18
  export const resolvePublicNetworkAddresses = async (
11
19
  rawHostname: string,
12
20
  lookup: NetworkLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
13
21
  ): Promise<PublicNetworkAddress[]> => {
14
22
  const hostname = normalizeNetworkHostname(rawHostname);
15
- if (!hostname || isUnsafeNetworkHostname(hostname)) throw new Error("Network target host is not allowed");
23
+ if (isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
16
24
 
17
- const literalFamily = networkAddressFamily(hostname);
18
- const addresses = literalFamily ? [{ address: hostname, family: literalFamily }] : await lookup(hostname, { all: true, verbatim: true });
25
+ const literal = parseNetworkAddress(rawHostname);
26
+ const addresses = literal ? [literal] : await lookup(hostname, { all: true, verbatim: true });
19
27
  if (addresses.length === 0) throw new Error("Network target could not be resolved");
20
28
 
21
29
  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 };
30
+ const literal = parseNetworkAddress(entry.address);
31
+ if (entry.family !== 4 && entry.family !== 6)
32
+ throw new NetworkTargetBlockedError("Network target returned an unsupported address family");
33
+ if (!literal || literal.family !== entry.family) throw new NetworkTargetBlockedError("Network target returned an invalid address");
34
+ return literal;
26
35
  });
27
36
  if (normalized.some((entry) => isUnsafeNetworkAddress(entry.address))) {
28
- throw new Error("Network target resolved to a private or reserved address");
37
+ throw new NetworkTargetBlockedError("Network target resolved to a private or reserved address");
29
38
  }
30
39
  return normalized;
31
40
  };
41
+
42
+ /** Socket lookup using only addresses already checked by the public resolver. */
43
+ export const pinnedNetworkLookup =
44
+ (addresses: readonly PublicNetworkAddress[]): LookupFunction =>
45
+ (_hostname, options, callback) => {
46
+ const family = options.family === 4 || options.family === 6 ? options.family : 0;
47
+ const candidates = family ? addresses.filter((entry) => entry.family === family) : [...addresses];
48
+ const selected = candidates[0];
49
+ if (!selected) {
50
+ callback(Object.assign(new Error("No validated address for requested IP family"), { code: "ENOTFOUND" }), "", family || undefined);
51
+ return;
52
+ }
53
+ if (options.all) callback(null, candidates);
54
+ else callback(null, selected.address, selected.family);
55
+ };
@@ -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,62 @@ 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
  };
@@ -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