@k2b/cloud 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/package.json +6 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-timeout.ts +71 -0
  6. package/src/_internal/define-app.ts +145 -89
  7. package/src/_internal/http-drain.ts +56 -0
  8. package/src/_internal/registry-validation.ts +3 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/status-preserving-ssr.ts +2 -0
  11. package/src/access/PermissionEditor.tsx +16 -17
  12. package/src/access/PrincipalPicker.tsx +45 -10
  13. package/src/access/messages.ts +8 -0
  14. package/src/access/principal-label.ts +18 -0
  15. package/src/access/ui.ts +1 -0
  16. package/src/account/EntitySearch.tsx +5 -4
  17. package/src/ai/action-review.ts +2 -1
  18. package/src/ai/app-skill-store.ts +1 -1
  19. package/src/ai/capabilities.ts +21 -3
  20. package/src/ai/capability-execution.ts +14 -12
  21. package/src/ai/chat/action-card.ts +7 -1
  22. package/src/ai/chat/action-messages.ts +14 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  24. package/src/ai/chat/action-question.tsx +202 -22
  25. package/src/ai/chat/action-text.ts +1 -1
  26. package/src/ai/chat-tasks.ts +2 -1
  27. package/src/ai/client/controller.ts +150 -32
  28. package/src/ai/client/transport.ts +14 -2
  29. package/src/ai/code-capability-routes.ts +27 -23
  30. package/src/ai/code-runtime-tools.ts +14 -16
  31. package/src/ai/code-source-tools.ts +16 -18
  32. package/src/ai/executor.ts +56 -21
  33. package/src/ai/fetch-file-tool.ts +67 -76
  34. package/src/ai/http.ts +1 -0
  35. package/src/ai/migrate.ts +13 -0
  36. package/src/ai/open-questions.ts +4 -8
  37. package/src/ai/projects.ts +1 -2
  38. package/src/ai/protocol.ts +2 -2
  39. package/src/ai/recent-resources.ts +12 -5
  40. package/src/ai/review-content.ts +77 -0
  41. package/src/ai/routes.ts +69 -31
  42. package/src/ai/runtime.ts +1 -0
  43. package/src/ai/skill-tool.ts +17 -6
  44. package/src/ai/skills-routes.ts +72 -27
  45. package/src/ai/skills.ts +139 -60
  46. package/src/ai/store.ts +168 -33
  47. package/src/ai/types.ts +10 -3
  48. package/src/ai/usage.ts +2 -2
  49. package/src/api/admin-core-settings.ts +15 -2
  50. package/src/api/admin-geo.ts +54 -0
  51. package/src/api/admin-linux-identities.ts +6 -3
  52. package/src/api/admin-outgoing-mail.ts +29 -15
  53. package/src/api/approvals-loader.ts +3 -3
  54. package/src/api/approvals.ts +51 -0
  55. package/src/api/apps.ts +4 -2
  56. package/src/api/capabilities.ts +258 -181
  57. package/src/api/capability-streams.ts +21 -13
  58. package/src/api/cli-plugins.ts +4 -1
  59. package/src/api/geo-glyphs.ts +11 -0
  60. package/src/api/geo.ts +128 -0
  61. package/src/api/help.ts +12 -4
  62. package/src/api/index.ts +5 -0
  63. package/src/api/mcp.ts +46 -38
  64. package/src/api/me-panel-consent.ts +8 -5
  65. package/src/api/search.ts +21 -14
  66. package/src/api/sync-ops.ts +19 -13
  67. package/src/api/widgets.ts +42 -18
  68. package/src/approvals/Approvals.tsx +5 -3
  69. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  70. package/src/browser/CloudResourceSearch.tsx +3 -13
  71. package/src/browser/PanelLeaveNotice.tsx +18 -0
  72. package/src/browser/navigation-search.ts +78 -25
  73. package/src/browser/panel-leave.ts +69 -0
  74. package/src/browser/panel-messages.ts +12 -0
  75. package/src/browser/panel-slot.ts +173 -24
  76. package/src/browser/panel.ts +3 -0
  77. package/src/browser/resource-search-messages.ts +2 -0
  78. package/src/capabilities/client.ts +28 -1
  79. package/src/capabilities/executions.ts +22 -3
  80. package/src/capabilities/geo-client.ts +57 -0
  81. package/src/capabilities/response.ts +2 -1
  82. package/src/capabilities/review-storage.ts +24 -0
  83. package/src/capabilities/reviews.ts +17 -3
  84. package/src/capabilities/server.ts +36 -16
  85. package/src/cli/access.ts +15 -23
  86. package/src/cli/admin/linux.ts +13 -2
  87. package/src/cli/admin/outgoing-mail.ts +26 -4
  88. package/src/cli/capabilities.ts +19 -0
  89. package/src/config/env.ts +0 -6
  90. package/src/contracts/app-access.ts +48 -0
  91. package/src/contracts/app.ts +4 -1
  92. package/src/contracts/approvals.ts +2 -2
  93. package/src/contracts/avatar.ts +186 -0
  94. package/src/contracts/capabilities.ts +148 -54
  95. package/src/contracts/geo-admin.ts +127 -0
  96. package/src/contracts/geo-map.ts +19 -0
  97. package/src/contracts/geo-route.ts +168 -0
  98. package/src/contracts/geo.ts +248 -0
  99. package/src/contracts/index.ts +4 -0
  100. package/src/contracts/outgoing-mail.ts +17 -4
  101. package/src/contracts/registry.ts +2 -0
  102. package/src/events/app-access.ts +68 -0
  103. package/src/events/live-delivery.ts +128 -0
  104. package/src/events/live.ts +14 -130
  105. package/src/geo/CloudMap.tsx +95 -0
  106. package/src/geo/map-messages.ts +17 -0
  107. package/src/geo/ui.ts +1 -0
  108. package/src/index.ts +2 -0
  109. package/src/server/client-ip.ts +36 -0
  110. package/src/server/index.ts +3 -4
  111. package/src/server/middleware/app-access.ts +312 -0
  112. package/src/server/middleware/auth.ts +1 -1
  113. package/src/server/middleware/invocation.ts +11 -0
  114. package/src/server/middleware/rate-limit.ts +4 -4
  115. package/src/server/services/access.ts +13 -9
  116. package/src/server/services/index.ts +0 -2
  117. package/src/server/services/services.ts +0 -2
  118. package/src/services/accounts/posix.ts +21 -7
  119. package/src/services/allowed-network-fetch.ts +16 -10
  120. package/src/services/app-access-metrics.ts +24 -0
  121. package/src/services/app-access.ts +123 -0
  122. package/src/services/app-discovery.ts +27 -0
  123. package/src/services/approvals/content.ts +40 -0
  124. package/src/services/approvals/index.ts +14 -6
  125. package/src/services/approvals/list.ts +6 -11
  126. package/src/services/approvals/question.ts +11 -7
  127. package/src/services/downstream-budget.ts +87 -0
  128. package/src/services/geo/admin.ts +262 -0
  129. package/src/services/geo/bucket.ts +67 -0
  130. package/src/services/geo/cache.ts +57 -0
  131. package/src/services/geo/costs.ts +5 -0
  132. package/src/services/geo/errors.ts +41 -0
  133. package/src/services/geo/fixtures/README.md +17 -0
  134. package/src/services/geo/fixtures/along.json +355 -0
  135. package/src/services/geo/fixtures/autocomplete.json +218 -0
  136. package/src/services/geo/fixtures/geocode.json +129 -0
  137. package/src/services/geo/fixtures/places.json +355 -0
  138. package/src/services/geo/fixtures/reverse.json +61 -0
  139. package/src/services/geo/fixtures/routing.json +57 -0
  140. package/src/services/geo/geoapify-route.ts +93 -0
  141. package/src/services/geo/geoapify.ts +281 -0
  142. package/src/services/geo/ledger.ts +122 -0
  143. package/src/services/geo/map.ts +160 -0
  144. package/src/services/geo/migrate.ts +25 -0
  145. package/src/services/geo/polyline.ts +184 -0
  146. package/src/services/geo/route-chain.ts +145 -0
  147. package/src/services/geo/route-costs.ts +9 -0
  148. package/src/services/geo/route-geometry.ts +39 -0
  149. package/src/services/geo/route-plan.ts +172 -0
  150. package/src/services/geo/route-service.ts +433 -0
  151. package/src/services/geo/service.ts +300 -0
  152. package/src/services/geo/settings.ts +49 -0
  153. package/src/services/help/index.ts +16 -7
  154. package/src/services/help/types.ts +6 -1
  155. package/src/services/identity/invocation-actor.ts +20 -2
  156. package/src/services/identity/issue-invocation.ts +67 -0
  157. package/src/services/identity/key-ring.ts +5 -3
  158. package/src/services/identity/runtime-config.ts +23 -0
  159. package/src/services/index.ts +9 -0
  160. package/src/services/mandates/index.ts +40 -3
  161. package/src/services/network-security.ts +6 -3
  162. package/src/services/network-transport.ts +99 -0
  163. package/src/services/notifications/catalog.ts +3 -2
  164. package/src/services/notifications/runtime.ts +1 -0
  165. package/src/services/notifications/web-push-transport.ts +45 -38
  166. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  167. package/src/services/outgoing-mail/index.ts +55 -1
  168. package/src/services/outgoing-mail/messages.ts +82 -3
  169. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  170. package/src/services/outgoing-mail/retention.ts +13 -1
  171. package/src/services/outgoing-mail/store.ts +36 -6
  172. package/src/services/outgoing-mail/test-send.ts +34 -4
  173. package/src/services/outgoing-mail/transport.ts +1 -1
  174. package/src/services/pdf/gotenberg.ts +11 -38
  175. package/src/services/pdf/template-preview.ts +1 -1
  176. package/src/services/public-http.ts +28 -40
  177. package/src/services/settings/app.ts +4 -1
  178. package/src/services/settings/core-settings.ts +88 -0
  179. package/src/services/settings/defaults.ts +8 -2
  180. package/src/services/settings/errors.ts +11 -0
  181. package/src/services/settings/geo-budget.ts +40 -0
  182. package/src/services/settings/index.ts +4 -3
  183. package/src/services/settings/store.ts +25 -17
  184. package/src/services/weather/geo.ts +29 -111
  185. package/src/services/weather/index.ts +4 -3
  186. package/src/services/weather/locations.ts +8 -5
  187. package/src/services/weather/migrate.ts +1 -0
  188. package/src/services/weather/settings.ts +0 -16
  189. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  190. package/src/shared/avatar-art.ts +400 -0
  191. package/src/shared/index.ts +2 -0
  192. package/src/shared/network-address.ts +22 -3
  193. package/src/shared/template-rendering.ts +36 -4
  194. package/src/shared-text/documents.ts +1 -1
  195. package/src/shared-text/index.ts +2 -0
  196. package/src/shared-text/log.ts +51 -3
  197. package/src/shared-text/snapshots.ts +340 -0
  198. package/src/ssr/AdminLayout.tsx +4 -2
  199. package/src/ssr/CloudShell.island.tsx +6 -0
  200. package/src/ssr/Layout.tsx +65 -15
  201. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  202. package/src/ssr/LayoutHelp.tsx +3 -2
  203. package/src/ssr/PanelRuntime.island.tsx +11 -7
  204. package/src/ssr/PwaLayout.tsx +2 -2
  205. package/src/ssr/RailApps.tsx +6 -0
  206. package/src/ssr/ShellRuntime.island.tsx +23 -7
  207. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  208. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
  209. package/src/ssr/admin-navigation.ts +1 -0
  210. package/src/ssr/app-badges.ts +6 -1
  211. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  212. package/src/ssr/help.ts +3 -2
  213. package/src/ssr/index.ts +1 -1
  214. package/src/ssr/layout-context.ts +2 -0
  215. package/src/ssr/mobile-menu-history.ts +4 -0
  216. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  217. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  218. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  219. package/src/ssr/page-swap.ts +28 -0
  220. package/src/ssr/page-trail-runtime.ts +32 -13
  221. package/src/ssr/panel-consent.ts +2 -2
  222. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  223. package/src/ssr/platform-messages.ts +8 -0
  224. package/src/ssr/runtime.ts +17 -0
  225. package/src/ssr/shell-data.tsx +10 -2
  226. package/src/styles/global.css +70 -6
  227. package/src/server/services/geo.ts +0 -215
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { type SQL, sql } from "bun";
2
3
  import { listApps } from "../../_internal/registry";
3
4
  import {
@@ -25,7 +26,7 @@ export class OutgoingMailError extends Error {
25
26
  constructor(
26
27
  public readonly code: MailErrorCode,
27
28
  message: string,
28
- public readonly status: 400 | 403 | 404 | 409 | 502 = 400,
29
+ public readonly status: 400 | 403 | 404 | 409 | 502 | 503 = 400,
29
30
  ) {
30
31
  super(message);
31
32
  }
@@ -245,8 +246,8 @@ const appsState = async (
245
246
  const registered = registeredApps ?? (await listApps());
246
247
  const { access, grants } = await accessRows(db);
247
248
  const [profile] = await db<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE is_default`;
248
- const logGrants = await db<{ app_id: string; source_app_id: string }[]>`
249
- SELECT app_id, source_app_id FROM outgoing_mail.app_log_access ORDER BY source_app_id`;
249
+ const logGrants = await db<{ app_id: string; source_app_id: string; level: string }[]>`
250
+ SELECT app_id, source_app_id, level FROM outgoing_mail.app_log_access ORDER BY source_app_id`;
250
251
  const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id), ...logGrants.map((g) => g.app_id)]);
251
252
  const items = [...ids]
252
253
  .map((appId): AdminMailApp => {
@@ -264,6 +265,12 @@ const appsState = async (
264
265
  : logGrants
265
266
  .filter((g) => g.app_id === appId && g.source_app_id !== "core" && g.source_app_id !== appId)
266
267
  .map((g) => g.source_app_id),
268
+ logContentApps:
269
+ appId === "core"
270
+ ? []
271
+ : logGrants
272
+ .filter((g) => g.app_id === appId && g.source_app_id !== "core" && g.source_app_id !== appId && g.level === "content")
273
+ .map((g) => g.source_app_id),
267
274
  mode,
268
275
  profiles: mode === "selected" ? grants.filter((g) => g.app_id === appId).map((g) => g.key) : [],
269
276
  };
@@ -300,6 +307,7 @@ const setAppAccess = async (appId: string, value: MailAppAccess, context: MailAu
300
307
  declared: false,
301
308
  readDeclared: false,
302
309
  logApps: [],
310
+ logContentApps: [],
303
311
  mode: "default",
304
312
  profiles: [],
305
313
  }
@@ -312,19 +320,40 @@ const logAppsFor = async (appId: string): Promise<string[]> => {
312
320
  WHERE app_id = ${appId} AND source_app_id <> 'core' AND source_app_id <> app_id ORDER BY source_app_id`;
313
321
  return rows.map((row) => row.source_app_id);
314
322
  };
323
+ /** Full exact IDs remain distinguishable after audit metadata string truncation. */
324
+ export const mailAppIdSha256 = (appId: string): string => createHash("sha256").update(appId, "utf8").digest("hex");
325
+ const grantAuditGroups = (grants: { source_app_id: string; level: string }[]) => {
326
+ const entries = grants.map((grant) => ({
327
+ appId: grant.source_app_id,
328
+ appIdSha256: mailAppIdSha256(grant.source_app_id),
329
+ level: grant.level,
330
+ }));
331
+ // Audit arrays retain at most 50 entries; the public grant limit is 100.
332
+ return [entries.slice(0, 50), entries.slice(50)];
333
+ };
315
334
  const setAppLogAccess = async (appId: string, value: MailLogAccess, context: MailAuditContext): Promise<AdminMailApp> => {
316
335
  const reader = MailLogAppIdSchema.safeParse(appId);
317
336
  const parsed = MailLogAccessSchema.safeParse(value);
318
337
  if (!reader.success || appId === "core" || !parsed.success || parsed.data.apps.includes(appId))
319
338
  throw new OutgoingMailError("bad_input", "Choose a valid reader and distinct source apps other than Core or the reader itself.");
320
339
  const apps = [...parsed.data.apps].sort();
340
+ const contentApps = parsed.data.contentApps ?? [];
321
341
  const registered = await listApps();
322
342
  return locked(async (tx) => {
343
+ const before = await tx<{ source_app_id: string; level: string }[]>`
344
+ SELECT source_app_id, level FROM outgoing_mail.app_log_access WHERE app_id = ${appId} ORDER BY source_app_id`;
323
345
  await tx`DELETE FROM outgoing_mail.app_log_access WHERE app_id = ${appId}`;
324
- await tx`INSERT INTO outgoing_mail.app_log_access(app_id, source_app_id, created_by)
325
- SELECT ${appId}, source_app_id, ${context.actor.userId ?? null}
346
+ await tx`INSERT INTO outgoing_mail.app_log_access(app_id, source_app_id, created_by, level)
347
+ SELECT ${appId}, source_app_id, ${context.actor.userId ?? null},
348
+ CASE WHEN source_app_id = ANY(${toPgTextArray(contentApps)}::text[]) THEN 'content' ELSE 'metadata' END
326
349
  FROM unnest(${toPgTextArray(apps)}::text[]) AS source_app_id`;
327
- await record(tx, context, "app_log_access.update", appId, { apps });
350
+ await record(tx, context, "app_log_access.update", appId, {
351
+ apps,
352
+ readingAppId: appId,
353
+ readingAppIdSha256: mailAppIdSha256(appId),
354
+ before: grantAuditGroups(before),
355
+ after: grantAuditGroups(apps.map((id) => ({ source_app_id: id, level: contentApps.includes(id) ? "content" : "metadata" }))),
356
+ });
328
357
  return (
329
358
  (await appsState(tx, registered)).items.find((app) => app.appId === appId) ?? {
330
359
  appId,
@@ -333,6 +362,7 @@ const setAppLogAccess = async (appId: string, value: MailLogAccess, context: Mai
333
362
  declared: false,
334
363
  readDeclared: false,
335
364
  logApps: [],
365
+ logContentApps: [],
336
366
  mode: "default",
337
367
  profiles: [],
338
368
  }
@@ -1,4 +1,6 @@
1
+ import { Socket } from "node:net";
1
2
  import { audit } from "../audit";
3
+ import { DownstreamBusyError, downstreamBudget } from "../downstream-budget";
2
4
  import { get } from "../settings";
3
5
  import { type MailAuditContext, OutgoingMailError, resolveMailCredentials } from "./store";
4
6
  import { resolveMailTransport } from "./transport";
@@ -18,22 +20,48 @@ export const outgoingMailTest = {
18
20
  // Fail before the external effect if audit storage is unavailable.
19
21
  await audit.record({ ...event, outcome: "allowed" });
20
22
  let transport: ReturnType<Awaited<ReturnType<typeof resolveMailTransport>>["build"]> | undefined;
23
+ const signal = AbortSignal.timeout(60_000);
24
+ let socket: Socket | undefined;
25
+ const stop = () => {
26
+ socket?.destroy();
27
+ transport?.close();
28
+ };
29
+ signal.addEventListener("abort", stop, { once: true });
30
+ let releaseSmtp: (() => void) | undefined;
21
31
  try {
22
- const resolved = await resolveMailTransport(profile);
32
+ releaseSmtp = await downstreamBudget.acquire({ downstream: "smtp", timeoutMs: 60_000, signal });
33
+ const resolved = await resolveMailTransport(profile, signal);
34
+ const build = (index: number) => {
35
+ socket = new Socket();
36
+ const ownedSocket = socket;
37
+ ownedSocket.once("connect", () => {
38
+ if (signal.aborted) ownedSocket.destroy();
39
+ });
40
+ return resolved.build(index, ownedSocket);
41
+ };
42
+ let selected = 0;
23
43
  for (let index = 0; index < resolved.count; index++) {
24
- transport = resolved.build(index);
44
+ signal.throwIfAborted();
45
+ transport = build(index);
25
46
  try {
26
47
  // CONN also labels socket errors after DATA. Only fall back during a
27
48
  // preflight that cannot send a message, then send once to that address.
28
49
  await transport.verify();
50
+ selected = index;
29
51
  break;
30
52
  } catch (error) {
53
+ socket?.destroy();
31
54
  transport.close();
32
55
  transport = undefined;
33
56
  if (!(error instanceof Error) || !("command" in error) || error.command !== "CONN" || index === resolved.count - 1) throw error;
34
57
  }
35
58
  }
36
59
  if (!transport) throw new Error("No validated SMTP address.");
60
+ // verify() closes its SMTP connection. The actual send gets a fresh
61
+ // owned socket to the same checked address, with no delivery fallback.
62
+ stop();
63
+ signal.throwIfAborted();
64
+ transport = build(selected);
37
65
  await transport.sendMail({
38
66
  from: { address: profile.fromAddress, name: profile.fromName ?? (await get<string>("app.name")) },
39
67
  to: recipient,
@@ -47,9 +75,11 @@ export const outgoingMailTest = {
47
75
  profile.smtpUser ? `\0${profile.smtpUser}\0${profile.smtpPassword ?? ""}` : null,
48
76
  ]);
49
77
  await audit.recordResultAfterSideEffect({ ...event, result: { ok: false, error: { code: "smtp_failed", message, status: 500 } } });
50
- throw new OutgoingMailError("smtp_failed", message, 502);
78
+ throw new OutgoingMailError("smtp_failed", message, error instanceof DownstreamBusyError ? 503 : 502);
51
79
  } finally {
52
- transport?.close();
80
+ signal.removeEventListener("abort", stop);
81
+ stop();
82
+ releaseSmtp?.();
53
83
  }
54
84
  },
55
85
  };
@@ -4,7 +4,7 @@ import { resolveMailHost } from "./resolve-host";
4
4
  import type { resolveMailCredentials } from "./store";
5
5
 
6
6
  export const resolveMailTransport = async (profile: Awaited<ReturnType<typeof resolveMailCredentials>>, signal?: AbortSignal) => {
7
- const target = await resolveMailHost(profile.smtpHost, signal);
7
+ const target = await resolveMailHost(profile.smtpHost, signal, undefined, profile.smtpPort);
8
8
  signal?.throwIfAborted();
9
9
  const count = target.addresses.length;
10
10
  return {
@@ -1,7 +1,9 @@
1
+ import { DownstreamBusyError, downstreamBudget } from "../downstream-budget";
1
2
  import * as settings from "../settings";
2
3
  import { offlineHtml } from "./offline-html";
3
4
 
4
5
  export type GotenbergRenderErrorCode =
6
+ | "busy"
5
7
  | "bad_input"
6
8
  | "not_configured"
7
9
  | "html_too_large"
@@ -117,6 +119,7 @@ const abortSignal = (timeoutMs: number): AbortSignal => {
117
119
 
118
120
  const sanitizeFetchError = (error: unknown): GotenbergRenderError => {
119
121
  if (error instanceof GotenbergRenderError) return error;
122
+ if (error instanceof DownstreamBusyError) return new GotenbergRenderError("busy", "PDF renderer is busy; retry later.", 503);
120
123
  if (error instanceof DOMException && error.name === "TimeoutError") {
121
124
  return new GotenbergRenderError("timeout", "Gotenberg request timed out.");
122
125
  }
@@ -179,10 +182,14 @@ async function requestPdf(
179
182
  const headers = new Headers();
180
183
  const auth = basicAuthHeader(config);
181
184
  if (auth) headers.set("Authorization", auth);
185
+ const baseUrl = normalizeBaseUrl(config.url);
182
186
  const signal = options.signal ? AbortSignal.any([options.signal, abortSignal(config.timeoutMs)]) : abortSignal(config.timeoutMs);
187
+ let release: (() => void) | undefined;
183
188
  let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
184
189
  try {
185
- const response = await (options.fetch ?? fetch)(`${normalizeBaseUrl(config.url)}${route}`, {
190
+ release = await downstreamBudget.acquire({ downstream: "gotenberg", timeoutMs: config.timeoutMs, signal });
191
+ signal.throwIfAborted();
192
+ const response = await (options.fetch ?? fetch)(`${baseUrl}${route}`, {
186
193
  method: "POST",
187
194
  headers,
188
195
  body: form,
@@ -221,6 +228,7 @@ async function requestPdf(
221
228
  throw sanitizeFetchError(error);
222
229
  } finally {
223
230
  reader?.releaseLock();
231
+ release?.();
224
232
  }
225
233
  }
226
234
 
@@ -285,47 +293,12 @@ export const mergePdfsWithConfig = async (
285
293
  throw new GotenbergRenderError("bad_input", "At least one PDF file is required.");
286
294
  }
287
295
 
288
- const baseUrl = normalizeBaseUrl(config.url);
289
296
  const form = new FormData();
290
297
  input.files.forEach((file, index) => {
291
298
  const filename = `${String(index + 1).padStart(6, "0")}.pdf`;
292
- const bytes = file.pdf.buffer.slice(file.pdf.byteOffset, file.pdf.byteOffset + file.pdf.byteLength) as ArrayBuffer;
293
- form.append("files", new Blob([bytes], { type: DEFAULT_PDF_CONTENT_TYPE }), filename);
299
+ form.append("files", new Blob([new Uint8Array(file.pdf)], { type: DEFAULT_PDF_CONTENT_TYPE }), filename);
294
300
  });
295
-
296
- const headers = new Headers();
297
- const authHeader = basicAuthHeader(config);
298
- if (authHeader) headers.set("Authorization", authHeader);
299
-
300
- const transport = options.fetch ?? fetch;
301
- let response: Response;
302
- try {
303
- response = await transport(`${baseUrl}/forms/pdfengines/merge`, {
304
- method: "POST",
305
- headers,
306
- body: form,
307
- signal: abortSignal(config.timeoutMs),
308
- });
309
- } catch (error) {
310
- throw sanitizeFetchError(error);
311
- }
312
-
313
- if (!response.ok) {
314
- throw new GotenbergRenderError("bad_response", `Gotenberg returned HTTP ${response.status}.`, response.status);
315
- }
316
-
317
- const buffer = await response.arrayBuffer();
318
- if (buffer.byteLength > config.maxPdfBytes) {
319
- throw new GotenbergRenderError(
320
- "pdf_too_large",
321
- `PDF output is too large (${buffer.byteLength} bytes, limit ${config.maxPdfBytes} bytes).`,
322
- );
323
- }
324
-
325
- return {
326
- pdf: new Uint8Array(buffer),
327
- contentType: response.headers.get("content-type") || DEFAULT_PDF_CONTENT_TYPE,
328
- };
301
+ return requestPdf("/forms/pdfengines/merge", form, config, options);
329
302
  };
330
303
 
331
304
  export const getGotenbergConfig = async (): Promise<GotenbergConfig> => ({
@@ -47,7 +47,7 @@ export type RenderTemplatePdfPreviewOptions = RenderHtmlToPdfOptions & {
47
47
  config?: GotenbergConfig;
48
48
  };
49
49
 
50
- const pdfErrorStatus = (error: GotenbergRenderError): number => (error.code === "not_configured" ? 400 : 502);
50
+ const pdfErrorStatus = (error: GotenbergRenderError): number => (error.code === "busy" ? 503 : error.code === "not_configured" ? 400 : 502);
51
51
 
52
52
  const renderPart = (
53
53
  template: string | null | undefined,
@@ -1,6 +1,7 @@
1
1
  import type { ClientRequest, IncomingMessage } from "node:http";
2
2
  import { type RequestOptions, request } from "node:https";
3
3
  import { resolvePublicNetworkAddresses } from "./network-security";
4
+ import { awaitNetworkOperation, readNetworkResponse } from "./network-transport";
4
5
 
5
6
  export type PublicHttpInput = {
6
7
  url: string;
@@ -24,29 +25,21 @@ export async function requestPublicHttps(
24
25
  const url = new URL(input.url);
25
26
  if (url.protocol !== "https:" || url.username || url.password) throw new Error("HTTPS_REQUIRED");
26
27
  input.signal.throwIfAborted();
27
- let cancelLookup = () => {};
28
- const addresses = await Promise.race([
29
- dependencies.resolve(url.hostname),
30
- new Promise<never>((_, reject) => {
31
- cancelLookup = () => reject(new Error("HTTP_CANCELLED"));
32
- input.signal.addEventListener("abort", cancelLookup, { once: true });
33
- if (input.signal.aborted) cancelLookup();
34
- }),
35
- ]).finally(() => input.signal.removeEventListener("abort", cancelLookup));
28
+ let addresses: Awaited<ReturnType<typeof resolvePublicNetworkAddresses>>;
29
+ try {
30
+ addresses = await awaitNetworkOperation(dependencies.resolve(url.hostname), input.signal);
31
+ } catch (error) {
32
+ if (input.signal.aborted) throw new Error("HTTP_CANCELLED");
33
+ throw error;
34
+ }
36
35
  input.signal.throwIfAborted();
37
36
  const target = addresses[0]!;
38
- return new Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }>((resolve, reject) => {
39
- let settled = false;
40
- const fail = () => finish(undefined);
41
- const finish = (value?: { status: number; headers: Record<string, string>; body: Uint8Array }) => {
42
- if (settled) return;
43
- settled = true;
44
- input.signal.removeEventListener("abort", abort);
45
- if (value) resolve(value);
46
- else reject(new Error("HTTP_FAILED"));
47
- };
48
- const outgoing = dependencies.request(
49
- {
37
+ try {
38
+ return await readNetworkResponse<{ status: number; headers: Record<string, string>; body: Uint8Array }>({
39
+ request: dependencies.request,
40
+ signal: input.signal,
41
+ body: input.body,
42
+ options: {
50
43
  hostname: target.address,
51
44
  family: target.family,
52
45
  port: url.port || 443,
@@ -56,7 +49,7 @@ export async function requestPublicHttps(
56
49
  method: input.method,
57
50
  headers: { ...input.headers, host: url.host, "accept-encoding": "identity" },
58
51
  },
59
- (incoming) => {
52
+ read: (incoming, finish, fail) => {
60
53
  const status = incoming.statusCode ?? 0;
61
54
  const headers: Record<string, string> = {};
62
55
  for (const name of ["content-type", "retry-after", "etag", "last-modified"]) {
@@ -65,37 +58,32 @@ export async function requestPublicHttps(
65
58
  }
66
59
  // A redirect never receives a second request or exposes a Location token.
67
60
  if (status >= 300 && status < 400 && status !== 304) {
68
- incoming.destroy();
69
61
  finish({ status, headers, body: new Uint8Array() });
70
62
  return;
71
63
  }
72
64
  if (incoming.headers["content-encoding"] && incoming.headers["content-encoding"] !== "identity") {
73
- incoming.destroy();
74
- fail();
65
+ fail(new Error("HTTP_FAILED"));
66
+ return;
67
+ }
68
+ if (Number(incoming.headers["content-length"]) > input.maxBytes) {
69
+ fail(new Error("HTTP_TOO_LARGE"));
75
70
  return;
76
71
  }
77
72
  const chunks: Uint8Array[] = [];
78
73
  let bytes = 0;
79
74
  incoming.on("data", (chunk: Buffer) => {
80
- if (settled) return;
75
+ if (incoming.destroyed) return;
81
76
  bytes += chunk.byteLength;
82
77
  if (bytes > input.maxBytes) {
83
- incoming.destroy();
84
- fail();
78
+ fail(new Error("HTTP_TOO_LARGE"));
85
79
  } else chunks.push(chunk);
86
80
  });
87
81
  incoming.on("end", () => finish({ status, headers, body: Buffer.concat(chunks, bytes) }));
88
- incoming.on("error", fail);
89
- incoming.on("aborted", fail);
90
82
  },
91
- );
92
- const abort = () => {
93
- outgoing.destroy();
94
- fail();
95
- };
96
- outgoing.on("error", fail); // Never expose errors containing injected headers.
97
- input.signal.addEventListener("abort", abort, { once: true });
98
- if (input.signal.aborted) abort();
99
- else outgoing.end(input.body);
100
- });
83
+ });
84
+ } catch (error) {
85
+ if (error instanceof Error && error.message === "HTTP_TOO_LARGE") throw error;
86
+ // Never expose errors containing injected headers.
87
+ throw new Error("HTTP_FAILED");
88
+ }
101
89
  }
@@ -13,6 +13,7 @@ import { ImageMetadataError } from "../image-metadata";
13
13
  import type { SettingEntry } from ".";
14
14
  import * as settingsPrimitives from ".";
15
15
  import { SETTINGS_MAP, validateSettingValue } from "./defaults";
16
+ import { GeoBudgetSettingError } from "./geo-budget";
16
17
  import { SettingUnreadableError } from "./store";
17
18
 
18
19
  /**
@@ -69,7 +70,8 @@ export const settingsService = {
69
70
  return ok(undefined);
70
71
  } catch (error) {
71
72
  // Preserve the typed HTTP status and recovery code through Hono.
72
- if (error instanceof ImageMetadataError || error instanceof SettingUnreadableError) throw error;
73
+ if (error instanceof ImageMetadataError || error instanceof SettingUnreadableError || error instanceof GeoBudgetSettingError)
74
+ throw error;
73
75
  const message = error instanceof Error ? error.message : String(error);
74
76
  if (message.startsWith("Unknown setting:")) {
75
77
  return fail(err.badInput(message));
@@ -85,6 +87,7 @@ export const settingsService = {
85
87
  await settingsPrimitives.remove(config.key);
86
88
  return ok(undefined);
87
89
  } catch (error) {
90
+ if (error instanceof GeoBudgetSettingError) throw error;
88
91
  return fail(err.internal(`Failed to reset setting: ${error instanceof Error ? error.message : String(error)}`));
89
92
  }
90
93
  },
@@ -1,4 +1,5 @@
1
1
  import { env } from "../../config/env";
2
+ import { GEO_MAX_CREDITS, GEO_MAX_RPS } from "../../contracts/geo-admin";
2
3
 
3
4
  /**
4
5
  * Platform-owned settings, declared in the `defineApp({ settings: ... })` shape.
@@ -204,6 +205,93 @@ export const CORE_SETTINGS = {
204
205
  description: "Five-field cron schedule used by all automatic cleanup jobs in app.timezone",
205
206
  },
206
207
 
208
+ // ── Places and addresses ──────────────────────────────────────────────
209
+ "geo.api_key": {
210
+ kind: "secret",
211
+ label: "Geoapify API key",
212
+ description: "Used only for HTTPS requests to the fixed EU endpoint.",
213
+ default: "",
214
+ presentation: {
215
+ translations: { de: { label: "Geoapify-Schlüssel", description: "Nur für HTTPS-Anfragen an den festen EU-Endpunkt." } },
216
+ },
217
+ },
218
+ "geo.plan": {
219
+ kind: "enum",
220
+ label: "Geoapify plan",
221
+ description: "Sets requests per second and the provider daily allowance.",
222
+ default: "free",
223
+ options: [
224
+ { value: "free", label: "Free" },
225
+ { value: "api10", label: "API 10" },
226
+ { value: "api25", label: "API 25" },
227
+ { value: "api50", label: "API 50" },
228
+ { value: "api100", label: "API 100" },
229
+ { value: "api250", label: "API 250" },
230
+ { value: "custom", label: "Custom" },
231
+ ],
232
+ presentation: {
233
+ translations: {
234
+ de: {
235
+ label: "Geoapify-Tarif",
236
+ description: "Bestimmt Anfragen pro Sekunde und das Tageskontingent des Anbieters.",
237
+ options: { custom: "Eigener" },
238
+ },
239
+ },
240
+ },
241
+ },
242
+ "geo.custom_rps": {
243
+ kind: "number",
244
+ label: "Custom requests per second",
245
+ description: "Used only with the custom plan.",
246
+ default: 5,
247
+ integer: true,
248
+ min: 1,
249
+ max: GEO_MAX_RPS,
250
+ presentation: { translations: { de: { label: "Eigene Anfragen pro Sekunde", description: "Gilt nur beim eigenen Tarif." } } },
251
+ },
252
+ "geo.custom_daily_credits": {
253
+ kind: "number",
254
+ label: "Custom daily provider credits",
255
+ description: "Provider allowance; used only with the custom plan.",
256
+ default: 3000,
257
+ integer: true,
258
+ min: 1,
259
+ max: GEO_MAX_CREDITS,
260
+ presentation: {
261
+ translations: { de: { label: "Eigene tägliche Anbieter-Credits", description: "Anbieterkontingent; gilt nur beim eigenen Tarif." } },
262
+ },
263
+ },
264
+ "geo.monthly_budget_credits": {
265
+ kind: "number",
266
+ label: "Monthly geo budget (credits)",
267
+ description: "Cloud-wide hard limit per calendar month in app.timezone.",
268
+ default: 90000,
269
+ integer: true,
270
+ min: 0,
271
+ max: GEO_MAX_CREDITS,
272
+ presentation: {
273
+ translations: { de: { label: "Monatsbudget Geo (Credits)", description: "Harte Cloud-Grenze je Kalendermonat in app.timezone." } },
274
+ },
275
+ },
276
+ "geo.person_daily_credits": {
277
+ kind: "number",
278
+ label: "Daily geo credits per person",
279
+ description: "Hard limit per access subject and local day; must not exceed the monthly budget. Agents and service accounts count too.",
280
+ default: 300,
281
+ integer: true,
282
+ min: 0,
283
+ max: GEO_MAX_CREDITS,
284
+ presentation: {
285
+ translations: {
286
+ de: {
287
+ label: "Tägliche Geo-Credits pro Person",
288
+ description:
289
+ "Harte Grenze je Zugriffsidentität und lokalem Tag; darf das Monatsbudget nicht überschreiten. Gilt auch für Agenten und Dienstkonten.",
290
+ },
291
+ },
292
+ },
293
+ },
294
+
207
295
  // ── AI ──────────────────────────────────────────────────────────────────
208
296
  "ai.enabled": {
209
297
  kind: "boolean",
@@ -162,7 +162,13 @@ export const SETTINGS: SettingDef[] = [
162
162
  ...toLegacySettingDefs(CORE_SETTINGS),
163
163
  ];
164
164
 
165
- const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
165
+ // Keep the existing permissive grammar without ambiguous regex backtracking.
166
+ const isEmail = (value: string): boolean => {
167
+ const at = value.indexOf("@");
168
+ if (at < 1 || value.indexOf("@", at + 1) !== -1 || /\s/.test(value)) return false;
169
+ const dot = value.indexOf(".", at + 2);
170
+ return dot !== -1 && dot < value.length - 1;
171
+ };
166
172
 
167
173
  const toStringValue = (value: unknown): string | null => {
168
174
  if (typeof value === "string") return value;
@@ -303,7 +309,7 @@ export const validateSettingValue = (def: SettingDef, raw: unknown): SettingVali
303
309
  : { ok: false, error: `${getSettingLabel(def)} must be one of: ${def.options.map((option) => option.value).join(", ")}` };
304
310
  case "email":
305
311
  if (typeof value !== "string") return { ok: false, error: `${getSettingLabel(def)} must be a valid email address` };
306
- return value.length === 0 || EMAIL_RE.test(value)
312
+ return value.length === 0 || isEmail(value)
307
313
  ? { ok: true, value }
308
314
  : { ok: false, error: `${getSettingLabel(def)} must be a valid email address` };
309
315
  case "url":
@@ -0,0 +1,11 @@
1
+ import { HTTPException } from "hono/http-exception";
2
+
3
+ export class SettingUnreadableError extends HTTPException {
4
+ readonly code = "setting_unreadable" as const;
5
+
6
+ constructor(readonly key: string) {
7
+ const message = `The stored value of setting "${key}" cannot be read by this application. Replace it with a new value or reset it to the default.`;
8
+ super(409, { message, res: Response.json({ code: "setting_unreadable", message }, { status: 409 }) });
9
+ this.name = "SettingUnreadableError";
10
+ }
11
+ }
@@ -0,0 +1,40 @@
1
+ import type { sql } from "bun";
2
+ import { HTTPException } from "hono/http-exception";
3
+ import { decryptValue } from "./crypto";
4
+ import { SETTINGS_MAP, validateSettingValue } from "./defaults";
5
+ import { SettingUnreadableError } from "./errors";
6
+
7
+ const MONTH = "geo.monthly_budget_credits";
8
+ const DAY = "geo.person_daily_credits";
9
+ export class GeoBudgetSettingError extends HTTPException {
10
+ readonly code = "geo_budget_invalid";
11
+ constructor() {
12
+ const message = "Daily geo credits must not exceed the monthly geo budget";
13
+ super(400, {
14
+ message,
15
+ res: Response.json({ code: "geo_budget_invalid", message, errors: { [DAY]: message, [MONTH]: message } }, { status: 400 }),
16
+ });
17
+ }
18
+ }
19
+ /** Validate the final pair once, holding the lock until the caller commits all mutations. */
20
+ export const assertGeoBudgetPair = async (tx: typeof sql, updates: Record<string, unknown>, resets: readonly string[]): Promise<void> => {
21
+ if (![MONTH, DAY].some((key) => Object.hasOwn(updates, key) || resets.includes(key))) return;
22
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended('core.geo.budget-settings', 0))`;
23
+ const resolve = async (key: string): Promise<unknown> => {
24
+ if (Object.hasOwn(updates, key)) return updates[key];
25
+ const def = SETTINGS_MAP.get(key);
26
+ if (resets.includes(key)) return def?.default;
27
+ const [row] = await tx<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key}`;
28
+ if (!row) return def?.default;
29
+ try {
30
+ const value = await decryptValue(row.value);
31
+ if (def && validateSettingValue(def, value).ok) return value;
32
+ } catch {
33
+ // Recovery can replace or reset a row without decrypting its old value.
34
+ }
35
+ throw new SettingUnreadableError(key);
36
+ };
37
+ const month = await resolve(MONTH);
38
+ const day = await resolve(DAY);
39
+ if (typeof month !== "number" || typeof day !== "number" || day > month) throw new GeoBudgetSettingError();
40
+ };
@@ -185,14 +185,15 @@ export async function get<T = unknown>(key: string): Promise<T> {
185
185
 
186
186
  /**
187
187
  * Write a setting. Pass `db` to run inside a caller's transaction — then the
188
- * caller must also call `invalidateSettingsCache` once it commits.
188
+ * caller owns geo budget pair validation before mutations and must call
189
+ * `invalidateSettingsCache` once it commits.
189
190
  */
190
191
  export async function set(key: string, value: unknown, db?: SqlClient, options?: { replaceUnreadable?: boolean }): Promise<void> {
191
192
  await writeKey(key, value, db, options);
192
193
  }
193
194
 
194
- export async function read(key: string): Promise<SettingRead> {
195
- return readKeyState(key);
195
+ export async function read(key: string, db?: SqlClient): Promise<SettingRead> {
196
+ return readKeyState(key, db);
196
197
  }
197
198
 
198
199
  export async function remove(key: string, db?: SqlClient): Promise<void> {