@k2b/cloud 0.37.0 → 0.39.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 +8 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +11 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +460 -66
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +46 -3
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/help.ts +23 -4
- package/src/api/index.ts +2 -1
- package/src/api/mcp.ts +8 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +17 -2
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/help.ts +136 -10
- package/src/server/services/access.ts +5 -1
- package/src/server/services/geo.ts +21 -4
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +22 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +67 -43
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +62 -11
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +72 -25
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +34 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +11 -0
- package/src/ssr/shell-shortcuts.ts +19 -0
- package/src/styles/effects.css +689 -185
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/resource-search.css +25 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
|
@@ -385,15 +385,16 @@ export const CORE_SETTINGS = {
|
|
|
385
385
|
default: 10000,
|
|
386
386
|
min: 100,
|
|
387
387
|
max: 120000,
|
|
388
|
-
description:
|
|
388
|
+
description:
|
|
389
|
+
"Maximum time in milliseconds for one Gotenberg render request. Gotenberg's own --api-timeout (default 30 s) also caps it.",
|
|
389
390
|
},
|
|
390
391
|
"gotenberg.max_html_bytes": {
|
|
391
392
|
kind: "number",
|
|
392
|
-
label: "Max
|
|
393
|
+
label: "Max input bytes",
|
|
393
394
|
default: 1048576,
|
|
394
395
|
min: 1024,
|
|
395
396
|
max: 10485760,
|
|
396
|
-
description: "Maximum
|
|
397
|
+
description: "Maximum size of one render request: HTML, header, footer, images and fonts together.",
|
|
397
398
|
},
|
|
398
399
|
"gotenberg.max_pdf_bytes": {
|
|
399
400
|
kind: "number",
|
|
@@ -3,25 +3,27 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Resolution order: DB value -> env fallback -> code default.
|
|
5
5
|
*
|
|
6
|
-
* Custom DB values are encrypted at rest using APP_SECRET.
|
|
7
|
-
*
|
|
6
|
+
* Custom DB values are encrypted at rest using APP_SECRET. Value reads use
|
|
7
|
+
* Redis cache-aside in `./store.ts` (5-minute TTL); stored-state reads use DB.
|
|
8
8
|
* `loadCache()` runs once at boot to normalize legacy rows and bootstrap
|
|
9
|
-
* env-backed entries into Postgres.
|
|
9
|
+
* env-backed entries into Postgres. Startup never replaces an existing row;
|
|
10
|
+
* env bootstrap only fills missing keys.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { redis, sql } from "bun";
|
|
13
14
|
import { decryptValue, encryptValue, getAppSecret } from "./crypto";
|
|
14
15
|
import { resolveSettingPresentation, SETTINGS, SETTINGS_MAP, type SettingDef, validateSettingValue } from "./defaults";
|
|
15
|
-
import {
|
|
16
|
+
import { deleteKey, readKey, readKeyState, readStates, type SettingRead, writeKey } from "./store";
|
|
16
17
|
|
|
17
18
|
type SqlClient = typeof sql;
|
|
18
19
|
|
|
19
20
|
type StoredRow = { key: string; value: string };
|
|
20
|
-
type PendingRow = {
|
|
21
|
+
type PendingRow = { value: unknown; originalCiphertext: string };
|
|
21
22
|
type NormalizationStats = {
|
|
22
23
|
encryptedLoaded: number;
|
|
23
24
|
normalizedUpdated: number;
|
|
24
25
|
envBootstrapped: number;
|
|
26
|
+
undecryptable: number;
|
|
25
27
|
invalidSkipped: number;
|
|
26
28
|
};
|
|
27
29
|
|
|
@@ -58,23 +60,18 @@ const shouldBootstrapValue = (def: SettingDef, value: unknown): boolean => {
|
|
|
58
60
|
}
|
|
59
61
|
};
|
|
60
62
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
ON CONFLICT (key)
|
|
67
|
-
DO UPDATE SET value = ${encryptedValue}, updated_at = now()
|
|
68
|
-
`;
|
|
69
|
-
await redis.del(REDIS_KEY(key));
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; value: unknown }>; stats: NormalizationStats }> => {
|
|
63
|
+
const normalizeStoredEntries = async (): Promise<{
|
|
64
|
+
rows: Array<{ key: string; value: unknown }>;
|
|
65
|
+
storedKeys: Set<string>;
|
|
66
|
+
stats: NormalizationStats;
|
|
67
|
+
}> => {
|
|
73
68
|
const rows = await sql<StoredRow[]>`SELECT key, value FROM settings.entries`;
|
|
69
|
+
const storedKeys = new Set(rows.map((row) => row.key));
|
|
74
70
|
const stats: NormalizationStats = {
|
|
75
71
|
encryptedLoaded: 0,
|
|
76
72
|
normalizedUpdated: 0,
|
|
77
73
|
envBootstrapped: 0,
|
|
74
|
+
undecryptable: 0,
|
|
78
75
|
invalidSkipped: 0,
|
|
79
76
|
};
|
|
80
77
|
const pendingRows = new Map<string, PendingRow>();
|
|
@@ -82,11 +79,17 @@ const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; va
|
|
|
82
79
|
for (const row of rows) {
|
|
83
80
|
try {
|
|
84
81
|
const value = await decryptValue(row.value);
|
|
85
|
-
pendingRows.set(row.key, {
|
|
82
|
+
pendingRows.set(row.key, { value, originalCiphertext: row.value });
|
|
86
83
|
stats.encryptedLoaded += 1;
|
|
87
84
|
} catch {
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
// An unreadable value is not a missing value (#1295). Usually this
|
|
86
|
+
// process runs with another APP_SECRET than the writer; applications
|
|
87
|
+
// with the right key still read this row. Keep its key in `storedKeys`
|
|
88
|
+
// so env bootstrap never replaces it, and never log the value.
|
|
89
|
+
console.warn(
|
|
90
|
+
`[settings] cannot decrypt stored value for "${row.key}" with this APP_SECRET; keeping the row unchanged, reads use the environment fallback or default`,
|
|
91
|
+
);
|
|
92
|
+
stats.undecryptable += 1;
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -109,28 +112,47 @@ const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; va
|
|
|
109
112
|
|
|
110
113
|
normalized.push({ key, value: validated.value });
|
|
111
114
|
|
|
112
|
-
if (
|
|
113
|
-
await
|
|
114
|
-
|
|
115
|
+
if (!isEqual(row.value, validated.value)) {
|
|
116
|
+
const encryptedValue = await encryptValue(validated.value);
|
|
117
|
+
const updated = await sql<{ key: string }[]>`
|
|
118
|
+
UPDATE settings.entries SET value = ${encryptedValue}, updated_at = now()
|
|
119
|
+
WHERE key = ${key} AND value = ${row.originalCiphertext}
|
|
120
|
+
RETURNING key
|
|
121
|
+
`;
|
|
122
|
+
if (updated.length > 0) {
|
|
123
|
+
await redis.del(REDIS_KEY(key));
|
|
124
|
+
stats.normalizedUpdated += 1;
|
|
125
|
+
}
|
|
115
126
|
}
|
|
116
127
|
}
|
|
117
128
|
|
|
118
|
-
return { rows: normalized, stats };
|
|
129
|
+
return { rows: normalized, storedKeys, stats };
|
|
119
130
|
};
|
|
120
131
|
|
|
121
132
|
const bootstrapEnvBackedEntries = async (config: {
|
|
122
133
|
rows: Array<{ key: string; value: unknown }>;
|
|
134
|
+
storedKeys: Set<string>;
|
|
123
135
|
stats: NormalizationStats;
|
|
124
136
|
}): Promise<Array<{ key: string; value: unknown }>> => {
|
|
125
137
|
const rowsByKey = new Map(config.rows.map((row) => [row.key, row.value]));
|
|
126
138
|
|
|
127
139
|
for (const def of SETTINGS) {
|
|
128
|
-
if
|
|
140
|
+
// Existing rows block bootstrap even if unreadable, unknown, or invalid.
|
|
141
|
+
if (config.storedKeys.has(def.key)) continue;
|
|
129
142
|
|
|
130
143
|
const envValue = resolveEnvValue(def, "bootstrap");
|
|
131
144
|
if (!shouldBootstrapValue(def, envValue)) continue;
|
|
132
145
|
|
|
133
|
-
await
|
|
146
|
+
const encryptedValue = await encryptValue(envValue);
|
|
147
|
+
const inserted = await sql<{ key: string }[]>`
|
|
148
|
+
INSERT INTO settings.entries (key, value, updated_at)
|
|
149
|
+
VALUES (${def.key}, ${encryptedValue}, now())
|
|
150
|
+
ON CONFLICT (key) DO NOTHING
|
|
151
|
+
RETURNING key
|
|
152
|
+
`;
|
|
153
|
+
if (inserted.length === 0) continue;
|
|
154
|
+
|
|
155
|
+
await redis.del(REDIS_KEY(def.key));
|
|
134
156
|
rowsByKey.set(def.key, envValue);
|
|
135
157
|
config.stats.envBootstrapped += 1;
|
|
136
158
|
}
|
|
@@ -140,16 +162,16 @@ const bootstrapEnvBackedEntries = async (config: {
|
|
|
140
162
|
|
|
141
163
|
/**
|
|
142
164
|
* Boot-time normalization + env-bootstrap. Runs once at startup. Does NOT
|
|
143
|
-
* populate any in-process cache —
|
|
144
|
-
* (`store.ts`)
|
|
165
|
+
* populate any in-process cache — value reads use Redis cache-aside
|
|
166
|
+
* (`store.ts`), and stored-state reads go directly to Postgres.
|
|
145
167
|
*/
|
|
146
168
|
export async function loadCache(): Promise<void> {
|
|
147
169
|
getAppSecret();
|
|
148
|
-
const { rows, stats } = await normalizeStoredEntries();
|
|
149
|
-
const bootstrappedRows = await bootstrapEnvBackedEntries({ rows, stats });
|
|
170
|
+
const { rows, storedKeys, stats } = await normalizeStoredEntries();
|
|
171
|
+
const bootstrappedRows = await bootstrapEnvBackedEntries({ rows, storedKeys, stats });
|
|
150
172
|
|
|
151
173
|
console.log(
|
|
152
|
-
`[settings] loaded ${bootstrappedRows.length} custom setting(s) (${stats.encryptedLoaded} encrypted, ${stats.normalizedUpdated} normalized, ${stats.envBootstrapped} env-bootstrapped, ${stats.invalidSkipped} skipped)`,
|
|
174
|
+
`[settings] loaded ${bootstrappedRows.length} custom setting(s) (${stats.encryptedLoaded} encrypted, ${stats.normalizedUpdated} normalized, ${stats.envBootstrapped} env-bootstrapped, ${stats.undecryptable} undecryptable, ${stats.invalidSkipped} skipped)`,
|
|
153
175
|
);
|
|
154
176
|
}
|
|
155
177
|
|
|
@@ -165,8 +187,12 @@ export async function get<T = unknown>(key: string): Promise<T> {
|
|
|
165
187
|
* Write a setting. Pass `db` to run inside a caller's transaction — then the
|
|
166
188
|
* caller must also call `invalidateSettingsCache` once it commits.
|
|
167
189
|
*/
|
|
168
|
-
export async function set(key: string, value: unknown, db?: SqlClient): Promise<void> {
|
|
169
|
-
await writeKey(key, value, db);
|
|
190
|
+
export async function set(key: string, value: unknown, db?: SqlClient, options?: { replaceUnreadable?: boolean }): Promise<void> {
|
|
191
|
+
await writeKey(key, value, db, options);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function read(key: string): Promise<SettingRead> {
|
|
195
|
+
return readKeyState(key);
|
|
170
196
|
}
|
|
171
197
|
|
|
172
198
|
export async function remove(key: string, db?: SqlClient): Promise<void> {
|
|
@@ -176,20 +202,17 @@ export async function remove(key: string, db?: SqlClient): Promise<void> {
|
|
|
176
202
|
import type { SettingEntry } from "../../contracts/shared";
|
|
177
203
|
|
|
178
204
|
export type { SettingEntry } from "../../contracts/shared";
|
|
179
|
-
export {
|
|
205
|
+
export type { SettingRead, StoredSettingState } from "./store";
|
|
206
|
+
export { invalidateSettingsCache, readStates, SettingUnreadableError } from "./store";
|
|
180
207
|
|
|
181
208
|
export async function getAll(locale?: string): Promise<SettingEntry[]> {
|
|
182
|
-
// Determine which keys have a custom row in Postgres (vs. falling back to
|
|
183
|
-
// env / code default). One indexed scan, then bulk-read all values.
|
|
184
|
-
const customRows = await sql<{ key: string }[]>`SELECT key FROM settings.entries`;
|
|
185
|
-
const customKeys = new Set(customRows.map((row) => row.key));
|
|
186
|
-
|
|
187
209
|
const allKeys = SETTINGS.map((def) => def.key);
|
|
188
|
-
const values = await
|
|
210
|
+
const values = await readStates(allKeys);
|
|
189
211
|
|
|
190
212
|
return SETTINGS.map((def) => {
|
|
191
213
|
const presentation = resolveSettingPresentation(def, locale);
|
|
192
|
-
const
|
|
214
|
+
const { value, stored } = values.get(def.key)!;
|
|
215
|
+
const isCustom = stored !== "none";
|
|
193
216
|
const envFallback = resolveEnvValue(def, "fallback");
|
|
194
217
|
const resetValue = envFallback ?? def.default;
|
|
195
218
|
|
|
@@ -200,12 +223,13 @@ export async function getAll(locale?: string): Promise<SettingEntry[]> {
|
|
|
200
223
|
description: presentation.description ?? "",
|
|
201
224
|
placeholder: presentation.placeholder,
|
|
202
225
|
group: def.group,
|
|
203
|
-
value
|
|
226
|
+
value,
|
|
204
227
|
default: def.default,
|
|
205
228
|
resetValue: def.kind === "secret" ? "" : resetValue,
|
|
206
|
-
valueSource:
|
|
229
|
+
valueSource: stored === "readable" ? "custom" : envFallback === undefined ? "default" : "env",
|
|
207
230
|
resetValueSource: envFallback === undefined ? "default" : "env",
|
|
208
231
|
isCustom,
|
|
232
|
+
unreadable: stored === "unreadable",
|
|
209
233
|
templateVars: "templateVars" in def ? def.templateVars : undefined,
|
|
210
234
|
options: presentation.options,
|
|
211
235
|
min: "min" in def ? def.min : undefined,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Settings store — Redis cache-aside read/write primitives.
|
|
3
3
|
*
|
|
4
|
-
* Pattern: each key is cached in Redis with a 5-minute TTL.
|
|
4
|
+
* Pattern: each key is cached in Redis with a 5-minute TTL. Value reads try Redis
|
|
5
5
|
* first, fall back to DB on miss, and populate Redis. Writes update DB and
|
|
6
6
|
* delete the Redis key (next reader repopulates with fresh DB state).
|
|
7
7
|
*
|
|
@@ -27,6 +27,19 @@ const REDIS_KEY = (k: string) => `settings:${k}`;
|
|
|
27
27
|
|
|
28
28
|
type SqlClient = typeof sql;
|
|
29
29
|
|
|
30
|
+
export type StoredSettingState = "none" | "readable" | "unreadable";
|
|
31
|
+
export type SettingRead = { value: unknown; stored: StoredSettingState };
|
|
32
|
+
|
|
33
|
+
export class SettingUnreadableError extends HTTPException {
|
|
34
|
+
readonly code = "setting_unreadable" as const;
|
|
35
|
+
|
|
36
|
+
constructor(readonly key: string) {
|
|
37
|
+
const message = `The stored value of setting "${key}" cannot be read by this application. Replace it with a new value or reset it to the default.`;
|
|
38
|
+
super(409, { message, res: Response.json({ code: "setting_unreadable", message }, { status: 409 }) });
|
|
39
|
+
this.name = "SettingUnreadableError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
/**
|
|
31
44
|
* Forget cached values so every container re-reads on next access.
|
|
32
45
|
*
|
|
@@ -70,6 +83,12 @@ const resolveFallback = (def: SettingDef | undefined): unknown => {
|
|
|
70
83
|
*/
|
|
71
84
|
export const readKey = async (key: string): Promise<unknown> => (await bulkRead([key])).get(key);
|
|
72
85
|
|
|
86
|
+
export const readKeyState = async (key: string): Promise<SettingRead> => {
|
|
87
|
+
const state = (await readStates([key])).get(key);
|
|
88
|
+
if (!state) throw new Error(`Missing read state for setting "${key}"`);
|
|
89
|
+
return state;
|
|
90
|
+
};
|
|
91
|
+
|
|
73
92
|
/** One warm MGET; missing rows are cached independently of local env/defaults. */
|
|
74
93
|
export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unknown>> => {
|
|
75
94
|
const result = new Map<string, unknown>();
|
|
@@ -131,6 +150,32 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
|
|
|
131
150
|
return result;
|
|
132
151
|
};
|
|
133
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Stored state is per process (APP_SECRET), but the plaintext cache is shared.
|
|
155
|
+
* State reads go directly to Postgres without reading or filling the cache;
|
|
156
|
+
* value reads (`get`, snapshot) keep using the cache.
|
|
157
|
+
*/
|
|
158
|
+
export const readStates = async (keys: readonly string[]): Promise<Map<string, SettingRead>> => {
|
|
159
|
+
const result = new Map<string, SettingRead>();
|
|
160
|
+
if (keys.length === 0) return result;
|
|
161
|
+
const rows = await sql<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray([...keys])}::text[])`;
|
|
162
|
+
const byKey = new Map(rows.map((row) => [row.key, row]));
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
const row = byKey.get(key);
|
|
165
|
+
const def = SETTINGS_MAP.get(key);
|
|
166
|
+
result.set(key, { stored: row ? "unreadable" : "none", value: resolveFallback(def) });
|
|
167
|
+
if (!row) continue;
|
|
168
|
+
try {
|
|
169
|
+
const value = await decryptValue(row.value);
|
|
170
|
+
const validated = def ? validateSettingValue(def, value) : { ok: true as const, value };
|
|
171
|
+
if (validated.ok) result.set(key, { stored: "readable", value: validated.value });
|
|
172
|
+
} catch {
|
|
173
|
+
// Keep this process's fallback without changing the stored row or cache.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
};
|
|
178
|
+
|
|
134
179
|
/**
|
|
135
180
|
* Get every known setting key (across all registered defs).
|
|
136
181
|
* Used by snapshot loader to determine what to bulk-read.
|
|
@@ -184,11 +229,12 @@ export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []):
|
|
|
184
229
|
/**
|
|
185
230
|
* Encrypt the value, upsert the DB row, invalidate the Redis key.
|
|
186
231
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
232
|
+
* Validate new values and refuse to overwrite unreadable stored rows unless
|
|
233
|
+
* the caller explicitly replaces them. The row check and write share a transaction.
|
|
234
|
+
* Lock with a no-op UPDATE so ROW EXCLUSIVE is acquired before the row lock,
|
|
235
|
+
* preserving lock order with table-level SHARE locks (#1303 review).
|
|
190
236
|
*/
|
|
191
|
-
export const writeKey = async (key: string, value: unknown, db?: SqlClient): Promise<void> => {
|
|
237
|
+
export const writeKey = async (key: string, value: unknown, db?: SqlClient, options?: { replaceUnreadable?: boolean }): Promise<void> => {
|
|
192
238
|
const def = SETTINGS_MAP.get(key);
|
|
193
239
|
if (!def) throw new Error(`Unknown setting: ${key}`);
|
|
194
240
|
const validated = validateSettingValue(def, value);
|
|
@@ -197,12 +243,38 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient): Pro
|
|
|
197
243
|
const storedValue =
|
|
198
244
|
def.kind === "image" && typeof validated.value === "string" ? stripImageDataUrlMetadata(validated.value) : validated.value;
|
|
199
245
|
const encrypted = await encryptValue(storedValue);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
246
|
+
const write = async (tx: SqlClient) => {
|
|
247
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
248
|
+
let [row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
|
|
249
|
+
if (!row) {
|
|
250
|
+
const inserted = await tx<{ key: string }[]>`
|
|
251
|
+
INSERT INTO settings.entries (key, value, updated_at)
|
|
252
|
+
VALUES (${key}, ${encrypted}, now())
|
|
253
|
+
ON CONFLICT (key) DO NOTHING RETURNING key
|
|
254
|
+
`;
|
|
255
|
+
if (inserted.length > 0) return;
|
|
256
|
+
// A concurrent insert can win after the missing-row read; lock and check its value before updating.
|
|
257
|
+
[row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
|
|
258
|
+
if (!row) continue;
|
|
259
|
+
}
|
|
260
|
+
if (options?.replaceUnreadable !== true) {
|
|
261
|
+
let readable = false;
|
|
262
|
+
try {
|
|
263
|
+
readable = validateSettingValue(def, await decryptValue(row.value)).ok;
|
|
264
|
+
} catch {
|
|
265
|
+
// Only the key is exposed by the recovery error.
|
|
266
|
+
}
|
|
267
|
+
if (!readable) throw new SettingUnreadableError(key);
|
|
268
|
+
}
|
|
269
|
+
await tx`
|
|
270
|
+
UPDATE settings.entries SET value = ${encrypted}, updated_at = now() WHERE key = ${key}
|
|
271
|
+
`;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
throw new Error(`Could not write setting "${key}" after 3 attempts`);
|
|
275
|
+
};
|
|
276
|
+
if (db) await write(db);
|
|
277
|
+
else await sql.begin(write);
|
|
206
278
|
|
|
207
279
|
// Passing a transaction means the caller owns invalidation, because the row
|
|
208
280
|
// is not visible to anyone else until they commit.
|
|
@@ -59,7 +59,7 @@ Most replies need no offer. Make at most one offer, in the last sentence of the
|
|
|
59
59
|
{%- if memoryToolEnabled %}
|
|
60
60
|
- The user corrected the tone of a result, such as a mail that is too formal, without stating a lasting rule: offer to remember it as their preference. Memory keeps only the user's own words, so a plain yes cannot be saved: suggest a one-line rule they can send back, such as "Always write mails casually and briefly".
|
|
61
61
|
{%- endif %}
|
|
62
|
-
- The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}
|
|
62
|
+
- The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}routine.
|
|
63
63
|
- A concrete next step clearly saves the user real work they may not know you can do: offer it, such as a manual follow-up the user mentioned, drafting the reply, or turning findings into Space tasks.
|
|
64
64
|
Offer only what the Skills, tool hints, and apps in this prompt support. Do not search mail, chats, or other data only to justify an offer, and do not claim how often something happened unless the user said it or this chat shows it. Do not start the offered work until the user agrees.
|
|
65
65
|
{%- if skillOffers %}
|
|
@@ -65,6 +65,7 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
|
|
|
65
65
|
return {
|
|
66
66
|
...app,
|
|
67
67
|
...resolveAppIdentityPresentation(app, requestedLocale),
|
|
68
|
+
...(app.panel ? { panel: { ...app.panel, label: translation.panelLabel ?? app.panel.label } } : {}),
|
|
68
69
|
adminNav: app.adminNav?.map((group) => ({
|
|
69
70
|
...group,
|
|
70
71
|
label: (group.id && translation.adminGroups?.[group.id]) || group.label,
|
package/src/shared/help.ts
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
import { localeFallbackChain } from "./locale";
|
|
2
2
|
|
|
3
|
+
export const HELP_KINDS = ["start", "task", "reference", "troubleshoot"] as const;
|
|
4
|
+
export type HelpKind = (typeof HELP_KINDS)[number];
|
|
5
|
+
/** The glossary's access levels; Help names only these, never app-specific permissions. */
|
|
6
|
+
export const HELP_ACCESS_LEVELS = ["view", "edit", "manage"] as const;
|
|
7
|
+
export type HelpAccessLevel = (typeof HELP_ACCESS_LEVELS)[number];
|
|
8
|
+
/** An article another article points to, resolved for the current reader. */
|
|
9
|
+
export type HelpArticleLink = {
|
|
10
|
+
appId: string;
|
|
11
|
+
appName: string;
|
|
12
|
+
/** The target app's accent colour (registry appearance), when it declares one. */
|
|
13
|
+
accent?: string;
|
|
14
|
+
documentId: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
icon?: string;
|
|
18
|
+
/** Canonical page path: `/help/apps/<appId>/<documentId>`. */
|
|
19
|
+
url: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type HelpLinkResolver = (ref: { appId?: string; documentId: string }) => HelpArticleLink | undefined;
|
|
23
|
+
|
|
24
|
+
/** References have the same app-id grammar as capability declarations. */
|
|
25
|
+
export const HELP_REFERENCE_PATTERN = /^(?:[a-z][a-z0-9-]*\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
26
|
+
export const parseHelpReference = (value: string): { appId?: string; documentId: string; fragment: string } | undefined => {
|
|
27
|
+
const [reference = "", ...fragments] = value.split("#");
|
|
28
|
+
if (!HELP_REFERENCE_PATTERN.test(reference)) return undefined;
|
|
29
|
+
const parts = reference.split("/");
|
|
30
|
+
return {
|
|
31
|
+
appId: parts.length === 2 ? parts[0] : undefined,
|
|
32
|
+
documentId: parts.at(-1)!,
|
|
33
|
+
fragment: fragments.length ? `#${fragments.join("#")}` : "",
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
3
37
|
export type HelpDocumentManifest = {
|
|
4
38
|
/** Resolved content locale on central Help manifests. */
|
|
5
39
|
locale?: string;
|
|
@@ -8,6 +42,8 @@ export type HelpDocumentManifest = {
|
|
|
8
42
|
icon?: string;
|
|
9
43
|
description?: string;
|
|
10
44
|
order: number;
|
|
45
|
+
/** Where the Help start lists the article; missing means `task`. */
|
|
46
|
+
kind?: HelpKind;
|
|
11
47
|
/** Core-owned endpoint used for debounced full-text search. */
|
|
12
48
|
searchUrl: string;
|
|
13
49
|
url: string;
|
|
@@ -24,8 +60,13 @@ export type HelpDocumentPayload = {
|
|
|
24
60
|
locale: string;
|
|
25
61
|
id: string;
|
|
26
62
|
title: string;
|
|
63
|
+
/** Agent text with Help-only presentation converted to portable Markdown. */
|
|
27
64
|
markdown: string;
|
|
28
65
|
html: string;
|
|
66
|
+
kind: HelpKind;
|
|
67
|
+
needs?: HelpAccessLevel;
|
|
68
|
+
next?: HelpArticleLink;
|
|
69
|
+
related: HelpArticleLink[];
|
|
29
70
|
};
|
|
30
71
|
|
|
31
72
|
/** Exact locale, then BCP 47 ancestors, then the corpus base locale. */
|
package/src/shared/index.ts
CHANGED
|
@@ -46,7 +46,15 @@ export * from "./login-method";
|
|
|
46
46
|
export { markdown } from "./markdown";
|
|
47
47
|
export { markdownCalloutSanitizeOptions } from "./markdown/callout-sanitizer";
|
|
48
48
|
export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
|
|
49
|
-
export {
|
|
49
|
+
export {
|
|
50
|
+
createProgressValue,
|
|
51
|
+
evaluateFormula,
|
|
52
|
+
evaluateTableCell,
|
|
53
|
+
formatValue,
|
|
54
|
+
isFormula,
|
|
55
|
+
isTotalRow,
|
|
56
|
+
parseProgressValue,
|
|
57
|
+
} from "./markdown/formula";
|
|
50
58
|
export type { MockCover, MockCoverIcon, MockCoverOptions, MockCoverTheme } from "./mock-cover";
|
|
51
59
|
export { createMockCover, createMockCoverSvg, parseDataUrl } from "./mock-cover";
|
|
52
60
|
export * from "./network-address";
|