@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -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,118 @@ 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 = [input.html, input.headerHtml ?? "", input.footerHtml ?? ""].reduce((sum, value) => sum + byteLength(value), 0)
130
+ + (input.assets ?? []).reduce((sum, asset) => sum + asset.data.size, 0);
131
+ if (total > config.maxHtmlBytes) throw new GotenbergRenderError("html_too_large", "HTML and assets exceed the configured input budget.");
119
132
  const form = new FormData();
120
133
  form.append("files", new Blob([input.html], { type: "text/html" }), "index.html");
121
134
  if (input.headerHtml?.trim()) form.append("files", new Blob([input.headerHtml], { type: "text/html" }), "header.html");
122
135
  if (input.footerHtml?.trim()) form.append("files", new Blob([input.footerHtml], { type: "text/html" }), "footer.html");
123
- form.append("preferCssPageSize", "true");
136
+ const names = new Set(["index.html", "header.html", "footer.html", "factur-x.xml"]);
137
+ for (const asset of input.assets ?? []) {
138
+ const name = fileName(asset.name);
139
+ if (names.has(name.toLowerCase())) throw new GotenbergRenderError("bad_input", "Asset filenames must be unique and cannot replace HTML documents.");
140
+ names.add(name.toLowerCase());
141
+ form.append("files", asset.data, name);
142
+ }
143
+ // Preserve the existing CSS page-size contract unless a caller supplies page options.
144
+ form.append("preferCssPageSize", String(!input.page));
124
145
  form.append("printBackground", "true");
146
+ if (input.tagged !== undefined) form.append("generateTaggedPdf", String(input.tagged));
147
+ if (input.page) {
148
+ const [width, height] = dimensions[input.page.format ?? "A4"];
149
+ form.append("paperWidth", String(width / 25.4));
150
+ form.append("paperHeight", String(height / 25.4));
151
+ form.append("landscape", String(input.page.landscape ?? false));
152
+ for (const side of ["top", "right", "bottom", "left"] as const) {
153
+ const value = input.page.margin?.[side] ?? 15;
154
+ if (!Number.isFinite(value) || value < 0) throw new GotenbergRenderError("bad_input", "Page margins must be nonnegative millimeters.");
155
+ form.append(`margin${side[0]!.toUpperCase()}${side.slice(1)}`, String(value / 25.4));
156
+ }
157
+ }
158
+ return form;
159
+ }
125
160
 
161
+ /** Bounded transport shared by HTML conversion and attachment embedding. */
162
+ async function requestPdf(route: string, form: FormData, config: GotenbergConfig, options: RenderHtmlToPdfOptions): Promise<RenderHtmlToPdfResult> {
126
163
  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;
164
+ const auth = basicAuthHeader(config);
165
+ if (auth) headers.set("Authorization", auth);
166
+ const signal = options.signal ? AbortSignal.any([options.signal, abortSignal(config.timeoutMs)]) : abortSignal(config.timeoutMs);
167
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
132
168
  try {
133
- response = await transport(`${baseUrl}/forms/chromium/convert/html`, {
134
- method: "POST",
135
- headers,
136
- body: form,
137
- signal: abortSignal(config.timeoutMs),
138
- });
169
+ const response = await (options.fetch ?? fetch)(`${normalizeBaseUrl(config.url)}${route}`, { method: "POST", headers, body: form, signal });
170
+ if (!response.ok) {
171
+ await response.body?.cancel();
172
+ throw new GotenbergRenderError("bad_response", `Gotenberg returned HTTP ${response.status}.`, response.status);
173
+ }
174
+ const contentType = response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE;
175
+ if (contentType.split(";")[0] !== DEFAULT_PDF_CONTENT_TYPE) {
176
+ await response.body?.cancel();
177
+ throw new GotenbergRenderError("bad_response", "Gotenberg did not return a PDF.");
178
+ }
179
+ if (!response.body) throw new GotenbergRenderError("bad_response", "Gotenberg returned an empty response.");
180
+ reader = response.body.getReader();
181
+ const chunks: Uint8Array[] = [];
182
+ let size = 0;
183
+ while (true) {
184
+ signal.throwIfAborted();
185
+ const item = await reader.read();
186
+ if (item.done) break;
187
+ size += item.value.byteLength;
188
+ if (size > config.maxPdfBytes) throw new GotenbergRenderError("pdf_too_large", "PDF output exceeds the configured budget.");
189
+ chunks.push(item.value);
190
+ }
191
+ const pdf = new Uint8Array(size);
192
+ let offset = 0;
193
+ for (const chunk of chunks) { pdf.set(chunk, offset); offset += chunk.byteLength; }
194
+ return { pdf, contentType };
139
195
  } catch (error) {
196
+ await reader?.cancel().catch(() => {});
140
197
  throw sanitizeFetchError(error);
141
- }
142
-
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
- }
198
+ } finally { reader?.releaseLock(); }
199
+ }
154
200
 
155
- return {
156
- pdf: new Uint8Array(buffer),
157
- contentType: response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE,
158
- };
159
- };
201
+ export const renderHtmlToPdfWithConfig = async (input: RenderHtmlToPdfInput, config: GotenbergConfig, options: RenderHtmlToPdfOptions = {}): Promise<RenderHtmlToPdfResult> =>
202
+ requestPdf("/forms/chromium/convert/html", htmlForm(input, config), config, options);
160
203
 
161
- export const renderFacturXHtmlToPdfWithConfig = async (
162
- input: RenderFacturXHtmlToPdfInput,
163
- config: GotenbergConfig,
164
- options: RenderHtmlToPdfOptions = {},
165
- ): Promise<RenderHtmlToPdfResult> => {
166
- const htmlBytes = byteLength(input.html);
204
+ export const renderFacturXHtmlToPdfWithConfig = async (input: RenderFacturXHtmlToPdfInput, config: GotenbergConfig, options: RenderHtmlToPdfOptions = {}): Promise<RenderHtmlToPdfResult> => {
167
205
  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) {
172
- 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).`,
177
- );
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");
206
+ if (!xmlBytes || xmlBytes > config.maxHtmlBytes) throw new GotenbergRenderError(xmlBytes ? "html_too_large" : "bad_input", "Invoice XML is empty or exceeds the configured input budget.");
207
+ const form = htmlForm(input, { ...config, maxHtmlBytes: config.maxHtmlBytes - xmlBytes });
185
208
  form.append("facturxXml", new Blob([input.xml], { type: "application/xml" }), "factur-x.xml");
186
209
  form.append("facturxConformanceLevel", input.conformanceLevel ?? "EN 16931");
187
210
  form.append("facturxDocumentType", input.documentType ?? "INVOICE");
188
211
  form.append("facturxVersion", input.facturXVersion ?? "1.0");
189
212
  form.append("pdfa", "PDF/A-3b");
190
- form.append("preferCssPageSize", "true");
191
- form.append("printBackground", "true");
213
+ return requestPdf("/forms/chromium/convert/html", form, config, options);
214
+ };
192
215
 
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).`);
216
+ export const attachPdfFilesWithConfig = async (input: AttachPdfFilesInput, config: GotenbergConfig, options: RenderHtmlToPdfOptions = {}): Promise<RenderHtmlToPdfResult> => {
217
+ if (!input.attachments.length) throw new GotenbergRenderError("bad_input", "At least one attachment is required.");
218
+ if (input.document.size + input.attachments.reduce((sum, file) => sum + file.data.size, 0) > config.maxPdfBytes)
219
+ throw new GotenbergRenderError("pdf_too_large", "PDF and attachments exceed the configured input budget.");
220
+ const form = new FormData();
221
+ form.append("files", input.document, "document.pdf");
222
+ const metadata: Record<string, { mimeType: string; relationship: string }> = Object.create(null);
223
+ const names = new Set<string>();
224
+ for (const attachment of input.attachments) {
225
+ const name = fileName(attachment.name);
226
+ if (names.has(name.toLowerCase())) throw new GotenbergRenderError("bad_input", "Attachment filenames must be unique.");
227
+ names.add(name.toLowerCase());
228
+ form.append("embeds", attachment.data, name);
229
+ metadata[name] = { mimeType: attachment.data.type || "application/octet-stream", relationship: attachment.relationship ?? "Unspecified" };
211
230
  }
212
- return { pdf: new Uint8Array(buffer), contentType: response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE };
231
+ form.append("embedsMetadata", JSON.stringify(metadata));
232
+ return requestPdf("/forms/pdfengines/embed", form, config, options);
213
233
  };
214
234
 
215
235
  export const mergePdfsWithConfig = async (
@@ -288,3 +308,6 @@ export const testGotenberg = async (options: RenderHtmlToPdfOptions = {}): Promi
288
308
  const result = await renderHtmlToPdf({ html: TEST_HTML, filename: "index.html" }, options);
289
309
  return { bytes: result.pdf.byteLength, contentType: result.contentType };
290
310
  };
311
+
312
+ export const attachPdfFiles = async (input: AttachPdfFilesInput, options: RenderHtmlToPdfOptions = {}): Promise<RenderHtmlToPdfResult> =>
313
+ attachPdfFilesWithConfig(input, await getGotenbergConfig(), options);
@@ -1,5 +1,6 @@
1
1
  export type {
2
2
  GotenbergConfig,
3
+ AttachPdfFilesInput,
3
4
  GotenbergRenderErrorCode,
4
5
  MergePdfsInput,
5
6
  RenderFacturXHtmlToPdfInput,
@@ -10,6 +11,8 @@ export type {
10
11
  export {
11
12
  GotenbergRenderError,
12
13
  getGotenbergConfig,
14
+ attachPdfFiles,
15
+ attachPdfFilesWithConfig,
13
16
  mergePdfs,
14
17
  mergePdfsWithConfig,
15
18
  renderFacturXHtmlToPdf,
@@ -2,6 +2,7 @@ import { sql } from "bun";
2
2
  import type { MutationResult, UserProfile } from "../../../contracts/shared";
3
3
  import { isAccountCategoryAllowed } from "../../account-category-policy";
4
4
  import { writeDeletedAccountAudit } from "../../account-lifecycle/audit";
5
+ import { emailAlreadyUsed, findAccountsByEmail, lockAccountEmail, normalizeAccountEmail } from "../../accounts/email-write";
5
6
  import { resolveStoredAdminState } from "../../accounts/model";
6
7
  import { writeLocalAccount } from "../../accounts/posix";
7
8
  import type { AuditActor } from "../../audit";
@@ -31,6 +32,7 @@ export const create = async (params: {
31
32
  admin?: boolean;
32
33
  actor?: AuditActor;
33
34
  }): Promise<MutationResult<{ id: string }>> => {
35
+ const email = normalizeAccountEmail(params.data.email);
34
36
  const uid = await createLocalUid();
35
37
  const admin = resolveStoredAdminState({
36
38
  provider: "local",
@@ -42,6 +44,8 @@ export const create = async (params: {
42
44
  return await writeLocalAccount(async (tx) => {
43
45
  if (!(await isAccountCategoryAllowed({ provider: "local", profile: params.profile }, tx)))
44
46
  return { ok: false, error: "This account category is disabled. Contact an administrator.", status: 403 };
47
+ await lockAccountEmail(tx, email);
48
+ if ((await findAccountsByEmail(tx, email)).length > 0) return emailAlreadyUsed();
45
49
  const rows = await tx<{ id: string }[]>`
46
50
  INSERT INTO auth.users (
47
51
  uid,
@@ -58,7 +62,7 @@ export const create = async (params: {
58
62
  ${uid},
59
63
  'local',
60
64
  ${params.profile},
61
- ${params.data.email},
65
+ ${email},
62
66
  ${params.data.givenname ?? ""},
63
67
  ${params.data.sn ?? ""},
64
68
  ${params.data.displayName ?? ""},
@@ -118,21 +122,26 @@ export const update = async (params: {
118
122
  mail?: string;
119
123
  };
120
124
  }): Promise<MutationResult<void>> => {
121
- const existingRows = await sql<DbRow[]>`SELECT id FROM auth.users WHERE id = ${params.id}::uuid AND provider = 'local'`;
122
- if (existingRows.length === 0) {
123
- return { ok: false, error: "Local user not found", status: 404 };
124
- }
125
-
126
- await sql`
127
- UPDATE auth.users
128
- SET given_name = CASE WHEN ${params.data.givenname !== undefined} THEN ${params.data.givenname ?? ""} ELSE given_name END,
129
- sn = CASE WHEN ${params.data.sn !== undefined} THEN ${params.data.sn ?? ""} ELSE sn END,
130
- display_name = CASE WHEN ${params.data.displayName !== undefined} THEN ${params.data.displayName ?? ""} ELSE display_name END,
131
- mail = CASE WHEN ${params.data.mail !== undefined} THEN ${params.data.mail ?? null} ELSE mail END
132
- WHERE id = ${params.id}::uuid
133
- `;
134
-
135
- return { ok: true, data: undefined };
125
+ return sql.begin(async (tx) => {
126
+ if (params.data.mail !== undefined) await lockAccountEmail(tx, params.data.mail);
127
+ else await tx`LOCK TABLE auth.users IN ROW EXCLUSIVE MODE`;
128
+ const [existing] = await tx<{ mail: string | null }[]>`
129
+ SELECT mail FROM auth.users WHERE id = ${params.id}::uuid AND provider = 'local' FOR UPDATE
130
+ `;
131
+ if (!existing) return { ok: false, error: "Local user not found", status: 404 };
132
+ const email = params.data.mail === undefined ? undefined : normalizeAccountEmail(params.data.mail);
133
+ const changed = email !== undefined && email !== normalizeAccountEmail(existing.mail ?? "");
134
+ if (changed && email && (await findAccountsByEmail(tx, email)).some((row) => row.id !== params.id)) return emailAlreadyUsed();
135
+ await tx`
136
+ UPDATE auth.users
137
+ SET given_name = CASE WHEN ${params.data.givenname !== undefined} THEN ${params.data.givenname ?? ""} ELSE given_name END,
138
+ sn = CASE WHEN ${params.data.sn !== undefined} THEN ${params.data.sn ?? ""} ELSE sn END,
139
+ display_name = CASE WHEN ${params.data.displayName !== undefined} THEN ${params.data.displayName ?? ""} ELSE display_name END,
140
+ mail = CASE WHEN ${changed} THEN ${email || null} ELSE mail END
141
+ WHERE id = ${params.id}::uuid
142
+ `;
143
+ return { ok: true, data: undefined };
144
+ });
136
145
  };
137
146
 
138
147
  export const setProfile = async (params: {
@@ -0,0 +1,101 @@
1
+ import { request, type RequestOptions } from "node:https";
2
+ import type { ClientRequest, IncomingMessage } from "node:http";
3
+ import { resolvePublicNetworkAddresses } from "./network-security";
4
+
5
+ export type PublicHttpInput = {
6
+ url: string;
7
+ method: string;
8
+ headers: Record<string, string>;
9
+ body?: Uint8Array;
10
+ maxBytes: number;
11
+ signal: AbortSignal;
12
+ };
13
+
14
+ /** One public HTTPS request, pinned to a validated address. Never redirects or retries.
15
+ * Callers own credential authorization, request budgets and response disclosure. */
16
+ export async function requestPublicHttps(
17
+ input: PublicHttpInput,
18
+ dependencies: {
19
+ resolve: typeof resolvePublicNetworkAddresses;
20
+ request: (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
21
+ } = { resolve: resolvePublicNetworkAddresses, request },
22
+ ) {
23
+ if (!Number.isSafeInteger(input.maxBytes) || input.maxBytes < 0) throw new Error("INVALID_BYTE_LIMIT");
24
+ const url = new URL(input.url);
25
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error("HTTPS_REQUIRED");
26
+ 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));
36
+ input.signal.throwIfAborted();
37
+ 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
+ {
50
+ hostname: target.address,
51
+ family: target.family,
52
+ port: url.port || 443,
53
+ servername: url.hostname.replace(/^\[|\]$/g, ""),
54
+ rejectUnauthorized: true,
55
+ path: url.pathname + url.search,
56
+ method: input.method,
57
+ headers: { ...input.headers, host: url.host, "accept-encoding": "identity" },
58
+ },
59
+ (incoming) => {
60
+ const status = incoming.statusCode ?? 0;
61
+ const headers: Record<string, string> = {};
62
+ for (const name of ["content-type", "retry-after", "etag", "last-modified"]) {
63
+ const value = incoming.headers[name];
64
+ if (typeof value === "string") headers[name] = value;
65
+ }
66
+ // A redirect never receives a second request or exposes a Location token.
67
+ if (status >= 300 && status < 400 && status !== 304) {
68
+ incoming.destroy();
69
+ finish({ status, headers, body: new Uint8Array() });
70
+ return;
71
+ }
72
+ if (incoming.headers["content-encoding"] && incoming.headers["content-encoding"] !== "identity") {
73
+ incoming.destroy();
74
+ fail();
75
+ return;
76
+ }
77
+ const chunks: Uint8Array[] = [];
78
+ let bytes = 0;
79
+ incoming.on("data", (chunk: Buffer) => {
80
+ if (settled) return;
81
+ bytes += chunk.byteLength;
82
+ if (bytes > input.maxBytes) {
83
+ incoming.destroy();
84
+ fail();
85
+ } else chunks.push(chunk);
86
+ });
87
+ incoming.on("end", () => finish({ status, headers, body: Buffer.concat(chunks, bytes) }));
88
+ incoming.on("error", fail);
89
+ incoming.on("aborted", fail);
90
+ },
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
+ });
101
+ }
@@ -0,0 +1,95 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { RailAdminSchema, RailAdminInputSchema, type RailAdminInput, type RailAdminState } from "../contracts/rail-admin";
3
+ import { RailShortcutSchema } from "../contracts/rail-preferences";
4
+ import { hasRole, type User } from "../contracts/shared";
5
+ import { buildAccessPrincipalCondition, createAccess } from "../server/services/access";
6
+
7
+ export class RailAdminError extends Error {
8
+ constructor(
9
+ public status: 400 | 403 | 409,
10
+ message: string,
11
+ ) {
12
+ super(message);
13
+ }
14
+ }
15
+ const requireAdmin = (actor: User | undefined) => {
16
+ if (!actor || !hasRole(actor, "admin")) throw new RailAdminError(403, "Administrator access required");
17
+ };
18
+
19
+ export const createRailShortcutsService = (db: SQL = sql) => {
20
+ const list = async (query: SQL = db): Promise<RailAdminState> => {
21
+ const [state] = await query<{ revision: number }[]>`SELECT revision FROM auth.rail_state WHERE singleton`;
22
+ const rows = await query<{ shortcut: unknown; access: unknown }[]>`
23
+ SELECT r.shortcut, COALESCE((
24
+ SELECT jsonb_agg(jsonb_build_object(
25
+ 'id', a.id, 'permission', a.permission, 'createdAt', a.created_at,
26
+ 'principal', CASE WHEN a.user_id IS NOT NULL THEN jsonb_build_object('type', 'user', 'userId', a.user_id)
27
+ WHEN a.group_id IS NOT NULL THEN jsonb_build_object('type', 'group', 'groupId', a.group_id)
28
+ ELSE jsonb_build_object('type', 'authenticated') END,
29
+ 'displayName', COALESCE(u.display_name, g.name, '')
30
+ ) ORDER BY a.created_at, a.id)
31
+ FROM auth.rail_shortcut_access ra JOIN auth.access a ON a.id = ra.access_id
32
+ LEFT JOIN auth.users u ON u.id = a.user_id LEFT JOIN auth.groups g ON g.id = a.group_id
33
+ WHERE ra.shortcut_id = r.id
34
+ ), '[]'::jsonb) AS access
35
+ FROM auth.rail_shortcuts r ORDER BY r.position, r.id
36
+ `;
37
+ return RailAdminSchema.parse({ revision: state?.revision ?? 0, entries: rows });
38
+ };
39
+ return {
40
+ async invalidateCache(actor: User | undefined) {
41
+ requireAdmin(actor);
42
+ // One generation switch invalidates every user, including concurrent old refills.
43
+ // Keep the configuration revision unchanged so open editors remain valid.
44
+ await db`UPDATE auth.rail_state SET cache_version = gen_random_uuid() WHERE singleton`;
45
+ },
46
+ async list(actor: User | undefined) {
47
+ requireAdmin(actor);
48
+ return list();
49
+ },
50
+ async save(actor: User | undefined, input: RailAdminInput) {
51
+ requireAdmin(actor);
52
+ const value = RailAdminInputSchema.parse(input);
53
+ return db.begin(async (tx) => {
54
+ const [state] = await tx<{ revision: number }[]>`SELECT revision FROM auth.rail_state WHERE singleton FOR UPDATE`;
55
+ if (state?.revision !== value.revision) throw new RailAdminError(409, "App bar configuration changed. Reload before saving.");
56
+ await tx`DELETE FROM auth.access WHERE id IN (SELECT access_id FROM auth.rail_shortcut_access)`;
57
+ await tx`DELETE FROM auth.rail_shortcuts`;
58
+ for (const [position, entry] of value.entries.entries()) {
59
+ await tx`INSERT INTO auth.rail_shortcuts(id, position, shortcut)
60
+ VALUES (${entry.shortcut.id}, ${position}, (${JSON.stringify(entry.shortcut)}::text)::jsonb)`;
61
+ const seen = new Set<string>();
62
+ for (const access of entry.access) {
63
+ const key = JSON.stringify(access.principal);
64
+ if (seen.has(key)) continue;
65
+ seen.add(key);
66
+ const created = await createAccess({ principal: access.principal, permission: "read" }, tx);
67
+ if (!created.ok) throw new RailAdminError(400, "A selected user or group no longer exists.");
68
+ await tx`INSERT INTO auth.rail_shortcut_access(shortcut_id, access_id) VALUES (${entry.shortcut.id}, ${created.data.id}::uuid)`;
69
+ }
70
+ }
71
+ await tx`UPDATE auth.rail_state SET revision = revision + 1 WHERE singleton`;
72
+ return list(tx);
73
+ });
74
+ },
75
+ async forUser(userId: string) {
76
+ const match = buildAccessPrincipalCondition({
77
+ subject: { type: "user", userId },
78
+ columns: {
79
+ userId: db`a.user_id`,
80
+ groupId: db`a.group_id`,
81
+ serviceAccountId: db`a.service_account_id`,
82
+ authenticatedOnly: db`a.authenticated_only`,
83
+ },
84
+ });
85
+ const rows = await db<{ shortcut: unknown }[]>`
86
+ SELECT r.shortcut FROM auth.rail_shortcuts r WHERE EXISTS (
87
+ SELECT 1 FROM auth.rail_shortcut_access ra JOIN auth.access a ON a.id = ra.access_id
88
+ WHERE ra.shortcut_id = r.id AND a.permission = 'read' AND ${match}
89
+ ) ORDER BY r.position, r.id
90
+ `;
91
+ return rows.map((row) => RailShortcutSchema.parse(row.shortcut));
92
+ },
93
+ };
94
+ };
95
+ export const railShortcuts = createRailShortcutsService();
@@ -0,0 +1,53 @@
1
+ import { requestCacheRedis } from "./request-cache-redis";
2
+ import { type RailSnapshot, RailSnapshotSchema } from "../contracts/rail-preferences";
3
+ import type { User } from "../contracts/shared";
4
+ import { railPreferences } from "./rail-preferences";
5
+ import { railShortcuts } from "./rail-shortcuts";
6
+
7
+ // Private request metadata: never serialized into the public User contract.
8
+ const versions = new WeakMap<User, string>();
9
+ export const setRailCacheVersion = (user: User, version: unknown) => {
10
+ if (typeof version === "string") versions.set(user, version);
11
+ };
12
+
13
+ /** TTL only bounds unused generations; freshness comes from the current identity query. */
14
+ const CACHE_TTL_SECONDS = 300;
15
+ export const createRailSnapshotReader =
16
+ (
17
+ load: (userId: string) => Promise<RailSnapshot>,
18
+ cache: { get(key: string): Promise<string | null>; set(key: string, value: string): Promise<unknown> },
19
+ ) =>
20
+ async (user: User): Promise<RailSnapshot> => {
21
+ const version = versions.get(user);
22
+ if (!version) return load(user.id);
23
+ const key = `appglobalcache:user:${user.id}:rail:v1:${version}`;
24
+ try {
25
+ const cached = await cache.get(key);
26
+ if (cached !== null) {
27
+ const parsed = RailSnapshotSchema.safeParse(JSON.parse(cached));
28
+ if (parsed.success) return parsed.data;
29
+ }
30
+ } catch {
31
+ /* Valkey is an optimization; authoritative reads still work. */
32
+ }
33
+ const value = await load(user.id);
34
+ // A concurrent mutation changes the generation. A late refill remains under
35
+ // this request's old key and cannot poison a subsequent request's snapshot.
36
+ try {
37
+ await cache.set(key, JSON.stringify(value));
38
+ } catch {
39
+ /* Retry on a later read. */
40
+ }
41
+ return value;
42
+ };
43
+
44
+ export const readRailSnapshot = createRailSnapshotReader(
45
+ async (userId) => {
46
+ const [personal, managedShortcuts] = await Promise.all([railPreferences.get(userId), railShortcuts.forUser(userId)]);
47
+ return { ...personal, managedShortcuts };
48
+ },
49
+ {
50
+ get: async (key) => (await requestCacheRedis()).get(key),
51
+ set: async (key, value) => (await requestCacheRedis()).set(key, value, "EX", CACHE_TTL_SECONDS),
52
+ },
53
+ );
@@ -0,0 +1,28 @@
1
+ import { RedisClient } from "bun";
2
+
3
+ // Cache commands must not queue across a disconnect: delayed fills/invalidations
4
+ // are not useful, and reads must be able to fall back to Postgres. Keep this
5
+ // connection separate from sessions, rate limits and other Redis consumers.
6
+ let client: RedisClient | undefined;
7
+ let initialConnection: Promise<void> | undefined;
8
+ let reconnecting: Promise<void> | undefined;
9
+
10
+ export const requestCacheRedis = async (): Promise<RedisClient> => {
11
+ if (!client) {
12
+ client = new RedisClient(process.env.VALKEY_URL || process.env.REDIS_URL, { enableOfflineQueue: false });
13
+ // The first operation waits for normal connection establishment (Bun's
14
+ // connection timeout applies). Later disconnected operations fail promptly.
15
+ initialConnection = client.connect().catch(() => {});
16
+ }
17
+ await initialConnection;
18
+ if (!client.connected && !reconnecting) {
19
+ // Bun reconnects automatically; also allow recovery after its retry limit.
20
+ reconnecting = client
21
+ .connect()
22
+ .catch(() => {})
23
+ .finally(() => {
24
+ reconnecting = undefined;
25
+ });
26
+ }
27
+ return client;
28
+ };