@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
package/src/ai/types.ts CHANGED
@@ -13,11 +13,32 @@ import type {
13
13
  import type { Usage } from "@k2b/nessi/ai";
14
14
  import type { z } from "zod";
15
15
  import type { CapabilitySentenceField } from "../_internal/capability-sentences";
16
- import type { CapabilityActionReview, CapabilityActionSentences, CloudResourceRef } from "../contracts/capabilities";
16
+ import type { CapabilityActionReviewData, CapabilityActionSentences, CloudResourceRef } from "../contracts/capabilities";
17
17
  import type { RequestActor } from "../server";
18
+ import type { AiActionCheck, AiStoredActionReview } from "./action-review";
18
19
  import type { AiLocalClient, AiLocalContext, AiLocalToolName } from "./local-tool-contracts";
19
20
  import type { AiTurnBlock } from "./protocol";
20
21
 
22
+ /** Current reader-confirmed metadata for a recently sent resource. */
23
+ export type AiRecentResource = {
24
+ ref: CloudResourceRef;
25
+ title: string;
26
+ icon?: string;
27
+ href?: string;
28
+ kind: string;
29
+ preview?: string;
30
+ appName: string;
31
+ count: number;
32
+ };
33
+
34
+ export type AiRecentSentResourceRef = {
35
+ ref: CloudResourceRef;
36
+ count: number;
37
+ lastSentAt: string;
38
+ /** Internal conversation UUID, only for authorized Studio reads. */
39
+ conversationId: string;
40
+ };
41
+
21
42
  export const AI_MODEL_CAPABILITIES = ["streaming", "tools", "vision", "transcription"] as const;
22
43
  export type AiModelCapability = (typeof AI_MODEL_CAPABILITIES)[number];
23
44
 
@@ -298,12 +319,18 @@ export type AiStoredMessage = {
298
319
  sourceHref?: string;
299
320
  };
300
321
  scheduledTask?: {
322
+ reason?: import("./chat-task-reasons").ChatTaskReason;
323
+ /** The message text is only the reason sentence; the chat words the reason itself instead. */
324
+ reasonOnly?: true;
325
+ stop?: { cause: "paused" | "deleted"; done: string[] };
301
326
  taskId: string;
302
327
  occurrenceId: string;
303
328
  scheduledFor: string;
304
329
  trigger: "scheduled" | "manual";
305
330
  };
306
331
  toolPresentations?: Record<string, AiToolPresentation>;
332
+ toolReviews?: Record<string, AiStoredActionReview>;
333
+ toolChecks?: Record<string, AiActionCheck>;
307
334
  /** How the person decided an approval; `expired` when the turn ended while it still waited. */
308
335
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
309
336
  /** Requests a website approval let through, by tool call and then by `AiWebsiteReceipt.index`. */
@@ -424,7 +451,7 @@ export type AiPendingTurnAction =
424
451
  name: string;
425
452
  args: unknown;
426
453
  message?: string;
427
- review?: CapabilityActionReview;
454
+ review?: CapabilityActionReviewData;
428
455
  allowAlways: boolean;
429
456
  /** Whether the approval can be remembered for this chat. */
430
457
  allowChat: boolean;
@@ -460,7 +487,7 @@ export type AiPendingTurnActionRecord = {
460
487
  name: string;
461
488
  args: unknown;
462
489
  message?: string;
463
- review?: CapabilityActionReview;
490
+ review?: CapabilityActionReviewData;
464
491
  approvalScope: string;
465
492
  allowAlways: boolean;
466
493
  /** Whether the approval can be remembered for this chat; defaults to `allowAlways`. */
@@ -613,6 +640,11 @@ export type AiConversationFileSnapshot = {
613
640
  };
614
641
 
615
642
  export type AiBackgroundRunContext = {
643
+ scheduledFor?: string;
644
+ trigger?: "scheduled" | "manual";
645
+ timezone?: string;
646
+ stopDeliveredAt?: string;
647
+ needsApproval?: { action: string };
616
648
  taskId: string;
617
649
  occurrenceId: string;
618
650
  context: Array<{ seq: number; kind: "message" | "summary"; message: Message }>;
@@ -757,6 +789,7 @@ export type AiConversationService = {
757
789
  before?: string;
758
790
  limit?: number;
759
791
  }): Promise<{ resources: AiConversationResourceRef[]; nextCursor?: string }>;
792
+ listRecentSentResourceRefs(input: { ownerUserId: string }): Promise<AiRecentSentResourceRef[]>;
760
793
  listUserConversationResources(input: {
761
794
  ownerUserId: string;
762
795
  search?: string;
@@ -977,7 +1010,10 @@ export type AiConversationService = {
977
1010
  seq: number;
978
1011
  }): Promise<boolean>;
979
1012
  /** Record an abort wish. The caller finalizes ownerless turns itself. */
980
- requestTurnAbort(input: { conversationId: string; turnId: string; reason?: string }): Promise<AiTurnAbortRequest>;
1013
+ requestTurnAbort(
1014
+ input: { conversationId: string; turnId: string; reason?: string },
1015
+ options?: { db: import("bun").SQL },
1016
+ ): Promise<AiTurnAbortRequest>;
981
1017
  completeTurn(input: {
982
1018
  conversationId: string;
983
1019
  turnId: string;
@@ -14,6 +14,7 @@ import {
14
14
  UserProviderSchema,
15
15
  } from "../contracts";
16
16
  import { type AuthContext, auth, expectUserBackedActor, jsonResponse, requiresAuth, respond, v } from "../server";
17
+ import { noTransform } from "../server/middleware/no-transform";
17
18
  import { accountsAppService as accountsService } from "../services";
18
19
 
19
20
  const EntitiesListResponseSchema = z.object({
@@ -91,7 +92,7 @@ type AccountsEntitiesRouteDependencies = {
91
92
  };
92
93
 
93
94
  export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRouteDependencies = {}) =>
94
- new Hono<AuthContext>().get(
95
+ new Hono<AuthContext>().use("/entities/*", noTransform()).get(
95
96
  "/entities",
96
97
  dependencies.authenticate ?? auth.requireRole("authenticated"),
97
98
  dependencies.requireUser ?? auth.requireUser(),
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { AiProjectLastAdminError, aiProjects } from "../ai/projects";
4
4
  import { AuthenticatedPrincipalSchema } from "../contracts/shared";
5
5
  import { type AuthContext, auth, err, fail, ok, respond, v } from "../server";
6
+ import { noTransform } from "../server/middleware/no-transform";
6
7
 
7
8
  const ListProjectsSchema = z
8
9
  .object({
@@ -20,6 +21,7 @@ const lastAdminConflict = (c: Parameters<typeof respond>[0], error: AiProjectLas
20
21
 
21
22
  export const createAdminAiProjectsRoutes = (authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin")) =>
22
23
  new Hono<AuthContext>()
24
+ .use("*", noTransform())
23
25
  .use("*", authenticate)
24
26
  .get("/", v("query", ListProjectsSchema), async (c) => {
25
27
  const query = c.req.valid("query");
@@ -4,6 +4,7 @@ import { aiAppSkills } from "../ai/app-skill-store";
4
4
  import { AiSkillInputError, AiSkillLastAdminError, AiSkillRevisionConflictError, aiSkills } from "../ai/skills";
5
5
  import { AuthenticatedPrincipalSchema } from "../contracts/shared";
6
6
  import { type AuthContext, auth, err, fail, ok, respond, v } from "../server";
7
+ import { noTransform } from "../server/middleware/no-transform";
7
8
 
8
9
  const ListSkillsSchema = z
9
10
  .object({
@@ -32,6 +33,7 @@ const installAppSkill = async (c: Parameters<typeof respond>[0], install: () =>
32
33
 
33
34
  export const createAdminAiSkillsRoutes = (authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin")) =>
34
35
  new Hono<AuthContext>()
36
+ .use("*", noTransform())
35
37
  .use("*", authenticate)
36
38
  .get("/", v("query", ListSkillsSchema), async (c) => {
37
39
  const query = c.req.valid("query");
@@ -3,6 +3,7 @@ import { describeRoute } from "hono-openapi";
3
3
  import { z } from "zod";
4
4
  import { aiUsage } from "../ai/usage";
5
5
  import { type AuthContext, auth, err, fail, ok, respond, v } from "../server";
6
+ import { noTransform } from "../server/middleware/no-transform";
6
7
  import { AiUsageQuerySchema } from "../shared/ai-usage";
7
8
 
8
9
  export const createAdminAiUsageRoutes = (
@@ -10,6 +11,7 @@ export const createAdminAiUsageRoutes = (
10
11
  service: typeof aiUsage = aiUsage,
11
12
  ) =>
12
13
  new Hono<AuthContext>()
14
+ .use("*", noTransform())
13
15
  .use("*", authenticate)
14
16
  .get(
15
17
  "/report",
@@ -46,9 +46,10 @@ const BulkSaveSchema = z.union([
46
46
  .object({
47
47
  updates: z.record(z.string(), z.unknown()).optional().default({}),
48
48
  resets: z.array(z.string()).optional().default([]),
49
+ replaceUnreadable: z.array(z.string()).optional().default([]),
49
50
  })
50
51
  .strict(),
51
- z.record(z.string(), z.unknown()).transform((updates) => ({ updates, resets: [] as string[] })),
52
+ z.record(z.string(), z.unknown()).transform((updates) => ({ updates, resets: [] as string[], replaceUnreadable: [] as string[] })),
52
53
  ]);
53
54
  const LEGAL_DOCUMENTS = [
54
55
  { kind: "terms", path: "/legal/terms" },
@@ -58,6 +59,13 @@ const LEGAL_DOCUMENTS = [
58
59
 
59
60
  type FieldErrors = Record<string, string>;
60
61
 
62
+ const unreadableSettingMessage = (locale: string): string =>
63
+ locale.startsWith("de")
64
+ ? "Der gespeicherte Wert kann von dieser Anwendung nicht gelesen werden. Ersetze ihn ausdrücklich oder nutze den Standardwert."
65
+ : "The stored value cannot be read by this application. Replace it explicitly or use the default.";
66
+ const settingsNotSavedMessage = (locale: string): string =>
67
+ locale.startsWith("de") ? "Die Einstellungen wurden nicht gespeichert." : "The settings were not saved.";
68
+
61
69
  const isKnownSetting = (key: string): boolean => SETTINGS_MAP.has(key);
62
70
 
63
71
  const AI_PROFILES_KEY = "ai.model_profiles_json";
@@ -335,168 +343,211 @@ const app = new Hono<AuthContext>()
335
343
  if (result.ok) return c.json({ ok: true });
336
344
  return c.json({ message: result.error, kind: result.kind }, result.status);
337
345
  })
338
- .put("/", auth.requireRole("admin"), v("json", BulkSaveSchema), async (c) => {
339
- const { updates, resets } = c.req.valid("json");
340
- const updateKeys = Object.keys(updates);
341
- const keys = [...updateKeys, ...resets];
346
+ .put(
347
+ "/",
348
+ auth.requireRole("admin"),
349
+ describeRoute({
350
+ tags: ["Administration"],
351
+ summary: "Save platform settings",
352
+ ...requiresAdmin,
353
+ responses: {
354
+ 204: { description: "Settings saved" },
355
+ 400: jsonResponse(z.object({ message: z.string(), errors: z.record(z.string(), z.string()).optional() }), "Invalid settings"),
356
+ 409: jsonResponse(
357
+ z.object({ message: z.string(), errors: z.record(z.string(), z.string()), code: z.literal("setting_unreadable").optional() }),
358
+ "Stored setting unreadable or model access conflict",
359
+ ),
360
+ },
361
+ }),
362
+ v("json", BulkSaveSchema),
363
+ async (c) => {
364
+ const { updates, resets, replaceUnreadable } = c.req.valid("json");
365
+ const replace = new Set(replaceUnreadable);
366
+ const updateKeys = Object.keys(updates);
367
+ const keys = [...updateKeys, ...resets];
342
368
 
343
- if (keys.length === 0) {
344
- return c.body(null, 204);
345
- }
369
+ if (keys.length === 0 && replace.size === 0) {
370
+ return c.body(null, 204);
371
+ }
346
372
 
347
- const ownership: FieldErrors = {};
348
- for (const key of keys) {
349
- if (!isKnownSetting(key)) {
350
- ownership[key] = `Unknown setting "${key}"`;
373
+ const ownership: FieldErrors = {};
374
+ for (const key of keys) {
375
+ if (!isKnownSetting(key)) {
376
+ ownership[key] = `Unknown setting "${key}"`;
377
+ }
351
378
  }
352
- }
353
- for (const key of resets) {
354
- if (key in updates) {
355
- ownership[key] = `Setting "${key}" cannot be updated and reset in the same save`;
379
+ for (const key of replace) {
380
+ if (!Object.hasOwn(updates, key)) ownership[key] = `Setting "${key}" must be included in updates to replace its unreadable value`;
381
+ }
382
+ for (const key of resets) {
383
+ if (key in updates) {
384
+ ownership[key] = `Setting "${key}" cannot be updated and reset in the same save`;
385
+ }
386
+ }
387
+ if (Object.keys(ownership).length > 0) {
388
+ return c.json({ message: "Invalid keys", errors: ownership }, 400);
356
389
  }
357
- }
358
- if (Object.keys(ownership).length > 0) {
359
- return c.json({ message: "Invalid keys", errors: ownership }, 400);
360
- }
361
390
 
362
- // Strip AI provider keys before anything is validated or written, so the
363
- // value that gets checked is the value that gets stored.
364
- const aiSplit = AI_PROFILES_KEY in updates ? splitAiProfileCredentials(updates[AI_PROFILES_KEY]) : null;
365
- if (AI_PROFILES_KEY in updates && !aiSplit) {
366
- // Unsplittable means the keys could not be separated out, and storing the
367
- // value as posted would put them back in a setting the browser reads.
368
- return c.json({ message: "Invalid values", errors: { [AI_PROFILES_KEY]: "Model profiles must be a JSON array" } }, 400);
369
- }
370
- let accessChanges: AiModelAccessChange[] = [];
371
- if (aiSplit) {
372
- try {
373
- aiSplit.profilesJson = prepareAiModelProfileImages(aiSplit.profilesJson);
374
- } catch (error) {
375
- if (error instanceof ImageMetadataError)
376
- return c.json({ message: error.message, code: error.code, errors: { [AI_PROFILES_KEY]: error.message } }, 422);
377
- throw error;
391
+ const states = await settings.readStates(updateKeys);
392
+ const unreadable: FieldErrors = {};
393
+ for (const key of updateKeys) {
394
+ if (states.get(key)?.stored === "unreadable" && !replace.has(key)) unreadable[key] = unreadableSettingMessage(getLocale(c));
378
395
  }
379
- try {
380
- const split = splitAiModelAccess(aiSplit.profilesJson);
381
- aiSplit.profilesJson = split.profilesJson;
382
- accessChanges = split.changes;
383
- } catch {
384
- return c.json(
385
- {
386
- message: "Invalid model permissions",
387
- errors: { [AI_PROFILES_KEY]: "Invalid Assistant model permissions. Reload the settings and try again." },
388
- },
389
- 400,
390
- );
396
+ if (Object.keys(unreadable).length > 0) {
397
+ return c.json({ message: settingsNotSavedMessage(getLocale(c)), errors: unreadable, code: "setting_unreadable" }, 409);
391
398
  }
392
- }
393
- const finalValues: Record<string, unknown> = aiSplit ? { ...updates, [AI_PROFILES_KEY]: aiSplit.profilesJson } : updates;
394
399
 
395
- // Validate everything up front. settings.set validates as it writes, so a
396
- // single bad field used to leave the earlier keys already saved — with the
397
- // AI profiles among them, whose save also prunes credentials.
398
- const invalid: FieldErrors = {};
399
- const validatedValues: Record<string, unknown> = {};
400
- for (const [key, value] of Object.entries(finalValues)) {
401
- const def = SETTINGS_MAP.get(key);
402
- if (!def) continue;
403
- const validated = validateSettingValue(def, value);
404
- if (validated.ok) validatedValues[key] = validated.value;
405
- else invalid[key] = validated.error;
406
- if (key === "freeipa.ca_cert") {
407
- const certificate = validateFreeIpaCaCert(value);
408
- if (!certificate.ok) invalid[key] = certificate.error;
400
+ // Strip AI provider keys before anything is validated or written, so the
401
+ // value that gets checked is the value that gets stored.
402
+ const aiSplit = AI_PROFILES_KEY in updates ? splitAiProfileCredentials(updates[AI_PROFILES_KEY]) : null;
403
+ if (AI_PROFILES_KEY in updates && !aiSplit) {
404
+ // Unsplittable means the keys could not be separated out, and storing the
405
+ // value as posted would put them back in a setting the browser reads.
406
+ return c.json({ message: "Invalid values", errors: { [AI_PROFILES_KEY]: "Model profiles must be a JSON array" } }, 400);
409
407
  }
410
- if (INTEGER_FREEIPA_SETTINGS.has(key) && (typeof value !== "number" || !Number.isInteger(value))) {
411
- invalid[key] = "Value must be a whole number";
408
+ let accessChanges: AiModelAccessChange[] = [];
409
+ if (aiSplit) {
410
+ try {
411
+ aiSplit.profilesJson = prepareAiModelProfileImages(aiSplit.profilesJson);
412
+ } catch (error) {
413
+ if (error instanceof ImageMetadataError)
414
+ return c.json({ message: error.message, code: error.code, errors: { [AI_PROFILES_KEY]: error.message } }, 422);
415
+ throw error;
416
+ }
417
+ try {
418
+ const split = splitAiModelAccess(aiSplit.profilesJson);
419
+ aiSplit.profilesJson = split.profilesJson;
420
+ accessChanges = split.changes;
421
+ } catch {
422
+ return c.json(
423
+ {
424
+ message: "Invalid model permissions",
425
+ errors: { [AI_PROFILES_KEY]: "Invalid Assistant model permissions. Reload the settings and try again." },
426
+ },
427
+ 400,
428
+ );
429
+ }
412
430
  }
413
- }
414
- if (Object.keys(invalid).length > 0) {
415
- return c.json({ message: "Invalid values", errors: invalid }, 400);
416
- }
431
+ const finalValues: Record<string, unknown> = aiSplit ? { ...updates, [AI_PROFILES_KEY]: aiSplit.profilesJson } : updates;
417
432
 
418
- let aiPlan: AiSettingsMutationPlan;
419
- try {
420
- aiPlan = await prepareAiSettingsMutation(validatedValues, resets, aiSplit, getLocale(c));
421
- } catch (error) {
422
- console.error("[settings] failed to validate the prospective AI configuration", error);
423
- return c.json({ message: "Failed to validate AI settings" }, 500);
424
- }
425
- if (Object.keys(aiPlan.errors).length > 0) {
426
- return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
427
- }
433
+ // Validate everything up front. settings.set validates as it writes, so a
434
+ // single bad field used to leave the earlier keys already saved — with the
435
+ // AI profiles among them, whose save also prunes credentials.
436
+ const invalid: FieldErrors = {};
437
+ const validatedValues: Record<string, unknown> = {};
438
+ for (const [key, value] of Object.entries(finalValues)) {
439
+ const def = SETTINGS_MAP.get(key);
440
+ if (!def) continue;
441
+ const validated = validateSettingValue(def, value);
442
+ if (validated.ok) validatedValues[key] = validated.value;
443
+ else invalid[key] = validated.error;
444
+ if (key === "freeipa.ca_cert") {
445
+ const certificate = validateFreeIpaCaCert(value);
446
+ if (!certificate.ok) invalid[key] = certificate.error;
447
+ }
448
+ if (INTEGER_FREEIPA_SETTINGS.has(key) && (typeof value !== "number" || !Number.isInteger(value))) {
449
+ invalid[key] = "Value must be a whole number";
450
+ }
451
+ }
452
+ if (Object.keys(invalid).length > 0) {
453
+ return c.json({ message: "Invalid values", errors: invalid }, 400);
454
+ }
428
455
 
429
- const fieldErrors: FieldErrors = {};
430
- try {
431
- // Every settings and AI credential write runs on the transaction's own
432
- // connection, so a failure rolls the whole save back.
433
- await sql.begin(async (tx) => {
434
- for (const [key, value] of Object.entries(validatedValues)) {
435
- try {
436
- await settings.set(key, value, tx);
437
- } catch (error) {
438
- fieldErrors[key] = `Failed to update ${key}`;
439
- throw error;
456
+ let aiPlan: AiSettingsMutationPlan;
457
+ try {
458
+ aiPlan = await prepareAiSettingsMutation(validatedValues, resets, aiSplit, getLocale(c));
459
+ } catch (error) {
460
+ console.error("[settings] failed to validate the prospective AI configuration", error);
461
+ return c.json({ message: "Failed to validate AI settings" }, 500);
462
+ }
463
+ if (Object.keys(aiPlan.errors).length > 0) {
464
+ return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
465
+ }
466
+
467
+ const fieldErrors: FieldErrors = {};
468
+ try {
469
+ // Every settings and AI credential write runs on the transaction's own
470
+ // connection, so a failure rolls the whole save back.
471
+ await sql.begin(async (tx) => {
472
+ for (const [key, value] of Object.entries(validatedValues)) {
473
+ try {
474
+ await settings.set(key, value, tx, { replaceUnreadable: replace.has(key) });
475
+ } catch (error) {
476
+ fieldErrors[key] = `Failed to update ${key}`;
477
+ throw error;
478
+ }
440
479
  }
441
- }
442
- for (const key of resets) {
443
- try {
444
- await settings.remove(key, tx);
445
- } catch (error) {
446
- fieldErrors[key] = `Failed to reset ${key}`;
447
- throw error;
480
+ for (const key of resets) {
481
+ try {
482
+ await settings.remove(key, tx);
483
+ } catch (error) {
484
+ fieldErrors[key] = `Failed to reset ${key}`;
485
+ throw error;
486
+ }
448
487
  }
449
- }
450
- const categoryKeys = keys.filter((key) => key.startsWith("user.category."));
451
- if (categoryKeys.length > 0)
452
- await audit.record(
453
- {
454
- action: "accounts.categories.configure",
455
- outcome: "allowed",
456
- actor: { userId: c.get("user").id },
457
- target: { type: "settings", id: "user.category" },
458
- metadata: {
459
- keys: categoryKeys,
460
- updates: Object.fromEntries(categoryKeys.filter((key) => key in validatedValues).map((key) => [key, validatedValues[key]])),
461
- resets: resets.filter((key) => categoryKeys.includes(key)),
488
+ const categoryKeys = keys.filter((key) => key.startsWith("user.category."));
489
+ if (categoryKeys.length > 0)
490
+ await audit.record(
491
+ {
492
+ action: "accounts.categories.configure",
493
+ outcome: "allowed",
494
+ actor: { userId: c.get("user").id },
495
+ target: { type: "settings", id: "user.category" },
496
+ metadata: {
497
+ keys: categoryKeys,
498
+ updates: Object.fromEntries(
499
+ categoryKeys.filter((key) => key in validatedValues).map((key) => [key, validatedValues[key]]),
500
+ ),
501
+ resets: resets.filter((key) => categoryKeys.includes(key)),
502
+ },
462
503
  },
504
+ tx,
505
+ );
506
+ if (aiSplit && aiPlan.keepCredentialProfileIds) {
507
+ await storeAiCredentials(aiSplit, aiPlan.keepCredentialProfileIds, tx);
508
+ } else if (aiPlan.keepCredentialProfileIds) {
509
+ await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
510
+ }
511
+ if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
512
+ if (aiPlan.modelProfileIds) {
513
+ await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, accessChanges, tx);
514
+ }
515
+ });
516
+ } catch (error) {
517
+ if (error instanceof settings.SettingUnreadableError) {
518
+ return c.json(
519
+ {
520
+ message: settingsNotSavedMessage(getLocale(c)),
521
+ errors: { [error.key]: unreadableSettingMessage(getLocale(c)) },
522
+ code: "setting_unreadable",
463
523
  },
464
- tx,
524
+ 409,
465
525
  );
466
- if (aiSplit && aiPlan.keepCredentialProfileIds) {
467
- await storeAiCredentials(aiSplit, aiPlan.keepCredentialProfileIds, tx);
468
- } else if (aiPlan.keepCredentialProfileIds) {
469
- await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
470
526
  }
471
- if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
472
- if (aiPlan.modelProfileIds) {
473
- await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, accessChanges, tx);
527
+ if (error instanceof ImageMetadataError) return c.json({ message: error.message, code: error.code, errors: fieldErrors }, 422);
528
+ if (error instanceof AiModelAccessConflict || error instanceof AiModelAccessInvalid) {
529
+ return c.json(
530
+ { message: error.message, errors: { [AI_PROFILES_KEY]: error.message } },
531
+ error instanceof AiModelAccessConflict ? 409 : 400,
532
+ );
474
533
  }
475
- });
476
- } catch (error) {
477
- if (error instanceof ImageMetadataError) return c.json({ message: error.message, code: error.code, errors: fieldErrors }, 422);
478
- if (error instanceof AiModelAccessConflict || error instanceof AiModelAccessInvalid) {
534
+ console.error("[settings] failed to commit settings update", error);
479
535
  return c.json(
480
- { message: error.message, errors: { [AI_PROFILES_KEY]: error.message } },
481
- error instanceof AiModelAccessConflict ? 409 : 400,
536
+ {
537
+ message: "Save failed",
538
+ errors: Object.keys(fieldErrors).length > 0 ? fieldErrors : { _form: "The settings could not be saved." },
539
+ },
540
+ 500,
482
541
  );
483
542
  }
484
- console.error("[settings] failed to commit settings update", error);
485
- return c.json(
486
- {
487
- message: "Save failed",
488
- errors: Object.keys(fieldErrors).length > 0 ? fieldErrors : { _form: "The settings could not be saved." },
489
- },
490
- 500,
491
- );
492
- }
493
543
 
494
- // Only now: dropping the cache before the commit would let another
495
- // container miss, read the pre-commit row and cache it for the full TTL.
496
- await invalidateCommittedSettings(keys);
544
+ // Only now: dropping the cache before the commit would let another
545
+ // container miss, read the pre-commit row and cache it for the full TTL.
546
+ await invalidateCommittedSettings(keys);
497
547
 
498
- return c.body(null, 204);
499
- })
548
+ return c.body(null, 204);
549
+ },
550
+ )
500
551
  .delete("/:key{.+}", auth.requireRole("admin"), async (c) => {
501
552
  const key = c.req.param("key");
502
553
  if (!isKnownSetting(key)) {
@@ -0,0 +1,80 @@
1
+ import { Hono, type MiddlewareHandler } from "hono";
2
+ import { describeRoute } from "hono-openapi";
3
+ import { z } from "zod";
4
+ import { GEOIP_MAX_DATABASE_BYTES, GEOIP_MAX_REQUEST_BYTES } from "../contracts/geoip";
5
+ import { type AuthContext, auth, v } from "../server";
6
+ import { REQUEST_IDLE_TIMEOUT_MAX_SECONDS, REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
7
+ import { GeoIpError, geoip } from "../services/geoip";
8
+
9
+ export const createAdminGeoIpRoutes = (
10
+ authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
11
+ service: typeof geoip = geoip,
12
+ ) =>
13
+ new Hono<AuthContext>()
14
+ .use(authenticate)
15
+ .onError((error, c) => {
16
+ if (error instanceof GeoIpError)
17
+ return c.json(
18
+ {
19
+ code: error.code,
20
+ ...(error.code === "geoip_too_large" ? { maxDatabaseBytes: GEOIP_MAX_DATABASE_BYTES } : {}),
21
+ ...(error.code === "geoip_request_too_large" ? { maxRequestBytes: GEOIP_MAX_REQUEST_BYTES } : {}),
22
+ },
23
+ error.status,
24
+ );
25
+ if (c.req.raw.signal.aborted) return new Response(null, { status: 499 });
26
+ throw error;
27
+ })
28
+ .get(
29
+ "/",
30
+ describeRoute({ tags: ["Sign-in locations"], summary: "Show active sign-in location databases and upload limits" }),
31
+ auth.requireOAuthScope("read", "admin"),
32
+ async (c) => c.json(await service.status(c.get("user"))),
33
+ )
34
+ .put(
35
+ "/",
36
+ describeRoute({
37
+ tags: ["Sign-in locations"],
38
+ summary: "Replace a City or ASN database with a raw or gzip MMDB upload",
39
+ requestBody: {
40
+ required: true,
41
+ content: {
42
+ "application/octet-stream": { schema: { type: "string", format: "binary" } },
43
+ "application/gzip": { schema: { type: "string", format: "binary" } },
44
+ },
45
+ },
46
+ }),
47
+ auth.requireOAuthScope("write", "admin"),
48
+ async (c) => {
49
+ setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_MAX_SECONDS);
50
+ try {
51
+ const length = c.req.header("Content-Length");
52
+ return c.json(
53
+ await service.upload(
54
+ c.get("user"),
55
+ c.req.raw.body ??
56
+ new ReadableStream({
57
+ start(controller) {
58
+ controller.close();
59
+ },
60
+ }),
61
+ {
62
+ contentLength: length === undefined ? undefined : Number(length),
63
+ signal: c.req.raw.signal,
64
+ },
65
+ ),
66
+ );
67
+ } finally {
68
+ setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS);
69
+ }
70
+ },
71
+ )
72
+ .delete(
73
+ "/:kind",
74
+ describeRoute({ tags: ["Sign-in locations"], summary: "Reset one sign-in database to the bundled snapshot" }),
75
+ auth.requireOAuthScope("write", "admin"),
76
+ v("param", z.object({ kind: z.enum(["city", "asn"]) })),
77
+ async (c) => c.json(await service.reset(c.get("user"), c.req.valid("param").kind)),
78
+ );
79
+
80
+ export default createAdminGeoIpRoutes();
@@ -7,6 +7,7 @@ import { listApps } from "../_internal/registry";
7
7
  import { CapabilityAppIdSchema } from "../contracts/capabilities";
8
8
  import { AI_INFERENCE_SCOPE } from "../contracts/shared";
9
9
  import { type AuthContext, auth, err, respond, v } from "../server";
10
+ import { noTransform } from "../server/middleware/no-transform";
10
11
  import { audit } from "../services/audit";
11
12
  import { getIdentitySigningKeyStatus, identityMetrics, revokeIdentitySigningKey, rewrapIdentitySigningKeys } from "../services/identity";
12
13
  import { WORKLOAD_SCOPES } from "../services/identity/workload-auth";
@@ -110,6 +111,8 @@ export const createAdminIdentityRoutes = (
110
111
  service: IdentityAdminDependencies = dependencies,
111
112
  ) =>
112
113
  new Hono<AuthContext>()
114
+ // Mandate lists hold every user's grant inputs, which users write at will and which can carry private text.
115
+ .use("*", noTransform())
113
116
  .use(authenticate)
114
117
  .use("*", async (c, next) => {
115
118
  c.header("Cache-Control", "no-store");