@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.
Files changed (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. 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: "Maximum time in milliseconds for one Gotenberg render request.",
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 HTML Bytes",
393
+ label: "Max input bytes",
393
394
  default: 1048576,
394
395
  min: 1024,
395
396
  max: 10485760,
396
- description: "Maximum HTML input size accepted before sending a render request.",
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. All read/write
7
- * goes through the Redis cache-aside layer in `./store.ts` (5-minute TTL).
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 { bulkRead, deleteKey, readKey, writeKey } from "./store";
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 = { key: string; value: unknown; rewrite: boolean; existed: boolean };
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 upsertEncryptedRow = async (key: string, value: unknown): Promise<void> => {
62
- const encryptedValue = await encryptValue(value);
63
- await sql`
64
- INSERT INTO settings.entries (key, value, updated_at)
65
- VALUES (${key}, ${encryptedValue}, now())
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, { key: row.key, value, rewrite: false, existed: true });
82
+ pendingRows.set(row.key, { value, originalCiphertext: row.value });
86
83
  stats.encryptedLoaded += 1;
87
84
  } catch {
88
- console.warn(`[settings] skipping invalid stored value for "${row.key}"`);
89
- stats.invalidSkipped += 1;
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 (row.rewrite || !row.existed || !isEqual(row.value, validated.value)) {
113
- await upsertEncryptedRow(key, validated.value);
114
- stats.normalizedUpdated += 1;
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 (rowsByKey.has(def.key)) continue;
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 upsertEncryptedRow(def.key, envValue);
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 — every read goes through Redis cache-aside
144
- * (`store.ts`) at request time.
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 { invalidateSettingsCache } from "./store";
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 bulkRead(allKeys);
210
+ const values = await readStates(allKeys);
189
211
 
190
212
  return SETTINGS.map((def) => {
191
213
  const presentation = resolveSettingPresentation(def, locale);
192
- const isCustom = customKeys.has(def.key);
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: values.get(def.key),
226
+ value,
204
227
  default: def.default,
205
228
  resetValue: def.kind === "secret" ? "" : resetValue,
206
- valueSource: isCustom ? "custom" : envFallback === undefined ? "default" : "env",
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,8 +1,9 @@
1
- import { get, getAll, loadCache, remove, set } from "./index";
1
+ import { get, getAll, loadCache, read, remove, set } from "./index";
2
2
 
3
3
  export const settings = {
4
4
  loadCache,
5
5
  get,
6
+ read,
6
7
  set,
7
8
  remove,
8
9
  getAll,
@@ -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. Reads try Redis
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
- * Validation is the caller's responsibility — the typed wrapper API
188
- * (createSettingsAPI) validates against the declared SettingDef before reaching
189
- * here. Direct callers must ensure the value matches the setting's kind.
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
- await (db ?? sql)`
201
- INSERT INTO settings.entries (key, value, updated_at)
202
- VALUES (${key}, ${encrypted}, now())
203
- ON CONFLICT (key)
204
- DO UPDATE SET value = ${encrypted}, updated_at = now()
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 %}scheduled task.
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,
@@ -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. */
@@ -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 { createProgressValue, evaluateFormula, formatValue, isFormula, isTotalRow, parseProgressValue } from "./markdown/formula";
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";