@ultimat3/core 23.0.0 → 25.0.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.
Files changed (98) hide show
  1. package/CLAUDE.md +29 -26
  2. package/README.md +98 -30
  3. package/package.json +4 -7
  4. package/src/actor.ts +9 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +53 -0
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +9 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +79 -0
  29. package/src/config-site.ts +14 -3
  30. package/src/config.ts +141 -173
  31. package/src/context.ts +29 -13
  32. package/src/cookie.ts +299 -0
  33. package/src/core-error-codes.ts +2 -0
  34. package/src/cursor-page.ts +41 -0
  35. package/src/cursor.ts +26 -5
  36. package/src/decimal-order.ts +5 -4
  37. package/src/deprecation.ts +77 -0
  38. package/src/dev-secrets.ts +18 -7
  39. package/src/drain-deadline.ts +43 -0
  40. package/src/env-example.ts +9 -29
  41. package/src/error-reporter-sentry.ts +7 -3
  42. package/src/errors.ts +18 -9
  43. package/src/exports/error-contract.ts +0 -1
  44. package/src/exports/observability.ts +1 -1
  45. package/src/exports/secrets.ts +3 -0
  46. package/src/finite-option.ts +1 -1
  47. package/src/flight-gate.ts +43 -16
  48. package/src/fnv1a.ts +19 -0
  49. package/src/generation-fence.ts +1 -1
  50. package/src/health-disclosure.ts +43 -0
  51. package/src/host-rules.ts +28 -1
  52. package/src/html-escape.ts +24 -0
  53. package/src/ids.ts +7 -7
  54. package/src/image/canvas.ts +76 -5
  55. package/src/image/errors.ts +3 -1
  56. package/src/image/pipeline.ts +17 -5
  57. package/src/image/png-pixels.ts +29 -6
  58. package/src/image/probe.ts +7 -2
  59. package/src/image/raster.ts +27 -2
  60. package/src/index.ts +99 -33
  61. package/src/iso-date.ts +1 -1
  62. package/src/lifecycle-errors.ts +1 -1
  63. package/src/lifecycle-readiness.ts +60 -2
  64. package/src/lifecycle-signals.ts +27 -2
  65. package/src/lifecycle-types.ts +96 -0
  66. package/src/lifecycle.ts +44 -133
  67. package/src/locale-direction.ts +1 -1
  68. package/src/logger.ts +92 -16
  69. package/src/mcp-exposure.ts +70 -8
  70. package/src/measurement-actor.ts +16 -1
  71. package/src/metric-errors.ts +32 -0
  72. package/src/metric-registry.ts +151 -0
  73. package/src/metric-series.ts +94 -0
  74. package/src/metrics.ts +9 -255
  75. package/src/nearest-name.ts +11 -2
  76. package/src/otlp-metric-exporter.ts +1 -1
  77. package/src/otlp-span-exporter.ts +1 -1
  78. package/src/otlp.ts +44 -13
  79. package/src/page.ts +4 -2
  80. package/src/pg-executor.ts +15 -0
  81. package/src/public-cause.ts +37 -0
  82. package/src/registrar.ts +22 -4
  83. package/src/retry.ts +40 -7
  84. package/src/route-rank.ts +36 -0
  85. package/src/same-origin.ts +1 -1
  86. package/src/sampler.ts +6 -2
  87. package/src/secrets-errors.ts +14 -3
  88. package/src/secrets-key-file.ts +139 -0
  89. package/src/secrets-store.ts +32 -16
  90. package/src/service.ts +5 -5
  91. package/src/single-flight.ts +1 -1
  92. package/src/source-mask.ts +14 -8
  93. package/src/store-mode.ts +23 -0
  94. package/src/telemetry.ts +1 -1
  95. package/src/theme-storage.ts +12 -0
  96. package/src/type-pins.ts +51 -1
  97. package/src/image/fixtures.ts +0 -263
  98. package/src/time-zone-name.ts +0 -14
@@ -2,6 +2,8 @@
2
2
  // per section and key by key. Carries no config KEY on purpose: `config-readers` counts a property
3
3
  // access outside `config.ts` as a reader, so this file only ever sees sections as opaque records.
4
4
 
5
+ import { isJsonObject } from './json-object';
6
+
5
7
  /** A section's patch: every key optional, and an explicit `undefined` meaning "not said". */
6
8
  export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
7
9
 
@@ -11,6 +13,12 @@ export type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
11
13
  */
12
14
  export function section<T extends object>(base: T, patch: Input<T> | undefined): T {
13
15
  if (patch === undefined) return base;
16
+ // A layer that wrote something other than an object (`null`, a string, a list) has no key to
17
+ // merge, and `Object.entries(null)` below was a native `TypeError` out of the validator's own
18
+ // caller. It is carried through AS WRITTEN — and stays, whatever later layers say — so the shape
19
+ // screen refuses it by name; dropped here, the app would run on defaults it believed it replaced.
20
+ if (!isJsonObject(base)) return base;
21
+ if (!isJsonObject(patch)) return patch as T;
14
22
  const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
15
23
  for (const [key, value] of Object.entries(patch)) {
16
24
  if (value !== undefined) out[key] = value;
@@ -28,7 +36,7 @@ export function layered<T extends object>(base: T, patches: readonly (Input<T> |
28
36
  return patches.reduce<T>((out, patch) => section(out, patch), base);
29
37
  }
30
38
 
31
- /** A whole-value key (`locales`, `roles`): the last layer that said something wins. */
39
+ /** A whole-value key (`roles`, `jobs.queues`): the last layer that said something wins. */
32
40
  export function lastSaid<T>(base: T, values: readonly (T | undefined)[]): T {
33
41
  let out = base;
34
42
  for (const value of values) if (value !== undefined) out = value;
@@ -6,7 +6,6 @@
6
6
  // were: shape, merge and screen are one subject.
7
7
 
8
8
  import { describeValue } from './error-render';
9
- import { ConfigInvalidError } from './errors';
10
9
 
11
10
  /**
12
11
  * The surfaces that render documents a browser navigates between. `api` answers JSON and `shared`
@@ -73,7 +72,7 @@ export interface NavigationSectionInput {
73
72
  }
74
73
 
75
74
  /**
76
- * Whole-value keys: the last layer that listed surfaces wins, as `locales` does — and so does the
75
+ * Whole-value keys: the last layer that listed surfaces wins, as `roles` does — and so does the
77
76
  * last one that set `speculation.prefetch` or listed `speculation.exclude`, each on its own.
78
77
  */
79
78
  export function mergeNavigation(layers: readonly NavigationSectionInput[]): NavigationSection {
@@ -138,29 +137,6 @@ function speculationIssues(speculation: unknown, issues: string[]): void {
138
137
  }
139
138
  }
140
139
 
141
- /**
142
- * `navigation.speculation` as some reader OUTSIDE `defineConfig` found it (`@ultimat3/cli` imports
143
- * the app's config module structurally): the defaults for what it does not say, and the SAME
144
- * refusal `defineConfig` gives for what it says wrongly. One validator — a second reader that
145
- * coerced `'eager'` to `'moderate'` or dropped a bad pattern would serve rules the app never wrote.
146
- */
147
- export function resolveSpeculation(said: unknown): SpeculationConfig {
148
- if (said === undefined) return DEFAULT_SPECULATION;
149
- const { speculation } = mergeNavigation([
150
- { navigation: { speculation: said as SpeculationInput } },
151
- ]).navigation;
152
- const issues: string[] = [];
153
- speculationIssues(speculation, issues);
154
- if (issues.length > 0) {
155
- throw new ConfigInvalidError({
156
- cause: issues.join('; '),
157
- fix: 'Correct navigation.speculation in app.config.ts: prefetch is "moderate", "conservative" or false, and exclude is a list of path patterns starting with "/"',
158
- meta: { issues },
159
- });
160
- }
161
- return speculation;
162
- }
163
-
164
140
  /** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
165
141
  export function navigationIssues(config: NavigationSection, issues: string[]): void {
166
142
  speculationIssues(config.navigation.speculation, issues);
package/src/config-pwa.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { describeValue } from './error-render';
14
14
 
15
15
  /**
16
- * `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
16
+ * `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `installController` is
17
17
  * real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
18
18
  * scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
19
19
  */
@@ -115,8 +115,8 @@ export interface PwaScreenshot {
115
115
  /**
116
116
  * The install chrome's two colours for one scheme, as CSS colour strings.
117
117
  *
118
- * ONE OF THE TWO PLACES A RAW COLOUR IS LEGAL, alongside `ThemeConfig.tokens` one section up, and
119
- * for a stronger reason than that one has: a browser paints the install splash and the address bar
118
+ * THE ONE PLACE IN `app.config.ts` A RAW COLOUR IS LEGAL (`theme.tokens`, the other, was deleted in
119
+ * 25.0.0 — the theme is `defineTheme` in `@ultimat3/ui`), because a browser paints the install splash and the address bar
120
120
  * from these before a single stylesheet has loaded, so there is no token to resolve them against
121
121
  * and no component anywhere in the loop.
122
122
  */
@@ -173,10 +173,20 @@ export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
173
173
  // `pwa.enabled` means an installable app, and an installable app that shows the browser's error
174
174
  // page offline is the failure the whole block exists to prevent, so this is required rather
175
175
  // than optional: the alternative is two meanings for one switch (axiom 1).
176
+ // Same origin, too: `//host/offline` and `/\host/offline` start with `/` and are still another
177
+ // host, which the worker would precache and serve as this app's offline page.
176
178
  const fallback: unknown = pwa.offline?.fallback;
177
- if (typeof fallback !== 'string' || !fallback.startsWith('/')) {
179
+ if (!isSameOriginPath(fallback)) {
178
180
  issues.push(
179
- `pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path like "/offline", and is ${describeValue(fallback)}`,
181
+ `pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path on this origin like "/offline", and is ${describeValue(fallback)}`,
182
+ );
183
+ }
184
+ for (const key of ['image', 'font'] as const) {
185
+ const placeholder: unknown = pwa.offline?.[key];
186
+ if (placeholder === undefined || placeholder === null || isSameOriginPath(placeholder))
187
+ continue;
188
+ issues.push(
189
+ `pwa.offline.${key} must be a path on this origin like "/offline.svg", and is ${describeValue(placeholder)}`,
180
190
  );
181
191
  }
182
192
  const personal: unknown = pwa.offline?.personalPages;
@@ -233,3 +243,30 @@ function manifestIssues(pwa: PwaConfig, issues: string[]): void {
233
243
  }
234
244
  }
235
245
  }
246
+
247
+ /** An origin no relative path can reach: a value that resolves anywhere else left this origin. */
248
+ const PROBE_ORIGIN = 'http://x.invalid';
249
+
250
+ /**
251
+ * A path on THIS origin, as a browser will resolve it — the one predicate for every URL the worker
252
+ * precaches and serves as an offline answer (`@ultimat3/pwa`'s build asks the same question).
253
+ * Judged by RESOLUTION, never by the raw prefix: the URL parser strips a tab, CR or LF anywhere and
254
+ * reads `\` as `/`, so `/\t/evil.test/x` is `//evil.test/x` once parsed. Every C0 control and DEL
255
+ * is refused before the parse, as `@ultimat3/http`'s sign-in redirect does; a dot segment that
256
+ * leaves a `//` pathname (`/.//evil.test`) is refused after it, since any reader without the base
257
+ * takes that pathname as a host.
258
+ */
259
+ export function isSameOriginPath(value: unknown): value is string {
260
+ if (typeof value !== 'string' || !value.startsWith('/')) return false;
261
+ for (let index = 0; index < value.length; index += 1) {
262
+ const code = value.charCodeAt(index);
263
+ if (code < 0x20 || code === 0x7f) return false;
264
+ }
265
+ let resolved: URL;
266
+ try {
267
+ resolved = new URL(value, PROBE_ORIGIN);
268
+ } catch {
269
+ return false;
270
+ }
271
+ return resolved.origin === PROBE_ORIGIN && !resolved.pathname.startsWith('//');
272
+ }
@@ -0,0 +1,131 @@
1
+ // Single responsibility: the `app.config.ts` keys a major DELETED, and the refusal an app still
2
+ // writing one gets. A deleted key the validator ignores is a switch with no wire — an operator sets
3
+ // it, redeploys and nothing changes — so each is refused by name, with the line that replaces it.
4
+
5
+ import { isJsonObject } from './json-object';
6
+
7
+ export interface RemovedConfigKey {
8
+ /** The major that deleted it. */
9
+ readonly removedIn: string;
10
+ /** What an app writes instead — the second half of the `fix:`. */
11
+ readonly instead: string;
12
+ }
13
+
14
+ /**
15
+ * Dotted paths, one row per deleted leaf. Every row older than 25.0.0 was silently carried through
16
+ * `section()` until 25.0.0 refused it — this table by name, then `config-keys.ts` closing the
17
+ * shape for any other key; a row is what turns "not a key" into the line that replaces it. A row
18
+ * is never removed: a config written against an older major must keep getting the instruction,
19
+ * not silence. `Object.freeze`d, read through `Object.hasOwn`, so `__proto__` in a layer names no
20
+ * row.
21
+ */
22
+ export const REMOVED_CONFIG_KEYS: Readonly<Record<string, RemovedConfigKey>> = Object.freeze({
23
+ locales: {
24
+ removedIn: '25.0.0',
25
+ instead:
26
+ "the app's locales are the keys of its catalogs: defineCatalogs({ default: 'en', locales: { en, es } }) from @ultimat3/i18n, in packages/i18n/src/index.ts",
27
+ },
28
+ defaultLocale: {
29
+ removedIn: '25.0.0',
30
+ instead:
31
+ "the fallback locale is defineCatalogs({ default: 'en', locales: { en } })'s default, from @ultimat3/i18n",
32
+ },
33
+ defaultTimeZone: {
34
+ removedIn: '25.0.0',
35
+ instead:
36
+ "nothing read it; there is no ambient zone — pass one at every call: formatDate(at, { locale, zone: 'Europe/Paris' }), task({ tz }), userActor({ tz })",
37
+ },
38
+ defaultCurrency: {
39
+ removedIn: '25.0.0',
40
+ instead:
41
+ "nothing read it; every Money carries its own currency ({ minor, currency: 'USD' }) — an app that wants a default declares its own constant in an app module",
42
+ },
43
+ 'jobs.driver': {
44
+ removedIn: '5.0.0',
45
+ instead:
46
+ 'nothing read it and boot always built Postgres; the driver is code — setJobDriver(postgresJobDriver({ executor })) from @ultimat3/jobs, or setJobDriver(memoryJobDriver()) in a test',
47
+ },
48
+ 'realtime.heartbeatMs': {
49
+ removedIn: '4.0.0',
50
+ instead:
51
+ "nothing read it; the page socket's beat is the sync node's, named in its hello reply, and the presence beat is a third of the presence ttl",
52
+ },
53
+ 'database.urlEnv': {
54
+ removedIn: '4.0.0',
55
+ instead:
56
+ 'nothing read it; the connection string is the DATABASE_URL environment variable, read by @ultimat3/db',
57
+ },
58
+ 'database.poolSize': {
59
+ removedIn: '4.0.0',
60
+ instead:
61
+ 'nothing read it; the pool is sized by the DATABASE_POOL_MAX environment variable, per process',
62
+ },
63
+ 'database.schema': {
64
+ removedIn: '4.0.0',
65
+ instead:
66
+ 'nothing read it and nothing emits SET search_path; entity() tables live in public, and there is no replacement',
67
+ },
68
+ 'pwa.installPrompt': {
69
+ removedIn: '8.0.0',
70
+ instead:
71
+ 'nothing read it; call installController() from @ultimat3/pwa in your own install affordance',
72
+ },
73
+ 'auth.afterSignInPath': {
74
+ removedIn: '8.0.0',
75
+ instead:
76
+ 'nothing read it; send the visitor where you mean from your sign-in route, the only code that can honour it',
77
+ },
78
+ 'ai.modelEnv': {
79
+ removedIn: '8.0.0',
80
+ instead:
81
+ "nothing read it; the model is the prompt's own, or llm({ model }) from @ultimat3/ai — read your own env key and pass it there",
82
+ },
83
+ 'cache.driver': {
84
+ removedIn: '9.0.0',
85
+ instead:
86
+ "cache.tiers is the one selector — name the rung: cache: { tiers: ['request-memo', 'lru', 'redis'] }",
87
+ },
88
+ 'cache.urlEnv': {
89
+ removedIn: '9.0.0',
90
+ instead:
91
+ "nothing read it; the redis tier reads the REDIS_URL environment variable, so name 'redis' in cache.tiers and set REDIS_URL",
92
+ },
93
+ 'realtime.tier': {
94
+ removedIn: '10.0.0',
95
+ instead:
96
+ "nothing read it; an app's realtime tier is what it declares — a channel() topic, a live: true query, persist: true on an entity",
97
+ },
98
+ 'theme.tokens': {
99
+ removedIn: '25.0.0',
100
+ instead:
101
+ "nothing read it; the app's theme is declared once with export const brand = defineTheme({ … }) from @ultimat3/ui, in apps/web/shared/theme.ts",
102
+ },
103
+ 'ai.mcp.path': {
104
+ removedIn: '25.0.0',
105
+ instead:
106
+ "an MCP endpoint's path is its own: defineAppMcp({ path: '/mcp' }) from @ultimat3/mcp, in apps/<app>/mcp.ts — the default is /mcp, and every endpoint mounts where its own metadata says",
107
+ },
108
+ });
109
+
110
+ const said = (layer: unknown, path: string): boolean => {
111
+ let at: unknown = layer;
112
+ for (const segment of path.split('.')) {
113
+ if (!isJsonObject(at) || !Object.hasOwn(at, segment)) return false;
114
+ at = at[segment];
115
+ }
116
+ // `undefined` is a layer not saying, the rule every other key follows.
117
+ return at !== undefined;
118
+ };
119
+
120
+ /** The removed keys one layer still writes, in table order. */
121
+ export function removedKeysIn(layer: unknown): readonly string[] {
122
+ return Object.keys(REMOVED_CONFIG_KEYS).filter((path) => said(layer, path));
123
+ }
124
+
125
+ /** One cause line per removed key, naming the major. */
126
+ export const removedKeyIssue = (path: string): string =>
127
+ `${path} was removed in ${REMOVED_CONFIG_KEYS[path]?.removedIn ?? 'a major'} and is no longer read`;
128
+
129
+ /** One instruction per removed key: delete the line, and what replaces it. */
130
+ export const removedKeyFix = (path: string): string =>
131
+ `delete ${path} from app.config.ts — ${REMOVED_CONFIG_KEYS[path]?.instead ?? ''}`;
@@ -0,0 +1,79 @@
1
+ // Single responsibility: the SHAPE half of `app.config.ts` validation — is this a section, a list,
2
+ // a boolean, one of a closed set — asked before any rule reads the value. Takes every key as a
3
+ // string and every value as `unknown`, and names no config key of its own: `config-readers` counts
4
+ // a property access outside the declaring files as a reader, so this file must not make one.
5
+
6
+ import { describeValue } from './error-render';
7
+ import { isJsonObject } from './json-object';
8
+
9
+ /** A string is worth echoing — it is the typo; anything else is described by shape. */
10
+ const said = (value: unknown): string =>
11
+ typeof value === 'string' ? `"${value}"` : describeValue(value);
12
+
13
+ /**
14
+ * Every section and list one LAYER wrote, compared with the same position in `reference` — the
15
+ * defaults merged with no layer, so the screen is derived and never a hand list of key names.
16
+ * Structure only: where the reference holds a section the layer may hold a section, where it holds
17
+ * a list, a list. `undefined` is a layer not saying; scalars are the per-key rules' business; and
18
+ * a position the reference leaves `null` or `undefined` (an optional block) is not judged here.
19
+ *
20
+ * It runs BEFORE the merge and its issues end the validation, because the merge and every rule
21
+ * after it read through the structure: `Object.entries(null)` and `null.length` are the native
22
+ * `TypeError`s the validator exists to replace with an instruction.
23
+ */
24
+ export function shapeIssues(reference: unknown, layer: unknown, issues: string[], path = ''): void {
25
+ if (!isJsonObject(reference) || !isJsonObject(layer)) return;
26
+ for (const [key, expected] of Object.entries(reference)) {
27
+ const at = path === '' ? key : `${path}.${key}`;
28
+ const value: unknown = layer[key];
29
+ if (value === undefined) continue;
30
+ if (Array.isArray(expected)) {
31
+ if (!Array.isArray(value)) issues.push(`${at} must be a list, not ${describeValue(value)}`);
32
+ } else if (isJsonObject(expected)) {
33
+ if (isJsonObject(value)) shapeIssues(expected, value, issues, at);
34
+ else issues.push(`${at} must be an object, not ${describeValue(value)}`);
35
+ }
36
+ }
37
+ }
38
+
39
+ /** Why `value` is not one of `allowed`, or `undefined` when it is. */
40
+ export function oneOfIssue(
41
+ key: string,
42
+ value: unknown,
43
+ allowed: readonly string[],
44
+ ): string | undefined {
45
+ if (allowed.some((known) => known === value)) return undefined;
46
+ return `${key} ${said(value)} is not one of ${allowed.join(', ')}`;
47
+ }
48
+
49
+ /**
50
+ * `typeof`, never truthiness: an untyped config writing `'false'` — a string out of an environment
51
+ * variable — is truthy, so the switch it meant to turn off stayed on and nothing said so.
52
+ */
53
+ export function booleanIssue(key: string, value: unknown): string | undefined {
54
+ return typeof value === 'boolean'
55
+ ? undefined
56
+ : `${key} must be true or false, not ${said(value)}`;
57
+ }
58
+
59
+ /** A route path the framework mounts or redirects to: absolute, or the browser resolves it. */
60
+ export function routePathIssue(key: string, value: unknown): string | undefined {
61
+ return typeof value === 'string' && value.startsWith('/')
62
+ ? undefined
63
+ : `${key} must be a path starting with /, not ${said(value)}`;
64
+ }
65
+
66
+ /** A list that names things: at least one entry, each a non-empty string, `what` each. */
67
+ export function nameListIssues(
68
+ key: string,
69
+ list: readonly unknown[],
70
+ what: string,
71
+ issues: string[],
72
+ ): void {
73
+ if (list.length === 0) issues.push(`${key} must list at least one ${what}`);
74
+ for (const entry of list) {
75
+ if (typeof entry !== 'string' || entry.trim() === '') {
76
+ issues.push(`${key} contains ${said(entry)}, not a ${what} name`);
77
+ }
78
+ }
79
+ }
@@ -3,6 +3,7 @@
3
3
  // Split from `config.ts` for `config-pwa.ts`' reason: that file sits at its 500-line ceiling.
4
4
 
5
5
  import { type Input, layered } from './config-merge';
6
+ import { describeValue } from './error-render';
6
7
 
7
8
  export interface SiteConfig {
8
9
  /**
@@ -108,10 +109,20 @@ export function siteIssues(config: SiteSections, issues: string[]): void {
108
109
  const issue = originIssue(origin);
109
110
  if (issue !== undefined) issues.push(issue);
110
111
  }
111
- for (const path of config.seo.robots.disallow) {
112
- if (!path.startsWith('/')) issues.push(`seo.robots.disallow entry "${path}" must start with /`);
112
+ // `unknown` entries: an untyped config reaches here with whatever it listed, and `5.startsWith`
113
+ // was a native `TypeError` thrown by the validator itself.
114
+ for (const path of config.seo.robots.disallow as readonly unknown[]) {
115
+ if (typeof path !== 'string') {
116
+ issues.push(`seo.robots.disallow entry must be a path string, not ${describeValue(path)}`);
117
+ } else if (!path.startsWith('/')) {
118
+ issues.push(`seo.robots.disallow entry "${path}" must start with /`);
119
+ }
113
120
  }
114
- for (const path of config.seo.sitemap.extra) {
121
+ for (const path of config.seo.sitemap.extra as readonly unknown[]) {
122
+ if (typeof path !== 'string') {
123
+ issues.push(`seo.sitemap.extra entry must be a path string, not ${describeValue(path)}`);
124
+ continue;
125
+ }
115
126
  // A PATH, never a URL: every `<loc>` is built against the one declared origin, and a query or
116
127
  // a fragment names a variant of a page, which a sitemap lists by its canonical URL alone.
117
128
  if (!path.startsWith('/') || path.startsWith('//') || /[?#]/.test(path)) {