@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
@@ -1,3 +1,4 @@
1
+ import { capabilityReviewView } from "../../_internal/capability-reviews";
1
2
  import {
2
3
  APP_APPROVAL_LIMITS,
3
4
  type AppActionSummary,
@@ -5,7 +6,7 @@ import {
5
6
  appActionDigest,
6
7
  APP_ACTION_LIMITS as limits,
7
8
  } from "../../contracts/app-approval";
8
- import { CapabilityActionReviewSchema } from "../../contracts/capabilities";
9
+ import { CapabilityActionReviewDataSchema } from "../../contracts/capabilities";
9
10
  import { DEFAULT_LOCALE } from "../../server/locale";
10
11
  import type { PendingApprovalRow } from "./list";
11
12
 
@@ -21,6 +22,8 @@ const cut = (value: string, limit: number): string => {
21
22
  return points.length > limit ? `${points.slice(0, limit - 1).join("")}…` : value;
22
23
  };
23
24
 
25
+ export const appApprovalText = (value: string, limit: number): string => cut(plain(value), limit);
26
+
24
27
  /** A deterministic, plain-text snapshot; the digest binds the device's choice to everything it shows. */
25
28
  export const appActionView = async (
26
29
  row: PendingApprovalRow,
@@ -45,7 +48,7 @@ export const appActionView = async (
45
48
  const first = lines[0] ?? plain(row.action);
46
49
  const prefix = `${appName}: `;
47
50
  const sentence = first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
48
- const review = CapabilityActionReviewSchema.safeParse(row.details);
51
+ const review = CapabilityActionReviewDataSchema.safeParse(row.details);
49
52
  let message: string | null;
50
53
  const details: AppActionView["details"] = [];
51
54
  const detail = (label: string, value: string, format?: "date" | "date-time") => {
@@ -60,11 +63,12 @@ export const appActionView = async (
60
63
  const messages: string[] = [];
61
64
  const reviewLines = new Set<string>();
62
65
  if (review.success) {
63
- const cleaned = plain(review.data.message);
66
+ const view = capabilityReviewView(review.data, { timeZone: "UTC" });
67
+ const cleaned = plain(view.message);
64
68
  if (cleaned !== sentence) messages.push(cleaned);
65
69
  reviewLines.add(cleaned);
66
- for (const line of review.data.message.split(/\r?\n/)) reviewLines.add(plain(line));
67
- for (const field of review.data.details ?? [])
70
+ for (const line of view.message.split(/\r?\n/)) reviewLines.add(plain(line));
71
+ for (const field of view.details ?? [])
68
72
  detail(field.label, field.value, field.format === "date" || field.format === "date-time" ? field.format : undefined);
69
73
  }
70
74
  for (const line of lines.slice(1)) {
@@ -1,7 +1,7 @@
1
1
  import { type SQL, sql } from "bun";
2
2
  import { capabilityRequestHash } from "../../capabilities/claims";
3
3
  import { APPROVAL_ACP_CLIENT } from "../../contracts/approvals";
4
- import type { CapabilityActionReview } from "../../contracts/capabilities";
4
+ import type { CapabilityActionReviewData } from "../../contracts/capabilities";
5
5
  import { isAppSessionActor } from "../../server/actor";
6
6
  import type { RequestActor, RequestCredentialKind } from "../../server/middleware/auth";
7
7
  import { audit } from "../audit";
@@ -23,7 +23,8 @@ export type ApprovalAssurance = "standard" | "confirm";
23
23
  */
24
24
  export const approvalAssurance = (rememberable: boolean): ApprovalAssurance => (rememberable ? "standard" : "confirm");
25
25
  export const approvalArgsDigest = (input: unknown): string => capabilityRequestHash(input);
26
- export const approvalReviewDigest = (review: CapabilityActionReview | null | undefined): string => capabilityRequestHash(review ?? null);
26
+ export const approvalReviewDigest = (review: CapabilityActionReviewData | null | undefined): string =>
27
+ capabilityRequestHash(review ?? null);
27
28
  export const approvalDecisionChannel = (input: {
28
29
  credentialKind?: RequestCredentialKind;
29
30
  actor?: RequestActor;
@@ -50,7 +51,7 @@ export type ApprovalRequestInput = {
50
51
  approverUserId: string;
51
52
  assurance: ApprovalAssurance;
52
53
  sentence: string;
53
- review?: CapabilityActionReview | null;
54
+ review?: CapabilityActionReviewData | null;
54
55
  input: unknown;
55
56
  expiresInMs: number;
56
57
  };
@@ -67,7 +68,7 @@ export type ApprovalRequest = {
67
68
  approverUserId: string;
68
69
  assurance: ApprovalAssurance;
69
70
  sentence: string;
70
- details: CapabilityActionReview | null;
71
+ details: CapabilityActionReviewData | null;
71
72
  argsDigest: string;
72
73
  reviewDigest: string;
73
74
  status: ApprovalStatus;
@@ -211,7 +212,7 @@ export type ApprovalConsumeResult =
211
212
  | { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" | "REVOKED" };
212
213
  export const consume = async (
213
214
  id: string,
214
- input: { input: unknown; review: CapabilityActionReview | null; requester: { kind: ApprovalRequesterKind; ref: string } },
215
+ input: { input: unknown; review: CapabilityActionReviewData | null; requester: { kind: ApprovalRequesterKind; ref: string } },
215
216
  db?: SQL,
216
217
  ): Promise<ApprovalConsumeResult> => {
217
218
  const argsDigest = approvalArgsDigest(input.input),
@@ -1,9 +1,10 @@
1
1
  import { sql } from "bun";
2
+ import { type AiStoredActionReview, storedActionReview } from "../../ai/action-review";
2
3
  import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsRememberedApprovals } from "../../ai/approvals";
3
4
  import type { AiTurnRunConfig } from "../../ai/types";
4
5
  import type { RuntimeAppMeta } from "../../contracts/app";
5
6
  import { APPROVALS_PAGE_SIZE, type ApprovalCounts, type PendingApproval } from "../../contracts/approvals";
6
- import { CapabilityActionReviewSchema } from "../../contracts/capabilities";
7
+ import { type CapabilityActionPolicy, CapabilityActionReviewDataSchema } from "../../contracts/capabilities";
7
8
  import type { ApprovalAssurance } from ".";
8
9
  import { decodeApprovalCursor, encodeApprovalCursor } from "./cursor";
9
10
 
@@ -18,6 +19,7 @@ export type PendingApprovalRow = {
18
19
  assurance: ApprovalAssurance;
19
20
  sentence: string;
20
21
  details: unknown;
22
+ storedReview: AiStoredActionReview | null;
21
23
  createdAt: Date;
22
24
  expiresAt: Date;
23
25
  cursorCreatedAt: string;
@@ -61,10 +63,21 @@ export const listPending = async (input: {
61
63
  to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "cursorCreatedAt",
62
64
  question.short_id AS "conversationShortId", question.title AS "conversationTitle",
63
65
  question.allow_chat AS "allowChat", question.allow_always AS "allowAlways",
64
- question.remember_tool_name AS "rememberToolName", question.tool_name AS "toolName", question.run_config AS "runConfig"
66
+ question.remember_tool_name AS "rememberToolName", question.tool_name AS "toolName", question.run_config AS "runConfig",
67
+ question.stored_review AS "storedReview"
65
68
  FROM open r JOIN LATERAL (
66
- SELECT c.short_id, c.title, p.allow_chat, p.allow_always, p.remember_tool_name, p.tool_name, t.run_config
69
+ SELECT c.short_id, c.title, p.allow_chat, p.allow_always, p.remember_tool_name, p.tool_name, t.run_config,
70
+ (SELECT m.meta->'toolReviews'->review.call_id FROM (
71
+ SELECT meta, conversation_id, loop_id, role, seq FROM ai.messages
72
+ UNION ALL
73
+ SELECT meta, conversation_id, loop_id, role, seq FROM ai.task_messages
74
+ ) m WHERE m.conversation_id=p.conversation_id AND m.loop_id=p.turn_id::text AND m.role='assistant'
75
+ AND m.meta->'toolReviews'->review.call_id IS NOT NULL
76
+ ORDER BY m.seq DESC LIMIT 1) AS stored_review
67
77
  FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id JOIN ai.turns t ON t.id=p.turn_id
78
+ -- A capability's own approval waits as "<call>-approval-<n>"; its review is stored under the call itself.
79
+ CROSS JOIN LATERAL (SELECT CASE WHEN p.kind = 'custom_approval'
80
+ THEN COALESCE(substring(p.call_id FROM '^(.*)-approval-[0-9]+$'), p.call_id) ELSE p.call_id END AS call_id) review
68
81
  WHERE p.approval_id=r.id AND p.status='pending' ORDER BY p.created_at DESC, p.id DESC LIMIT 1
69
82
  ) question ON true
70
83
  WHERE ${matches(input.appId, input.link)}
@@ -93,9 +106,17 @@ export const countPending = async (input: { approverUserId: string }): Promise<A
93
106
  export const presentPendingApproval = (
94
107
  row: PendingApprovalRow,
95
108
  apps: readonly Pick<RuntimeAppMeta, "id" | "name" | "icon">[],
109
+ policy?: CapabilityActionPolicy,
96
110
  ): PendingApproval => {
97
111
  const app = apps.find((entry) => entry.id === row.appId);
98
- const review = CapabilityActionReviewSchema.nullable().safeParse(row.details);
112
+ const review = CapabilityActionReviewDataSchema.nullable().safeParse(row.details);
113
+ const originalReview = review.success ? review.data : null;
114
+ const displayReview =
115
+ originalReview && "sentence" in originalReview
116
+ ? row.storedReview && "sentence" in row.storedReview
117
+ ? row.storedReview
118
+ : storedActionReview(originalReview, policy)
119
+ : originalReview;
99
120
  const remember = row.assurance === "standard" && aiTurnAllowsRememberedApprovals(row.runConfig);
100
121
  return {
101
122
  id: row.id,
@@ -103,7 +124,7 @@ export const presentPendingApproval = (
103
124
  action: row.action,
104
125
  assurance: row.assurance,
105
126
  sentence: row.sentence,
106
- review: review.success ? review.data : null,
127
+ review: displayReview && "legacy" in displayReview ? originalReview : displayReview,
107
128
  requester: {
108
129
  kind: "chat",
109
130
  appId: row.requesterAppId,
@@ -1,9 +1,12 @@
1
+ import type { NewBrowserSignInNotifier } from "../known-browsers";
2
+
1
3
  export type AuthNotificationDeliveryResult = {
2
4
  id: string;
3
5
  status: "queued" | "delivered" | "suppressed" | "error";
4
6
  };
5
7
 
6
8
  export type AuthNotificationSender = {
9
+ sendNewBrowserSignIn: NewBrowserSignInNotifier;
7
10
  sendMagicLink: (input: { email: string; token: string; magicLink: string; locale?: string }) => Promise<AuthNotificationDeliveryResult>;
8
11
  sendIpaLoginHint: (input: { email: string; loginUrl: string; locale?: string }) => Promise<AuthNotificationDeliveryResult>;
9
12
  sendPasswordReset: (input: { email: string; resetLink: string; locale?: string }) => Promise<AuthNotificationDeliveryResult>;
@@ -0,0 +1,164 @@
1
+ import { sql } from "bun";
2
+ import { HTTPException } from "hono/http-exception";
3
+ import {
4
+ DBIP_ATTRIBUTION,
5
+ GEOIP_MAX_DATABASE_BYTES,
6
+ GEOIP_MAX_REQUEST_BYTES,
7
+ type GeoIpAdminDatabase,
8
+ type GeoIpAdminStatus,
9
+ type GeoIpKind,
10
+ } from "../../contracts/geoip";
11
+ import { createGeoIp, type GeoIp, type GeoIpDatabaseInfo, getGeoIp } from "../../server/request-context/geoip";
12
+ import { audit } from "../audit";
13
+ import { GeoIpError, parseGeoIpUpload, readGeoIpUpload } from "./upload";
14
+
15
+ export { GEOIP_MAX_DATABASE_BYTES, GEOIP_MAX_REQUEST_BYTES } from "../../contracts/geoip";
16
+ export { GeoIpError } from "./upload";
17
+
18
+ type Actor = { id: string; roles: readonly string[]; uid?: string; provider?: string };
19
+ export type GeoIpUploadInfo = {
20
+ kind: GeoIpKind;
21
+ sha256: string;
22
+ size: number;
23
+ database_type: string;
24
+ build_epoch: Date;
25
+ description: string | null;
26
+ uploaded_at: Date;
27
+ uploaded_by: string | null;
28
+ uploader_name: string | null;
29
+ };
30
+ const requireAdmin = (actor: Actor) => {
31
+ if (!actor.roles.includes("admin")) throw new HTTPException(403, { message: "Administrator access required." });
32
+ };
33
+ let bundledInfo: ReturnType<GeoIp["status"]> | undefined;
34
+ const getBundledInfo = () => (bundledInfo ??= createGeoIp().status());
35
+ const isDbIp = (value: string) => /db[\s-]?ip/i.test(value);
36
+
37
+ export const geoIpAdminStatus = (
38
+ rows: GeoIpUploadInfo[],
39
+ bundled: { city: GeoIpDatabaseInfo | null; asn: GeoIpDatabaseInfo | null },
40
+ now: number,
41
+ ): GeoIpAdminStatus => {
42
+ const database = (kind: GeoIpKind): GeoIpAdminDatabase => {
43
+ const row = rows.find((row) => row.kind === kind);
44
+ const info = bundled[kind];
45
+ const buildDate = row?.build_epoch ?? info?.buildDate;
46
+ return {
47
+ kind,
48
+ source: row ? "uploaded" : (info?.source ?? null),
49
+ bundled: info !== null,
50
+ databaseType: row?.database_type ?? info?.databaseType ?? null,
51
+ buildDate: buildDate?.toISOString() ?? null,
52
+ stale: buildDate !== undefined && now - buildDate.getTime() > 183 * 24 * 60 * 60 * 1000,
53
+ dbIp: row ? isDbIp(`${row.database_type} ${row.description ?? ""}`) : info !== null,
54
+ upload: row
55
+ ? {
56
+ sha256: row.sha256,
57
+ size: row.size,
58
+ uploadedAt: row.uploaded_at.toISOString(),
59
+ uploadedBy: row.uploaded_by ? { id: row.uploaded_by, name: row.uploader_name ?? row.uploaded_by } : null,
60
+ }
61
+ : null,
62
+ };
63
+ };
64
+ const databases: GeoIpAdminStatus["databases"] = [database("city"), database("asn")];
65
+ return {
66
+ databases,
67
+ attribution: databases.some((item) => item.dbIp) ? DBIP_ATTRIBUTION : null,
68
+ limits: { maxDatabaseBytes: GEOIP_MAX_DATABASE_BYTES, maxRequestBytes: GEOIP_MAX_REQUEST_BYTES },
69
+ };
70
+ };
71
+
72
+ let uploading = false;
73
+ // Each write and its reader install run one after another in this process, so the local reader follows the
74
+ // commit order; other instances follow through their refresh.
75
+ let writes: Promise<unknown> = Promise.resolve();
76
+ const serialized = <T>(write: () => Promise<T>): Promise<T> => {
77
+ const run = writes.then(write, write);
78
+ writes = run.catch(() => {});
79
+ return run;
80
+ };
81
+ export const createGeoIpService = (options: { db?: typeof sql; reader?: GeoIp; bundled?: () => ReturnType<GeoIp["status"]> } = {}) => {
82
+ const db = options.db ?? sql;
83
+ const reader = () => options.reader ?? getGeoIp();
84
+ const status = async (actor: Actor, config: { now?: () => number } = {}): Promise<GeoIpAdminStatus> => {
85
+ requireAdmin(actor);
86
+ const rows = await db<GeoIpUploadInfo[]>`
87
+ SELECT d.kind, d.sha256, (d.metadata->>'size')::int AS size, d.database_type, d.build_epoch,
88
+ d.metadata->'description'->>'en' AS description, d.uploaded_at, d.uploaded_by,
89
+ COALESCE(NULLIF(u.display_name, ''), u.uid) AS uploader_name
90
+ FROM geoip.databases d LEFT JOIN auth.users u ON u.id = d.uploaded_by`;
91
+ return geoIpAdminStatus(rows, (options.bundled ?? getBundledInfo)(), (config.now ?? Date.now)());
92
+ };
93
+ return {
94
+ status,
95
+ async upload(actor: Actor, body: ReadableStream<Uint8Array>, config: { contentLength?: number; signal?: AbortSignal }) {
96
+ requireAdmin(actor);
97
+ if (uploading) throw new GeoIpError("geoip_upload_busy");
98
+ uploading = true;
99
+ try {
100
+ const bytes = await readGeoIpUpload(body, config.contentLength);
101
+ const { kind, database, metadata } = parseGeoIpUpload(bytes);
102
+ const sha256 = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
103
+ database.info.sha256 = sha256;
104
+ config.signal?.throwIfAborted();
105
+ await serialized(async () => {
106
+ await db.begin(async (tx) => {
107
+ await tx`
108
+ INSERT INTO geoip.databases (kind, data, sha256, database_type, build_epoch, metadata, uploaded_by)
109
+ VALUES (${kind}, ${bytes}, ${sha256}, ${metadata.databaseType}, ${metadata.buildEpoch}::timestamptz,
110
+ ${JSON.stringify(metadata)}::jsonb, ${actor.id}::uuid)
111
+ ON CONFLICT (kind) DO UPDATE SET data = EXCLUDED.data, sha256 = EXCLUDED.sha256,
112
+ database_type = EXCLUDED.database_type, build_epoch = EXCLUDED.build_epoch, metadata = EXCLUDED.metadata,
113
+ uploaded_by = EXCLUDED.uploaded_by, uploaded_at = now()`;
114
+ await audit.record(
115
+ {
116
+ action: "core.geoip.upload",
117
+ outcome: "allowed",
118
+ actor: { ...actor, userId: actor.id },
119
+ metadata: { kind, databaseType: metadata.databaseType, buildDate: metadata.buildEpoch, sha256, size: bytes.length },
120
+ },
121
+ tx,
122
+ );
123
+ config.signal?.throwIfAborted();
124
+ });
125
+ reader().install(kind, database);
126
+ });
127
+ return { ...(await status(actor)), kind };
128
+ } finally {
129
+ uploading = false;
130
+ }
131
+ },
132
+ async reset(actor: Actor, kind: GeoIpKind) {
133
+ requireAdmin(actor);
134
+ const changed = await serialized(async () => {
135
+ const deleted = await db.begin(async (tx) => {
136
+ const [row] = await tx<{ sha256: string; database_type: string; build_epoch: Date; size: number }[]>`
137
+ DELETE FROM geoip.databases WHERE kind = ${kind}
138
+ RETURNING sha256, database_type, build_epoch, octet_length(data) AS size`;
139
+ if (!row) return false;
140
+ await audit.record(
141
+ {
142
+ action: "core.geoip.reset",
143
+ outcome: "allowed",
144
+ actor: { ...actor, userId: actor.id },
145
+ metadata: {
146
+ kind,
147
+ databaseType: row.database_type,
148
+ buildDate: row.build_epoch.toISOString(),
149
+ sha256: row.sha256,
150
+ size: row.size,
151
+ },
152
+ },
153
+ tx,
154
+ );
155
+ return true;
156
+ });
157
+ reader().install(kind, null);
158
+ return deleted;
159
+ });
160
+ return { ...(await status(actor)), changed };
161
+ },
162
+ };
163
+ };
164
+ export const geoip = createGeoIpService();
@@ -0,0 +1,30 @@
1
+ import { sql } from "bun";
2
+ import { GEOIP_MAX_DATABASE_BYTES, type GeoIpKind } from "../../contracts/geoip";
3
+ import type { GeoIpUploads } from "../../server/request-context/geoip";
4
+
5
+ const SLICE_BYTES = 8 * 1024 * 1024;
6
+ export const createGeoIpUploads = (db: typeof sql = sql): GeoIpUploads => ({
7
+ async versions() {
8
+ const rows = await db<{ kind: GeoIpKind; sha256: string }[]>`SELECT kind, sha256 FROM geoip.databases`;
9
+ return Object.fromEntries(rows.map((row) => [row.kind, row.sha256]));
10
+ },
11
+ async read(kind, sha256) {
12
+ const [row] = await db<{ size: number }[]>`
13
+ SELECT octet_length(data) AS size FROM geoip.databases WHERE kind = ${kind} AND sha256 = ${sha256}`;
14
+ if (!row) return null;
15
+ if (row.size < 1 || row.size > GEOIP_MAX_DATABASE_BYTES) throw new Error("Invalid GeoIP database size");
16
+ const bytes = Buffer.allocUnsafe(row.size);
17
+ for (let offset = 0; offset < bytes.length; offset += SLICE_BYTES) {
18
+ const length = Math.min(SLICE_BYTES, bytes.length - offset);
19
+ const [slice] = await db<{ data: Uint8Array }[]>`
20
+ SELECT substring(data FROM ${offset + 1} FOR ${length}) AS data
21
+ FROM geoip.databases WHERE kind = ${kind} AND sha256 = ${sha256}`;
22
+ if (!slice) return null;
23
+ if (slice.data.byteLength !== length) throw new Error("Incomplete GeoIP database slice");
24
+ bytes.set(slice.data, offset);
25
+ }
26
+ return bytes;
27
+ },
28
+ });
29
+
30
+ export const geoIpUploads = createGeoIpUploads();
@@ -0,0 +1,150 @@
1
+ import { Readable } from "node:stream";
2
+ import { createGunzip } from "node:zlib";
3
+ import { Reader } from "mmdb-lib";
4
+ import { GEOIP_MAX_DATABASE_BYTES, GEOIP_MAX_REQUEST_BYTES, type GeoIpErrorCode, type GeoIpKind } from "../../contracts/geoip";
5
+ import { hasBoundedMmdbMetadata, openGeoIpDatabase, truncateUtf8 } from "../../server/request-context/geoip";
6
+
7
+ const probeAddresses = [
8
+ ...Array.from({ length: 512 }, (_, i) => [`${i >> 1}.${(i % 2) * 128}.0.0`, `${(0x2000 + i * 16).toString(16)}::`]).flat(),
9
+ "1.1.1.1",
10
+ "8.8.8.8",
11
+ "9.9.9.9",
12
+ "2001:4860:4860::8888",
13
+ "2606:4700:4700::1111",
14
+ "2a01:4f8::1",
15
+ ];
16
+
17
+ const errors = {
18
+ geoip_request_too_large: { status: 413, message: "The request body exceeds 128 MiB. Upload the .mmdb.gz file." },
19
+ geoip_too_large: { status: 413, message: "The GeoIP database exceeds 192 MiB unpacked." },
20
+ geoip_empty: { status: 400, message: "The GeoIP upload is empty." },
21
+ geoip_invalid: { status: 422, message: "Upload a valid IPv6 MMDB database with a build date." },
22
+ geoip_unsupported_type: { status: 422, message: "Upload a City or ASN MMDB database." },
23
+ geoip_upload_busy: { status: 409, message: "Another GeoIP upload is being processed. Try again shortly." },
24
+ } as const;
25
+ export class GeoIpError extends Error {
26
+ readonly status;
27
+ constructor(public readonly code: GeoIpErrorCode) {
28
+ super(errors[code].message);
29
+ this.status = errors[code].status;
30
+ }
31
+ }
32
+
33
+ /** Backpressure reaches the request; both limits are enforced before retaining a chunk. */
34
+ export const readGeoIpUpload = async (body: ReadableStream<Uint8Array>, contentLength?: number): Promise<Buffer> => {
35
+ if (contentLength !== undefined && contentLength > GEOIP_MAX_REQUEST_BYTES) throw new GeoIpError("geoip_request_too_large");
36
+ const reader = body.getReader();
37
+ let requestBytes = 0;
38
+ async function* counted() {
39
+ while (true) {
40
+ const chunk = await reader.read();
41
+ if (chunk.done) return;
42
+ requestBytes += chunk.value.byteLength;
43
+ if (requestBytes > GEOIP_MAX_REQUEST_BYTES) throw new GeoIpError("geoip_request_too_large");
44
+ if (chunk.value.byteLength) yield chunk.value;
45
+ }
46
+ }
47
+ let source: Readable | undefined;
48
+ let gunzip: ReturnType<typeof createGunzip> | undefined;
49
+ try {
50
+ const chunks = counted();
51
+ const prefix: Uint8Array[] = [];
52
+ let prefixBytes = 0;
53
+ while (prefixBytes < 2) {
54
+ const next = await chunks.next();
55
+ if (next.done) break;
56
+ prefix.push(next.value);
57
+ prefixBytes += next.value.byteLength;
58
+ }
59
+ if (!prefixBytes) throw new GeoIpError("geoip_empty");
60
+ const first = prefix[0]!;
61
+ const gzip = first[0] === 0x1f && (first.byteLength > 1 ? first[1] : prefix[1]?.[0]) === 0x8b;
62
+ source = Readable.from(
63
+ (async function* () {
64
+ yield* prefix;
65
+ yield* chunks;
66
+ })(),
67
+ );
68
+ let output = source;
69
+ if (gzip) {
70
+ gunzip = createGunzip();
71
+ source.on("error", (error) => gunzip?.destroy(error));
72
+ output = source.pipe(gunzip);
73
+ }
74
+ // A resizable backing store avoids retaining all chunks plus a second full-size concatenation.
75
+ const storage = new ArrayBuffer(0, { maxByteLength: GEOIP_MAX_DATABASE_BYTES });
76
+ let size = 0;
77
+ for await (const chunk of output) {
78
+ if (!(chunk instanceof Uint8Array)) throw new GeoIpError("geoip_invalid");
79
+ if (size + chunk.byteLength > GEOIP_MAX_DATABASE_BYTES) throw new GeoIpError("geoip_too_large");
80
+ const needed = size + chunk.byteLength;
81
+ if (needed > storage.byteLength)
82
+ storage.resize(Math.min(GEOIP_MAX_DATABASE_BYTES, Math.ceil(needed / (8 * 1024 * 1024)) * 8 * 1024 * 1024));
83
+ new Uint8Array(storage).set(chunk, size);
84
+ size = needed;
85
+ }
86
+ if (!size) throw new GeoIpError("geoip_empty");
87
+ storage.resize(size);
88
+ return Buffer.from(storage);
89
+ } catch (error) {
90
+ if (error instanceof GeoIpError) throw error;
91
+ throw new GeoIpError("geoip_invalid");
92
+ } finally {
93
+ source?.destroy();
94
+ gunzip?.destroy();
95
+ await reader.cancel().catch(() => {});
96
+ reader.releaseLock();
97
+ }
98
+ };
99
+
100
+ export const parseGeoIpUpload = (bytes: Uint8Array) => {
101
+ if (!hasBoundedMmdbMetadata(bytes)) throw new GeoIpError("geoip_invalid");
102
+ try {
103
+ const metadata = new Reader(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)).metadata;
104
+ const type = metadata.databaseType.toLowerCase();
105
+ const kind: GeoIpKind | null = type.includes("city") ? "city" : type.includes("asn") ? "asn" : null;
106
+ if (!kind) throw new GeoIpError("geoip_unsupported_type");
107
+ const database = openGeoIpDatabase(kind, bytes, "uploaded");
108
+ if (!database || Buffer.byteLength(metadata.databaseType) > 128) throw new GeoIpError("geoip_invalid");
109
+ const description: unknown = metadata.description;
110
+ const english =
111
+ typeof description === "object" && description !== null && "en" in description && typeof description.en === "string"
112
+ ? description.en
113
+ : "";
114
+ const languages: unknown = metadata.languages;
115
+ const subset = {
116
+ databaseType: metadata.databaseType,
117
+ buildEpoch: metadata.buildEpoch.toISOString(),
118
+ ipVersion: metadata.ipVersion,
119
+ // `languages` is optional in MMDB metadata; mmdb-lib leaves it undefined despite its type.
120
+ languages: (Array.isArray(languages) ? languages : [])
121
+ .filter((language): language is string => typeof language === "string")
122
+ .slice(0, 16)
123
+ .map((language) => truncateUtf8(language, 32)),
124
+ description: { en: truncateUtf8(english, 256) },
125
+ nodeCount: metadata.nodeCount,
126
+ recordSize: metadata.recordSize,
127
+ size: bytes.byteLength,
128
+ };
129
+ if (
130
+ subset.databaseType.includes("\0") ||
131
+ subset.description.en.includes("\0") ||
132
+ subset.languages.some((language) => language.includes("\0"))
133
+ )
134
+ throw new GeoIpError("geoip_invalid");
135
+ // Pretty JSON is larger than PostgreSQL jsonb text, including its separator spaces.
136
+ if (Buffer.byteLength(JSON.stringify(subset, null, 1)) > 4096) throw new GeoIpError("geoip_invalid");
137
+ // A bounded probe catches gross corruption, not every bad record; request lookups still fail closed. Each
138
+ // address is one lookup that keeps nothing: at most 32 KiB of decode work and one decoded record in memory.
139
+ // A crafted file with records at that cap can block this Core process for about ten seconds; normal files take
140
+ // milliseconds.
141
+ for (const address of probeAddresses) {
142
+ const record = database.get(address);
143
+ if (record !== null && (typeof record !== "object" || Array.isArray(record))) throw new GeoIpError("geoip_invalid");
144
+ }
145
+ return { kind, database, metadata: subset };
146
+ } catch (error) {
147
+ if (error instanceof GeoIpError) throw error;
148
+ throw new GeoIpError("geoip_invalid");
149
+ }
150
+ };
@@ -2,9 +2,13 @@ import { type Context, Hono } from "hono";
2
2
  import { CLOUD_INVOCATION_JWKS_PATH, CLOUD_SESSION_JWKS_PATH, IDENTITY_JWKS_MAX_AGE_SECONDS } from "./constants";
3
3
  import { listIdentityJwks } from "./key-ring";
4
4
 
5
+ // If-None-Match uses weak comparison, including lists and the wildcard.
6
+ const matchesEtag = (header: string | undefined, etag: string): boolean =>
7
+ header?.trim() === "*" || (header?.match(/(?:W\/)?"[^"]*"/g) ?? []).some((tag) => tag.replace(/^W\//, "") === etag.replace(/^W\//, ""));
8
+
5
9
  const serveJwks = (purpose: "session" | "invocation") => async (c: Context) => {
6
10
  const jwks = await listIdentityJwks(purpose);
7
- if (c.req.header("if-none-match") === jwks.etag) {
11
+ if (matchesEtag(c.req.header("if-none-match"), jwks.etag)) {
8
12
  c.header("ETag", jwks.etag);
9
13
  c.header("Cache-Control", `public, max-age=${IDENTITY_JWKS_MAX_AGE_SECONDS}, must-revalidate`);
10
14
  return c.body(null, 304);
@@ -114,8 +114,8 @@ export type { AccountLifecycleNotificationSender } from "./account-lifecycle/not
114
114
  export { lifecycleJobs } from "./account-lifecycle/scheduler";
115
115
 
116
116
  export { settings } from "./settings/namespace";
117
- export { loadCache, get, set, remove, getAll } from "./settings";
118
- export type { SettingEntry } from "./settings";
117
+ export { loadCache, get, read, readStates, set, remove, getAll, SettingUnreadableError } from "./settings";
118
+ export type { SettingEntry, SettingRead, StoredSettingState } from "./settings";
119
119
  export { SETTINGS, SETTINGS_MAP, registerSettings } from "./settings/defaults";
120
120
  export { validateSettingValue, normalizeSettingValue, getSettingLabel } from "./settings/defaults";
121
121
  export type { SettingDef, SettingKind, SettingOption } from "./settings/defaults";
@@ -164,6 +164,9 @@ export {
164
164
  renderHtmlToPdfWithConfig,
165
165
  renderTemplatePdfPreview,
166
166
  testGotenberg,
167
+ preparePdfImage,
168
+ PDF_IMAGE_MAX_INPUT_BYTES,
169
+ PDF_IMAGE_MAX_BOXES,
167
170
  } from "./pdf";
168
171
  export type {
169
172
  BuildPresetPdfHtmlInput,
@@ -184,6 +187,12 @@ export type {
184
187
  TemplatePdfPreviewError,
185
188
  TemplatePdfPreviewPhase,
186
189
  TemplatePdfPreviewResult,
190
+ PdfImageBox,
191
+ PdfImageVariant,
192
+ PdfImageErrorCode,
193
+ PreparePdfImageInput,
194
+ PreparePdfImageOptions,
195
+ PreparePdfImageResult,
187
196
  } from "./pdf";
188
197
 
189
198
  export { createSyncOpsRoutes, SYNC_OPS_DEAD_LETTER_LIMIT } from "./sync-ops";
@@ -198,7 +207,16 @@ export type {
198
207
 
199
208
  export { latestTopicCursor } from "./topic-cursor";
200
209
  export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
201
- export { AppApprovalError, appApproval, type AppDeviceAdministrator, type AppDeviceEnrollmentNotice } from "./app-approval";
210
+ export {
211
+ AppApprovalError,
212
+ appApproval,
213
+ type AppDeviceAdministrator,
214
+ type AppDeviceEnrollmentNotice,
215
+ type AppApprovalConfig,
216
+ type AppTerminalLogins,
217
+ type AppTerminalLoginInput,
218
+ readAppApprovalConfig,
219
+ } from "./app-approval";
202
220
  export { PwaError, pwaDevices, type PwaDeviceAdministrator, type PwaDevicePairedNotice } from "./pwa-devices";
203
221
  export { legalConsent } from "./legal-consent";
204
222
 
@@ -228,3 +246,14 @@ export {
228
246
  resolveAllowedNetworkAddresses,
229
247
  } from "./network-security";
230
248
  export type { NetworkLookup, PublicNetworkAddress } from "./network-security";
249
+
250
+ export { geoip } from "./geoip";
251
+ export {
252
+ knownBrowsers,
253
+ KNOWN_BROWSER_LIMITS,
254
+ SIGN_IN_METHODS,
255
+ type SignInMethod,
256
+ type NewBrowserSignInNotice,
257
+ type NewBrowserSignInNotifier,
258
+ } from "./known-browsers";
259
+ export { LASTING_ACCESS_SIGN_IN_SECONDS, requireConfirmedBrowserSignIn } from "./session/recent";