@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -5,6 +5,8 @@ import { HTTPException } from "hono/http-exception";
5
5
  import { env } from "../../config/env";
6
6
  import { PWA_AUTH_PATH, PWA_COOKIES } from "../../contracts/pwa";
7
7
  import type { User } from "../../contracts/shared";
8
+ import { getLocale } from "../../server/locale";
9
+ import { describeRequest } from "../../server/request-context";
8
10
  import { isAccountCategoryAllowed } from "../account-category-policy";
9
11
  import { isAccountExpired } from "../account-model";
10
12
  import { audit } from "../audit";
@@ -12,6 +14,17 @@ import { IDENTITY_CLOCK_TOLERANCE_SECONDS, IDENTITY_ROLLOUT_MARGIN_MS } from "..
12
14
  import { invalidateIdentitySignerCache, type PreparedIdentitySigner, prepareIdentitySigner } from "../identity/key-ring";
13
15
  import { getIdentityRuntimeConfig } from "../identity/runtime-config";
14
16
  import { type CloudSessionClaims, isSessionJwtCandidate, signSessionToken, verifySessionToken } from "../identity/session-token";
17
+ import {
18
+ announceNewBrowser,
19
+ type NewBrowserSignInNotice,
20
+ type NewBrowserSignInNotifier,
21
+ newBrowserCookie,
22
+ noticeFirstName,
23
+ readBrowserCookie,
24
+ rememberSignIn,
25
+ type SignInMethod,
26
+ setBrowserCookie,
27
+ } from "../known-browsers";
15
28
  import * as settings from "../settings";
16
29
  import { loadJwtSession, loadJwtSessionIdentity } from "./user";
17
30
 
@@ -144,12 +157,24 @@ const revokeToken = async (token: string, requestId?: string | null): Promise<vo
144
157
  }
145
158
  };
146
159
 
160
+ export class SessionEpochChangedError extends Error {}
161
+ /** The Cloud Login device that approved a sign-in was removed before the session was issued. */
162
+ export class SessionAppDeviceInactiveError extends Error {}
163
+
147
164
  export type SessionIssueOptions = {
165
+ authEpoch?: number;
148
166
  ttlSeconds: number;
167
+ /** Only web session.create supplies browser persistence, never mobile issuance. */
168
+ knownBrowser?: Pick<
169
+ Parameters<typeof rememberSignIn>[1],
170
+ "cookie" | "browser" | "os" | "country" | "city" | "method" | "device" | "locale" | "announce"
171
+ >;
149
172
  /** Pre-chosen family id, for callers that record it in the same transaction. */
150
173
  sid?: string;
151
174
  /** Binds the family to a paired phone of the mobile app; the device must be active. */
152
175
  pwaDeviceId?: string | null;
176
+ /** The active Cloud Login device that approved this web sign-in. */
177
+ appDeviceId?: string;
153
178
  /** Web sign-ins update `last_login_local`; app renewals track use per device instead. */
154
179
  recordLogin?: boolean;
155
180
  };
@@ -162,12 +187,14 @@ type PreparedFamily = {
162
187
  expiresAt: Date;
163
188
  signer: PreparedIdentitySigner;
164
189
  options: SessionIssueOptions;
190
+ notice: NewBrowserSignInNotice | null;
165
191
  };
166
192
 
167
193
  const SIGNER_INACTIVE = "Prepared Cloud session signer is no longer active";
168
194
  const isSignerInactive = (error: unknown) => error instanceof Error && error.message === SIGNER_INACTIVE;
169
195
 
170
- /** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows. */
196
+ /** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows; `NO KEY UPDATE`
197
+ * serializes with every other account lock but lets foreign-key checks of concurrent writes pass. */
171
198
  const insertFamily = async (
172
199
  tx: SQL,
173
200
  c: Context,
@@ -175,19 +202,22 @@ const insertFamily = async (
175
202
  signer: PreparedIdentitySigner,
176
203
  options: SessionIssueOptions,
177
204
  ): Promise<PreparedFamily> => {
178
- const issuedAt = new Date();
179
- const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
180
205
  const sid = options.sid ?? crypto.randomUUID();
181
206
  const requestId = c.req.header("x-request-id")?.slice(0, 64) ?? null;
182
207
  const userAgent = c.req.header("user-agent")?.slice(0, 256) ?? null;
183
- const [user] = await tx<Array<{ auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>>`
184
- SELECT auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR UPDATE
208
+ const [user] = await tx<
209
+ Array<{ given_name: string | null; auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>
210
+ >`
211
+ SELECT given_name, auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
185
212
  `;
186
213
  if (!user) throw new Error("Cannot create a session for an unknown user");
214
+ const issuedAt = new Date();
215
+ const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
187
216
  if (!(await isAccountCategoryAllowed(user, tx)))
188
217
  throw new HTTPException(403, { message: "This account category is disabled. Contact an administrator." });
189
218
  const epoch = Number(user.auth_epoch);
190
219
  if (!Number.isSafeInteger(epoch) || epoch < 0) throw new Error("User auth_epoch is invalid");
220
+ if (options.authEpoch !== undefined && epoch !== options.authEpoch) throw new SessionEpochChangedError();
191
221
  if (options.pwaDeviceId) {
192
222
  // Device revocation locks the same user row first, so a revoked device never gets a new family.
193
223
  const [device] = await tx`
@@ -195,6 +225,14 @@ const insertFamily = async (
195
225
  `;
196
226
  if (!device) throw new Error("Cannot create an app session for an inactive device");
197
227
  }
228
+ if (options.appDeviceId) {
229
+ // A device that decides a terminal sign-in holds its row and then takes a key-share lock on this user row, so the
230
+ // user row above is held without blocking key-share locks. Removals that end the device's sign-ins hold the user
231
+ // row too, so they wait for this family.
232
+ const [device] = await tx`SELECT id FROM auth.app_devices
233
+ WHERE id=${options.appDeviceId}::uuid AND user_id=${userId}::uuid AND revoked_at IS NULL`;
234
+ if (!device) throw new SessionAppDeviceInactiveError("Cannot create a session for an inactive sign-in device");
235
+ }
198
236
  const [activeKey] = await tx<Array<{ kid: string }>>`
199
237
  UPDATE auth.signing_keys
200
238
  SET verify_until = GREATEST(verify_until, ${new Date(expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS)})
@@ -204,12 +242,15 @@ const insertFamily = async (
204
242
  if (!activeKey) throw new Error(SIGNER_INACTIVE);
205
243
  await tx`
206
244
  INSERT INTO auth.session_families (
207
- sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id
245
+ sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id, app_device_id
208
246
  ) VALUES (${sid}, ${userId}, ${epoch}, ${signer.kid}, ${issuedAt}, ${expiresAt}, ${requestId}, ${userAgent},
209
- NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null})
247
+ NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null}, ${options.appDeviceId ?? null})
210
248
  `;
249
+ const notice = options.knownBrowser
250
+ ? await rememberSignIn(tx, { ...options.knownBrowser, userId, firstName: noticeFirstName(user.given_name), now: issuedAt })
251
+ : null;
211
252
  if (options.recordLogin !== false) await tx`UPDATE auth.users SET last_login_local = ${issuedAt} WHERE id = ${userId}::uuid`;
212
- return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options };
253
+ return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options, notice };
213
254
  };
214
255
 
215
256
  /** Signs a committed family. Null when the signer was revoked meanwhile; the family is revoked then. */
@@ -244,7 +285,13 @@ const signFamily = async (family: PreparedFamily): Promise<string | null> => {
244
285
  };
245
286
 
246
287
  /** One issuance attempt is retried once when the signing key changes underneath it. */
247
- const issueJwtSession = async (c: Context, userId: string, options: SessionIssueOptions, attempt = 0): Promise<string> => {
288
+ const issueJwtSession = async (
289
+ c: Context,
290
+ userId: string,
291
+ options: SessionIssueOptions,
292
+ attempt = 0,
293
+ committedNotice: NewBrowserSignInNotice | null = null,
294
+ ): Promise<{ token: string; notice: NewBrowserSignInNotice | null }> => {
248
295
  const signer = await prepareIdentitySigner("session");
249
296
  let family: PreparedFamily;
250
297
  try {
@@ -252,13 +299,14 @@ const issueJwtSession = async (c: Context, userId: string, options: SessionIssue
252
299
  } catch (error) {
253
300
  if (attempt === 0 && isSignerInactive(error)) {
254
301
  invalidateIdentitySignerCache("session", signer.kid);
255
- return issueJwtSession(c, userId, options, 1);
302
+ return issueJwtSession(c, userId, options, 1, committedNotice);
256
303
  }
257
304
  throw error;
258
305
  }
259
306
  const token = await signFamily(family);
260
- if (token) return token;
261
- if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1);
307
+ const notice = committedNotice ?? family.notice;
308
+ if (token) return { token, notice };
309
+ if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1, notice);
262
310
  throw new Error("Cloud session signer changed repeatedly during issuance");
263
311
  };
264
312
 
@@ -281,7 +329,7 @@ const issueInTransaction = async <T>(
281
329
  value = await sql.begin((tx) =>
282
330
  work(tx, async (userId, options) => {
283
331
  if (family) throw new Error("Only one session family per transaction");
284
- family = await insertFamily(tx, c, userId, signer, options);
332
+ family = await insertFamily(tx, c, userId, signer, { ...options, knownBrowser: undefined });
285
333
  return family.sid;
286
334
  }),
287
335
  );
@@ -294,7 +342,7 @@ const issueInTransaction = async <T>(
294
342
  }
295
343
  if (!family) return { value, token: null };
296
344
  const token = await signFamily(family);
297
- return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)) };
345
+ return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)).token };
298
346
  }
299
347
  };
300
348
 
@@ -349,11 +397,43 @@ export const session = {
349
397
 
350
398
  getBearerToken: (c: Context): string | null => parseBearer(c.req.header("Authorization")),
351
399
 
352
- create: async (c: Context, userId: string): Promise<string> => {
400
+ create: async (
401
+ c: Context,
402
+ userId: string,
403
+ signIn: {
404
+ method: SignInMethod;
405
+ device?: string | null;
406
+ /** Null means this sign-in announces nothing, including during maintenance. */
407
+ notify: NewBrowserSignInNotifier | null;
408
+ /** Account epoch an earlier approval was bound to; issuance fails when sign-out everywhere moved it. */
409
+ authEpoch?: number;
410
+ /** The Cloud Login device that approved this sign-in; removing the device can end the session. */
411
+ appDeviceId?: string;
412
+ },
413
+ ): Promise<string> => {
353
414
  const expiryHours = await settings.get<number>("user.session.expiry_hours");
354
415
  if (!Number.isFinite(expiryHours) || expiryHours < 1) throw new Error("user.session.expiry_hours must be a positive number");
355
416
  const ttlSeconds = Math.floor(expiryHours * 60 * 60);
356
- const clientToken = await issueJwtSession(c, userId, { ttlSeconds });
417
+ const cookie = readBrowserCookie(c) ?? newBrowserCookie();
418
+ const { browser, os, country, city } = describeRequest(c);
419
+ const { token: clientToken, notice } = await issueJwtSession(c, userId, {
420
+ ttlSeconds,
421
+ authEpoch: signIn.authEpoch,
422
+ appDeviceId: signIn.appDeviceId,
423
+ knownBrowser: {
424
+ cookie,
425
+ browser,
426
+ os,
427
+ country,
428
+ city,
429
+ method: signIn.method,
430
+ device: signIn.device ?? null,
431
+ locale: getLocale(c),
432
+ announce: signIn.notify !== null,
433
+ },
434
+ });
435
+ // Handoff precedes the response so the person signing in cannot turn the notice off first.
436
+ if (signIn.notify !== null && notice) await announceNewBrowser(signIn.notify, notice);
357
437
 
358
438
  setCookie(c, "session_token", clientToken, {
359
439
  httpOnly: true,
@@ -362,6 +442,7 @@ export const session = {
362
442
  maxAge: ttlSeconds,
363
443
  path: "/",
364
444
  });
445
+ setBrowserCookie(c, cookie);
365
446
  return clientToken;
366
447
  },
367
448
 
@@ -11,6 +11,15 @@ export type EligibleAccount = {
11
11
  account_expires: Date | null;
12
12
  };
13
13
 
14
+ /** New personal access requires a browser sign-in from the last ten minutes. */
15
+ export const LASTING_ACCESS_SIGN_IN_SECONDS = 600;
16
+
17
+ export class ConfirmedBrowserSignInError extends Error {
18
+ constructor(readonly code: "REAUTHENTICATE" | "FORBIDDEN") {
19
+ super(code === "REAUTHENTICATE" ? "Sign in again to confirm it is you." : "This account cannot create new access.");
20
+ }
21
+ }
22
+
14
23
  type Reject = (code: "REAUTHENTICATE" | "FORBIDDEN") => never;
15
24
 
16
25
  /** The account exists, has not expired and its category is enabled. */
@@ -40,3 +49,7 @@ export const requireRecentWebSession = async (
40
49
  if (!row) return reject("REAUTHENTICATE");
41
50
  return requireEligibleAccount(tx, actor.userId, reject);
42
51
  };
52
+
53
+ /** Checks the browser session inside the transaction that creates personal access. */
54
+ export const requireConfirmedBrowserSignIn = (tx: SQL, signIn: { userId: string; sid: string }, reject: Reject): Promise<EligibleAccount> =>
55
+ requireRecentWebSession(tx, signIn, LASTING_ACCESS_SIGN_IN_SECONDS, reject);
@@ -12,13 +12,14 @@
12
12
  * `services/weather/forecast.ts`, OpenAPI middleware, cron callbacks).
13
13
  * Loosely typed (caller provides the value type via the `T` generic).
14
14
  *
15
- * Backend: cache-aside via `store.ts` (Redis 5min TTL + DB fallback). All
16
- * functions are async — sync render-time access uses the per-request snapshot
15
+ * Backend: value reads use cache-aside via `store.ts` (Redis 5min TTL + DB
16
+ * fallback); stored-state reads use Postgres. All functions are async — sync
17
+ * render-time access uses the per-request snapshot
17
18
  * exposed via `c.get("settings")`.
18
19
  */
19
20
 
20
21
  import type { AppSettingsMap, KindToType } from "../../contracts/settings-types";
21
- import { deleteKey, readKey, writeKey } from "./store";
22
+ import { deleteKey, readKey, readKeyState, type StoredSettingState, writeKey } from "./store";
22
23
 
23
24
  /**
24
25
  * Per-app typed settings API. Keys constrained to those declared in the
@@ -26,7 +27,8 @@ import { deleteKey, readKey, writeKey } from "./store";
26
27
  */
27
28
  export interface SettingsAPI<F extends Record<string, unknown>> {
28
29
  get<K extends keyof F & string>(key: K): Promise<F[K]>;
29
- set<K extends keyof F & string>(key: K, value: F[K]): Promise<void>;
30
+ read<K extends keyof F & string>(key: K): Promise<{ value: F[K]; stored: StoredSettingState }>;
31
+ set<K extends keyof F & string>(key: K, value: F[K], options?: { replaceUnreadable?: boolean }): Promise<void>;
30
32
  remove<K extends keyof F & string>(key: K): Promise<void>;
31
33
  }
32
34
 
@@ -40,7 +42,8 @@ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
40
42
  [K in keyof S]: KindToType<S[K]["kind"]>;
41
43
  }> => ({
42
44
  get: async (key) => readKey(key) as never,
43
- set: async (key, value) => writeKey(key, value),
45
+ read: async (key) => readKeyState(key) as never,
46
+ set: async (key, value, options) => writeKey(key, value, undefined, options),
44
47
  remove: async (key) => deleteKey(key),
45
48
  });
46
49
 
@@ -56,6 +59,8 @@ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
56
59
  */
57
60
  export const coreSettings = {
58
61
  get: <T = unknown>(key: string): Promise<T> => readKey(key) as Promise<T>,
59
- set: (key: string, value: unknown): Promise<void> => writeKey(key, value),
62
+ read: <T = unknown>(key: string): Promise<{ value: T; stored: StoredSettingState }> =>
63
+ readKeyState(key) as Promise<{ value: T; stored: StoredSettingState }>,
64
+ set: (key: string, value: unknown, options?: { replaceUnreadable?: boolean }): Promise<void> => writeKey(key, value, undefined, options),
60
65
  remove: (key: string): Promise<void> => deleteKey(key),
61
66
  };
@@ -13,6 +13,7 @@ import { ImageMetadataError } from "../image-metadata";
13
13
  import type { SettingEntry } from ".";
14
14
  import * as settingsPrimitives from ".";
15
15
  import { SETTINGS_MAP, validateSettingValue } from "./defaults";
16
+ import { SettingUnreadableError } from "./store";
16
17
 
17
18
  /**
18
19
  * Redact secret-kind setting values before they leave the server.
@@ -54,7 +55,7 @@ export const settingsService = {
54
55
 
55
56
  return paginateItems(filtered, config?.pagination);
56
57
  },
57
- update: async (config: { key: string; value: unknown }) => {
58
+ update: async (config: { key: string; value: unknown; replaceUnreadable?: boolean }) => {
58
59
  if (!SETTINGS_MAP.has(config.key)) {
59
60
  return fail(err.badInput(`Unknown setting: ${config.key}`));
60
61
  }
@@ -64,11 +65,11 @@ export const settingsService = {
64
65
  return fail(err.badInput(validated.error));
65
66
  }
66
67
  try {
67
- await settingsPrimitives.set(config.key, validated.value);
68
+ await settingsPrimitives.set(config.key, validated.value, undefined, { replaceUnreadable: config.replaceUnreadable });
68
69
  return ok(undefined);
69
70
  } catch (error) {
70
- // A Result cannot carry 422; the typed HTTPException keeps its status through Hono.
71
- if (error instanceof ImageMetadataError) throw error;
71
+ // Preserve the typed HTTP status and recovery code through Hono.
72
+ if (error instanceof ImageMetadataError || error instanceof SettingUnreadableError) throw error;
72
73
  const message = error instanceof Error ? error.message : String(error);
73
74
  if (message.startsWith("Unknown setting:")) {
74
75
  return fail(err.badInput(message));
@@ -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,8 +3,8 @@
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
9
  * env-backed entries into Postgres. Startup never replaces an existing row;
10
10
  * env bootstrap only fills missing keys.
@@ -13,7 +13,7 @@
13
13
  import { redis, sql } from "bun";
14
14
  import { decryptValue, encryptValue, getAppSecret } from "./crypto";
15
15
  import { resolveSettingPresentation, SETTINGS, SETTINGS_MAP, type SettingDef, validateSettingValue } from "./defaults";
16
- import { bulkRead, deleteKey, readKey, writeKey } from "./store";
16
+ import { deleteKey, readKey, readKeyState, readStates, type SettingRead, writeKey } from "./store";
17
17
 
18
18
  type SqlClient = typeof sql;
19
19
 
@@ -162,8 +162,8 @@ const bootstrapEnvBackedEntries = async (config: {
162
162
 
163
163
  /**
164
164
  * Boot-time normalization + env-bootstrap. Runs once at startup. Does NOT
165
- * populate any in-process cache — every read goes through Redis cache-aside
166
- * (`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.
167
167
  */
168
168
  export async function loadCache(): Promise<void> {
169
169
  getAppSecret();
@@ -187,8 +187,12 @@ export async function get<T = unknown>(key: string): Promise<T> {
187
187
  * Write a setting. Pass `db` to run inside a caller's transaction — then the
188
188
  * caller must also call `invalidateSettingsCache` once it commits.
189
189
  */
190
- export async function set(key: string, value: unknown, db?: SqlClient): Promise<void> {
191
- 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);
192
196
  }
193
197
 
194
198
  export async function remove(key: string, db?: SqlClient): Promise<void> {
@@ -198,20 +202,17 @@ export async function remove(key: string, db?: SqlClient): Promise<void> {
198
202
  import type { SettingEntry } from "../../contracts/shared";
199
203
 
200
204
  export type { SettingEntry } from "../../contracts/shared";
201
- export { invalidateSettingsCache } from "./store";
205
+ export type { SettingRead, StoredSettingState } from "./store";
206
+ export { invalidateSettingsCache, readStates, SettingUnreadableError } from "./store";
202
207
 
203
208
  export async function getAll(locale?: string): Promise<SettingEntry[]> {
204
- // Determine which keys have a custom row in Postgres (vs. falling back to
205
- // env / code default). One indexed scan, then bulk-read all values.
206
- const customRows = await sql<{ key: string }[]>`SELECT key FROM settings.entries`;
207
- const customKeys = new Set(customRows.map((row) => row.key));
208
-
209
209
  const allKeys = SETTINGS.map((def) => def.key);
210
- const values = await bulkRead(allKeys);
210
+ const values = await readStates(allKeys);
211
211
 
212
212
  return SETTINGS.map((def) => {
213
213
  const presentation = resolveSettingPresentation(def, locale);
214
- const isCustom = customKeys.has(def.key);
214
+ const { value, stored } = values.get(def.key)!;
215
+ const isCustom = stored !== "none";
215
216
  const envFallback = resolveEnvValue(def, "fallback");
216
217
  const resetValue = envFallback ?? def.default;
217
218
 
@@ -222,12 +223,13 @@ export async function getAll(locale?: string): Promise<SettingEntry[]> {
222
223
  description: presentation.description ?? "",
223
224
  placeholder: presentation.placeholder,
224
225
  group: def.group,
225
- value: values.get(def.key),
226
+ value,
226
227
  default: def.default,
227
228
  resetValue: def.kind === "secret" ? "" : resetValue,
228
- valueSource: isCustom ? "custom" : envFallback === undefined ? "default" : "env",
229
+ valueSource: stored === "readable" ? "custom" : envFallback === undefined ? "default" : "env",
229
230
  resetValueSource: envFallback === undefined ? "default" : "env",
230
231
  isCustom,
232
+ unreadable: stored === "unreadable",
231
233
  templateVars: "templateVars" in def ? def.templateVars : undefined,
232
234
  options: presentation.options,
233
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.
@@ -16,6 +16,7 @@ import { accounts } from "./accounts";
16
16
  import { audit } from "./audit";
17
17
  import { logger } from "./logging";
18
18
  import { isUniqueViolation, toPgTextArray } from "./postgres";
19
+ import { ConfirmedBrowserSignInError, requireConfirmedBrowserSignIn } from "./session/recent";
19
20
  import { coreSettings } from "./settings/api";
20
21
 
21
22
  const CHALLENGE_TTL_SECONDS = 300;
@@ -185,6 +186,7 @@ export const beginRegistration = async (params: { user: User }): Promise<Result<
185
186
 
186
187
  export const finishRegistration = async (params: {
187
188
  user: User;
189
+ browserSignIn: { userId: string; sid: string };
188
190
  name: string;
189
191
  response: RegistrationResponseJSON;
190
192
  }): Promise<Result<WebAuthnPasskey>> => {
@@ -221,6 +223,16 @@ export const finishRegistration = async (params: {
221
223
 
222
224
  try {
223
225
  return sql.begin(async (tx) => {
226
+ if (params.browserSignIn.userId !== params.user.id) return fail(err.forbidden("The browser sign-in belongs to another account."));
227
+ try {
228
+ await requireConfirmedBrowserSignIn(tx, params.browserSignIn, (code) => {
229
+ throw new ConfirmedBrowserSignInError(code);
230
+ });
231
+ } catch (error) {
232
+ if (error instanceof ConfirmedBrowserSignInError) return fail({ code: error.code, status: 403, message: error.message });
233
+ throw error;
234
+ }
235
+
224
236
  const [row] = await tx<DbPasskeyRow[]>`
225
237
  INSERT INTO auth.webauthn_credentials (
226
238
  user_id,
@@ -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,