@k2b/cloud 0.40.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 (279) hide show
  1. package/package.json +9 -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-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { type SQL, sql } from "bun";
2
3
  import { listApps } from "../../_internal/registry";
3
4
  import {
@@ -25,7 +26,7 @@ export class OutgoingMailError extends Error {
25
26
  constructor(
26
27
  public readonly code: MailErrorCode,
27
28
  message: string,
28
- public readonly status: 400 | 403 | 404 | 409 | 502 = 400,
29
+ public readonly status: 400 | 403 | 404 | 409 | 502 | 503 = 400,
29
30
  ) {
30
31
  super(message);
31
32
  }
@@ -245,8 +246,8 @@ const appsState = async (
245
246
  const registered = registeredApps ?? (await listApps());
246
247
  const { access, grants } = await accessRows(db);
247
248
  const [profile] = await db<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE is_default`;
248
- const logGrants = await db<{ app_id: string; source_app_id: string }[]>`
249
- SELECT app_id, source_app_id FROM outgoing_mail.app_log_access ORDER BY source_app_id`;
249
+ const logGrants = await db<{ app_id: string; source_app_id: string; level: string }[]>`
250
+ SELECT app_id, source_app_id, level FROM outgoing_mail.app_log_access ORDER BY source_app_id`;
250
251
  const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id), ...logGrants.map((g) => g.app_id)]);
251
252
  const items = [...ids]
252
253
  .map((appId): AdminMailApp => {
@@ -264,6 +265,12 @@ const appsState = async (
264
265
  : logGrants
265
266
  .filter((g) => g.app_id === appId && g.source_app_id !== "core" && g.source_app_id !== appId)
266
267
  .map((g) => g.source_app_id),
268
+ logContentApps:
269
+ appId === "core"
270
+ ? []
271
+ : logGrants
272
+ .filter((g) => g.app_id === appId && g.source_app_id !== "core" && g.source_app_id !== appId && g.level === "content")
273
+ .map((g) => g.source_app_id),
267
274
  mode,
268
275
  profiles: mode === "selected" ? grants.filter((g) => g.app_id === appId).map((g) => g.key) : [],
269
276
  };
@@ -300,6 +307,7 @@ const setAppAccess = async (appId: string, value: MailAppAccess, context: MailAu
300
307
  declared: false,
301
308
  readDeclared: false,
302
309
  logApps: [],
310
+ logContentApps: [],
303
311
  mode: "default",
304
312
  profiles: [],
305
313
  }
@@ -312,19 +320,40 @@ const logAppsFor = async (appId: string): Promise<string[]> => {
312
320
  WHERE app_id = ${appId} AND source_app_id <> 'core' AND source_app_id <> app_id ORDER BY source_app_id`;
313
321
  return rows.map((row) => row.source_app_id);
314
322
  };
323
+ /** Full exact IDs remain distinguishable after audit metadata string truncation. */
324
+ export const mailAppIdSha256 = (appId: string): string => createHash("sha256").update(appId, "utf8").digest("hex");
325
+ const grantAuditGroups = (grants: { source_app_id: string; level: string }[]) => {
326
+ const entries = grants.map((grant) => ({
327
+ appId: grant.source_app_id,
328
+ appIdSha256: mailAppIdSha256(grant.source_app_id),
329
+ level: grant.level,
330
+ }));
331
+ // Audit arrays retain at most 50 entries; the public grant limit is 100.
332
+ return [entries.slice(0, 50), entries.slice(50)];
333
+ };
315
334
  const setAppLogAccess = async (appId: string, value: MailLogAccess, context: MailAuditContext): Promise<AdminMailApp> => {
316
335
  const reader = MailLogAppIdSchema.safeParse(appId);
317
336
  const parsed = MailLogAccessSchema.safeParse(value);
318
337
  if (!reader.success || appId === "core" || !parsed.success || parsed.data.apps.includes(appId))
319
338
  throw new OutgoingMailError("bad_input", "Choose a valid reader and distinct source apps other than Core or the reader itself.");
320
339
  const apps = [...parsed.data.apps].sort();
340
+ const contentApps = parsed.data.contentApps ?? [];
321
341
  const registered = await listApps();
322
342
  return locked(async (tx) => {
343
+ const before = await tx<{ source_app_id: string; level: string }[]>`
344
+ SELECT source_app_id, level FROM outgoing_mail.app_log_access WHERE app_id = ${appId} ORDER BY source_app_id`;
323
345
  await tx`DELETE FROM outgoing_mail.app_log_access WHERE app_id = ${appId}`;
324
- await tx`INSERT INTO outgoing_mail.app_log_access(app_id, source_app_id, created_by)
325
- SELECT ${appId}, source_app_id, ${context.actor.userId ?? null}
346
+ await tx`INSERT INTO outgoing_mail.app_log_access(app_id, source_app_id, created_by, level)
347
+ SELECT ${appId}, source_app_id, ${context.actor.userId ?? null},
348
+ CASE WHEN source_app_id = ANY(${toPgTextArray(contentApps)}::text[]) THEN 'content' ELSE 'metadata' END
326
349
  FROM unnest(${toPgTextArray(apps)}::text[]) AS source_app_id`;
327
- await record(tx, context, "app_log_access.update", appId, { apps });
350
+ await record(tx, context, "app_log_access.update", appId, {
351
+ apps,
352
+ readingAppId: appId,
353
+ readingAppIdSha256: mailAppIdSha256(appId),
354
+ before: grantAuditGroups(before),
355
+ after: grantAuditGroups(apps.map((id) => ({ source_app_id: id, level: contentApps.includes(id) ? "content" : "metadata" }))),
356
+ });
328
357
  return (
329
358
  (await appsState(tx, registered)).items.find((app) => app.appId === appId) ?? {
330
359
  appId,
@@ -333,6 +362,7 @@ const setAppLogAccess = async (appId: string, value: MailLogAccess, context: Mai
333
362
  declared: false,
334
363
  readDeclared: false,
335
364
  logApps: [],
365
+ logContentApps: [],
336
366
  mode: "default",
337
367
  profiles: [],
338
368
  }
@@ -1,4 +1,6 @@
1
+ import { Socket } from "node:net";
1
2
  import { audit } from "../audit";
3
+ import { DownstreamBusyError, downstreamBudget } from "../downstream-budget";
2
4
  import { get } from "../settings";
3
5
  import { type MailAuditContext, OutgoingMailError, resolveMailCredentials } from "./store";
4
6
  import { resolveMailTransport } from "./transport";
@@ -18,22 +20,48 @@ export const outgoingMailTest = {
18
20
  // Fail before the external effect if audit storage is unavailable.
19
21
  await audit.record({ ...event, outcome: "allowed" });
20
22
  let transport: ReturnType<Awaited<ReturnType<typeof resolveMailTransport>>["build"]> | undefined;
23
+ const signal = AbortSignal.timeout(60_000);
24
+ let socket: Socket | undefined;
25
+ const stop = () => {
26
+ socket?.destroy();
27
+ transport?.close();
28
+ };
29
+ signal.addEventListener("abort", stop, { once: true });
30
+ let releaseSmtp: (() => void) | undefined;
21
31
  try {
22
- const resolved = await resolveMailTransport(profile);
32
+ releaseSmtp = await downstreamBudget.acquire({ downstream: "smtp", timeoutMs: 60_000, signal });
33
+ const resolved = await resolveMailTransport(profile, signal);
34
+ const build = (index: number) => {
35
+ socket = new Socket();
36
+ const ownedSocket = socket;
37
+ ownedSocket.once("connect", () => {
38
+ if (signal.aborted) ownedSocket.destroy();
39
+ });
40
+ return resolved.build(index, ownedSocket);
41
+ };
42
+ let selected = 0;
23
43
  for (let index = 0; index < resolved.count; index++) {
24
- transport = resolved.build(index);
44
+ signal.throwIfAborted();
45
+ transport = build(index);
25
46
  try {
26
47
  // CONN also labels socket errors after DATA. Only fall back during a
27
48
  // preflight that cannot send a message, then send once to that address.
28
49
  await transport.verify();
50
+ selected = index;
29
51
  break;
30
52
  } catch (error) {
53
+ socket?.destroy();
31
54
  transport.close();
32
55
  transport = undefined;
33
56
  if (!(error instanceof Error) || !("command" in error) || error.command !== "CONN" || index === resolved.count - 1) throw error;
34
57
  }
35
58
  }
36
59
  if (!transport) throw new Error("No validated SMTP address.");
60
+ // verify() closes its SMTP connection. The actual send gets a fresh
61
+ // owned socket to the same checked address, with no delivery fallback.
62
+ stop();
63
+ signal.throwIfAborted();
64
+ transport = build(selected);
37
65
  await transport.sendMail({
38
66
  from: { address: profile.fromAddress, name: profile.fromName ?? (await get<string>("app.name")) },
39
67
  to: recipient,
@@ -47,9 +75,11 @@ export const outgoingMailTest = {
47
75
  profile.smtpUser ? `\0${profile.smtpUser}\0${profile.smtpPassword ?? ""}` : null,
48
76
  ]);
49
77
  await audit.recordResultAfterSideEffect({ ...event, result: { ok: false, error: { code: "smtp_failed", message, status: 500 } } });
50
- throw new OutgoingMailError("smtp_failed", message, 502);
78
+ throw new OutgoingMailError("smtp_failed", message, error instanceof DownstreamBusyError ? 503 : 502);
51
79
  } finally {
52
- transport?.close();
80
+ signal.removeEventListener("abort", stop);
81
+ stop();
82
+ releaseSmtp?.();
53
83
  }
54
84
  },
55
85
  };
@@ -4,7 +4,7 @@ import { resolveMailHost } from "./resolve-host";
4
4
  import type { resolveMailCredentials } from "./store";
5
5
 
6
6
  export const resolveMailTransport = async (profile: Awaited<ReturnType<typeof resolveMailCredentials>>, signal?: AbortSignal) => {
7
- const target = await resolveMailHost(profile.smtpHost, signal);
7
+ const target = await resolveMailHost(profile.smtpHost, signal, undefined, profile.smtpPort);
8
8
  signal?.throwIfAborted();
9
9
  const count = target.addresses.length;
10
10
  return {
@@ -1,7 +1,9 @@
1
+ import { DownstreamBusyError, downstreamBudget } from "../downstream-budget";
1
2
  import * as settings from "../settings";
2
3
  import { offlineHtml } from "./offline-html";
3
4
 
4
5
  export type GotenbergRenderErrorCode =
6
+ | "busy"
5
7
  | "bad_input"
6
8
  | "not_configured"
7
9
  | "html_too_large"
@@ -117,6 +119,7 @@ const abortSignal = (timeoutMs: number): AbortSignal => {
117
119
 
118
120
  const sanitizeFetchError = (error: unknown): GotenbergRenderError => {
119
121
  if (error instanceof GotenbergRenderError) return error;
122
+ if (error instanceof DownstreamBusyError) return new GotenbergRenderError("busy", "PDF renderer is busy; retry later.", 503);
120
123
  if (error instanceof DOMException && error.name === "TimeoutError") {
121
124
  return new GotenbergRenderError("timeout", "Gotenberg request timed out.");
122
125
  }
@@ -179,10 +182,14 @@ async function requestPdf(
179
182
  const headers = new Headers();
180
183
  const auth = basicAuthHeader(config);
181
184
  if (auth) headers.set("Authorization", auth);
185
+ const baseUrl = normalizeBaseUrl(config.url);
182
186
  const signal = options.signal ? AbortSignal.any([options.signal, abortSignal(config.timeoutMs)]) : abortSignal(config.timeoutMs);
187
+ let release: (() => void) | undefined;
183
188
  let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
184
189
  try {
185
- const response = await (options.fetch ?? fetch)(`${normalizeBaseUrl(config.url)}${route}`, {
190
+ release = await downstreamBudget.acquire({ downstream: "gotenberg", timeoutMs: config.timeoutMs, signal });
191
+ signal.throwIfAborted();
192
+ const response = await (options.fetch ?? fetch)(`${baseUrl}${route}`, {
186
193
  method: "POST",
187
194
  headers,
188
195
  body: form,
@@ -221,6 +228,7 @@ async function requestPdf(
221
228
  throw sanitizeFetchError(error);
222
229
  } finally {
223
230
  reader?.releaseLock();
231
+ release?.();
224
232
  }
225
233
  }
226
234
 
@@ -285,47 +293,12 @@ export const mergePdfsWithConfig = async (
285
293
  throw new GotenbergRenderError("bad_input", "At least one PDF file is required.");
286
294
  }
287
295
 
288
- const baseUrl = normalizeBaseUrl(config.url);
289
296
  const form = new FormData();
290
297
  input.files.forEach((file, index) => {
291
298
  const filename = `${String(index + 1).padStart(6, "0")}.pdf`;
292
- const bytes = file.pdf.buffer.slice(file.pdf.byteOffset, file.pdf.byteOffset + file.pdf.byteLength) as ArrayBuffer;
293
- form.append("files", new Blob([bytes], { type: DEFAULT_PDF_CONTENT_TYPE }), filename);
299
+ form.append("files", new Blob([new Uint8Array(file.pdf)], { type: DEFAULT_PDF_CONTENT_TYPE }), filename);
294
300
  });
295
-
296
- const headers = new Headers();
297
- const authHeader = basicAuthHeader(config);
298
- if (authHeader) headers.set("Authorization", authHeader);
299
-
300
- const transport = options.fetch ?? fetch;
301
- let response: Response;
302
- try {
303
- response = await transport(`${baseUrl}/forms/pdfengines/merge`, {
304
- method: "POST",
305
- headers,
306
- body: form,
307
- signal: abortSignal(config.timeoutMs),
308
- });
309
- } catch (error) {
310
- throw sanitizeFetchError(error);
311
- }
312
-
313
- if (!response.ok) {
314
- throw new GotenbergRenderError("bad_response", `Gotenberg returned HTTP ${response.status}.`, response.status);
315
- }
316
-
317
- const buffer = await response.arrayBuffer();
318
- if (buffer.byteLength > config.maxPdfBytes) {
319
- throw new GotenbergRenderError(
320
- "pdf_too_large",
321
- `PDF output is too large (${buffer.byteLength} bytes, limit ${config.maxPdfBytes} bytes).`,
322
- );
323
- }
324
-
325
- return {
326
- pdf: new Uint8Array(buffer),
327
- contentType: response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE,
328
- };
301
+ return requestPdf("/forms/pdfengines/merge", form, config, options);
329
302
  };
330
303
 
331
304
  export const getGotenbergConfig = async (): Promise<GotenbergConfig> => ({
@@ -1,4 +1,5 @@
1
1
  import { markdownInfoBlocks, markdownLinkReference, renderMarkdownLink } from "@k2b/ui";
2
+ import { decodeHTML } from "entities";
2
3
  import { Marked, Renderer, type Tokens } from "marked";
3
4
  import postcss from "postcss";
4
5
  import {
@@ -165,6 +166,161 @@ a.k2b-reference { padding: .05em .3em; border-radius: .3em; background: #eeeff2;
165
166
  .k2b-reference__icon, .k2b-text-link__external { display: none; }
166
167
  `;
167
168
 
169
+ /**
170
+ * Break-anywhere lowers table column width to one character, splitting even
171
+ * short numbers. Cells keep words whole; long runs get explicit break points.
172
+ */
173
+ const TABLE_CELL_PRINT_CSS = `
174
+ th, td { overflow-wrap: break-word; word-break: normal; }
175
+ `;
176
+
177
+ // Up to 20 characters covers nearly every real word, including German
178
+ // compounds and IBAN-like values below that length. Longer URLs, code, hashes
179
+ // or paths need a 10-character step: even nine columns fit the document
180
+ // preset's narrowest row (about 90 table characters). A 20-character step
181
+ // clipped six columns of long inline code that fitted before this protection.
182
+ const MAX_WHOLE_CELL_RUN = 20;
183
+ const CELL_RUN_BREAK_STEP = 10;
184
+
185
+ // References (including semicolonless ones) and literal code points are raw
186
+ // pieces. Decode for whitespace and grapheme boundaries, but preserve spelling.
187
+ // Limit names to 32 characters, the longest HTML reference name.
188
+ const CHARACTER_REFERENCE = /&(?:#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]{0,31});?/gu;
189
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });
190
+
191
+ const runUnits = (pieces: { raw: string; decoded: string }[]): string[] => {
192
+ const decoded = pieces.map((piece) => piece.decoded).join("");
193
+ const ends = new Set(Array.from(GRAPHEMES.segment(decoded), ({ index, segment }) => index + segment.length));
194
+ const units: string[] = [];
195
+ let at = 0;
196
+ let unit = "";
197
+ // A boundary must end both a grapheme and a raw piece, even if it decodes
198
+ // to several graphemes (such as &ampx or an unknown reference-like name).
199
+ for (const piece of pieces) {
200
+ unit += piece.raw;
201
+ at += piece.decoded.length;
202
+ if (ends.has(at)) {
203
+ units.push(unit);
204
+ unit = "";
205
+ }
206
+ }
207
+ return units;
208
+ };
209
+
210
+ // Inline formatting does not end a run: `aaa**bbb**ccc` prints one unbroken
211
+ // word. Any other element, such as <br>, <wbr>, an image, a span, or a
212
+ // block, starts a new run, so a short value beside it never breaks.
213
+ const INLINE_FORMATTING = new Set([
214
+ "a",
215
+ "b",
216
+ "code",
217
+ "del",
218
+ "em",
219
+ "i",
220
+ "ins",
221
+ "kbd",
222
+ "mark",
223
+ "s",
224
+ "samp",
225
+ "small",
226
+ "strong",
227
+ "sub",
228
+ "sup",
229
+ "u",
230
+ "var",
231
+ ]);
232
+
233
+ // Graphics and formulas keep their own layout. In raw-text and RCDATA elements
234
+ // a <wbr> would be literal text, not markup.
235
+ const SKIPPED_IN_CELLS = new Set([
236
+ "svg",
237
+ "math",
238
+ "style",
239
+ "script",
240
+ "textarea",
241
+ "title",
242
+ "xmp",
243
+ "iframe",
244
+ "noembed",
245
+ "noframes",
246
+ "noscript",
247
+ ]);
248
+
249
+ const breakLongCellRuns = (html: string): string => {
250
+ let skippedDepth = 0;
251
+ let buffer = "";
252
+ // Units of the run that the previous text node in the cell ended with.
253
+ let carried = 0;
254
+ const pieces = new RegExp(`(${CHARACTER_REFERENCE.source})|.`, "gsu");
255
+ const breakText = (text: string): string => {
256
+ let output = "";
257
+ let run: { raw: string; decoded: string }[] = [];
258
+ const flushRun = () => {
259
+ const units = runUnits(run);
260
+ run = [];
261
+ const start = carried;
262
+ carried += units.length;
263
+ // A run that continues in a later text node is counted when it gets
264
+ // there; earlier nodes stay as written, so at most 30 units stay whole.
265
+ if (carried <= MAX_WHOLE_CELL_RUN) {
266
+ output += units.join("");
267
+ return;
268
+ }
269
+ output += units
270
+ .map((unit, index) => ((start + index) % CELL_RUN_BREAK_STEP === 0 && start + index > 0 ? `<wbr>${unit}` : unit))
271
+ .join("");
272
+ };
273
+ for (const [raw, reference] of text.matchAll(pieces)) {
274
+ const decoded = reference === undefined ? raw : decodeHTML(raw);
275
+ // Literal and referenced whitespace end runs, except no-break characters.
276
+ if (/^[^\S\u00a0\u2007\u202f\ufeff]+$/u.test(decoded)) {
277
+ flushRun();
278
+ carried = 0;
279
+ output += raw;
280
+ } else {
281
+ run.push({ raw, decoded });
282
+ }
283
+ }
284
+ flushRun();
285
+ return output;
286
+ };
287
+
288
+ return new HTMLRewriter()
289
+ .on("td *, th *", {
290
+ element(element) {
291
+ if (INLINE_FORMATTING.has(element.tagName)) return;
292
+ carried = 0;
293
+ // Void elements and self-closing foreign elements have no end tag. HTML
294
+ // ignores the self-closing flag elsewhere: <textarea/> still opens raw text.
295
+ if (!element.canHaveContent) return;
296
+ const skipped = SKIPPED_IN_CELLS.has(element.tagName);
297
+ if (skipped) skippedDepth += 1;
298
+ // One end tag handler per element: a second registration would replace this one.
299
+ element.onEndTag(() => {
300
+ carried = 0;
301
+ if (skipped) skippedDepth -= 1;
302
+ });
303
+ },
304
+ })
305
+ .on("td, th", {
306
+ element() {
307
+ carried = 0;
308
+ },
309
+ text(chunk) {
310
+ if (skippedDepth) return;
311
+ // HTMLRewriter may split one text node (even an entity) into chunks.
312
+ buffer += chunk.text;
313
+ if (chunk.lastInTextNode) {
314
+ chunk.replace(breakText(buffer), { html: true });
315
+ buffer = "";
316
+ } else {
317
+ chunk.remove();
318
+ }
319
+ },
320
+ })
321
+ .transform(html);
322
+ };
323
+
168
324
  const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
169
325
 
170
326
  const escapeHtml = (value: string): string =>
@@ -258,7 +414,9 @@ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
258
414
  const suppliedCustomCss = input.customCss?.trim() ?? "";
259
415
  const customCss = suppliedCustomCss ? validateCustomCss(input.customCss ?? "") : "";
260
416
  const presetCss = templateId ? TEMPLATE_CSS[templateId] : customCss ? "" : TEMPLATE_CSS.document;
261
- const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, LINK_PRINT_CSS, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
417
+ const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, LINK_PRINT_CSS, TABLE_CELL_PRINT_CSS, input.css ? styleText(input.css) : ""]
418
+ .filter(Boolean)
419
+ .join("\n");
262
420
  const stylesheet = `${baseCss}${baseCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
263
421
  return `<!doctype html>
264
422
  <html lang="en">
@@ -268,7 +426,7 @@ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
268
426
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'">
269
427
  <style>${stylesheet}</style>
270
428
  </head>
271
- <body><main class="markdown-document">${input.html}</main></body>
429
+ <body><main class="markdown-document">${breakLongCellRuns(input.html)}</main></body>
272
430
  </html>`;
273
431
  };
274
432
 
@@ -47,7 +47,7 @@ export type RenderTemplatePdfPreviewOptions = RenderHtmlToPdfOptions & {
47
47
  config?: GotenbergConfig;
48
48
  };
49
49
 
50
- const pdfErrorStatus = (error: GotenbergRenderError): number => (error.code === "not_configured" ? 400 : 502);
50
+ const pdfErrorStatus = (error: GotenbergRenderError): number => (error.code === "busy" ? 503 : error.code === "not_configured" ? 400 : 502);
51
51
 
52
52
  const renderPart = (
53
53
  template: string | null | undefined,
@@ -1,6 +1,7 @@
1
1
  import type { ClientRequest, IncomingMessage } from "node:http";
2
2
  import { type RequestOptions, request } from "node:https";
3
3
  import { resolvePublicNetworkAddresses } from "./network-security";
4
+ import { awaitNetworkOperation, readNetworkResponse } from "./network-transport";
4
5
 
5
6
  export type PublicHttpInput = {
6
7
  url: string;
@@ -24,29 +25,21 @@ export async function requestPublicHttps(
24
25
  const url = new URL(input.url);
25
26
  if (url.protocol !== "https:" || url.username || url.password) throw new Error("HTTPS_REQUIRED");
26
27
  input.signal.throwIfAborted();
27
- let cancelLookup = () => {};
28
- const addresses = await Promise.race([
29
- dependencies.resolve(url.hostname),
30
- new Promise<never>((_, reject) => {
31
- cancelLookup = () => reject(new Error("HTTP_CANCELLED"));
32
- input.signal.addEventListener("abort", cancelLookup, { once: true });
33
- if (input.signal.aborted) cancelLookup();
34
- }),
35
- ]).finally(() => input.signal.removeEventListener("abort", cancelLookup));
28
+ let addresses: Awaited<ReturnType<typeof resolvePublicNetworkAddresses>>;
29
+ try {
30
+ addresses = await awaitNetworkOperation(dependencies.resolve(url.hostname), input.signal);
31
+ } catch (error) {
32
+ if (input.signal.aborted) throw new Error("HTTP_CANCELLED");
33
+ throw error;
34
+ }
36
35
  input.signal.throwIfAborted();
37
36
  const target = addresses[0]!;
38
- return new Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }>((resolve, reject) => {
39
- let settled = false;
40
- const fail = () => finish(undefined);
41
- const finish = (value?: { status: number; headers: Record<string, string>; body: Uint8Array }) => {
42
- if (settled) return;
43
- settled = true;
44
- input.signal.removeEventListener("abort", abort);
45
- if (value) resolve(value);
46
- else reject(new Error("HTTP_FAILED"));
47
- };
48
- const outgoing = dependencies.request(
49
- {
37
+ try {
38
+ return await readNetworkResponse<{ status: number; headers: Record<string, string>; body: Uint8Array }>({
39
+ request: dependencies.request,
40
+ signal: input.signal,
41
+ body: input.body,
42
+ options: {
50
43
  hostname: target.address,
51
44
  family: target.family,
52
45
  port: url.port || 443,
@@ -56,7 +49,7 @@ export async function requestPublicHttps(
56
49
  method: input.method,
57
50
  headers: { ...input.headers, host: url.host, "accept-encoding": "identity" },
58
51
  },
59
- (incoming) => {
52
+ read: (incoming, finish, fail) => {
60
53
  const status = incoming.statusCode ?? 0;
61
54
  const headers: Record<string, string> = {};
62
55
  for (const name of ["content-type", "retry-after", "etag", "last-modified"]) {
@@ -65,37 +58,32 @@ export async function requestPublicHttps(
65
58
  }
66
59
  // A redirect never receives a second request or exposes a Location token.
67
60
  if (status >= 300 && status < 400 && status !== 304) {
68
- incoming.destroy();
69
61
  finish({ status, headers, body: new Uint8Array() });
70
62
  return;
71
63
  }
72
64
  if (incoming.headers["content-encoding"] && incoming.headers["content-encoding"] !== "identity") {
73
- incoming.destroy();
74
- fail();
65
+ fail(new Error("HTTP_FAILED"));
66
+ return;
67
+ }
68
+ if (Number(incoming.headers["content-length"]) > input.maxBytes) {
69
+ fail(new Error("HTTP_TOO_LARGE"));
75
70
  return;
76
71
  }
77
72
  const chunks: Uint8Array[] = [];
78
73
  let bytes = 0;
79
74
  incoming.on("data", (chunk: Buffer) => {
80
- if (settled) return;
75
+ if (incoming.destroyed) return;
81
76
  bytes += chunk.byteLength;
82
77
  if (bytes > input.maxBytes) {
83
- incoming.destroy();
84
- fail();
78
+ fail(new Error("HTTP_TOO_LARGE"));
85
79
  } else chunks.push(chunk);
86
80
  });
87
81
  incoming.on("end", () => finish({ status, headers, body: Buffer.concat(chunks, bytes) }));
88
- incoming.on("error", fail);
89
- incoming.on("aborted", fail);
90
82
  },
91
- );
92
- const abort = () => {
93
- outgoing.destroy();
94
- fail();
95
- };
96
- outgoing.on("error", fail); // Never expose errors containing injected headers.
97
- input.signal.addEventListener("abort", abort, { once: true });
98
- if (input.signal.aborted) abort();
99
- else outgoing.end(input.body);
100
- });
83
+ });
84
+ } catch (error) {
85
+ if (error instanceof Error && error.message === "HTTP_TOO_LARGE") throw error;
86
+ // Never expose errors containing injected headers.
87
+ throw new Error("HTTP_FAILED");
88
+ }
101
89
  }