@k2b/cloud 0.41.0 → 0.42.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 (227) hide show
  1. package/package.json +6 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-timeout.ts +71 -0
  6. package/src/_internal/define-app.ts +145 -89
  7. package/src/_internal/http-drain.ts +56 -0
  8. package/src/_internal/registry-validation.ts +3 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/status-preserving-ssr.ts +2 -0
  11. package/src/access/PermissionEditor.tsx +16 -17
  12. package/src/access/PrincipalPicker.tsx +45 -10
  13. package/src/access/messages.ts +8 -0
  14. package/src/access/principal-label.ts +18 -0
  15. package/src/access/ui.ts +1 -0
  16. package/src/account/EntitySearch.tsx +5 -4
  17. package/src/ai/action-review.ts +2 -1
  18. package/src/ai/app-skill-store.ts +1 -1
  19. package/src/ai/capabilities.ts +21 -3
  20. package/src/ai/capability-execution.ts +14 -12
  21. package/src/ai/chat/action-card.ts +7 -1
  22. package/src/ai/chat/action-messages.ts +14 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  24. package/src/ai/chat/action-question.tsx +202 -22
  25. package/src/ai/chat/action-text.ts +1 -1
  26. package/src/ai/chat-tasks.ts +2 -1
  27. package/src/ai/client/controller.ts +150 -32
  28. package/src/ai/client/transport.ts +14 -2
  29. package/src/ai/code-capability-routes.ts +27 -23
  30. package/src/ai/code-runtime-tools.ts +14 -16
  31. package/src/ai/code-source-tools.ts +16 -18
  32. package/src/ai/executor.ts +56 -21
  33. package/src/ai/fetch-file-tool.ts +67 -76
  34. package/src/ai/http.ts +1 -0
  35. package/src/ai/migrate.ts +13 -0
  36. package/src/ai/open-questions.ts +4 -8
  37. package/src/ai/projects.ts +1 -2
  38. package/src/ai/protocol.ts +2 -2
  39. package/src/ai/recent-resources.ts +12 -5
  40. package/src/ai/review-content.ts +77 -0
  41. package/src/ai/routes.ts +69 -31
  42. package/src/ai/runtime.ts +1 -0
  43. package/src/ai/skill-tool.ts +17 -6
  44. package/src/ai/skills-routes.ts +72 -27
  45. package/src/ai/skills.ts +139 -60
  46. package/src/ai/store.ts +168 -33
  47. package/src/ai/types.ts +10 -3
  48. package/src/ai/usage.ts +2 -2
  49. package/src/api/admin-core-settings.ts +15 -2
  50. package/src/api/admin-geo.ts +54 -0
  51. package/src/api/admin-linux-identities.ts +6 -3
  52. package/src/api/admin-outgoing-mail.ts +29 -15
  53. package/src/api/approvals-loader.ts +3 -3
  54. package/src/api/approvals.ts +51 -0
  55. package/src/api/apps.ts +4 -2
  56. package/src/api/capabilities.ts +258 -181
  57. package/src/api/capability-streams.ts +21 -13
  58. package/src/api/cli-plugins.ts +4 -1
  59. package/src/api/geo-glyphs.ts +11 -0
  60. package/src/api/geo.ts +128 -0
  61. package/src/api/help.ts +12 -4
  62. package/src/api/index.ts +5 -0
  63. package/src/api/mcp.ts +46 -38
  64. package/src/api/me-panel-consent.ts +8 -5
  65. package/src/api/search.ts +21 -14
  66. package/src/api/sync-ops.ts +19 -13
  67. package/src/api/widgets.ts +42 -18
  68. package/src/approvals/Approvals.tsx +5 -3
  69. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  70. package/src/browser/CloudResourceSearch.tsx +3 -13
  71. package/src/browser/PanelLeaveNotice.tsx +18 -0
  72. package/src/browser/navigation-search.ts +78 -25
  73. package/src/browser/panel-leave.ts +69 -0
  74. package/src/browser/panel-messages.ts +12 -0
  75. package/src/browser/panel-slot.ts +173 -24
  76. package/src/browser/panel.ts +3 -0
  77. package/src/browser/resource-search-messages.ts +2 -0
  78. package/src/capabilities/client.ts +28 -1
  79. package/src/capabilities/executions.ts +22 -3
  80. package/src/capabilities/geo-client.ts +57 -0
  81. package/src/capabilities/response.ts +2 -1
  82. package/src/capabilities/review-storage.ts +24 -0
  83. package/src/capabilities/reviews.ts +17 -3
  84. package/src/capabilities/server.ts +36 -16
  85. package/src/cli/access.ts +15 -23
  86. package/src/cli/admin/linux.ts +13 -2
  87. package/src/cli/admin/outgoing-mail.ts +26 -4
  88. package/src/cli/capabilities.ts +19 -0
  89. package/src/config/env.ts +0 -6
  90. package/src/contracts/app-access.ts +48 -0
  91. package/src/contracts/app.ts +4 -1
  92. package/src/contracts/approvals.ts +2 -2
  93. package/src/contracts/avatar.ts +186 -0
  94. package/src/contracts/capabilities.ts +148 -54
  95. package/src/contracts/geo-admin.ts +127 -0
  96. package/src/contracts/geo-map.ts +19 -0
  97. package/src/contracts/geo-route.ts +168 -0
  98. package/src/contracts/geo.ts +248 -0
  99. package/src/contracts/index.ts +4 -0
  100. package/src/contracts/outgoing-mail.ts +17 -4
  101. package/src/contracts/registry.ts +2 -0
  102. package/src/events/app-access.ts +68 -0
  103. package/src/events/live-delivery.ts +128 -0
  104. package/src/events/live.ts +14 -130
  105. package/src/geo/CloudMap.tsx +95 -0
  106. package/src/geo/map-messages.ts +17 -0
  107. package/src/geo/ui.ts +1 -0
  108. package/src/index.ts +2 -0
  109. package/src/server/client-ip.ts +36 -0
  110. package/src/server/index.ts +3 -4
  111. package/src/server/middleware/app-access.ts +312 -0
  112. package/src/server/middleware/auth.ts +1 -1
  113. package/src/server/middleware/invocation.ts +11 -0
  114. package/src/server/middleware/rate-limit.ts +4 -4
  115. package/src/server/services/access.ts +13 -9
  116. package/src/server/services/index.ts +0 -2
  117. package/src/server/services/services.ts +0 -2
  118. package/src/services/accounts/posix.ts +21 -7
  119. package/src/services/allowed-network-fetch.ts +16 -10
  120. package/src/services/app-access-metrics.ts +24 -0
  121. package/src/services/app-access.ts +123 -0
  122. package/src/services/app-discovery.ts +27 -0
  123. package/src/services/approvals/content.ts +40 -0
  124. package/src/services/approvals/index.ts +14 -6
  125. package/src/services/approvals/list.ts +6 -11
  126. package/src/services/approvals/question.ts +11 -7
  127. package/src/services/downstream-budget.ts +87 -0
  128. package/src/services/geo/admin.ts +262 -0
  129. package/src/services/geo/bucket.ts +67 -0
  130. package/src/services/geo/cache.ts +57 -0
  131. package/src/services/geo/costs.ts +5 -0
  132. package/src/services/geo/errors.ts +41 -0
  133. package/src/services/geo/fixtures/README.md +17 -0
  134. package/src/services/geo/fixtures/along.json +355 -0
  135. package/src/services/geo/fixtures/autocomplete.json +218 -0
  136. package/src/services/geo/fixtures/geocode.json +129 -0
  137. package/src/services/geo/fixtures/places.json +355 -0
  138. package/src/services/geo/fixtures/reverse.json +61 -0
  139. package/src/services/geo/fixtures/routing.json +57 -0
  140. package/src/services/geo/geoapify-route.ts +93 -0
  141. package/src/services/geo/geoapify.ts +281 -0
  142. package/src/services/geo/ledger.ts +122 -0
  143. package/src/services/geo/map.ts +160 -0
  144. package/src/services/geo/migrate.ts +25 -0
  145. package/src/services/geo/polyline.ts +184 -0
  146. package/src/services/geo/route-chain.ts +145 -0
  147. package/src/services/geo/route-costs.ts +9 -0
  148. package/src/services/geo/route-geometry.ts +39 -0
  149. package/src/services/geo/route-plan.ts +172 -0
  150. package/src/services/geo/route-service.ts +433 -0
  151. package/src/services/geo/service.ts +300 -0
  152. package/src/services/geo/settings.ts +49 -0
  153. package/src/services/help/index.ts +16 -7
  154. package/src/services/help/types.ts +6 -1
  155. package/src/services/identity/invocation-actor.ts +20 -2
  156. package/src/services/identity/issue-invocation.ts +67 -0
  157. package/src/services/identity/key-ring.ts +5 -3
  158. package/src/services/identity/runtime-config.ts +23 -0
  159. package/src/services/index.ts +9 -0
  160. package/src/services/mandates/index.ts +40 -3
  161. package/src/services/network-security.ts +6 -3
  162. package/src/services/network-transport.ts +99 -0
  163. package/src/services/notifications/catalog.ts +3 -2
  164. package/src/services/notifications/runtime.ts +1 -0
  165. package/src/services/notifications/web-push-transport.ts +45 -38
  166. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  167. package/src/services/outgoing-mail/index.ts +55 -1
  168. package/src/services/outgoing-mail/messages.ts +82 -3
  169. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  170. package/src/services/outgoing-mail/retention.ts +13 -1
  171. package/src/services/outgoing-mail/store.ts +36 -6
  172. package/src/services/outgoing-mail/test-send.ts +34 -4
  173. package/src/services/outgoing-mail/transport.ts +1 -1
  174. package/src/services/pdf/gotenberg.ts +11 -38
  175. package/src/services/pdf/template-preview.ts +1 -1
  176. package/src/services/public-http.ts +28 -40
  177. package/src/services/settings/app.ts +4 -1
  178. package/src/services/settings/core-settings.ts +88 -0
  179. package/src/services/settings/defaults.ts +8 -2
  180. package/src/services/settings/errors.ts +11 -0
  181. package/src/services/settings/geo-budget.ts +40 -0
  182. package/src/services/settings/index.ts +4 -3
  183. package/src/services/settings/store.ts +25 -17
  184. package/src/services/weather/geo.ts +29 -111
  185. package/src/services/weather/index.ts +4 -3
  186. package/src/services/weather/locations.ts +8 -5
  187. package/src/services/weather/migrate.ts +1 -0
  188. package/src/services/weather/settings.ts +0 -16
  189. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  190. package/src/shared/avatar-art.ts +400 -0
  191. package/src/shared/index.ts +2 -0
  192. package/src/shared/network-address.ts +22 -3
  193. package/src/shared/template-rendering.ts +36 -4
  194. package/src/shared-text/documents.ts +1 -1
  195. package/src/shared-text/index.ts +2 -0
  196. package/src/shared-text/log.ts +51 -3
  197. package/src/shared-text/snapshots.ts +340 -0
  198. package/src/ssr/AdminLayout.tsx +4 -2
  199. package/src/ssr/CloudShell.island.tsx +6 -0
  200. package/src/ssr/Layout.tsx +65 -15
  201. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  202. package/src/ssr/LayoutHelp.tsx +3 -2
  203. package/src/ssr/PanelRuntime.island.tsx +11 -7
  204. package/src/ssr/PwaLayout.tsx +2 -2
  205. package/src/ssr/RailApps.tsx +6 -0
  206. package/src/ssr/ShellRuntime.island.tsx +23 -7
  207. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  208. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
  209. package/src/ssr/admin-navigation.ts +1 -0
  210. package/src/ssr/app-badges.ts +6 -1
  211. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  212. package/src/ssr/help.ts +3 -2
  213. package/src/ssr/index.ts +1 -1
  214. package/src/ssr/layout-context.ts +2 -0
  215. package/src/ssr/mobile-menu-history.ts +4 -0
  216. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  217. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  218. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  219. package/src/ssr/page-swap.ts +28 -0
  220. package/src/ssr/page-trail-runtime.ts +32 -13
  221. package/src/ssr/panel-consent.ts +2 -2
  222. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  223. package/src/ssr/platform-messages.ts +8 -0
  224. package/src/ssr/runtime.ts +17 -0
  225. package/src/ssr/shell-data.tsx +10 -2
  226. package/src/styles/global.css +70 -6
  227. package/src/server/services/geo.ts +0 -215
package/src/ai/store.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
2
2
  import type { Usage } from "@k2b/nessi/ai";
3
3
  import { type SQL, sql } from "bun";
4
- import { type CapabilityActionReviewData, CapabilityActionReviewDataSchema } from "../contracts/capabilities";
4
+ import { capabilityRequestHash } from "../capabilities/claims";
5
+ import { restoreReviewSnapshot, serializeReviewSnapshot } from "../capabilities/review-storage";
6
+ import { type CapabilityActionReviewPresentation, CapabilityActionReviewPresentationSchema } from "../contracts/capabilities";
5
7
  import { logger } from "../services/logging";
6
8
  import { toPgTextArray } from "../services/postgres";
7
9
  import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck, type AiStoredActionReview } from "./action-review";
@@ -14,6 +16,13 @@ import { aiTurnErrorText } from "./chat/turn-error";
14
16
  import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
15
17
  import type { AiTurnBlock } from "./protocol";
16
18
  import { collectRecentSentResourceRefs, RECENT_SENT_CONVERSATION_SCAN_LIMIT, RECENT_SENT_MESSAGE_SCAN_LIMIT } from "./recent-sent-refs";
19
+ import {
20
+ compactCapabilityReview,
21
+ compactReviewMapSql,
22
+ compactReviewMetaSql,
23
+ compactStoredReview,
24
+ storedReviewWithContent,
25
+ } from "./review-content";
17
26
  import { AiRunTimeout } from "./run-timeout";
18
27
  import { withAiShortId, withAiShortIdForDb } from "./short-id";
19
28
  import { parseAiTodoPlan } from "./todo-contracts";
@@ -530,9 +539,9 @@ const parseJsonValue = <T>(value: unknown): T => {
530
539
  return value as T;
531
540
  };
532
541
 
533
- const parseCapabilityActionReview = (value: unknown): CapabilityActionReviewData | undefined => {
542
+ const parseCapabilityActionReview = (value: unknown): CapabilityActionReviewPresentation | undefined => {
534
543
  if (value === null || value === undefined) return undefined;
535
- const parsed = CapabilityActionReviewDataSchema.safeParse(parseJsonValue(value));
544
+ const parsed = CapabilityActionReviewPresentationSchema.safeParse(restoreReviewSnapshot(parseJsonValue(value)));
536
545
  return parsed.success ? parsed.data : undefined;
537
546
  };
538
547
 
@@ -664,6 +673,14 @@ const intValue = (value: number | string | null | undefined): number => Math.max
664
673
 
665
674
  const rowToMessage = (row: MessageRow): AiStoredMessage => {
666
675
  const message = parseJsonValue<Message>(row.message);
676
+ const meta = row.meta ? parseJsonValue<AiStoredMessage["meta"]>(row.meta) : null;
677
+ if (meta?.toolReviews)
678
+ meta.toolReviews = Object.fromEntries(
679
+ Object.entries(meta.toolReviews).map(([callId, review]) => [
680
+ callId,
681
+ compactStoredReview(review, { conversationId: row.conversation_id, turnId: row.loop_id ?? "", callId }),
682
+ ]),
683
+ );
667
684
  return {
668
685
  id: row.id,
669
686
  shortId: row.short_id,
@@ -679,7 +696,7 @@ const rowToMessage = (row: MessageRow): AiStoredMessage => {
679
696
  loopAggregate: row.loop_aggregate ? parseJsonValue<LoopAggregate>(row.loop_aggregate) : null,
680
697
  loopDoneReason: row.loop_done_reason,
681
698
  compactedAt: row.compacted_at ? iso(row.compacted_at) : null,
682
- meta: row.meta ? parseJsonValue<AiStoredMessage["meta"]>(row.meta) : null,
699
+ meta,
683
700
  feedback:
684
701
  Number(row.feedback_rating) === 1 || Number(row.feedback_rating) === -1
685
702
  ? {
@@ -733,7 +750,16 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
733
750
  name: row.tool_name,
734
751
  args: row.args,
735
752
  message: row.message ?? undefined,
736
- review: parseCapabilityActionReview(row.review),
753
+ review: (() => {
754
+ const review = parseCapabilityActionReview(row.review);
755
+ return review
756
+ ? compactCapabilityReview(review, {
757
+ conversationId: row.conversation_id,
758
+ turnId: row.turn_id,
759
+ callId: row.kind === "custom_approval" ? row.call_id.replace(/-approval-[0-9]+$/, "") : row.call_id,
760
+ })
761
+ : undefined;
762
+ })(),
737
763
  allowAlways: row.allow_always,
738
764
  allowChat: row.allow_always || row.allow_chat === true,
739
765
  ...(row.remember_tool_name === AI_WEBSITE_APPROVAL_TOOL ? { website: row.approval_scope } : {}),
@@ -749,7 +775,16 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
749
775
  name: row.tool_name,
750
776
  args: row.args,
751
777
  message: row.message ?? undefined,
752
- review: parseCapabilityActionReview(row.review),
778
+ review: (() => {
779
+ const review = parseCapabilityActionReview(row.review);
780
+ return review
781
+ ? compactCapabilityReview(review, {
782
+ conversationId: row.conversation_id,
783
+ turnId: row.turn_id,
784
+ callId: row.kind === "custom_approval" ? row.call_id.replace(/-approval-[0-9]+$/, "") : row.call_id,
785
+ })
786
+ : undefined;
787
+ })(),
753
788
  approvalScope: row.approval_scope,
754
789
  allowAlways: row.allow_always,
755
790
  allowChat: row.allow_always || row.allow_chat === true,
@@ -994,7 +1029,7 @@ const recordAiToolMeta = async (
994
1029
  UPDATE ${db(table)}
995
1030
  SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
996
1031
  ${key}::text, COALESCE(meta->${key}::text, '{}'::jsonb) ||
997
- jsonb_build_object(${input.callId}::text, ${JSON.stringify(value)}::text::jsonb)
1032
+ jsonb_build_object(${input.callId}::text, ${key === "toolReviews" ? serializeReviewSnapshot(value) : JSON.stringify(value)}::text::jsonb)
998
1033
  )
999
1034
  WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
1000
1035
  AND role = 'assistant'
@@ -1005,8 +1040,16 @@ const recordAiToolMeta = async (
1005
1040
  if (!written) throw new Error("AI tool call message is unavailable.");
1006
1041
  };
1007
1042
 
1008
- export const recordAiToolReview = (input: { conversationId: string; turnId: string; callId: string; review: AiStoredActionReview }) =>
1009
- recordAiToolMeta(input, "toolReviews", input.review);
1043
+ export const recordAiToolReview = async (input: {
1044
+ conversationId: string;
1045
+ turnId: string;
1046
+ callId: string;
1047
+ review: AiStoredActionReview;
1048
+ }): Promise<AiStoredActionReview> => {
1049
+ const stored = storedReviewWithContent(input.review, input);
1050
+ await recordAiToolMeta(input, "toolReviews", stored);
1051
+ return compactStoredReview(stored, input);
1052
+ };
1010
1053
 
1011
1054
  export const recordAiToolCheck = (input: { conversationId: string; turnId: string; callId: string; check: AiActionCheck }, db = sql) =>
1012
1055
  recordAiToolMeta(input, "toolChecks", input.check, db);
@@ -1319,11 +1362,45 @@ export const saveAiDraftInTransaction = async (
1319
1362
  };
1320
1363
  };
1321
1364
 
1365
+ export class AiConversationCreationError extends Error {
1366
+ constructor(readonly reason: "conflict" | "unavailable") {
1367
+ super(
1368
+ reason === "conflict"
1369
+ ? "This creation request was already used with different input."
1370
+ : "The conversation from this request is no longer available.",
1371
+ );
1372
+ }
1373
+ }
1374
+
1322
1375
  export const aiConversations: AiConversationService = {
1376
+ getDraftSubmission: async ({ conversationId, ownerUserId, revision }) => {
1377
+ const [row] = await sql<{ message_id: string; turn_id: string | null }[]>`
1378
+ SELECT message.short_id AS message_id, turn.short_id AS turn_id
1379
+ FROM ai.messages message
1380
+ JOIN ai.conversations conversation ON conversation.id = message.conversation_id
1381
+ LEFT JOIN ai.turns turn ON turn.id::text = message.loop_id AND turn.conversation_id = conversation.id
1382
+ WHERE conversation.id = ${conversationId}::uuid AND conversation.created_by_user_id = ${ownerUserId}::uuid
1383
+ AND conversation.archived_at IS NULL AND message.role = 'user'
1384
+ AND message.meta->>'submittedDraftRevision' = ${String(revision)}
1385
+ LIMIT 1
1386
+ `;
1387
+ return row ? { messageId: row.message_id, turnId: row.turn_id } : null;
1388
+ },
1323
1389
  createConversation: async (input) => {
1324
- const rows = await withAiShortId(
1325
- "idx_ai_conversations_short_id",
1326
- (shortId) => sql<ConversationRow[]>`
1390
+ const normalized = {
1391
+ title: input.title?.trim() || "New chat",
1392
+ description: input.description?.trim() ?? "",
1393
+ projectId: input.projectId ?? null,
1394
+ draft: input.draft ?? [],
1395
+ preloadTools: input.preloadTools ?? [],
1396
+ allowedTools: input.allowedTools ?? null,
1397
+ launchedByAppId: input.launchedByAppId?.trim() || null,
1398
+ };
1399
+ const insert = async (db: SQL) => {
1400
+ const rows = await withAiShortIdForDb(
1401
+ db,
1402
+ "idx_ai_conversations_short_id",
1403
+ (attempt, shortId) => attempt<ConversationRow[]>`
1327
1404
  INSERT INTO ai.conversations (
1328
1405
  short_id,
1329
1406
  title,
@@ -1339,8 +1416,8 @@ export const aiConversations: AiConversationService = {
1339
1416
  )
1340
1417
  SELECT
1341
1418
  ${shortId},
1342
- ${input.title?.trim() || "New chat"},
1343
- ${input.description?.trim() ?? ""},
1419
+ ${normalized.title},
1420
+ ${normalized.description},
1344
1421
  ${input.projectId ?? null}::uuid,
1345
1422
  ${JSON.stringify(input.draft ?? [])}::text::jsonb,
1346
1423
  ${input.draft?.length ? 1 : 0},
@@ -1352,10 +1429,38 @@ export const aiConversations: AiConversationService = {
1352
1429
  WHERE ${input.projectId ?? null}::uuid IS NULL
1353
1430
  OR EXISTS (SELECT 1 FROM ai.projects project WHERE project.id = ${input.projectId ?? null}::uuid)
1354
1431
  RETURNING *
1355
- `,
1356
- );
1357
- if (!rows[0]) throw new Error("Project does not exist.");
1358
- return rowToConversation(rows[0]);
1432
+ `,
1433
+ );
1434
+ if (!rows[0]) throw new Error("Project does not exist.");
1435
+ return rowToConversation(rows[0]);
1436
+ };
1437
+ if (!input.requestId) return insert(sql);
1438
+ const hash = capabilityRequestHash(normalized);
1439
+ return sql.begin(async (tx) => {
1440
+ const reserved = await tx<{ request_id: string }[]>`
1441
+ INSERT INTO ai.conversation_creations (owner_user_id, request_id, input_hash)
1442
+ VALUES (${input.ownerUserId}::uuid, ${input.requestId}::uuid, ${hash})
1443
+ ON CONFLICT (owner_user_id, request_id) DO NOTHING RETURNING request_id
1444
+ `;
1445
+ const [claim] = await tx<{ input_hash: string; conversation_id: string | null }[]>`
1446
+ SELECT input_hash, conversation_id FROM ai.conversation_creations
1447
+ WHERE owner_user_id = ${input.ownerUserId}::uuid AND request_id = ${input.requestId}::uuid
1448
+ FOR UPDATE
1449
+ `;
1450
+ if (!claim || claim.input_hash !== hash) throw new AiConversationCreationError("conflict");
1451
+ if (!reserved.length) {
1452
+ const [existing] = await tx<ConversationRow[]>`
1453
+ SELECT * FROM ai.conversations WHERE id = ${claim.conversation_id}::uuid
1454
+ AND created_by_user_id = ${input.ownerUserId}::uuid AND archived_at IS NULL
1455
+ `;
1456
+ if (!existing) throw new AiConversationCreationError("unavailable");
1457
+ return rowToConversation(existing);
1458
+ }
1459
+ const conversation = await insert(tx);
1460
+ await tx`UPDATE ai.conversation_creations SET conversation_id = ${conversation.id}::uuid
1461
+ WHERE owner_user_id = ${input.ownerUserId}::uuid AND request_id = ${input.requestId}::uuid`;
1462
+ return conversation;
1463
+ });
1359
1464
  },
1360
1465
 
1361
1466
  forkConversation: async (input) => {
@@ -2442,7 +2547,10 @@ export const aiConversations: AiConversationService = {
2442
2547
  // summaries are hidden. The active summary sorts after archived rows
2443
2548
  // sharing its checkpoint seq, marking where the model context begins.
2444
2549
  const rows = await sql<MessageRow[]>`
2445
- SELECT *
2550
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2551
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2552
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2553
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
2446
2554
  FROM ai.messages
2447
2555
  WHERE conversation_id = ${input.conversationId}
2448
2556
  AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
@@ -2467,7 +2575,10 @@ export const aiConversations: AiConversationService = {
2467
2575
  ORDER BY seq DESC
2468
2576
  LIMIT ${limit}
2469
2577
  )
2470
- SELECT *
2578
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2579
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2580
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2581
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
2471
2582
  FROM ai.messages
2472
2583
  WHERE conversation_id = ${input.conversationId}
2473
2584
  AND seq IN (SELECT seq FROM page_seqs)
@@ -2496,7 +2607,10 @@ export const aiConversations: AiConversationService = {
2496
2607
  const pattern = `%${query.toLowerCase()}%`;
2497
2608
  const rows = await sql<(MessageRow & { page_rank: number | string })[]>`
2498
2609
  WITH matching AS (
2499
- SELECT *, DENSE_RANK() OVER (ORDER BY seq DESC) AS page_rank
2610
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2611
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2612
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2613
+ ${compactReviewMetaSql(todoMetaJson())} AS meta, DENSE_RANK() OVER (ORDER BY seq DESC) AS page_rank
2500
2614
  FROM ai.messages
2501
2615
  WHERE conversation_id = ${input.conversationId}::uuid
2502
2616
  AND role <> 'tool_result'
@@ -2620,7 +2734,10 @@ export const aiConversations: AiConversationService = {
2620
2734
 
2621
2735
  listContextMessages: async (input) => {
2622
2736
  const rows = await sql<MessageRow[]>`
2623
- SELECT *
2737
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2738
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2739
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2740
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
2624
2741
  FROM ai.messages
2625
2742
  WHERE conversation_id = ${input.conversationId}
2626
2743
  AND compacted_at IS NULL
@@ -2631,7 +2748,10 @@ export const aiConversations: AiConversationService = {
2631
2748
 
2632
2749
  listTurnMessages: async (input) => {
2633
2750
  const rows = await sql<MessageRow[]>`
2634
- SELECT *
2751
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
2752
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
2753
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
2754
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
2635
2755
  FROM (SELECT * FROM ai.messages UNION ALL SELECT * FROM ai.task_messages) messages
2636
2756
  WHERE conversation_id = ${input.conversationId}
2637
2757
  AND loop_id = ${input.loopId}
@@ -3042,7 +3162,7 @@ export const aiConversations: AiConversationService = {
3042
3162
  const [waits, metadata] = await Promise.all([
3043
3163
  activeTurnWaits(rows[0].id),
3044
3164
  sql<{ tool_reviews: Record<string, AiStoredActionReview> | null; tool_checks: Record<string, AiActionCheck> | null }[]>`
3045
- SELECT meta->'toolReviews' AS tool_reviews, meta->'toolChecks' AS tool_checks
3165
+ SELECT ${compactReviewMapSql(sql`meta->'toolReviews'`)} AS tool_reviews, meta->'toolChecks' AS tool_checks
3046
3166
  FROM (
3047
3167
  SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
3048
3168
  UNION ALL
@@ -3062,7 +3182,8 @@ export const aiConversations: AiConversationService = {
3062
3182
  turn: rowToTurn(rows[0]),
3063
3183
  liveBlocks: rowToLiveBlocks(rows[0]).map((block) => {
3064
3184
  if (block.kind !== "tool") return block;
3065
- const review = reviews.get(block.callId);
3185
+ const snapshot = reviews.get(block.callId);
3186
+ const review = snapshot ? compactStoredReview(snapshot, { ...input, turnId: rows[0]!.id, callId: block.callId }) : undefined;
3066
3187
  const check = checks.get(block.callId);
3067
3188
  return { ...block, ...(review ? { review } : {}), ...(check ? { check } : {}) };
3068
3189
  }),
@@ -3546,7 +3667,7 @@ export const aiConversations: AiConversationService = {
3546
3667
  ${input.name},
3547
3668
  ${JSON.stringify(input.args ?? null)}::text::jsonb,
3548
3669
  ${input.message ?? null},
3549
- ${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
3670
+ ${input.review ? serializeReviewSnapshot("sentence" in input.review ? storedReviewWithContent(input.review, input) : input.review) : null}::text::jsonb,
3550
3671
  ${input.approvalScope},
3551
3672
  ${input.allowAlways},
3552
3673
  ${input.allowChat ?? input.allowAlways},
@@ -3574,7 +3695,8 @@ export const aiConversations: AiConversationService = {
3574
3695
 
3575
3696
  listPendingTurnActions: async (input) => {
3576
3697
  const rows = await sql<PendingActionRow[]>`
3577
- SELECT *
3698
+ SELECT approval_id, turn_id, conversation_id, call_id, kind, status, tool_name, args, message,
3699
+ review - 'content' AS review, approval_scope, allow_always, allow_chat, remember_tool_name, frontend_mode, resolved_event
3578
3700
  FROM ai.pending_actions
3579
3701
  WHERE conversation_id = ${input.conversationId}
3580
3702
  AND turn_id = ${input.turnId}
@@ -3586,7 +3708,8 @@ export const aiConversations: AiConversationService = {
3586
3708
 
3587
3709
  getPendingTurnAction: async (input) => {
3588
3710
  const rows = await sql<PendingActionRow[]>`
3589
- SELECT *
3711
+ SELECT approval_id, turn_id, conversation_id, call_id, kind, status, tool_name, args, message,
3712
+ review - 'content' AS review, approval_scope, allow_always, allow_chat, remember_tool_name, frontend_mode, resolved_event
3590
3713
  FROM ai.pending_actions
3591
3714
  WHERE conversation_id = ${input.conversationId}
3592
3715
  AND turn_id = ${input.turnId}
@@ -3610,7 +3733,7 @@ export const aiConversations: AiConversationService = {
3610
3733
  listTurnToolReviews: async (input) => {
3611
3734
  // Only the review metadata, never the messages themselves, which can carry large tool results.
3612
3735
  const rows = await sql<{ tool_reviews: Record<string, AiStoredActionReview> | null }[]>`
3613
- SELECT meta->'toolReviews' AS tool_reviews
3736
+ SELECT ${compactReviewMapSql(sql`meta->'toolReviews'`)} AS tool_reviews
3614
3737
  FROM (
3615
3738
  SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
3616
3739
  UNION ALL
@@ -3622,12 +3745,19 @@ export const aiConversations: AiConversationService = {
3622
3745
  AND meta ? 'toolReviews'
3623
3746
  ORDER BY seq ASC
3624
3747
  `;
3625
- return new Map(rows.flatMap((row) => Object.entries(row.tool_reviews ?? {})));
3748
+ return new Map(
3749
+ rows.flatMap((row) =>
3750
+ Object.entries(row.tool_reviews ?? {}).map(
3751
+ ([callId, review]) => [callId, compactStoredReview(review, { ...input, callId })] as const,
3752
+ ),
3753
+ ),
3754
+ );
3626
3755
  },
3627
3756
 
3628
3757
  listPendingActionRecords: async (input) => {
3629
3758
  const rows = await sql<PendingActionRow[]>`
3630
- SELECT *
3759
+ SELECT approval_id, turn_id, conversation_id, call_id, kind, status, tool_name, args, message,
3760
+ review - 'content' AS review, approval_scope, allow_always, allow_chat, remember_tool_name, frontend_mode, resolved_event
3631
3761
  FROM ai.pending_actions
3632
3762
  WHERE conversation_id = ${input.conversationId}
3633
3763
  AND turn_id = ${input.turnId}
@@ -3639,7 +3769,8 @@ export const aiConversations: AiConversationService = {
3639
3769
 
3640
3770
  listResolvedPendingActions: async (input) => {
3641
3771
  const rows = await sql<PendingActionRow[]>`
3642
- SELECT *
3772
+ SELECT approval_id, turn_id, conversation_id, call_id, kind, status, tool_name, args, message,
3773
+ review - 'content' AS review, approval_scope, allow_always, allow_chat, remember_tool_name, frontend_mode, resolved_event
3643
3774
  FROM ai.pending_actions
3644
3775
  WHERE conversation_id = ${input.conversationId}
3645
3776
  AND turn_id = ${input.turnId}
@@ -3803,7 +3934,11 @@ export const aiConversations: AiConversationService = {
3803
3934
  const rows = input.turnId
3804
3935
  ? (
3805
3936
  await sql<MessageRow[]>`
3806
- SELECT * FROM ai.messages
3937
+ SELECT id, short_id, conversation_id, seq, kind, message, loop_id, model_profile_id, provider_model,
3938
+ usage, stop_reason, loop_aggregate, loop_done_reason, compacted_at, created_at,
3939
+ feedback_rating, feedback_reasons, feedback_comment, feedback_updated_at,
3940
+ ${compactReviewMetaSql(todoMetaJson())} AS meta
3941
+ FROM ai.messages
3807
3942
  WHERE conversation_id = ${input.conversationId}::uuid AND compacted_at IS NULL
3808
3943
  AND ${visibleToTurn(input.turnId)}
3809
3944
  ORDER BY seq ASC
package/src/ai/types.ts CHANGED
@@ -15,7 +15,7 @@ import type { z } from "zod";
15
15
  import type { CapabilitySentenceField } from "../_internal/capability-sentences";
16
16
  import type {
17
17
  CapabilityActionCallReview,
18
- CapabilityActionReviewData,
18
+ CapabilityActionReviewPresentation,
19
19
  CapabilityActionSentences,
20
20
  CloudResourceRef,
21
21
  } from "../contracts/capabilities";
@@ -457,7 +457,7 @@ export type AiPendingTurnAction =
457
457
  args: unknown;
458
458
  message?: string;
459
459
  /** Stored review, including the declared ask reason for new-shape reviews. */
460
- review?: CapabilityActionReviewData;
460
+ review?: CapabilityActionReviewPresentation;
461
461
  /** Owning application, with its registry name in the turn's request locale. */
462
462
  app?: { id: string; name: string };
463
463
  /** The chat's current title, when known. */
@@ -499,7 +499,7 @@ export type AiPendingTurnActionRecord = {
499
499
  name: string;
500
500
  args: unknown;
501
501
  message?: string;
502
- review?: CapabilityActionReviewData;
502
+ review?: CapabilityActionReviewPresentation;
503
503
  approvalScope: string;
504
504
  allowAlways: boolean;
505
505
  /** Whether the approval can be remembered for this chat; defaults to `allowAlways`. */
@@ -736,7 +736,14 @@ export type AiTurnSweepResult = {
736
736
  };
737
737
 
738
738
  export type AiConversationService = {
739
+ getDraftSubmission(input: {
740
+ conversationId: string;
741
+ ownerUserId: string;
742
+ revision: number;
743
+ }): Promise<{ messageId: string; turnId: string | null } | null>;
739
744
  createConversation(input: {
745
+ /** Stable identity for one logical creation; replay never creates a replacement. */
746
+ requestId?: string;
740
747
  ownerUserId: string;
741
748
  title?: string;
742
749
  description?: string;
package/src/ai/usage.ts CHANGED
@@ -317,8 +317,8 @@ export const aiUsage = {
317
317
  : field === "appId"
318
318
  ? sql`e.app_id`
319
319
  : sql`e.task`;
320
- const label = field === "userId" ? sql`COALESCE(NULLIF(u.display_name,''),u.uid,sa.name,e.user_id::text)` : key;
321
- return sql<{ id: string; label: string }[]>`SELECT DISTINCT ${key} AS id,${label} AS label FROM (${events(since, until)}) e
320
+ const label = field === "userId" ? sql`COALESCE(NULLIF(u.display_name,''),u.uid,sa.name)` : key;
321
+ return sql<{ id: string; label: string | null }[]>`SELECT DISTINCT ${key} AS id,${label} AS label FROM (${events(since, until)}) e
322
322
  LEFT JOIN auth.users u ON u.id=e.user_id LEFT JOIN auth.service_accounts sa ON sa.id=e.user_id WHERE ${key} IS NOT NULL
323
323
  AND (position(lower(${search}) in lower(${label}))>0 OR ${key}=${search} OR (${field === "userId"} AND position(lower(${search}) in lower(COALESCE(u.uid,'')))>0))
324
324
  ORDER BY label,id LIMIT ${query.perPage}`;
@@ -39,6 +39,7 @@ import { GotenbergRenderError, testGotenberg } from "../services/pdf";
39
39
  import * as settings from "../services/settings";
40
40
  import { SETTINGS_MAP, validateSettingValue } from "../services/settings/defaults";
41
41
 
42
+ import { assertGeoBudgetPair, GeoBudgetSettingError } from "../services/settings/geo-budget";
42
43
  import { invalidateSettingsCacheForAdmin } from "../services/settings/store";
43
44
 
44
45
  const BulkSaveSchema = z.union([
@@ -67,6 +68,9 @@ const settingsNotSavedMessage = (locale: string): string =>
67
68
  locale.startsWith("de") ? "Die Einstellungen wurden nicht gespeichert." : "The settings were not saved.";
68
69
 
69
70
  const isKnownSetting = (key: string): boolean => SETTINGS_MAP.has(key);
71
+ // The Geoapify key is written only where Cloud verifies it with the provider first, so a refused key never replaces a working one.
72
+ const GEO_KEY = "geo.api_key";
73
+ const GEO_KEY_DEDICATED = "Set or remove the Geoapify key with /api/admin/core/geo/key, which verifies it first.";
70
74
 
71
75
  const AI_PROFILES_KEY = "ai.model_profiles_json";
72
76
  const AI_ENABLED_KEY = "ai.enabled";
@@ -333,7 +337,10 @@ const app = new Hono<AuthContext>()
333
337
  return c.json({ ok: true, bytes: result.bytes, contentType: result.contentType });
334
338
  } catch (error) {
335
339
  if (error instanceof GotenbergRenderError) {
336
- return c.json({ message: error.message, code: error.code, status: error.status }, error.code === "not_configured" ? 400 : 502);
340
+ return c.json(
341
+ { message: error.message, code: error.code, status: error.status },
342
+ error.code === "busy" ? 503 : error.code === "not_configured" ? 400 : 502,
343
+ );
337
344
  }
338
345
  return c.json({ message: "Failed to test Gotenberg PDF rendering" }, 500);
339
346
  }
@@ -384,6 +391,7 @@ const app = new Hono<AuthContext>()
384
391
  ownership[key] = `Setting "${key}" cannot be updated and reset in the same save`;
385
392
  }
386
393
  }
394
+ if (keys.includes(GEO_KEY)) ownership[GEO_KEY] = GEO_KEY_DEDICATED;
387
395
  if (Object.keys(ownership).length > 0) {
388
396
  return c.json({ message: "Invalid keys", errors: ownership }, 400);
389
397
  }
@@ -469,11 +477,12 @@ const app = new Hono<AuthContext>()
469
477
  // Every settings and AI credential write runs on the transaction's own
470
478
  // connection, so a failure rolls the whole save back.
471
479
  await sql.begin(async (tx) => {
480
+ await assertGeoBudgetPair(tx, validatedValues, resets);
472
481
  for (const [key, value] of Object.entries(validatedValues)) {
473
482
  try {
474
483
  await settings.set(key, value, tx, { replaceUnreadable: replace.has(key) });
475
484
  } catch (error) {
476
- fieldErrors[key] = `Failed to update ${key}`;
485
+ fieldErrors[key] = `Failed to save ${key}`;
477
486
  throw error;
478
487
  }
479
488
  }
@@ -514,6 +523,7 @@ const app = new Hono<AuthContext>()
514
523
  }
515
524
  });
516
525
  } catch (error) {
526
+ if (error instanceof GeoBudgetSettingError) return error.getResponse();
517
527
  if (error instanceof settings.SettingUnreadableError) {
518
528
  return c.json(
519
529
  {
@@ -553,6 +563,7 @@ const app = new Hono<AuthContext>()
553
563
  if (!isKnownSetting(key)) {
554
564
  return c.json({ message: `Unknown setting "${key}"` }, 400);
555
565
  }
566
+ if (key === GEO_KEY) return c.json({ message: GEO_KEY_DEDICATED, errors: { [GEO_KEY]: GEO_KEY_DEDICATED } }, 400);
556
567
 
557
568
  let aiPlan: AiSettingsMutationPlan;
558
569
  try {
@@ -567,6 +578,7 @@ const app = new Hono<AuthContext>()
567
578
 
568
579
  try {
569
580
  await sql.begin(async (tx) => {
581
+ await assertGeoBudgetPair(tx, {}, [key]);
570
582
  await settings.remove(key, tx);
571
583
  if (key.startsWith("user.category."))
572
584
  await audit.record(
@@ -588,6 +600,7 @@ const app = new Hono<AuthContext>()
588
600
  }
589
601
  });
590
602
  } catch (error) {
603
+ if (error instanceof GeoBudgetSettingError || error instanceof settings.SettingUnreadableError) return error.getResponse();
591
604
  console.error(`[settings] failed to reset "${key}"`, error);
592
605
  return c.json({ message: "Reset failed" }, 500);
593
606
  }
@@ -0,0 +1,54 @@
1
+ import { Hono, type MiddlewareHandler } from "hono";
2
+ import { describeRoute } from "hono-openapi";
3
+ import { GeoBudgetInputSchema, GeoKeyInputSchema } from "../contracts/geo-admin";
4
+ import { type AuthContext, auth, v } from "../server";
5
+ import { GeoAdminError, geoAdmin } from "../services/geo/admin";
6
+
7
+ export const createAdminGeoRoutes = (
8
+ authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
9
+ service: Pick<typeof geoAdmin, "status" | "usage" | "setKey" | "removeKey" | "saveBudget"> = geoAdmin,
10
+ ) =>
11
+ new Hono<AuthContext>()
12
+ .use(authenticate)
13
+ .onError((error, c) => {
14
+ if (error instanceof GeoAdminError) return c.json(error.body, error.status);
15
+ if (c.req.raw.signal.aborted) return new Response(null, { status: 499 });
16
+ throw error;
17
+ })
18
+ .get(
19
+ "/",
20
+ describeRoute({ tags: ["Places and maps"], summary: "Show the Geoapify key state, plan and budgets" }),
21
+ auth.requireOAuthScope("read", "admin"),
22
+ async (c) => c.json(await service.status(c.get("user"))),
23
+ )
24
+ .get(
25
+ "/usage",
26
+ describeRoute({ tags: ["Places and maps"], summary: "Show this month's and today's geo credits with shares per function" }),
27
+ auth.requireOAuthScope("read", "admin"),
28
+ async (c) => c.json(await service.usage(c.get("user"))),
29
+ )
30
+ .put(
31
+ "/key",
32
+ describeRoute({
33
+ tags: ["Places and maps"],
34
+ summary: "Verify a Geoapify key with one provider lookup, then store it; a refused key changes nothing",
35
+ }),
36
+ auth.requireOAuthScope("write", "admin"),
37
+ v("json", GeoKeyInputSchema),
38
+ async (c) => c.json(await service.setKey(c.get("user"), c.req.valid("json").apiKey, c.req.raw.signal)),
39
+ )
40
+ .delete(
41
+ "/key",
42
+ describeRoute({ tags: ["Places and maps"], summary: "Remove the Geoapify key; places and maps turn off for everyone" }),
43
+ auth.requireOAuthScope("write", "admin"),
44
+ async (c) => c.json(await service.removeKey(c.get("user"))),
45
+ )
46
+ .put(
47
+ "/budget",
48
+ describeRoute({ tags: ["Places and maps"], summary: "Save the plan, the monthly budget and the daily limit per person" }),
49
+ auth.requireOAuthScope("write", "admin"),
50
+ v("json", GeoBudgetInputSchema),
51
+ async (c) => c.json(await service.saveBudget(c.get("user"), c.req.valid("json"))),
52
+ );
53
+
54
+ export default createAdminGeoRoutes();
@@ -6,7 +6,9 @@ import { type AuthContext, auth, v } from "../server";
6
6
  import { PosixError, posix } from "../services/accounts/posix";
7
7
 
8
8
  const Id = z.object({ id: z.uuid() });
9
- const Configure = z.object({ config: LinuxIdentityConfigurationSchema, rangeReserved: z.boolean() }).strict();
9
+ const Configure = z
10
+ .object({ config: LinuxIdentityConfigurationSchema, rangeReserved: z.boolean(), replaceUnreadable: z.boolean().optional() })
11
+ .strict();
10
12
 
11
13
  export const createAdminLinuxIdentityRoutes = (
12
14
  authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
@@ -32,12 +34,13 @@ export const createAdminLinuxIdentityRoutes = (
32
34
  describeRoute({
33
35
  tags: ["Linux identities"],
34
36
  summary: "Configure automatic local Linux identity assignment; does not enable computer login",
37
+ description: "Unreadable stored configuration returns 409 setting_unreadable unless replaceUnreadable is explicitly true.",
35
38
  }),
36
39
  v("json", Configure),
37
40
  async (c) => {
38
- const { config, rangeReserved } = c.req.valid("json");
41
+ const { config, rangeReserved, replaceUnreadable } = c.req.valid("json");
39
42
  if (config.enabled && !rangeReserved) throw new PosixError("range_confirmation_required", 400);
40
- return c.json(await service.configure(c.get("user"), config));
43
+ return c.json(await service.configure(c.get("user"), config, { replaceUnreadable }));
41
44
  },
42
45
  )
43
46
  .get(