@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 +2 -2
- package/src/access/GroupCoverage.tsx +2 -1
- package/src/access/PermissionEditor.tsx +57 -28
- package/src/access/PrincipalPicker.tsx +2 -0
- package/src/access/messages.ts +2 -0
- package/src/ai/capabilities.ts +122 -11
- package/src/ai/firecrawl-tools.ts +3 -30
- package/src/browser/reload.ts +32 -1
- package/src/services/index.ts +8 -0
- package/src/services/network-security.ts +51 -11
- package/src/shared/network-address.ts +117 -45
- package/src/ssr/LayoutHelp.tsx +2 -1
- package/src/styles/global.css +20 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "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.
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 = () =>
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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}>
|
|
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
|
package/src/access/messages.ts
CHANGED
|
@@ -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",
|
package/src/ai/capabilities.ts
CHANGED
|
@@ -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
|
-
|
|
439
|
-
|
|
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 (
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
/**
|
|
459
|
-
|
|
460
|
-
|
|
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 (
|
|
154
|
+
if (isUnsafeNetworkHostname(hostname)) {
|
|
180
155
|
throw new Error("URL host is not allowed.");
|
|
181
156
|
}
|
|
182
157
|
|
|
183
|
-
|
|
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
|
|
package/src/browser/reload.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
package/src/services/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
18
|
-
const addresses =
|
|
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
|
|
23
|
-
if (entry.family !== 4 && entry.family !== 6)
|
|
24
|
-
|
|
25
|
-
|
|
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 (
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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("::")
|
|
61
|
-
[ipv6ToBigInt("
|
|
62
|
-
[ipv6ToBigInt("
|
|
63
|
-
[ipv6ToBigInt("
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
if (family === 4) return UNSAFE_IPV4_RANGES.some(([base, bits]) => ipv4InRange(address, base, bits));
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -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:
|
|
190
|
+
heading.scrollIntoView({ block: "start", behavior: prefersReducedMotion() ? "auto" : "smooth" });
|
|
190
191
|
return true;
|
|
191
192
|
};
|
|
192
193
|
|
package/src/styles/global.css
CHANGED
|
@@ -255,8 +255,26 @@ html:has(.k2b-mobile-shell) body {
|
|
|
255
255
|
--color-selection-fg: rgb(239 246 255);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
@
|
|
259
|
-
|
|
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
|