@k2b/cloud 0.45.0 → 0.47.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 +11 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +40 -0
- package/src/_internal/heartbeat.ts +19 -0
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +9 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +6 -0
- package/src/_internal/workload-credential.ts +153 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/capabilities.ts +99 -15
- package/src/api/index.ts +3 -0
- package/src/api/mcp.ts +4 -2
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +100 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +69 -0
- package/src/capabilities/response.ts +4 -1
- package/src/capabilities/server-invoke.ts +64 -36
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +7 -1
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +27 -21
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/index.ts +9 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +37 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/client-ip.ts +1 -1
- package/src/services/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/app-keys.ts +298 -0
- package/src/services/gateway.ts +6 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/identity/resource-readers.ts +80 -0
- package/src/services/identity/runtime-config.ts +14 -6
- package/src/services/identity/workload-runtime.ts +253 -0
- package/src/services/index.ts +4 -1
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/service-account-credentials.ts +63 -21
- package/src/services/settings/api.ts +3 -3
- package/src/services/settings/binding.ts +45 -0
- package/src/services/settings/core-settings.ts +99 -1
- package/src/services/settings/defaults.ts +16 -2
- package/src/services/settings/env-status.ts +69 -0
- package/src/services/settings/index.ts +69 -26
- package/src/services/settings/store.ts +73 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/start.ts +36 -0
- package/src/styles/global.css +2 -0
- package/src/workflows/ai/index.ts +1 -0
- package/src/workflows/ai/runtime.ts +16 -8
- package/src/workflows/ai/settings.ts +5 -0
- package/src/shared/markdown/formula.ts +0 -1283
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { env } from "../../config/env";
|
|
2
|
+
import { DEFAULT_WORKFLOW_AI_BUDGET, validateWorkflowAiBudget } from "../../config/workflow-ai";
|
|
2
3
|
import { GEO_MAX_CREDITS, GEO_MAX_RPS } from "../../contracts/geo-admin";
|
|
4
|
+
import { DEFAULT_TRUSTED_PROXIES, validateNetworkAllowlist, validateTrustedProxies } from "./validation";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Platform-owned settings, declared in the `defineApp({ settings: ... })` shape.
|
|
@@ -30,6 +32,101 @@ const IPA_ACCOUNT_TRANSITION_OPTIONS = [
|
|
|
30
32
|
] as const;
|
|
31
33
|
|
|
32
34
|
export const CORE_SETTINGS = {
|
|
35
|
+
"outgoing_mail.allowed_targets": {
|
|
36
|
+
kind: "string_list",
|
|
37
|
+
default: [],
|
|
38
|
+
label: "Outgoing mail allowed targets",
|
|
39
|
+
description: "Exact internal SMTP and IMAP hosts, IPs or CIDRs. Changes apply to new connections.",
|
|
40
|
+
envVariable: "OUTGOING_MAIL_ALLOWED_TARGETS",
|
|
41
|
+
envBootstrap: () => env.$readPresent("OUTGOING_MAIL_ALLOWED_TARGETS"),
|
|
42
|
+
validate: validateNetworkAllowlist,
|
|
43
|
+
presentation: {
|
|
44
|
+
translations: {
|
|
45
|
+
de: {
|
|
46
|
+
label: "Erlaubte Ziele für ausgehende E-Mails",
|
|
47
|
+
description: "Exakte interne SMTP- und IMAP-Hosts, IPs oder CIDRs. Änderungen gelten für neue Verbindungen.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
"gateway.trusted_proxies": {
|
|
53
|
+
kind: "string_list",
|
|
54
|
+
default: DEFAULT_TRUSTED_PROXIES,
|
|
55
|
+
label: "Trusted gateway proxies",
|
|
56
|
+
description: "IP addresses or CIDRs allowed to supply forwarded client addresses. Changes take effect after gateway restart.",
|
|
57
|
+
envVariable: "GATEWAY_TRUSTED_PROXIES",
|
|
58
|
+
envBootstrap: () => env.$readPresent("GATEWAY_TRUSTED_PROXIES"),
|
|
59
|
+
validate: validateTrustedProxies,
|
|
60
|
+
presentation: {
|
|
61
|
+
translations: {
|
|
62
|
+
de: {
|
|
63
|
+
label: "Vertrauenswürdige Gateway-Proxys",
|
|
64
|
+
description:
|
|
65
|
+
"IP-Adressen oder CIDRs, die weitergeleitete Client-Adressen liefern dürfen. Änderungen wirken nach einem Gateway-Neustart.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"ai.workflow_budget": {
|
|
71
|
+
kind: "text",
|
|
72
|
+
default: JSON.stringify(DEFAULT_WORKFLOW_AI_BUDGET),
|
|
73
|
+
label: "Workflow AI budget",
|
|
74
|
+
description:
|
|
75
|
+
"JSON limits for source, rate and concurrency admission. Changes apply to new work; globalConcurrent also sets worker capacity and requires restarting all AI workers.",
|
|
76
|
+
envVariable: "WORKFLOW_AI_BUDGET",
|
|
77
|
+
envBootstrap: () => {
|
|
78
|
+
const value = env.$readPresent("WORKFLOW_AI_BUDGET");
|
|
79
|
+
return value === undefined ? undefined : JSON.stringify(value);
|
|
80
|
+
},
|
|
81
|
+
validate: validateWorkflowAiBudget,
|
|
82
|
+
presentation: {
|
|
83
|
+
translations: {
|
|
84
|
+
de: {
|
|
85
|
+
label: "KI-Workflow-Budget",
|
|
86
|
+
description:
|
|
87
|
+
"JSON-Grenzen für Quellen, Rate und gleichzeitige Arbeit. Änderungen gelten für neue Arbeit; globalConcurrent bestimmt auch die Worker-Kapazität und erfordert den Neustart aller KI-Worker.",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
"ai.workflow_attempt_timeout_ms": {
|
|
93
|
+
kind: "number",
|
|
94
|
+
default: 60000,
|
|
95
|
+
label: "Workflow AI attempt timeout (ms)",
|
|
96
|
+
description: "Absolute attempt deadline including admission and repair. Changes apply to new attempts.",
|
|
97
|
+
envVariable: "WORKFLOW_AI_ATTEMPT_TIMEOUT_MS",
|
|
98
|
+
envBootstrap: () => env.$readPresent("WORKFLOW_AI_ATTEMPT_TIMEOUT_MS"),
|
|
99
|
+
integer: true,
|
|
100
|
+
min: 1,
|
|
101
|
+
presentation: {
|
|
102
|
+
translations: {
|
|
103
|
+
de: {
|
|
104
|
+
label: "Zeitlimit je KI-Workflow-Versuch (ms)",
|
|
105
|
+
description: "Absolute Frist einschließlich Zulassung und Reparatur. Änderungen gelten für neue Versuche.",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
"ai.workflow_queue_timeout_ms": {
|
|
111
|
+
kind: "number",
|
|
112
|
+
default: 60000,
|
|
113
|
+
label: "Workflow AI queue timeout (ms)",
|
|
114
|
+
description: "Maximum queue wait before visible failure. Changes apply to new requests and the next recovery scan.",
|
|
115
|
+
envVariable: "WORKFLOW_AI_QUEUE_TIMEOUT_MS",
|
|
116
|
+
envBootstrap: () => env.$readPresent("WORKFLOW_AI_QUEUE_TIMEOUT_MS"),
|
|
117
|
+
integer: true,
|
|
118
|
+
min: 1,
|
|
119
|
+
presentation: {
|
|
120
|
+
translations: {
|
|
121
|
+
de: {
|
|
122
|
+
label: "Wartezeitlimit für KI-Workflows (ms)",
|
|
123
|
+
description:
|
|
124
|
+
"Maximale Wartezeit bis zum sichtbaren Fehler. Änderungen gelten für neue Anfragen und den nächsten Wiederherstellungslauf.",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
|
|
33
130
|
"storage.endpoint": {
|
|
34
131
|
kind: "url",
|
|
35
132
|
label: "Garage S3 endpoint",
|
|
@@ -228,6 +325,7 @@ export const CORE_SETTINGS = {
|
|
|
228
325
|
placeholder: "e.g. https://cloud.example.org",
|
|
229
326
|
envFallback: () => env.APP_URL,
|
|
230
327
|
envBootstrap: () => env.APP_URL,
|
|
328
|
+
envVariable: "APP_URL",
|
|
231
329
|
},
|
|
232
330
|
"app.home_path": {
|
|
233
331
|
kind: "string",
|
|
@@ -494,8 +592,8 @@ export const CORE_SETTINGS = {
|
|
|
494
592
|
default: "",
|
|
495
593
|
description: "Internal base URL of the Gotenberg service used for HTML-to-PDF rendering.",
|
|
496
594
|
placeholder: "e.g. http://gotenberg:3000",
|
|
497
|
-
envFallback: () => env.GOTENBERG_URL,
|
|
498
595
|
envBootstrap: () => env.GOTENBERG_URL,
|
|
596
|
+
envVariable: "GOTENBERG_URL",
|
|
499
597
|
},
|
|
500
598
|
"gotenberg.username": {
|
|
501
599
|
kind: "string",
|
|
@@ -32,6 +32,12 @@ type SettingCommon = {
|
|
|
32
32
|
group: string;
|
|
33
33
|
envFallback?: SettingEnvResolver;
|
|
34
34
|
envBootstrap?: SettingEnvResolver;
|
|
35
|
+
/** Environment name for migration diagnostics; values are never returned. */
|
|
36
|
+
envVariable?: string;
|
|
37
|
+
/** Discard these stored settings when this normalized value changes. */
|
|
38
|
+
resetOnChange?: readonly string[];
|
|
39
|
+
/** Additional server-side validation after kind normalization. */
|
|
40
|
+
validate?: (value: unknown) => string | undefined;
|
|
35
41
|
};
|
|
36
42
|
|
|
37
43
|
type SettingStringLikeKind = "string" | "text" | "email" | "url" | "secret" | "image" | "cron" | "timezone" | "template";
|
|
@@ -118,6 +124,9 @@ export const toLegacySettingDefs = (settings: Record<string, unknown>, requested
|
|
|
118
124
|
presentation: compilePresentation(d.presentation, baseLocale, key),
|
|
119
125
|
envFallback: d.envFallback as (() => unknown) | undefined,
|
|
120
126
|
envBootstrap: d.envBootstrap as (() => unknown) | undefined,
|
|
127
|
+
envVariable: d.envVariable as string | undefined,
|
|
128
|
+
validate: d.validate as SettingDef["validate"],
|
|
129
|
+
resetOnChange: d.resetOnChange as readonly string[] | undefined,
|
|
121
130
|
templateVars: d.templateVars as readonly string[] | undefined,
|
|
122
131
|
options: d.options as ReadonlyArray<{ value: string; label: string }> | undefined,
|
|
123
132
|
min: d.min as number | undefined,
|
|
@@ -262,7 +271,11 @@ export const normalizeSettingValue = (def: SettingDef, raw: unknown): unknown =>
|
|
|
262
271
|
};
|
|
263
272
|
|
|
264
273
|
export const validateSettingValue = (def: SettingDef, raw: unknown): SettingValidationResult => {
|
|
274
|
+
if (def.validate && def.kind === "string_list" && Array.isArray(raw) && raw.some((entry) => typeof entry !== "string"))
|
|
275
|
+
return { ok: false, error: `${getSettingLabel(def)} must be a list of strings` };
|
|
265
276
|
const value = normalizeSettingValue(def, raw);
|
|
277
|
+
const error = def.validate?.(value);
|
|
278
|
+
if (error) return { ok: false, error };
|
|
266
279
|
|
|
267
280
|
if (def.key === "app.documentation_url") {
|
|
268
281
|
try {
|
|
@@ -356,7 +369,7 @@ export const validateSettingValue = (def: SettingDef, raw: unknown): SettingVali
|
|
|
356
369
|
export const SETTINGS_MAP = new Map(SETTINGS.map((setting) => [setting.key, setting] as const));
|
|
357
370
|
|
|
358
371
|
/** Fields whose divergence changes behaviour rather than just wording. */
|
|
359
|
-
const CONFLICT_FIELDS = ["kind", "default", "min", "max"] as const;
|
|
372
|
+
const CONFLICT_FIELDS = ["kind", "default", "min", "max", "resetOnChange"] as const;
|
|
360
373
|
|
|
361
374
|
export function registerSettings(defs: SettingDef[]): void {
|
|
362
375
|
for (const def of defs) {
|
|
@@ -368,7 +381,8 @@ export function registerSettings(defs: SettingDef[]): void {
|
|
|
368
381
|
// the winner would then depend on which container loaded which module.
|
|
369
382
|
const existing = SETTINGS[existingIndex] as Record<string, unknown>;
|
|
370
383
|
const incoming = def as unknown as Record<string, unknown>;
|
|
371
|
-
const conflicts = CONFLICT_FIELDS.filter((field) => JSON.stringify(existing[field]) !== JSON.stringify(incoming[field]));
|
|
384
|
+
const conflicts: string[] = CONFLICT_FIELDS.filter((field) => JSON.stringify(existing[field]) !== JSON.stringify(incoming[field]));
|
|
385
|
+
if (existing.validate !== incoming.validate) conflicts.push("validate");
|
|
372
386
|
if (conflicts.length > 0) {
|
|
373
387
|
throw new Error(
|
|
374
388
|
`Setting "${def.key}" is registered twice with different ${conflicts.join(", ")}. ` +
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { EnvSettingStatus } from "../../contracts/settings-env";
|
|
2
|
+
import { SETTINGS, type SettingDef, validateSettingValue } from "./defaults";
|
|
3
|
+
import { readStates, type SettingRead } from "./store";
|
|
4
|
+
|
|
5
|
+
const equal = (a: unknown, b: unknown, jsonText = false): boolean => {
|
|
6
|
+
if (jsonText && typeof a === "string" && typeof b === "string") {
|
|
7
|
+
try {
|
|
8
|
+
return equal(JSON.parse(a), JSON.parse(b));
|
|
9
|
+
} catch {
|
|
10
|
+
return a === b;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => equal(v, b[i]));
|
|
14
|
+
if (a && b && typeof a === "object" && typeof b === "object") {
|
|
15
|
+
const left = Object.entries(a),
|
|
16
|
+
right = new Map(Object.entries(b));
|
|
17
|
+
return left.length === right.size && left.every(([k, v]) => right.has(k) && equal(v, right.get(k)));
|
|
18
|
+
}
|
|
19
|
+
return a === b;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Compare locally, return presence and state only, including for secret settings. */
|
|
23
|
+
export const envSettingStatus = (def: SettingDef, stored: SettingRead): EnvSettingStatus => {
|
|
24
|
+
let envValue: unknown,
|
|
25
|
+
invalidEnvValue = false,
|
|
26
|
+
envValuePresent = false;
|
|
27
|
+
try {
|
|
28
|
+
const raw = (def.envBootstrap ?? def.envFallback)?.();
|
|
29
|
+
envValuePresent = raw !== undefined;
|
|
30
|
+
if (envValuePresent) {
|
|
31
|
+
const validated = validateSettingValue(def, raw);
|
|
32
|
+
if (validated.ok) envValue = validated.value;
|
|
33
|
+
else invalidEnvValue = true;
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
envValuePresent = true;
|
|
37
|
+
invalidEnvValue = true;
|
|
38
|
+
}
|
|
39
|
+
const unreadableStoredValue = stored.stored === "unreadable";
|
|
40
|
+
const storedValuePresent = stored.stored !== "none";
|
|
41
|
+
const conflict =
|
|
42
|
+
envValuePresent && storedValuePresent
|
|
43
|
+
? invalidEnvValue || unreadableStoredValue
|
|
44
|
+
? "unknown"
|
|
45
|
+
: equal(envValue, stored.value, def.key === "ai.workflow_budget")
|
|
46
|
+
? "none"
|
|
47
|
+
: "differs"
|
|
48
|
+
: "none";
|
|
49
|
+
return {
|
|
50
|
+
key: def.key,
|
|
51
|
+
envVariable: def.envVariable ?? null,
|
|
52
|
+
storedValuePresent,
|
|
53
|
+
envValuePresent,
|
|
54
|
+
conflict,
|
|
55
|
+
unreadableStoredValue,
|
|
56
|
+
invalidEnvValue,
|
|
57
|
+
needsAttention: invalidEnvValue || unreadableStoredValue || conflict !== "none" || (envValuePresent && !storedValuePresent),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const readEnvSettingsStatus = async (): Promise<EnvSettingStatus[]> => {
|
|
62
|
+
const defs = SETTINGS.filter((def) => def.envBootstrap || def.envFallback);
|
|
63
|
+
const states = await readStates(defs.map((def) => def.key));
|
|
64
|
+
return defs.map((def) => {
|
|
65
|
+
const state = states.get(def.key);
|
|
66
|
+
if (!state) throw new Error(`Missing stored state for ${def.key}`);
|
|
67
|
+
return envSettingStatus(def, state);
|
|
68
|
+
});
|
|
69
|
+
};
|
|
@@ -11,14 +11,24 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { redis, sql } from "bun";
|
|
14
|
+
import { bindSettingValue, replaceSettingValue, unwrapSettingValue } from "./binding";
|
|
14
15
|
import { decryptValue, encryptValue, getAppSecret } from "./crypto";
|
|
15
16
|
import { resolveSettingPresentation, SETTINGS, SETTINGS_MAP, type SettingDef, validateSettingValue } from "./defaults";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
deleteKey,
|
|
19
|
+
lockSettingChanges,
|
|
20
|
+
readKey,
|
|
21
|
+
readKeyState,
|
|
22
|
+
readStates,
|
|
23
|
+
resetChangedSettings,
|
|
24
|
+
type SettingRead,
|
|
25
|
+
writeKey,
|
|
26
|
+
} from "./store";
|
|
17
27
|
|
|
18
28
|
type SqlClient = typeof sql;
|
|
19
29
|
|
|
20
30
|
type StoredRow = { key: string; value: string };
|
|
21
|
-
type PendingRow = { value: unknown; originalCiphertext: string };
|
|
31
|
+
type PendingRow = { value: unknown; originalValue: unknown; originalCiphertext: string };
|
|
22
32
|
type NormalizationStats = {
|
|
23
33
|
encryptedLoaded: number;
|
|
24
34
|
normalizedUpdated: number;
|
|
@@ -32,7 +42,13 @@ const REDIS_KEY = (key: string) => `settings:${key}`;
|
|
|
32
42
|
const isEqual = (left: unknown, right: unknown): boolean => JSON.stringify(left) === JSON.stringify(right);
|
|
33
43
|
|
|
34
44
|
const resolveEnvValue = (def: SettingDef | undefined, mode: "fallback" | "bootstrap"): unknown => {
|
|
35
|
-
|
|
45
|
+
let raw: unknown;
|
|
46
|
+
try {
|
|
47
|
+
raw = mode === "fallback" ? def?.envFallback?.() : def?.envBootstrap?.();
|
|
48
|
+
} catch {
|
|
49
|
+
console.warn(`[settings] ignoring invalid ${mode} value for "${def?.key}"`);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
36
52
|
if (!def || raw === undefined) return undefined;
|
|
37
53
|
|
|
38
54
|
const validated = validateSettingValue(def, raw);
|
|
@@ -54,7 +70,7 @@ const shouldBootstrapValue = (def: SettingDef, value: unknown): boolean => {
|
|
|
54
70
|
return typeof value === "number" && Number.isFinite(value);
|
|
55
71
|
case "string_list":
|
|
56
72
|
case "number_list":
|
|
57
|
-
return Array.isArray(value) && value.length > 0;
|
|
73
|
+
return Array.isArray(value) && (value.length > 0 || def.envVariable !== undefined);
|
|
58
74
|
default:
|
|
59
75
|
return typeof value === "string" && value.trim().length > 0;
|
|
60
76
|
}
|
|
@@ -79,7 +95,7 @@ const normalizeStoredEntries = async (): Promise<{
|
|
|
79
95
|
for (const row of rows) {
|
|
80
96
|
try {
|
|
81
97
|
const value = await decryptValue(row.value);
|
|
82
|
-
pendingRows.set(row.key, { value, originalCiphertext: row.value });
|
|
98
|
+
pendingRows.set(row.key, { value: unwrapSettingValue(row.key, value), originalValue: value, originalCiphertext: row.value });
|
|
83
99
|
stats.encryptedLoaded += 1;
|
|
84
100
|
} catch {
|
|
85
101
|
// An unreadable value is not a missing value (#1295). Usually this
|
|
@@ -113,7 +129,7 @@ const normalizeStoredEntries = async (): Promise<{
|
|
|
113
129
|
normalized.push({ key, value: validated.value });
|
|
114
130
|
|
|
115
131
|
if (!isEqual(row.value, validated.value)) {
|
|
116
|
-
const encryptedValue = await encryptValue(validated.value);
|
|
132
|
+
const encryptedValue = await encryptValue(replaceSettingValue(key, row.originalValue, validated.value));
|
|
117
133
|
const updated = await sql<{ key: string }[]>`
|
|
118
134
|
UPDATE settings.entries SET value = ${encryptedValue}, updated_at = now()
|
|
119
135
|
WHERE key = ${key} AND value = ${row.originalCiphertext}
|
|
@@ -136,26 +152,53 @@ const bootstrapEnvBackedEntries = async (config: {
|
|
|
136
152
|
}): Promise<Array<{ key: string; value: unknown }>> => {
|
|
137
153
|
const rowsByKey = new Map(config.rows.map((row) => [row.key, row.value]));
|
|
138
154
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
const imported = new Set<string>();
|
|
156
|
+
await sql.begin(async (tx) => {
|
|
157
|
+
// Import access before its endpoint regardless of declaration order.
|
|
158
|
+
const definitions = [...SETTINGS].sort((a, b) => Number(Boolean(a.resetOnChange?.length)) - Number(Boolean(b.resetOnChange?.length)));
|
|
159
|
+
for (const def of definitions) {
|
|
160
|
+
// Existing rows block bootstrap even if unreadable, unknown, or invalid.
|
|
161
|
+
if (config.storedKeys.has(def.key)) continue;
|
|
162
|
+
let envValue = resolveEnvValue(def, "bootstrap");
|
|
163
|
+
if (!shouldBootstrapValue(def, envValue)) continue;
|
|
164
|
+
await lockSettingChanges(tx, def.key);
|
|
165
|
+
let bindingRefused = false;
|
|
166
|
+
// Do not import access from an environment that names other endpoints.
|
|
167
|
+
for (const owner of SETTINGS.filter((owner) => owner.resetOnChange?.includes(def.key))) {
|
|
168
|
+
const current = await readKeyState(owner.key, tx);
|
|
169
|
+
const expected = resolveEnvValue(owner, "bootstrap") ?? resolveEnvValue(owner, "fallback") ?? owner.default;
|
|
170
|
+
if (current.stored !== "none" && (current.stored === "unreadable" || !isEqual(current.value, expected))) {
|
|
171
|
+
envValue = def.default;
|
|
172
|
+
bindingRefused = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const prior = def.resetOnChange?.length ? await readKeyState(def.key, tx) : undefined;
|
|
176
|
+
const encryptedValue = await encryptValue(
|
|
177
|
+
bindingRefused
|
|
178
|
+
? envValue
|
|
179
|
+
: await bindSettingValue(def.key, envValue, async (owner) => {
|
|
180
|
+
const current = await readKeyState(owner, tx);
|
|
181
|
+
const ownerDef = SETTINGS_MAP.get(owner);
|
|
182
|
+
if (current.stored === "unreadable") throw new Error(`Cannot bind setting to unreadable ${owner}`);
|
|
183
|
+
return current.stored === "none" ? (resolveEnvValue(ownerDef, "bootstrap") ?? current.value) : current.value;
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
const inserted = await tx<{ key: string }[]>`
|
|
187
|
+
INSERT INTO settings.entries (key, value, updated_at)
|
|
188
|
+
VALUES (${def.key}, ${encryptedValue}, now())
|
|
189
|
+
ON CONFLICT (key) DO NOTHING
|
|
190
|
+
RETURNING key
|
|
191
|
+
`;
|
|
192
|
+
if (inserted.length === 0) continue;
|
|
193
|
+
// A newly bootstrapped endpoint must not inherit previously stored access.
|
|
194
|
+
// Values imported together belong to the same environment configuration.
|
|
195
|
+
await resetChangedSettings(tx, def.key, envValue, imported, prior);
|
|
196
|
+
imported.add(def.key);
|
|
197
|
+
rowsByKey.set(def.key, envValue);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
await Promise.all([...imported].map((key) => redis.del(REDIS_KEY(key), ...(SETTINGS_MAP.get(key)?.resetOnChange ?? []).map(REDIS_KEY))));
|
|
201
|
+
config.stats.envBootstrapped += imported.size;
|
|
159
202
|
|
|
160
203
|
return [...rowsByKey.entries()].map(([key, value]) => ({ key, value }));
|
|
161
204
|
};
|
|
@@ -20,6 +20,7 @@ import { claimCacheFill, completeCacheFill, MISSING_SETTING } from "../cache-fil
|
|
|
20
20
|
import { stripImageDataUrlMetadata } from "../image-metadata";
|
|
21
21
|
import { toPgTextArray } from "../postgres";
|
|
22
22
|
import { requestCacheRedis } from "../request-cache-redis";
|
|
23
|
+
import { bindSettingValue, resolveBoundSetting, settingOwners, unwrapSettingValue } from "./binding";
|
|
23
24
|
import { decryptValue, encryptValue } from "./crypto";
|
|
24
25
|
import { SETTINGS, SETTINGS_MAP, type SettingDef, validateSettingValue } from "./defaults";
|
|
25
26
|
import { SettingUnreadableError } from "./errors";
|
|
@@ -32,7 +33,7 @@ const REDIS_KEY = (k: string) => `settings:${k}`;
|
|
|
32
33
|
type SqlClient = typeof sql;
|
|
33
34
|
|
|
34
35
|
export type StoredSettingState = "none" | "readable" | "unreadable";
|
|
35
|
-
export type SettingRead = { value: unknown; stored: StoredSettingState };
|
|
36
|
+
export type SettingRead = { value: unknown; stored: StoredSettingState; bindingMismatch?: true };
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Forget cached values so every container re-reads on next access.
|
|
@@ -43,7 +44,8 @@ export type SettingRead = { value: unknown; stored: StoredSettingState };
|
|
|
43
44
|
* therefore invalidate after it commits.
|
|
44
45
|
*/
|
|
45
46
|
export const invalidateSettingsCache = async (keys: readonly string[]): Promise<void> => {
|
|
46
|
-
if (keys.length > 0)
|
|
47
|
+
if (keys.length > 0)
|
|
48
|
+
await (await requestCacheRedis()).del(...keys.flatMap((key) => [key, ...(SETTINGS_MAP.get(key)?.resetOnChange ?? [])]).map(REDIS_KEY));
|
|
47
49
|
};
|
|
48
50
|
const REDIS_TTL_SEC = 300;
|
|
49
51
|
|
|
@@ -87,6 +89,15 @@ export const readKeyState = async (key: string, db: SqlClient = sql): Promise<Se
|
|
|
87
89
|
export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unknown>> => {
|
|
88
90
|
const result = new Map<string, unknown>();
|
|
89
91
|
if (keys.length === 0) return result;
|
|
92
|
+
// Bound access is read with its endpoint in one database snapshot. Never
|
|
93
|
+
// resolve it from an independently cached secret.
|
|
94
|
+
const boundKeys = keys.filter((key) => settingOwners(key).length > 0);
|
|
95
|
+
if (boundKeys.length) {
|
|
96
|
+
const bound = await readStates(boundKeys);
|
|
97
|
+
const other = await bulkRead(keys.filter((key) => !bound.has(key)));
|
|
98
|
+
for (const key of keys) result.set(key, bound.has(key) ? bound.get(key)!.value : other.get(key));
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
90
101
|
let cached: Array<string | null>;
|
|
91
102
|
try {
|
|
92
103
|
cached = await (await requestCacheRedis()).mget(...keys.map(REDIS_KEY));
|
|
@@ -152,17 +163,29 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
|
|
|
152
163
|
export const readStates = async (keys: readonly string[], db: SqlClient = sql): Promise<Map<string, SettingRead>> => {
|
|
153
164
|
const result = new Map<string, SettingRead>();
|
|
154
165
|
if (keys.length === 0) return result;
|
|
155
|
-
const
|
|
166
|
+
const snapshotKeys = [...new Set(keys.flatMap((key) => [key, ...settingOwners(key).map((owner) => owner.key)]))];
|
|
167
|
+
const rows = await db<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray(snapshotKeys)}::text[])`;
|
|
156
168
|
const byKey = new Map(rows.map((row) => [row.key, row]));
|
|
169
|
+
const resolve = async (key: string) => {
|
|
170
|
+
const row = byKey.get(key);
|
|
171
|
+
const def = SETTINGS_MAP.get(key);
|
|
172
|
+
if (!row) return resolveFallback(def);
|
|
173
|
+
const raw = await decryptValue(row.value);
|
|
174
|
+
const checked = def ? validateSettingValue(def, unwrapSettingValue(key, raw)) : { ok: true as const, value: raw };
|
|
175
|
+
if (!checked.ok) throw new SettingUnreadableError(key);
|
|
176
|
+
return checked.value;
|
|
177
|
+
};
|
|
157
178
|
for (const key of keys) {
|
|
158
179
|
const row = byKey.get(key);
|
|
159
180
|
const def = SETTINGS_MAP.get(key);
|
|
160
181
|
result.set(key, { stored: row ? "unreadable" : "none", value: resolveFallback(def) });
|
|
161
182
|
if (!row) continue;
|
|
162
183
|
try {
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
184
|
+
const raw = await decryptValue(row.value);
|
|
185
|
+
const bound = await resolveBoundSetting(key, raw, resolve);
|
|
186
|
+
const validated = def ? validateSettingValue(def, bound.value) : { ok: true as const, value: bound.value };
|
|
187
|
+
if (validated.ok)
|
|
188
|
+
result.set(key, { stored: "readable", value: validated.value, ...(bound.mismatch ? { bindingMismatch: true } : {}) });
|
|
166
189
|
} catch {
|
|
167
190
|
// Keep this process's fallback without changing the stored row or cache.
|
|
168
191
|
}
|
|
@@ -220,6 +243,37 @@ export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []):
|
|
|
220
243
|
return { deleted };
|
|
221
244
|
};
|
|
222
245
|
|
|
246
|
+
/** Serialize a setting and every value it discards, including missing rows. */
|
|
247
|
+
export const lockSettingChanges = async (tx: SqlClient, key: string): Promise<void> => {
|
|
248
|
+
const owners = SETTINGS.filter((def) => def.resetOnChange?.length && (def.key === key || def.resetOnChange.includes(key)));
|
|
249
|
+
for (const owner of owners.sort((a, b) => a.key.localeCompare(b.key)))
|
|
250
|
+
await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`settings.change:${owner.key}`}, 0))`;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/** Keep an empty row so environment bootstrap cannot restore discarded access. */
|
|
254
|
+
export const resetChangedSettings = async (
|
|
255
|
+
tx: SqlClient,
|
|
256
|
+
key: string,
|
|
257
|
+
value: unknown,
|
|
258
|
+
preserve: ReadonlySet<string> = new Set(),
|
|
259
|
+
prior?: SettingRead,
|
|
260
|
+
): Promise<void> => {
|
|
261
|
+
const def = SETTINGS_MAP.get(key);
|
|
262
|
+
if (!def?.resetOnChange?.length) return;
|
|
263
|
+
const previous = prior ?? (await readKeyState(key, tx));
|
|
264
|
+
if (previous.stored !== "unreadable" && JSON.stringify(previous.value) === JSON.stringify(value)) return;
|
|
265
|
+
for (const target of def.resetOnChange) {
|
|
266
|
+
if (preserve.has(target)) continue;
|
|
267
|
+
const targetDef = SETTINGS_MAP.get(target);
|
|
268
|
+
if (!targetDef) throw new Error(`Unknown setting: ${target}`);
|
|
269
|
+
const empty = await encryptValue(targetDef.default);
|
|
270
|
+
await tx`
|
|
271
|
+
INSERT INTO settings.entries (key, value, updated_at) VALUES (${target}, ${empty}, now())
|
|
272
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
|
|
273
|
+
`;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
223
277
|
/**
|
|
224
278
|
* Encrypt the value, upsert the DB row, invalidate the Redis key.
|
|
225
279
|
*
|
|
@@ -238,8 +292,16 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
|
|
|
238
292
|
|
|
239
293
|
const storedValue =
|
|
240
294
|
def.kind === "image" && typeof validated.value === "string" ? stripImageDataUrlMetadata(validated.value) : validated.value;
|
|
241
|
-
const encrypted = await encryptValue(storedValue);
|
|
242
295
|
const write = async (tx: SqlClient) => {
|
|
296
|
+
await lockSettingChanges(tx, key);
|
|
297
|
+
await resetChangedSettings(tx, key, validated.value);
|
|
298
|
+
const encrypted = await encryptValue(
|
|
299
|
+
await bindSettingValue(key, storedValue, async (owner) => {
|
|
300
|
+
const state = await readKeyState(owner, tx);
|
|
301
|
+
if (state.stored === "unreadable") throw new SettingUnreadableError(owner);
|
|
302
|
+
return state.value;
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
243
305
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
244
306
|
let [row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
|
|
245
307
|
if (!row) {
|
|
@@ -256,7 +318,7 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
|
|
|
256
318
|
if (options?.replaceUnreadable !== true) {
|
|
257
319
|
let readable = false;
|
|
258
320
|
try {
|
|
259
|
-
readable = validateSettingValue(def, await decryptValue(row.value)).ok;
|
|
321
|
+
readable = validateSettingValue(def, unwrapSettingValue(key, await decryptValue(row.value))).ok;
|
|
260
322
|
} catch {
|
|
261
323
|
// Only the key is exposed by the recovery error.
|
|
262
324
|
}
|
|
@@ -283,7 +345,10 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
|
|
|
283
345
|
|
|
284
346
|
/** Delete the DB row and invalidate Redis. With `db`, the caller owns geo budget pair validation and post-commit invalidation. */
|
|
285
347
|
export const deleteKey = async (key: string, db?: SqlClient): Promise<void> => {
|
|
348
|
+
const def = SETTINGS_MAP.get(key);
|
|
286
349
|
const remove = async (tx: SqlClient) => {
|
|
350
|
+
await lockSettingChanges(tx, key);
|
|
351
|
+
await resetChangedSettings(tx, key, resolveFallback(def));
|
|
287
352
|
await tx`DELETE FROM settings.entries WHERE key = ${key}`;
|
|
288
353
|
};
|
|
289
354
|
if (db) await remove(db);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { BlockList, isIP } from "node:net";
|
|
2
|
+
import { parseNetworkAllowlist } from "../../shared/network-address";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_TRUSTED_PROXIES = ["127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"];
|
|
5
|
+
|
|
6
|
+
/** One parser for admin writes and the gateway's startup check. */
|
|
7
|
+
export const trustedProxySet = (entries: readonly string[]): BlockList => {
|
|
8
|
+
const set = new BlockList();
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const [address = "", prefix, ...rest] = entry.split("/");
|
|
11
|
+
const version = isIP(address);
|
|
12
|
+
const max = version === 4 ? 32 : 128;
|
|
13
|
+
if (!version || rest.length || address.includes("%") || (prefix !== undefined && !/^\d{1,3}$/.test(prefix)))
|
|
14
|
+
throw new Error("Trusted proxies must contain only IP addresses or CIDR ranges");
|
|
15
|
+
const bits = prefix === undefined ? max : Number(prefix);
|
|
16
|
+
if (bits < 1 || bits > max) throw new Error("Trusted proxy CIDR prefixes must be greater than zero and within the address width");
|
|
17
|
+
set.addSubnet(address, bits, version === 4 ? "ipv4" : "ipv6");
|
|
18
|
+
}
|
|
19
|
+
return set;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const validateTrustedProxies = (value: unknown): string | undefined => {
|
|
23
|
+
if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === "string"))
|
|
24
|
+
return "Trusted proxies must be a list of strings";
|
|
25
|
+
try {
|
|
26
|
+
trustedProxySet(value);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return error instanceof Error ? error.message : "Invalid trusted proxies";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const validateNetworkAllowlist = (value: unknown): string | undefined => {
|
|
33
|
+
if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === "string"))
|
|
34
|
+
return "Network targets must be a list of strings";
|
|
35
|
+
try {
|
|
36
|
+
parseNetworkAllowlist(value);
|
|
37
|
+
} catch {
|
|
38
|
+
return "Network targets must be exact host names, IP addresses or canonical CIDRs; /0 is forbidden. Host:port and [ipv6]:port are supported.";
|
|
39
|
+
}
|
|
40
|
+
};
|
package/src/shared/index.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// Cloud-specific shared utils (NOT in stdlib)
|
|
2
2
|
|
|
3
|
+
// The standalone editor owns the formula engine; Cloud preserves its existing shared API.
|
|
4
|
+
export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "@k2b/editor/formula";
|
|
5
|
+
export {
|
|
6
|
+
createProgressValue,
|
|
7
|
+
evaluateFormula,
|
|
8
|
+
evaluateTableCell,
|
|
9
|
+
formatValue,
|
|
10
|
+
isFormula,
|
|
11
|
+
isTotalRow,
|
|
12
|
+
parseProgressValue,
|
|
13
|
+
} from "@k2b/editor/formula";
|
|
3
14
|
// Re-export from stdlib for backward compatibility
|
|
4
15
|
// Prefer importing directly from @k2b/stdlib
|
|
5
16
|
export { dates, dates as calendar, encoding, fileIcons, gradients } from "@k2b/stdlib";
|
|
@@ -49,16 +60,6 @@ export * from "./locale-preference";
|
|
|
49
60
|
export * from "./login-method";
|
|
50
61
|
export { markdown } from "./markdown";
|
|
51
62
|
export { markdownCalloutSanitizeOptions } from "./markdown/callout-sanitizer";
|
|
52
|
-
export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
|
|
53
|
-
export {
|
|
54
|
-
createProgressValue,
|
|
55
|
-
evaluateFormula,
|
|
56
|
-
evaluateTableCell,
|
|
57
|
-
formatValue,
|
|
58
|
-
isFormula,
|
|
59
|
-
isTotalRow,
|
|
60
|
-
parseProgressValue,
|
|
61
|
-
} from "./markdown/formula";
|
|
62
63
|
export type { MockCover, MockCoverIcon, MockCoverOptions, MockCoverTheme } from "./mock-cover";
|
|
63
64
|
export { createMockCover, createMockCoverSvg, parseDataUrl } from "./mock-cover";
|
|
64
65
|
export * from "./network-address";
|
|
@@ -20,8 +20,17 @@
|
|
|
20
20
|
* NULL/datetime cell-formatting, no zebra columns. If you need any of
|
|
21
21
|
* those, you're rendering data not prose; use the Grids app.
|
|
22
22
|
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type EvalContext,
|
|
26
|
+
evaluateFormula,
|
|
27
|
+
formatValue,
|
|
28
|
+
isFormula,
|
|
29
|
+
isTotalRow,
|
|
30
|
+
type ProgressValue,
|
|
31
|
+
parseProgressValue,
|
|
32
|
+
} from "@k2b/editor/formula";
|
|
23
33
|
import type { MarkedExtension, Tokens } from "marked";
|
|
24
|
-
import { type EvalContext, evaluateFormula, formatValue, isFormula, isTotalRow, type ProgressValue, parseProgressValue } from "../formula";
|
|
25
34
|
import { escapeHtml } from "../shared";
|
|
26
35
|
|
|
27
36
|
type Align = "left" | "right" | "center" | null;
|
package/src/ssr/AdminArea.tsx
CHANGED
|
@@ -84,7 +84,7 @@ export const AdminAreaSaveStatusHead = () => (
|
|
|
84
84
|
</div>
|
|
85
85
|
);
|
|
86
86
|
export const AdminAreaSaveStatusFoot = () => (
|
|
87
|
-
<div class="sticky bottom-0 flex justify-end bg-(--k2b-surface) px-
|
|
87
|
+
<div class="sticky bottom-0 flex justify-end bg-(--k2b-surface) px-2 py-3 md:hidden">
|
|
88
88
|
<SettingsSaveStatus />
|
|
89
89
|
</div>
|
|
90
90
|
);
|
|
@@ -124,7 +124,7 @@ export function AdminAreaPane(props: {
|
|
|
124
124
|
minSize={props.minSize}
|
|
125
125
|
defaultSize={props.minSize}
|
|
126
126
|
>
|
|
127
|
-
<div class="app-rows
|
|
127
|
+
<div class="app-rows pb-[var(--ui-space-shell)]">{props.children}</div>
|
|
128
128
|
{props.saveStatus ? <AdminAreaSaveStatusFoot /> : null}
|
|
129
129
|
</AppWorkspace.MainPane>
|
|
130
130
|
);
|
package/src/ssr/AdminLayout.tsx
CHANGED
|
@@ -47,7 +47,7 @@ export default function AdminLayout(props: Props) {
|
|
|
47
47
|
const breadcrumbs: Breadcrumb[] = [{ title: t.start, href: "/" }, { title: overview ? t.admin : title }];
|
|
48
48
|
return (
|
|
49
49
|
<Layout c={c} fullWidth fullPage title={breadcrumbs}>
|
|
50
|
-
<AppWorkspace mobileSurface="flush" class="min-h-0 flex-1"
|
|
50
|
+
<AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
|
|
51
51
|
<AdminSidebar currentPath={currentPath} apps={runtime.apps} />
|
|
52
52
|
<AppWorkspace.Content>
|
|
53
53
|
{props.ownMain ? (
|