@k2b/cloud 0.6.0 → 0.8.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 (349) hide show
  1. package/README.md +2 -2
  2. package/package.json +22 -11
  3. package/scripts/README.md +10 -0
  4. package/scripts/browser-performance.ts +14 -0
  5. package/scripts/build-pdf-renderer.ts +39 -0
  6. package/scripts/build.ts +20 -35
  7. package/scripts/preload.ts +16 -12
  8. package/scripts/sync-recovery-smoke.ts +22 -6
  9. package/src/_internal/app-presentation.ts +14 -2
  10. package/src/_internal/build-metadata.ts +8 -1
  11. package/src/_internal/capabilities.ts +131 -7
  12. package/src/_internal/capability-streams.ts +81 -0
  13. package/src/_internal/define-app.ts +80 -37
  14. package/src/_internal/heartbeat.ts +4 -0
  15. package/src/_internal/help-catalog.ts +27 -172
  16. package/src/_internal/help.ts +13 -76
  17. package/src/_internal/page-responses.ts +2 -0
  18. package/src/_internal/postgres-application-name.ts +30 -0
  19. package/src/_internal/process-identity.ts +15 -0
  20. package/src/_internal/process-sync.ts +1 -1
  21. package/src/_internal/readiness.ts +4 -1
  22. package/src/_internal/registry-validation.ts +29 -25
  23. package/src/_internal/registry.ts +2 -53
  24. package/src/_internal/runtime-context.ts +5 -16
  25. package/src/_internal/server-timing.ts +13 -0
  26. package/src/_internal/status-preserving-ssr.ts +18 -6
  27. package/src/_internal/web-vitals-asset.ts +10 -0
  28. package/src/access/PermissionEditor.tsx +18 -151
  29. package/src/access/PrincipalPicker.tsx +110 -0
  30. package/src/access/ResourceApiKeys.tsx +1 -1
  31. package/src/access/ui.ts +1 -0
  32. package/src/ai/admin.ts +14 -13
  33. package/src/ai/assistant-models.ts +12 -1
  34. package/src/ai/audio-format.ts +34 -0
  35. package/src/ai/audio-tool.ts +90 -0
  36. package/src/ai/browser-code-contracts.ts +201 -0
  37. package/src/ai/browser.ts +4 -0
  38. package/src/ai/capabilities.ts +55 -36
  39. package/src/ai/capability-execution.ts +47 -12
  40. package/src/ai/chat/blocks.tsx +229 -90
  41. package/src/ai/chat/builtin-tools.tsx +2 -4
  42. package/src/ai/chat/capability-result.ts +15 -0
  43. package/src/ai/chat/capability-table.tsx +2 -10
  44. package/src/ai/chat/composer-adapter.ts +83 -3
  45. package/src/ai/chat/message-actions.tsx +8 -7
  46. package/src/ai/chat/message-utils.ts +25 -1
  47. package/src/ai/chat/messages.ts +2 -0
  48. package/src/ai/chat/presentation.tsx +31 -47
  49. package/src/ai/chat/primitives.tsx +43 -1
  50. package/src/ai/chat/tool-disclosure.tsx +21 -2
  51. package/src/ai/chat/tool-groups.ts +58 -0
  52. package/src/ai/chat-quotas.ts +25 -0
  53. package/src/ai/chat-task-contracts.ts +14 -10
  54. package/src/ai/chat-tasks.ts +297 -43
  55. package/src/ai/client/controller.ts +165 -39
  56. package/src/ai/client/file-source.ts +12 -6
  57. package/src/ai/client/live-connection.ts +1 -0
  58. package/src/ai/client/projection.ts +29 -11
  59. package/src/ai/client/transport.ts +6 -0
  60. package/src/ai/code-capability-routes.ts +105 -0
  61. package/src/ai/code-capability-transport.ts +39 -0
  62. package/src/ai/code-execution.ts +53 -0
  63. package/src/ai/code-mode-skill.ts +118 -0
  64. package/src/ai/code-runtime-tools.ts +179 -0
  65. package/src/ai/code-source-contracts.ts +270 -0
  66. package/src/ai/code-source-tools.ts +188 -0
  67. package/src/ai/compaction.ts +27 -6
  68. package/src/ai/data-analysis-skill.ts +10 -0
  69. package/src/ai/default-tools.ts +99 -3
  70. package/src/ai/dictation-runtime.ts +171 -0
  71. package/src/ai/dictations.ts +188 -0
  72. package/src/ai/draft-content.ts +49 -0
  73. package/src/ai/enrich.ts +6 -0
  74. package/src/ai/executor.ts +189 -33
  75. package/src/ai/fetch-file-tool.ts +2 -2
  76. package/src/ai/file-content-version.ts +27 -0
  77. package/src/ai/file-context.ts +2 -1
  78. package/src/ai/file-media-type.ts +12 -0
  79. package/src/ai/file-reference-contracts.ts +15 -0
  80. package/src/ai/file-tools.ts +8 -1
  81. package/src/ai/files-store.ts +212 -71
  82. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  83. package/src/ai/grids-skill.ts +42 -10
  84. package/src/ai/http.ts +26 -8
  85. package/src/ai/index.ts +26 -3
  86. package/src/ai/inference-calls.ts +197 -0
  87. package/src/ai/live-events.ts +5 -1
  88. package/src/ai/live.ts +1 -1
  89. package/src/ai/markdown-pdf-tool.ts +2 -2
  90. package/src/ai/memories.ts +30 -27
  91. package/src/ai/memory-learning-runs.ts +1 -1
  92. package/src/ai/memory-learning.ts +64 -32
  93. package/src/ai/memory-tool.ts +1 -5
  94. package/src/ai/memory-workflow-evidence.ts +9 -4
  95. package/src/ai/message-queue.ts +178 -0
  96. package/src/ai/migrate.ts +189 -11
  97. package/src/ai/model-pricing.ts +43 -0
  98. package/src/ai/pdf-render-worker.ts +75 -0
  99. package/src/ai/pdf-render.fixture.ts +28 -0
  100. package/src/ai/pdf-render.ts +77 -0
  101. package/src/ai/projects-routes.ts +18 -43
  102. package/src/ai/projects.ts +33 -14
  103. package/src/ai/protocol.ts +3 -0
  104. package/src/ai/provider.ts +1 -8
  105. package/src/ai/quota-provider.ts +192 -0
  106. package/src/ai/quota-report.ts +152 -0
  107. package/src/ai/quotas-migrate.ts +45 -0
  108. package/src/ai/quotas.ts +168 -0
  109. package/src/ai/routes.ts +241 -11
  110. package/src/ai/run-timeout.ts +12 -0
  111. package/src/ai/runtime-tools.ts +7 -5
  112. package/src/ai/runtime.ts +74 -17
  113. package/src/ai/settings.ts +36 -8
  114. package/src/ai/short-id.ts +4 -5
  115. package/src/ai/skill-catalog.ts +0 -10
  116. package/src/ai/skill-search.ts +58 -0
  117. package/src/ai/skill-seeds.ts +114 -20
  118. package/src/ai/skill-tool.ts +21 -5
  119. package/src/ai/skills-routes.ts +49 -4
  120. package/src/ai/skills.ts +358 -163
  121. package/src/ai/store.ts +416 -201
  122. package/src/ai/stream.ts +5 -0
  123. package/src/ai/structured-runs.ts +4 -3
  124. package/src/ai/structured.ts +12 -1
  125. package/src/ai/system-prompt.ts +14 -2
  126. package/src/ai/task-contracts.ts +84 -0
  127. package/src/ai/task-execution.ts +92 -0
  128. package/src/ai/timeline.ts +2 -5
  129. package/src/ai/todo-contracts.ts +32 -0
  130. package/src/ai/todo-tool.ts +19 -0
  131. package/src/ai/tool-audit.ts +30 -40
  132. package/src/ai/tools.ts +6 -0
  133. package/src/ai/transcription.ts +202 -0
  134. package/src/ai/turn-timing.ts +114 -0
  135. package/src/ai/types.ts +58 -10
  136. package/src/ai/ui.tsx +3 -0
  137. package/src/ai/usage.ts +118 -118
  138. package/src/ai/vision-tool.ts +60 -10
  139. package/src/api/admin-ai-projects.ts +2 -2
  140. package/src/api/admin-ai-quotas.ts +80 -0
  141. package/src/api/admin-ai-skills.ts +48 -5
  142. package/src/api/admin-ai-usage.ts +2 -2
  143. package/src/api/admin-core-settings.ts +25 -0
  144. package/src/api/admin-identity.ts +91 -51
  145. package/src/api/admin-linux-identities.ts +10 -4
  146. package/src/api/admin-rail.ts +58 -0
  147. package/src/api/announcements.ts +14 -0
  148. package/src/api/app-approval.ts +2 -2
  149. package/src/api/auth.ts +1 -1
  150. package/src/api/capabilities.ts +324 -43
  151. package/src/api/capability-streams.ts +262 -0
  152. package/src/api/help.ts +8 -44
  153. package/src/api/index.ts +8 -2
  154. package/src/api/mcp.ts +45 -34
  155. package/src/api/me-web-vitals.ts +35 -0
  156. package/src/api/me.ts +2 -0
  157. package/src/api/search/schemas.ts +26 -15
  158. package/src/api/search.bench.ts +1 -1
  159. package/src/api/search.ts +54 -17
  160. package/src/browser/CloudResourceSearch.tsx +640 -702
  161. package/src/browser/app-approval-vault.ts +19 -7
  162. package/src/browser/command-bridge.ts +109 -0
  163. package/src/browser/command-shortcuts.ts +76 -0
  164. package/src/browser/commands.ts +67 -0
  165. package/src/browser/navigation-search.ts +48 -0
  166. package/src/browser/notifications.ts +1 -1
  167. package/src/browser/resource-picker.tsx +5 -18
  168. package/src/browser/resource-search-dialog.ts +9 -0
  169. package/src/browser/resource-search-input.ts +46 -0
  170. package/src/browser/resource-search-messages.ts +76 -40
  171. package/src/browser/resource-search.ts +7 -0
  172. package/src/browser/search-bridge.ts +102 -0
  173. package/src/browser/search-commands.ts +59 -0
  174. package/src/browser/search.ts +13 -0
  175. package/src/browser/spotlight-position.ts +208 -0
  176. package/src/browser/testing.ts +6 -0
  177. package/src/browser/web-vitals.ts +25 -0
  178. package/src/capabilities/claims.ts +179 -0
  179. package/src/capabilities/client.ts +34 -0
  180. package/src/capabilities/command-link.ts +16 -0
  181. package/src/capabilities/executions.ts +298 -0
  182. package/src/capabilities/migrate.ts +81 -0
  183. package/src/capabilities/server.ts +116 -35
  184. package/src/capabilities/stream-body.ts +16 -0
  185. package/src/capabilities/streams.ts +28 -0
  186. package/src/cli/admin/ai-quotas.ts +161 -0
  187. package/src/cli/admin/ai-skills.ts +64 -0
  188. package/src/cli/admin/ai-usage.ts +53 -30
  189. package/src/cli/admin/app-credentials.ts +77 -0
  190. package/src/cli/admin/data.ts +17 -0
  191. package/src/cli/admin/index.ts +10 -1
  192. package/src/cli/admin/instance.ts +2 -2
  193. package/src/cli/admin/linux.ts +4 -2
  194. package/src/cli/admin/sync.ts +1 -1
  195. package/src/cli/capabilities.ts +42 -0
  196. package/src/cli/capability-streams.ts +25 -0
  197. package/src/cli/index.ts +3 -2
  198. package/src/config/define-env.ts +97 -0
  199. package/src/config/env.ts +147 -51
  200. package/src/config/index.ts +3 -1
  201. package/src/contracts/app.ts +23 -3
  202. package/src/contracts/capabilities.ts +75 -5
  203. package/src/contracts/capability-streams.ts +46 -0
  204. package/src/contracts/commands.ts +49 -0
  205. package/src/contracts/index.ts +5 -2
  206. package/src/contracts/rail-admin.ts +38 -0
  207. package/src/contracts/rail-preferences.ts +16 -0
  208. package/src/contracts/registry.ts +5 -27
  209. package/src/contracts/settings-types.ts +1 -1
  210. package/src/contracts/shared.ts +18 -5
  211. package/src/contracts/web-vitals.ts +27 -0
  212. package/src/desktop/index.ts +1 -0
  213. package/src/desktop/solid.tsx +1 -1
  214. package/src/index.ts +8 -6
  215. package/src/server/index.ts +3 -0
  216. package/src/server/middleware/auth.ts +3 -2
  217. package/src/server/middleware/route-template.ts +9 -3
  218. package/src/server/middleware/runtime.ts +2 -1
  219. package/src/server/middleware/settings.ts +17 -11
  220. package/src/server/ratelimit.ts +1 -1
  221. package/src/server/services/access-revision.ts +10 -0
  222. package/src/server/services/access.ts +3 -1
  223. package/src/server/services/index.ts +1 -0
  224. package/src/services/account-category-policy.ts +9 -0
  225. package/src/services/account-lifecycle/audit.ts +1 -1
  226. package/src/services/accounts/app.ts +12 -2
  227. package/src/services/accounts/email-write.ts +23 -0
  228. package/src/services/accounts/groups.ts +15 -4
  229. package/src/services/accounts/identities.ts +249 -0
  230. package/src/services/accounts/identity-reconciliation.ts +87 -0
  231. package/src/services/accounts/ipa-data.ts +2 -2
  232. package/src/services/accounts/local-groups.ts +2 -2
  233. package/src/services/accounts/notification-sender.ts +12 -2
  234. package/src/services/accounts/posix.ts +45 -29
  235. package/src/services/accounts/users.ts +2 -2
  236. package/src/services/announcements/index.ts +69 -8
  237. package/src/services/app-approval.ts +11 -10
  238. package/src/services/audit/index.ts +1 -1
  239. package/src/services/cache-fill.ts +35 -0
  240. package/src/services/help/index.ts +109 -0
  241. package/src/services/help/maintenance.ts +25 -0
  242. package/src/services/help/store.ts +180 -0
  243. package/src/services/help/types.ts +46 -0
  244. package/src/services/identity/invocation-actor.ts +2 -2
  245. package/src/services/identity/key-config.ts +8 -5
  246. package/src/services/identity/key-ring.ts +1 -1
  247. package/src/services/identity/runtime-config.ts +6 -5
  248. package/src/services/index.ts +15 -0
  249. package/src/services/ipa/posix.ts +1 -1
  250. package/src/services/ipa/sync.ts +1 -1
  251. package/src/services/ipa/users.ts +268 -221
  252. package/src/services/legal-consent.ts +1 -1
  253. package/src/services/logging/index.ts +15 -13
  254. package/src/services/logging/trace.ts +2 -1
  255. package/src/services/logging/web-vitals.ts +81 -0
  256. package/src/services/mandates/index.ts +21 -4
  257. package/src/services/mandates/policy.ts +165 -2
  258. package/src/services/notifications/batches.ts +125 -57
  259. package/src/services/notifications/browser.ts +12 -0
  260. package/src/services/notifications/index.ts +0 -2
  261. package/src/services/notifications/platform.ts +0 -4
  262. package/src/services/oauth-tokens.ts +3 -3
  263. package/src/services/pdf/gotenberg.ts +131 -72
  264. package/src/services/pdf/index.ts +11 -8
  265. package/src/services/providers/local/users.ts +25 -16
  266. package/src/services/public-http.ts +101 -0
  267. package/src/services/rail-shortcuts.ts +95 -0
  268. package/src/services/rail-snapshot.ts +53 -0
  269. package/src/services/request-cache-redis.ts +29 -0
  270. package/src/services/session/index.ts +15 -2
  271. package/src/services/session/user.ts +62 -37
  272. package/src/services/settings/app.ts +5 -1
  273. package/src/services/settings/core-settings.ts +26 -35
  274. package/src/services/settings/defaults.ts +5 -0
  275. package/src/services/settings/index.ts +3 -3
  276. package/src/services/settings/store.ts +58 -84
  277. package/src/services/webauthn.ts +1 -1
  278. package/src/shared/ai-costs.ts +42 -0
  279. package/src/shared/ai-platform-prompt.ts +1 -1
  280. package/src/shared/ai-quotas.ts +136 -0
  281. package/src/shared/ai-usage.ts +6 -16
  282. package/src/shared/app-presentation.ts +21 -15
  283. package/src/shared/capability-messages.ts +14 -0
  284. package/src/shared/help.ts +6 -42
  285. package/src/shared/index.ts +3 -0
  286. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  287. package/src/ssr/AdminLayout.tsx +7 -5
  288. package/src/ssr/AdminSidebar.tsx +30 -16
  289. package/src/ssr/AppLaunchpad.island.tsx +33 -136
  290. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  291. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  292. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  293. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  294. package/src/ssr/Layout.tsx +17 -6
  295. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  296. package/src/ssr/LayoutFooter.tsx +1 -1
  297. package/src/ssr/LayoutHeader.tsx +46 -14
  298. package/src/ssr/LayoutHelp.tsx +22 -23
  299. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  300. package/src/ssr/LayoutRail.tsx +17 -9
  301. package/src/ssr/MinimalLayout.tsx +1 -1
  302. package/src/ssr/MobileNavigation.tsx +71 -0
  303. package/src/ssr/MobileProfileActions.tsx +49 -0
  304. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  305. package/src/ssr/RailApps.island.tsx +2 -2
  306. package/src/ssr/RailEditor.tsx +17 -2
  307. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  308. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  309. package/src/ssr/admin-navigation.ts +11 -1
  310. package/src/ssr/help.ts +29 -0
  311. package/src/ssr/islands/SearchBar.tsx +67 -0
  312. package/src/ssr/islands/index.ts +3 -1
  313. package/src/ssr/layout-context.ts +2 -2
  314. package/src/ssr/layout-help-search.ts +15 -0
  315. package/src/ssr/mobile-menu-history.ts +70 -0
  316. package/src/ssr/platform-messages.ts +16 -8
  317. package/src/ssr/profile-actions.ts +42 -0
  318. package/src/ssr/rail-context.ts +8 -4
  319. package/src/ssr/rail-messages.ts +2 -0
  320. package/src/ssr/rail-navigation.ts +3 -3
  321. package/src/ssr/workspace-navigation.ts +75 -0
  322. package/src/styles/effects.css +6 -6
  323. package/src/styles/global.css +54 -0
  324. package/src/styles/input.css +43 -41
  325. package/src/styles/resource-search.css +505 -0
  326. package/src/styles/utilities-feedback.css +13 -0
  327. package/src/styles/utilities-layout.css +1 -5
  328. package/src/styles/utilities-navigation.css +3 -39
  329. package/src/types/ambient.d.ts +5 -0
  330. package/src/workflows/ai/index.ts +2 -2
  331. package/src/workflows/ai/runtime.ts +19 -90
  332. package/src/workflows/ai/store.ts +2 -0
  333. package/src/workflows/ai/types.ts +6 -82
  334. package/src/workflows/ai-actions.ts +16 -13
  335. package/src/workflows/definition.ts +10 -5
  336. package/src/workflows/store/actions.ts +44 -20
  337. package/src/workflows/store/index.ts +4 -2
  338. package/src/workflows/store/runs.ts +36 -0
  339. package/src/workflows/store/worker-pool.ts +55 -0
  340. package/src/workflows/store/worker-runtime.ts +90 -0
  341. package/src/ai/kit-skill.ts +0 -22
  342. package/src/ai/usage-migrate.ts +0 -81
  343. package/src/browser/notification-target.ts +0 -27
  344. package/src/contracts/notification-live.ts +0 -66
  345. package/src/server/services/freeipa/test-certificates.ts +0 -44
  346. package/src/services/notifications/live.ts +0 -50
  347. package/src/services/session/test-fixture.ts +0 -18
  348. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  349. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -4,6 +4,7 @@ import type { Worker } from "@k2b/sync";
4
4
  import { sql } from "bun";
5
5
  import { lazySync } from "../../_internal/process-sync";
6
6
  import { markdown } from "../../shared/markdown";
7
+ import { type AuditActor, audit } from "../audit";
7
8
  import { logger, trace } from "../logging";
8
9
  import { parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
9
10
 
@@ -406,7 +407,7 @@ export const createDraft = async (params: {
406
407
  subject: string;
407
408
  bodyMarkdown: string;
408
409
  selection: NotificationBatchSelection;
409
- createdBy: string;
410
+ actor: AuditActor & { userId: string };
410
411
  }): Promise<Result<NotificationBatch>> => {
411
412
  const subject = params.subject.trim();
412
413
  const bodyMarkdown = params.bodyMarkdown.trim();
@@ -422,12 +423,24 @@ export const createDraft = async (params: {
422
423
  }
423
424
  const hash = selectionHash(selection);
424
425
  const bodyHtml = markdown.renderSync(bodyMarkdown);
425
- const rows = await sql<BatchRow[]>`
426
- INSERT INTO notifications.batches (subject, body_markdown, body_html, selection, selection_hash, created_by)
427
- VALUES (${subject}, ${bodyMarkdown}, ${bodyHtml}, ${JSON.stringify(selection)}::jsonb, ${hash}, ${params.createdBy}::uuid)
428
- RETURNING *
429
- `;
430
- return ok(mapBatch(rows[0]!));
426
+ return sql.begin(async (tx) => {
427
+ const rows = await tx<BatchRow[]>`
428
+ INSERT INTO notifications.batches (subject, body_markdown, body_html, selection, selection_hash, created_by)
429
+ VALUES (${subject}, ${bodyMarkdown}, ${bodyHtml}, ${JSON.stringify(selection)}::text::jsonb, ${hash}, ${params.actor.userId}::uuid)
430
+ RETURNING *
431
+ `;
432
+ const batch = mapBatch(rows[0]!);
433
+ await audit.record(
434
+ {
435
+ action: "accounts.notification_batch.create",
436
+ outcome: "allowed",
437
+ actor: params.actor,
438
+ target: { type: "notification_batch", id: batch.id },
439
+ },
440
+ tx,
441
+ );
442
+ return ok(batch);
443
+ });
431
444
  };
432
445
 
433
446
  export const get = async (id: string): Promise<NotificationBatch | null> => {
@@ -504,7 +517,7 @@ export const listRecipients = async (params: {
504
517
 
505
518
  export const finalize = async (params: {
506
519
  id: string;
507
- actorUserId: string;
520
+ actor: AuditActor & { userId: string };
508
521
  expectedSelectionHash: string;
509
522
  expectedDeliverableCount: number;
510
523
  expectedRecipientHash: string;
@@ -568,7 +581,7 @@ export const finalize = async (params: {
568
581
  UPDATE notifications.batches
569
582
  SET
570
583
  status = 'ready',
571
- finalized_by = ${params.actorUserId}::uuid,
584
+ finalized_by = ${params.actor.userId}::uuid,
572
585
  finalized_at = now(),
573
586
  target_count = ${candidates.length},
574
587
  deliverable_count = ${deliverableCount},
@@ -576,6 +589,16 @@ export const finalize = async (params: {
576
589
  WHERE id = ${params.id}::uuid
577
590
  RETURNING *
578
591
  `;
592
+ await audit.record(
593
+ {
594
+ action: "accounts.notification_batch.finalize",
595
+ outcome: "allowed",
596
+ actor: params.actor,
597
+ target: { type: "notification_batch", id: params.id },
598
+ metadata: { targetCount: candidates.length, deliverableCount },
599
+ },
600
+ tx,
601
+ );
579
602
  return ok(mapBatch(updated[0]!));
580
603
  });
581
604
 
@@ -584,28 +607,45 @@ export const finalize = async (params: {
584
607
  return ok({ batch: result.data, jobId });
585
608
  };
586
609
 
587
- export const retryFailed = async (params: { id: string }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
610
+ export const retryFailed = async (params: {
611
+ id: string;
612
+ actor: AuditActor & { userId: string };
613
+ }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
588
614
  const batch = await get(params.id);
589
615
  if (!batch) return fail(err.notFound("Notification batch not found"));
590
616
  if (batch.status === "draft" || batch.status === "cancelled") {
591
617
  return fail(err.conflict("Only finalized notification batches can retry recipients"));
592
618
  }
593
619
 
594
- const updated = await sql<BatchRow[]>`
595
- UPDATE notifications.batch_recipients
596
- SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
597
- WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
598
- RETURNING user_id
599
- `;
600
- if (updated.length === 0) {
601
- return fail(err.conflict("No failed deliverable recipients can be retried"));
602
- }
620
+ const result = await sql.begin(async (tx) => {
621
+ const updated = await tx<BatchRow[]>`
622
+ UPDATE notifications.batch_recipients
623
+ SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
624
+ WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
625
+ RETURNING user_id
626
+ `;
627
+ if (updated.length === 0) {
628
+ return fail(err.conflict("No failed deliverable recipients can be retried"));
629
+ }
603
630
 
604
- await sql`
605
- UPDATE notifications.batches
606
- SET status = 'ready', completed_at = NULL, last_error = NULL
607
- WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
608
- `;
631
+ await tx`
632
+ UPDATE notifications.batches
633
+ SET status = 'ready', completed_at = NULL, last_error = NULL
634
+ WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
635
+ `;
636
+ await audit.record(
637
+ {
638
+ action: "accounts.notification_batch.retry_failed",
639
+ outcome: "allowed",
640
+ actor: params.actor,
641
+ target: { type: "notification_batch", id: params.id },
642
+ metadata: { recipientCount: updated.length },
643
+ },
644
+ tx,
645
+ );
646
+ return ok(null);
647
+ });
648
+ if (!result.ok) return result;
609
649
  const refreshed = await refreshBatchCounters(params.id);
610
650
  // A completing worker may already have sampled zero remaining recipients.
611
651
  // Explicit retries need their own wakeup even while that worker holds its key.
@@ -615,6 +655,7 @@ export const retryFailed = async (params: { id: string }): Promise<Result<{ batc
615
655
 
616
656
  export const retryRecipient = async (params: {
617
657
  id: string;
658
+ actor: AuditActor & { userId: string };
618
659
  userId: string;
619
660
  }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
620
661
  const batch = await get(params.id);
@@ -623,30 +664,44 @@ export const retryRecipient = async (params: {
623
664
  return fail(err.conflict("Only finalized notification batches can retry recipients"));
624
665
  }
625
666
 
626
- const updated = await sql<BatchRow[]>`
627
- UPDATE notifications.batch_recipients
628
- SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
629
- WHERE batch_id = ${params.id}::uuid
630
- AND user_id = ${params.userId}::uuid
631
- AND status = 'error'
632
- AND recipient IS NOT NULL
633
- RETURNING *
634
- `;
635
- if (!updated[0]) {
636
- const existing = await sql<BatchRow[]>`
637
- SELECT status, recipient
638
- FROM notifications.batch_recipients
639
- WHERE batch_id = ${params.id}::uuid AND user_id = ${params.userId}::uuid
667
+ const result = await sql.begin(async (tx) => {
668
+ const updated = await tx<BatchRow[]>`
669
+ UPDATE notifications.batch_recipients
670
+ SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
671
+ WHERE batch_id = ${params.id}::uuid
672
+ AND user_id = ${params.userId}::uuid
673
+ AND status = 'error'
674
+ AND recipient IS NOT NULL
675
+ RETURNING *
640
676
  `;
641
- if (!existing[0]) return fail(err.notFound("Notification recipient not found"));
642
- return fail(err.conflict("Only failed deliverable recipients can be retried"));
643
- }
677
+ if (!updated[0]) {
678
+ const existing = await tx<BatchRow[]>`
679
+ SELECT status, recipient
680
+ FROM notifications.batch_recipients
681
+ WHERE batch_id = ${params.id}::uuid AND user_id = ${params.userId}::uuid
682
+ `;
683
+ if (!existing[0]) return fail(err.notFound("Notification recipient not found"));
684
+ return fail(err.conflict("Only failed deliverable recipients can be retried"));
685
+ }
644
686
 
645
- await sql`
646
- UPDATE notifications.batches
647
- SET status = 'ready', completed_at = NULL, last_error = NULL
648
- WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
649
- `;
687
+ await tx`
688
+ UPDATE notifications.batches
689
+ SET status = 'ready', completed_at = NULL, last_error = NULL
690
+ WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
691
+ `;
692
+ await audit.record(
693
+ {
694
+ action: "accounts.notification_batch.retry_recipient",
695
+ outcome: "allowed",
696
+ actor: params.actor,
697
+ target: { type: "notification_batch", id: params.id },
698
+ metadata: { recipientUserId: params.userId },
699
+ },
700
+ tx,
701
+ );
702
+ return ok(null);
703
+ });
704
+ if (!result.ok) return result;
650
705
  const refreshed = await refreshBatchCounters(params.id);
651
706
  const { jobId } = await batchJob().submit({
652
707
  key: `${params.id}:retry:${crypto.randomUUID()}`,
@@ -655,17 +710,29 @@ export const retryRecipient = async (params: {
655
710
  return ok({ batch: refreshed ?? batch, jobId });
656
711
  };
657
712
 
658
- export const removeDraft = async (params: { id: string }): Promise<Result<{ id: string }>> => {
659
- const rows = await sql<BatchRow[]>`
660
- DELETE FROM notifications.batches
661
- WHERE id = ${params.id}::uuid AND status = 'draft'
662
- RETURNING id
663
- `;
664
- if (rows[0]) return ok({ id: rows[0].id as string });
665
-
666
- const batch = await get(params.id);
667
- if (!batch) return fail(err.notFound("Notification batch not found"));
668
- return fail(err.conflict("Only draft notification batches can be deleted"));
713
+ export const removeDraft = async (params: { id: string; actor: AuditActor & { userId: string } }): Promise<Result<{ id: string }>> => {
714
+ return sql.begin(async (tx) => {
715
+ const rows = await tx<BatchRow[]>`
716
+ DELETE FROM notifications.batches
717
+ WHERE id = ${params.id}::uuid AND status = 'draft'
718
+ RETURNING id
719
+ `;
720
+ if (rows[0]) {
721
+ await audit.record(
722
+ {
723
+ action: "accounts.notification_batch.delete",
724
+ outcome: "allowed",
725
+ actor: params.actor,
726
+ target: { type: "notification_batch", id: params.id },
727
+ },
728
+ tx,
729
+ );
730
+ return ok({ id: params.id });
731
+ }
732
+ const existing = await tx`SELECT id FROM notifications.batches WHERE id = ${params.id}::uuid`;
733
+ if (!existing[0]) return fail(err.notFound("Notification batch not found"));
734
+ return fail(err.conflict("Only draft notification batches can be deleted"));
735
+ });
669
736
  };
670
737
 
671
738
  export const start = async (): Promise<void> => {
@@ -719,4 +786,5 @@ export const __notificationBatchTest = {
719
786
  selectionHash,
720
787
  recipientHash,
721
788
  resolveCandidates,
789
+ processBatchChunk,
722
790
  };
@@ -152,6 +152,18 @@ const browserDriver = {
152
152
  },
153
153
  deliver: async (value: unknown) => {
154
154
  const payload: BrowserDeliveryPayload = BrowserDeliveryPayloadSchema.parse(value);
155
+ // A queued payload can outlive logout, disable, or rebinding this browser to another user.
156
+ const [endpoint] = await sql<{ id: string }[]>`
157
+ SELECT id FROM notifications.endpoints
158
+ WHERE id = ${payload.endpointId}::uuid AND channel = 'browser'
159
+ AND verified_at IS NOT NULL AND disabled_at IS NULL
160
+ `;
161
+ if (!endpoint) {
162
+ throw Object.assign(new Error("Browser notification endpoint is no longer registered"), {
163
+ code: "endpoint_gone",
164
+ retryable: false,
165
+ });
166
+ }
155
167
  await ensureWebPushConfigured();
156
168
  try {
157
169
  await sendPinnedWebPush(
@@ -6,7 +6,6 @@ import { logger } from "../logging";
6
6
  import { escapeLikePattern } from "../postgres";
7
7
  import { sendEmail } from "./email";
8
8
  import "./browser";
9
- import { notificationLive } from "./live";
10
9
  import { notificationObservability } from "./observability";
11
10
  import { sendTypedNotification, type TypedNotificationSendResult } from "./platform";
12
11
  import { userNotifications } from "./user";
@@ -610,7 +609,6 @@ export const notifications = {
610
609
  sendAllPendingSystem,
611
610
  getStatusSummary,
612
611
  getSearchSummary,
613
- live: notificationLive,
614
612
  observability: notificationObservability,
615
613
  user: userNotifications,
616
614
  };
@@ -15,7 +15,6 @@ import { encryptSecret } from "../secrets";
15
15
  import { ensureNotificationDefinition } from "./catalog";
16
16
  import { getNotificationChannel, type NotificationDestination, type ResolvedNotificationRecipient } from "./channels";
17
17
  import { processNotificationDelivery } from "./dispatcher";
18
- import { notificationLive } from "./live";
19
18
  import { enqueueNotificationDeliveries, enqueueNotificationDelivery } from "./runtime";
20
19
 
21
20
  export type TypedNotificationDeliveryStatus = "deferred" | "pending" | "sending" | "delivered" | "suppressed" | "failed";
@@ -406,9 +405,6 @@ export const sendTypedNotification = async <
406
405
  });
407
406
 
408
407
  if (persisted.prepared) {
409
- if (resolved.recipient.userId && (requiredChannelSet.has("browser") || preferred.includes("browser"))) {
410
- await notificationLive.publish({ userId: resolved.recipient.userId, eventId: identity.id, presentation });
411
- }
412
408
  for (const deliveryId of persisted.requiredIds) {
413
409
  const result = await processNotificationDelivery(deliveryId);
414
410
  if (result.status === "retry") await enqueueNotificationDelivery(deliveryId, result.retryAfterMs);
@@ -6,7 +6,7 @@ import { isAccountExpired } from "./account-model";
6
6
  import { IDENTITY_JWKS_MAX_AGE_SECONDS } from "./identity/constants";
7
7
  import { getIdentityRuntimeConfig } from "./identity/runtime-config";
8
8
  import type { ServiceAccount } from "./service-accounts";
9
- import { buildProjectedUser, userProjectionSql } from "./session/user";
9
+ import { buildProjectedUser, userProjectionJoin, userProjectionSql } from "./session/user";
10
10
 
11
11
  type ServiceAccountActorRow = Record<string, unknown> & {
12
12
  service_account_id: string;
@@ -116,7 +116,7 @@ export const resolveOAuthTokenActor = async (
116
116
  FROM oauth.clients c
117
117
  JOIN auth.service_accounts sa ON sa.id = ${serviceAccountId}::uuid AND sa.status = 'active'
118
118
  LEFT JOIN auth.users u ON u.id = sa.delegated_user_id
119
- LEFT JOIN auth.user_ipa_data ui ON ui.user_id = u.id
119
+ ${userProjectionJoin()}
120
120
  WHERE c.client_id = ${clientId}
121
121
  `;
122
122
  const row = rows[0];
@@ -139,7 +139,7 @@ export const resolveOAuthTokenActor = async (
139
139
  SELECT ${userProjectionSql(groupsAdmin)}
140
140
  FROM oauth.clients c
141
141
  JOIN auth.users u ON ${predicate}
142
- LEFT JOIN auth.user_ipa_data ui ON ui.user_id = u.id
142
+ ${userProjectionJoin()}
143
143
  WHERE c.client_id = ${clientId}
144
144
  `;
145
145
  const user = rows[0] ? buildProjectedUser(rows[0]) : null;
@@ -34,6 +34,18 @@ export type RenderHtmlToPdfInput = {
34
34
  headerHtml?: string | null;
35
35
  footerHtml?: string | null;
36
36
  filename?: string;
37
+ assets?: Array<{ name: string; data: Blob }>;
38
+ page?: {
39
+ format?: "A4" | "A3" | "A5" | "Letter" | "Legal";
40
+ landscape?: boolean;
41
+ margin?: { top?: number; right?: number; bottom?: number; left?: number };
42
+ };
43
+ tagged?: boolean;
44
+ };
45
+
46
+ export type AttachPdfFilesInput = {
47
+ document: Blob;
48
+ attachments: Array<{ name: string; data: Blob; relationship?: "Source" | "Data" | "Alternative" | "Supplement" | "Unspecified" }>;
37
49
  };
38
50
 
39
51
  export type RenderFacturXHtmlToPdfInput = RenderHtmlToPdfInput & {
@@ -59,6 +71,7 @@ export type GotenbergFetch = (input: string | URL | Request, init?: RequestInit)
59
71
 
60
72
  export type RenderHtmlToPdfOptions = {
61
73
  fetch?: GotenbergFetch;
74
+ signal?: AbortSignal;
62
75
  };
63
76
 
64
77
  const DEFAULT_PDF_CONTENT_TYPE = "application/pdf";
@@ -105,111 +118,154 @@ const sanitizeFetchError = (error: unknown): GotenbergRenderError => {
105
118
  return new GotenbergRenderError("request_failed", "Gotenberg request failed.");
106
119
  };
107
120
 
108
- export const renderHtmlToPdfWithConfig = async (
109
- input: RenderHtmlToPdfInput,
110
- config: GotenbergConfig,
111
- options: RenderHtmlToPdfOptions = {},
112
- ): Promise<RenderHtmlToPdfResult> => {
113
- const htmlBytes = byteLength(input.html);
114
- if (htmlBytes > config.maxHtmlBytes) {
115
- throw new GotenbergRenderError("html_too_large", `HTML input is too large (${htmlBytes} bytes, limit ${config.maxHtmlBytes} bytes).`);
116
- }
121
+ const dimensions = { A4: [210, 297], A3: [297, 420], A5: [148, 210], Letter: [215.9, 279.4], Legal: [215.9, 355.6] } as const;
122
+ const fileName = (name: string) => {
123
+ if (!name || name.length > 180 || /[\\/\x00-\x1f]/.test(name) || name === "." || name === "..")
124
+ throw new GotenbergRenderError("bad_input", "Use a plain filename without directories.");
125
+ return name;
126
+ };
117
127
 
118
- const baseUrl = normalizeBaseUrl(config.url);
128
+ function htmlForm(input: RenderHtmlToPdfInput, config: GotenbergConfig): FormData {
129
+ const total =
130
+ [input.html, input.headerHtml ?? "", input.footerHtml ?? ""].reduce((sum, value) => sum + byteLength(value), 0) +
131
+ (input.assets ?? []).reduce((sum, asset) => sum + asset.data.size, 0);
132
+ if (total > config.maxHtmlBytes) throw new GotenbergRenderError("html_too_large", "HTML and assets exceed the configured input budget.");
119
133
  const form = new FormData();
120
134
  form.append("files", new Blob([input.html], { type: "text/html" }), "index.html");
121
135
  if (input.headerHtml?.trim()) form.append("files", new Blob([input.headerHtml], { type: "text/html" }), "header.html");
122
136
  if (input.footerHtml?.trim()) form.append("files", new Blob([input.footerHtml], { type: "text/html" }), "footer.html");
123
- form.append("preferCssPageSize", "true");
137
+ const names = new Set(["index.html", "header.html", "footer.html", "factur-x.xml"]);
138
+ for (const asset of input.assets ?? []) {
139
+ const name = fileName(asset.name);
140
+ if (names.has(name.toLowerCase()))
141
+ throw new GotenbergRenderError("bad_input", "Asset filenames must be unique and cannot replace HTML documents.");
142
+ names.add(name.toLowerCase());
143
+ form.append("files", asset.data, name);
144
+ }
145
+ // Preserve the existing CSS page-size contract unless a caller supplies page options.
146
+ form.append("preferCssPageSize", String(!input.page));
124
147
  form.append("printBackground", "true");
148
+ if (input.tagged !== undefined) form.append("generateTaggedPdf", String(input.tagged));
149
+ if (input.page) {
150
+ const [width, height] = dimensions[input.page.format ?? "A4"];
151
+ form.append("paperWidth", String(width / 25.4));
152
+ form.append("paperHeight", String(height / 25.4));
153
+ form.append("landscape", String(input.page.landscape ?? false));
154
+ for (const side of ["top", "right", "bottom", "left"] as const) {
155
+ const value = input.page.margin?.[side] ?? 15;
156
+ if (!Number.isFinite(value) || value < 0)
157
+ throw new GotenbergRenderError("bad_input", "Page margins must be nonnegative millimeters.");
158
+ form.append(`margin${side[0]!.toUpperCase()}${side.slice(1)}`, String(value / 25.4));
159
+ }
160
+ }
161
+ return form;
162
+ }
125
163
 
164
+ /** Bounded transport shared by HTML conversion and attachment embedding. */
165
+ async function requestPdf(
166
+ route: string,
167
+ form: FormData,
168
+ config: GotenbergConfig,
169
+ options: RenderHtmlToPdfOptions,
170
+ ): Promise<RenderHtmlToPdfResult> {
126
171
  const headers = new Headers();
127
- const authHeader = basicAuthHeader(config);
128
- if (authHeader) headers.set("Authorization", authHeader);
129
-
130
- const transport = options.fetch ?? fetch;
131
- let response: Response;
172
+ const auth = basicAuthHeader(config);
173
+ if (auth) headers.set("Authorization", auth);
174
+ const signal = options.signal ? AbortSignal.any([options.signal, abortSignal(config.timeoutMs)]) : abortSignal(config.timeoutMs);
175
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
132
176
  try {
133
- response = await transport(`${baseUrl}/forms/chromium/convert/html`, {
177
+ const response = await (options.fetch ?? fetch)(`${normalizeBaseUrl(config.url)}${route}`, {
134
178
  method: "POST",
135
179
  headers,
136
180
  body: form,
137
- signal: abortSignal(config.timeoutMs),
181
+ signal,
138
182
  });
183
+ if (!response.ok) {
184
+ await response.body?.cancel();
185
+ throw new GotenbergRenderError("bad_response", `Gotenberg returned HTTP ${response.status}.`, response.status);
186
+ }
187
+ const contentType = response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE;
188
+ if (contentType.split(";")[0] !== DEFAULT_PDF_CONTENT_TYPE) {
189
+ await response.body?.cancel();
190
+ throw new GotenbergRenderError("bad_response", "Gotenberg did not return a PDF.");
191
+ }
192
+ if (!response.body) throw new GotenbergRenderError("bad_response", "Gotenberg returned an empty response.");
193
+ reader = response.body.getReader();
194
+ const chunks: Uint8Array[] = [];
195
+ let size = 0;
196
+ while (true) {
197
+ signal.throwIfAborted();
198
+ const item = await reader.read();
199
+ if (item.done) break;
200
+ size += item.value.byteLength;
201
+ if (size > config.maxPdfBytes) throw new GotenbergRenderError("pdf_too_large", "PDF output exceeds the configured budget.");
202
+ chunks.push(item.value);
203
+ }
204
+ const pdf = new Uint8Array(size);
205
+ let offset = 0;
206
+ for (const chunk of chunks) {
207
+ pdf.set(chunk, offset);
208
+ offset += chunk.byteLength;
209
+ }
210
+ return { pdf, contentType };
139
211
  } catch (error) {
212
+ await reader?.cancel().catch(() => {});
140
213
  throw sanitizeFetchError(error);
214
+ } finally {
215
+ reader?.releaseLock();
141
216
  }
217
+ }
142
218
 
143
- if (!response.ok) {
144
- throw new GotenbergRenderError("bad_response", `Gotenberg returned HTTP ${response.status}.`, response.status);
145
- }
146
-
147
- const buffer = await response.arrayBuffer();
148
- if (buffer.byteLength > config.maxPdfBytes) {
149
- throw new GotenbergRenderError(
150
- "pdf_too_large",
151
- `PDF output is too large (${buffer.byteLength} bytes, limit ${config.maxPdfBytes} bytes).`,
152
- );
153
- }
154
-
155
- return {
156
- pdf: new Uint8Array(buffer),
157
- contentType: response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE,
158
- };
159
- };
219
+ export const renderHtmlToPdfWithConfig = async (
220
+ input: RenderHtmlToPdfInput,
221
+ config: GotenbergConfig,
222
+ options: RenderHtmlToPdfOptions = {},
223
+ ): Promise<RenderHtmlToPdfResult> => requestPdf("/forms/chromium/convert/html", htmlForm(input, config), config, options);
160
224
 
161
225
  export const renderFacturXHtmlToPdfWithConfig = async (
162
226
  input: RenderFacturXHtmlToPdfInput,
163
227
  config: GotenbergConfig,
164
228
  options: RenderHtmlToPdfOptions = {},
165
229
  ): Promise<RenderHtmlToPdfResult> => {
166
- const htmlBytes = byteLength(input.html);
167
230
  const xmlBytes = byteLength(input.xml);
168
- if (htmlBytes > config.maxHtmlBytes) {
169
- throw new GotenbergRenderError("html_too_large", `HTML input is too large (${htmlBytes} bytes, limit ${config.maxHtmlBytes} bytes).`);
170
- }
171
- if (xmlBytes === 0 || xmlBytes > config.maxHtmlBytes) {
231
+ if (!xmlBytes || xmlBytes > config.maxHtmlBytes)
172
232
  throw new GotenbergRenderError(
173
- xmlBytes === 0 ? "bad_input" : "html_too_large",
174
- xmlBytes === 0
175
- ? "Factur-X XML must not be empty."
176
- : `Factur-X XML input is too large (${xmlBytes} bytes, limit ${config.maxHtmlBytes} bytes).`,
233
+ xmlBytes ? "html_too_large" : "bad_input",
234
+ "Invoice XML is empty or exceeds the configured input budget.",
177
235
  );
178
- }
179
-
180
- const baseUrl = normalizeBaseUrl(config.url);
181
- const form = new FormData();
182
- form.append("files", new Blob([input.html], { type: "text/html" }), "index.html");
183
- if (input.headerHtml?.trim()) form.append("files", new Blob([input.headerHtml], { type: "text/html" }), "header.html");
184
- if (input.footerHtml?.trim()) form.append("files", new Blob([input.footerHtml], { type: "text/html" }), "footer.html");
236
+ const form = htmlForm(input, { ...config, maxHtmlBytes: config.maxHtmlBytes - xmlBytes });
185
237
  form.append("facturxXml", new Blob([input.xml], { type: "application/xml" }), "factur-x.xml");
186
238
  form.append("facturxConformanceLevel", input.conformanceLevel ?? "EN 16931");
187
239
  form.append("facturxDocumentType", input.documentType ?? "INVOICE");
188
240
  form.append("facturxVersion", input.facturXVersion ?? "1.0");
189
241
  form.append("pdfa", "PDF/A-3b");
190
- form.append("preferCssPageSize", "true");
191
- form.append("printBackground", "true");
242
+ return requestPdf("/forms/chromium/convert/html", form, config, options);
243
+ };
192
244
 
193
- const headers = new Headers();
194
- const authHeader = basicAuthHeader(config);
195
- if (authHeader) headers.set("Authorization", authHeader);
196
- let response: Response;
197
- try {
198
- response = await (options.fetch ?? fetch)(`${baseUrl}/forms/chromium/convert/html`, {
199
- method: "POST",
200
- headers,
201
- body: form,
202
- signal: abortSignal(config.timeoutMs),
203
- });
204
- } catch (error) {
205
- throw sanitizeFetchError(error);
206
- }
207
- if (!response.ok) throw new GotenbergRenderError("bad_response", `Gotenberg returned HTTP ${response.status}.`, response.status);
208
- const buffer = await response.arrayBuffer();
209
- if (buffer.byteLength > config.maxPdfBytes) {
210
- throw new GotenbergRenderError("pdf_too_large", `PDF output is too large (${buffer.byteLength} bytes, limit ${config.maxPdfBytes} bytes).`);
245
+ export const attachPdfFilesWithConfig = async (
246
+ input: AttachPdfFilesInput,
247
+ config: GotenbergConfig,
248
+ options: RenderHtmlToPdfOptions = {},
249
+ ): Promise<RenderHtmlToPdfResult> => {
250
+ if (!input.attachments.length) throw new GotenbergRenderError("bad_input", "At least one attachment is required.");
251
+ if (input.document.size + input.attachments.reduce((sum, file) => sum + file.data.size, 0) > config.maxPdfBytes)
252
+ throw new GotenbergRenderError("pdf_too_large", "PDF and attachments exceed the configured input budget.");
253
+ const form = new FormData();
254
+ form.append("files", input.document, "document.pdf");
255
+ const metadata: Record<string, { mimeType: string; relationship: string }> = Object.create(null);
256
+ const names = new Set<string>();
257
+ for (const attachment of input.attachments) {
258
+ const name = fileName(attachment.name);
259
+ if (names.has(name.toLowerCase())) throw new GotenbergRenderError("bad_input", "Attachment filenames must be unique.");
260
+ names.add(name.toLowerCase());
261
+ form.append("embeds", attachment.data, name);
262
+ metadata[name] = {
263
+ mimeType: attachment.data.type || "application/octet-stream",
264
+ relationship: attachment.relationship ?? "Unspecified",
265
+ };
211
266
  }
212
- return { pdf: new Uint8Array(buffer), contentType: response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE };
267
+ form.append("embedsMetadata", JSON.stringify(metadata));
268
+ return requestPdf("/forms/pdfengines/embed", form, config, options);
213
269
  };
214
270
 
215
271
  export const mergePdfsWithConfig = async (
@@ -288,3 +344,6 @@ export const testGotenberg = async (options: RenderHtmlToPdfOptions = {}): Promi
288
344
  const result = await renderHtmlToPdf({ html: TEST_HTML, filename: "index.html" }, options);
289
345
  return { bytes: result.pdf.byteLength, contentType: result.contentType };
290
346
  };
347
+
348
+ export const attachPdfFiles = async (input: AttachPdfFilesInput, options: RenderHtmlToPdfOptions = {}): Promise<RenderHtmlToPdfResult> =>
349
+ attachPdfFilesWithConfig(input, await getGotenbergConfig(), options);
@@ -1,4 +1,5 @@
1
1
  export type {
2
+ AttachPdfFilesInput,
2
3
  GotenbergConfig,
3
4
  GotenbergRenderErrorCode,
4
5
  MergePdfsInput,
@@ -8,6 +9,8 @@ export type {
8
9
  RenderHtmlToPdfResult,
9
10
  } from "./gotenberg";
10
11
  export {
12
+ attachPdfFiles,
13
+ attachPdfFilesWithConfig,
11
14
  GotenbergRenderError,
12
15
  getGotenbergConfig,
13
16
  mergePdfs,
@@ -18,14 +21,6 @@ export {
18
21
  renderHtmlToPdfWithConfig,
19
22
  testGotenberg,
20
23
  } from "./gotenberg";
21
- export type {
22
- RenderTemplatePdfPreviewInput,
23
- RenderTemplatePdfPreviewOptions,
24
- TemplatePdfPreviewError,
25
- TemplatePdfPreviewPhase,
26
- TemplatePdfPreviewResult,
27
- } from "./template-preview";
28
- export { renderTemplatePdfPreview } from "./template-preview";
29
24
  export type {
30
25
  MarkdownPdfErrorCode,
31
26
  MarkdownPdfTemplateId,
@@ -41,3 +36,11 @@ export {
41
36
  renderMarkdownToPdf,
42
37
  renderMarkdownToPdfWithConfig,
43
38
  } from "./markdown";
39
+ export type {
40
+ RenderTemplatePdfPreviewInput,
41
+ RenderTemplatePdfPreviewOptions,
42
+ TemplatePdfPreviewError,
43
+ TemplatePdfPreviewPhase,
44
+ TemplatePdfPreviewResult,
45
+ } from "./template-preview";
46
+ export { renderTemplatePdfPreview } from "./template-preview";