@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
@@ -5,6 +5,7 @@ import type { CapabilityActionPolicy } from "../../contracts/capabilities";
5
5
  import { CapabilityAppIdSchema } from "../../contracts/capabilities";
6
6
  import { isAccountCategoryAllowed } from "../account-category-policy";
7
7
  import { audit } from "../audit";
8
+ import { requireRegisteredAppAccess, resolveIssuanceAuthority } from "../identity/issue-invocation";
8
9
  import { logger } from "../logging";
9
10
  import { parsePgJsonValue } from "../postgres";
10
11
  import { isMandatePolicyNarrowing, type MandatePolicyV1, MandatePolicyV1Schema, mandatePolicyAllows, parseMandatePolicy } from "./policy";
@@ -213,6 +214,33 @@ const validSubject = async (subject: MandateSubject, db: SQL): Promise<boolean>
213
214
  return row?.active === true;
214
215
  };
215
216
 
217
+ const subjectAppAccess = async (
218
+ subject: MandateSubject,
219
+ appIds: readonly string[],
220
+ db: SQL,
221
+ groupsAdmin?: readonly string[],
222
+ ): Promise<boolean> => {
223
+ try {
224
+ const authority = await resolveIssuanceAuthority(
225
+ {
226
+ sub: subject.id,
227
+ principal_type: subject.type,
228
+ access_subject_type: subject.type,
229
+ access_subject_id: subject.id,
230
+ credential_kind: "mandate",
231
+ scopes: [],
232
+ },
233
+ "core",
234
+ db,
235
+ groupsAdmin,
236
+ );
237
+ for (const appId of new Set(appIds)) await requireRegisteredAppAccess(authority, appId, { db });
238
+ return true;
239
+ } catch {
240
+ return false;
241
+ }
242
+ };
243
+
216
244
  const canActForSubject = (authority: MandateInteractiveAuthority | MandateAdminAuthority, subject: MandateSubject): boolean =>
217
245
  authority.kind === "admin" || (subject.type === "user" && subject.id === authority.userId);
218
246
 
@@ -548,6 +576,12 @@ const setMandateState = async (
548
576
  return changedRow(input.mandateId, input.expectedRevision, input.authority, db, async (current) => {
549
577
  if (state === "active" && input.authority.kind === "workload") return fail(err.forbidden("Workloads cannot resume mandates"));
550
578
  if (state === "active" && !(await validSubject(current.subject, db))) return ok(null);
579
+ if (
580
+ state === "active" &&
581
+ !(await subjectAppAccess(current.subject, [current.ownerAppId, ...(current.policy.apps === "*" ? [] : current.policy.apps)], db))
582
+ ) {
583
+ return fail(err.forbidden("App access was denied. Ask someone who manages access before resuming this mandate."));
584
+ }
551
585
  if (current.state === state) return ok(current);
552
586
  if (state === "paused" && current.state !== "active") return ok(null);
553
587
  if (state === "active" && current.state !== "paused") return ok(null);
@@ -632,7 +666,7 @@ export const validateMandateIssueAuthority = async (
632
666
  expectedRevision?: number;
633
667
  requestId?: string;
634
668
  },
635
- options: { db?: SQL } = {},
669
+ options: { db?: SQL; groupsAdmin?: readonly string[] } = {},
636
670
  ): Promise<Result<MandateIssueAuthority>> => {
637
671
  if (!UUID.safeParse(input.mandateId).success) {
638
672
  return denyMandateIssue(err.forbidden("Mandate identifier is invalid"));
@@ -695,6 +729,9 @@ export const validateMandateIssueAuthority = async (
695
729
  return denyMandateIssue(err.forbidden(MANDATE_POLICY_DENIED_MESSAGE));
696
730
  }
697
731
  const mandate = mapMandate(row);
732
+ if (!(await subjectAppAccess(mandate.subject, [mandate.ownerAppId, target.data], db, options.groupsAdmin))) {
733
+ return denyMandateIssue(err.forbidden("App access was denied. Ask someone who manages access before running this mandate."));
734
+ }
698
735
  return ok({
699
736
  mandateId: mandate.id,
700
737
  mandateRevision: mandate.revision,
@@ -759,10 +796,10 @@ const issueAuditParams = <T>(
759
796
  export const withMandateIssueAuthority = <T>(
760
797
  input: Parameters<typeof validateMandateIssueAuthority>[0],
761
798
  use: (authority: MandateIssueAuthority) => Promise<T>,
762
- options: { db?: SQL } = {},
799
+ options: { db?: SQL; groupsAdmin?: readonly string[] } = {},
763
800
  ): Promise<Result<T>> => {
764
801
  const run = async (db: SQL): Promise<Result<T>> => {
765
- const authority = await validateMandateIssueAuthority(input, { db });
802
+ const authority = await validateMandateIssueAuthority(input, { db, groupsAdmin: options.groupsAdmin });
766
803
  if (!authority.ok) {
767
804
  mandateMetrics.increment(metricForIssueDenial(authority.error));
768
805
  return audit.recordResult(issueAuditParams(input, authority, input.expectedRevision ?? null, db));
@@ -27,9 +27,10 @@ export const resolveAllowedNetworkAddresses = async (
27
27
  rawHostname: string,
28
28
  allowlist: NetworkAllowlist,
29
29
  lookup: NetworkLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
30
+ port?: number,
30
31
  ): Promise<PublicNetworkAddress[]> => {
31
32
  const hostname = normalizeNetworkHostname(rawHostname);
32
- const trustedHost = allowlist.hosts.has(hostname);
33
+ const trustedHost = allowlist.hosts.has(hostname) || (port !== undefined && allowlist.hostPorts?.get(hostname)?.has(port) === true);
33
34
  if (!trustedHost && isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
34
35
 
35
36
  const literal = parseNetworkAddress(rawHostname);
@@ -45,7 +46,7 @@ export const resolveAllowedNetworkAddresses = async (
45
46
  });
46
47
  if (
47
48
  !trustedHost &&
48
- normalized.some((entry) => isUnsafeNetworkAddress(entry.address) && !isAllowlistedNetworkAddress(allowlist, entry.address))
49
+ normalized.some((entry) => isUnsafeNetworkAddress(entry.address) && !isAllowlistedNetworkAddress(allowlist, entry.address, port))
49
50
  ) {
50
51
  throw new NetworkTargetBlockedError("Network target resolved to a private or reserved address");
51
52
  }
@@ -59,15 +60,17 @@ export const isNetworkTargetBlocked = async (
59
60
  {
60
61
  resolve = resolveAllowedNetworkAddresses,
61
62
  timeoutMs = 5000,
63
+ port,
62
64
  }: {
63
65
  resolve?: typeof resolveAllowedNetworkAddresses;
64
66
  timeoutMs?: number;
67
+ port?: number;
65
68
  } = {},
66
69
  ): Promise<boolean> => {
67
70
  let timer: ReturnType<typeof setTimeout> | undefined;
68
71
  try {
69
72
  await Promise.race([
70
- resolve(hostname, allowlist),
73
+ resolve(hostname, allowlist, undefined, port),
71
74
  new Promise<void>((resolve) => {
72
75
  timer = setTimeout(resolve, timeoutMs);
73
76
  }),
@@ -0,0 +1,99 @@
1
+ import type { ClientRequest, IncomingMessage } from "node:http";
2
+ import type { RequestOptions } from "node:https";
3
+
4
+ /** Stop waiting even when a resolver cannot cancel its underlying DNS work. */
5
+ export const awaitNetworkOperation = <T>(work: Promise<T>, signal: AbortSignal): Promise<T> =>
6
+ new Promise<T>((resolve, reject) => {
7
+ const cleanup = () => signal.removeEventListener("abort", abort);
8
+ const abort = () => {
9
+ cleanup();
10
+ reject(signal.reason);
11
+ };
12
+ signal.addEventListener("abort", abort, { once: true });
13
+ work.then(
14
+ (value) => {
15
+ cleanup();
16
+ if (signal.aborted) reject(signal.reason);
17
+ else resolve(value);
18
+ },
19
+ (error) => {
20
+ cleanup();
21
+ reject(error);
22
+ },
23
+ );
24
+ if (signal.aborted) abort();
25
+ });
26
+
27
+ /** One caller-owned budget, shared by lookup, address attempts, redirects and body reads. */
28
+ export const withNetworkDeadline = async <T>(
29
+ timeoutMs: number,
30
+ callerSignal: AbortSignal | undefined,
31
+ timeoutError: Error,
32
+ work: (signal: AbortSignal) => Promise<T>,
33
+ ): Promise<T> => {
34
+ const controller = new AbortController();
35
+ const abort = () => controller.abort(callerSignal?.reason);
36
+ callerSignal?.addEventListener("abort", abort, { once: true });
37
+ if (callerSignal?.aborted) abort();
38
+ const timer = setTimeout(() => controller.abort(timeoutError), timeoutMs);
39
+ try {
40
+ controller.signal.throwIfAborted();
41
+ return await awaitNetworkOperation(work(controller.signal), controller.signal);
42
+ } finally {
43
+ clearTimeout(timer);
44
+ callerSignal?.removeEventListener("abort", abort);
45
+ }
46
+ };
47
+
48
+ export type NetworkRequestFactory = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
49
+
50
+ /** Lifecycle for already validated/pinned request options. Does not resolve or authorize targets. */
51
+ export const readNetworkResponse = <T>(input: {
52
+ request: NetworkRequestFactory;
53
+ options: RequestOptions;
54
+ signal: AbortSignal;
55
+ body?: Uint8Array;
56
+ read: (response: IncomingMessage, resolve: (value: T) => void, reject: (error: Error) => void) => void;
57
+ }): Promise<T> =>
58
+ new Promise<T>((resolve, reject) => {
59
+ input.signal.throwIfAborted();
60
+ let settled = false;
61
+ let outgoing: ClientRequest | undefined;
62
+ let incoming: IncomingMessage | undefined;
63
+ const finish = (result: { value: T } | { error: Error }) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ input.signal.removeEventListener("abort", abort);
67
+ // Discarded bodies must never be drained after the operation has settled.
68
+ incoming?.destroy();
69
+ outgoing?.destroy();
70
+ if ("error" in result) reject(result.error);
71
+ else resolve(result.value);
72
+ };
73
+ const fail = (error: Error) => finish({ error });
74
+ const abort = () => fail(input.signal.reason);
75
+ input.signal.addEventListener("abort", abort, { once: true });
76
+ try {
77
+ outgoing = input.request(input.options, (response) => {
78
+ incoming = response;
79
+ response.on("error", fail);
80
+ response.once("aborted", () => fail(new Error("Network response was aborted")));
81
+ if (settled) {
82
+ response.destroy();
83
+ return;
84
+ }
85
+ try {
86
+ input.read(response, (value) => finish({ value }), fail);
87
+ } catch (error) {
88
+ fail(error instanceof Error ? error : new Error(String(error)));
89
+ }
90
+ });
91
+ outgoing.on("error", fail);
92
+ if (settled || input.signal.aborted) {
93
+ if (input.signal.aborted) abort();
94
+ outgoing.destroy();
95
+ } else outgoing.end(input.body);
96
+ } catch (error) {
97
+ fail(error instanceof Error ? error : new Error(String(error)));
98
+ }
99
+ });
@@ -9,8 +9,9 @@ const RETRY_MS = 5_000;
9
9
  const log = logger("notifications:catalog");
10
10
 
11
11
  const isSchemaUnavailable = (error: unknown): boolean => {
12
- if (!error || typeof error !== "object" || !("code" in error)) return false;
13
- return error.code === "42P01" || error.code === "42703" || error.code === "3F000";
12
+ if (!error || typeof error !== "object") return false;
13
+ const code = ("errno" in error ? error.errno : undefined) ?? ("code" in error ? error.code : undefined);
14
+ return code === "42P01" || code === "42703" || code === "3F000";
14
15
  };
15
16
 
16
17
  const upsertDefinition = async (definition: AnyBoundNotificationDefinition, db: typeof sql = sql): Promise<void> => {
@@ -95,6 +95,7 @@ const recover = (): Promise<void> => {
95
95
  const lifecycle = createRuntimeLifecycle({
96
96
  start: async () => {
97
97
  recoveryTasks.open();
98
+ // Local delivery orchestration; email enters the shared SMTP budget.
98
99
  const concurrency = Math.min(Math.max(Math.floor(runtimeOptions.concurrency ?? 4), 1), 16);
99
100
  const recoveryIntervalMs = Math.max(runtimeOptions.recoveryIntervalMs ?? 30_000, 5_000);
100
101
  worker = await deliveryJob().process(
@@ -4,6 +4,7 @@ import webpush, { type RequestDetails as WebPushRequestDetails, type RequestOpti
4
4
  import { type BrowserPushSubscription, BrowserPushSubscriptionSchema } from "../../contracts/user-notifications";
5
5
  import { normalizeNetworkHostname } from "../../shared/network-address";
6
6
  import { type PublicNetworkAddress, resolvePublicNetworkAddresses } from "../network-security";
7
+ import { awaitNetworkOperation, readNetworkResponse, withNetworkDeadline } from "../network-transport";
7
8
 
8
9
  const RESPONSE_LIMIT_BYTES = 64 * 1_024;
9
10
  const REQUEST_TIMEOUT_MS = 15_000;
@@ -19,6 +20,7 @@ type RequestDetailsFactory = (
19
20
  type WebPushTransportDeps = {
20
21
  generateRequestDetails?: RequestDetailsFactory;
21
22
  request?: RequestFactory;
23
+ signal?: AbortSignal;
22
24
  resolve?: (hostname: string) => Promise<PublicNetworkAddress[]>;
23
25
  };
24
26
 
@@ -31,7 +33,9 @@ export const buildPinnedWebPushRequests = async (
31
33
  const validatedSubscription = BrowserPushSubscriptionSchema.parse(subscription);
32
34
  const endpoint = new URL(validatedSubscription.endpoint);
33
35
  const hostname = normalizeNetworkHostname(endpoint.hostname);
34
- const resolvedAddresses = await (deps.resolve ?? resolvePublicNetworkAddresses)(hostname);
36
+ deps.signal?.throwIfAborted();
37
+ const resolving = (deps.resolve ?? resolvePublicNetworkAddresses)(hostname);
38
+ const resolvedAddresses = deps.signal ? await awaitNetworkOperation(resolving, deps.signal) : await resolving;
35
39
  const uniqueAddresses = resolvedAddresses.filter(
36
40
  (target, index) => resolvedAddresses.findIndex((candidate) => candidate.address === target.address) === index,
37
41
  );
@@ -61,32 +65,30 @@ export const buildPinnedWebPushRequests = async (
61
65
  headers: { ...details.headers, host: endpoint.host },
62
66
  servername: hostname,
63
67
  rejectUnauthorized: true,
64
- timeout: REQUEST_TIMEOUT_MS,
65
68
  })),
66
69
  };
67
70
  };
68
71
 
69
- const sendPreparedWebPushRequest = async (request: RequestFactory, options: HttpsRequestOptions, body: Buffer | null): Promise<void> =>
70
- new Promise<void>((resolve, reject) => {
71
- let settled = false;
72
- let responseReceived = false;
73
- const finish = (result: { error?: Error }) => {
74
- if (settled) return;
75
- settled = true;
76
- if (result.error) {
77
- reject(responseReceived ? Object.assign(result.error, { providerResponse: true }) : result.error);
78
- } else {
79
- resolve();
80
- }
81
- };
82
- const outgoing = request(options, (response) => {
72
+ const sendPreparedWebPushRequest = (
73
+ request: RequestFactory,
74
+ options: HttpsRequestOptions,
75
+ body: Buffer | null,
76
+ signal: AbortSignal,
77
+ ): Promise<void> => {
78
+ let responseReceived = false;
79
+ return readNetworkResponse<void>({
80
+ request,
81
+ options,
82
+ body: body ?? undefined,
83
+ signal,
84
+ read: (response, resolve, reject) => {
83
85
  responseReceived = true;
86
+ const finish = (result: { error?: Error }) => (result.error ? reject(result.error) : resolve());
84
87
  let responseBody = "";
85
88
  let bytes = 0;
86
89
  response.on("data", (chunk: Buffer | string) => {
87
90
  bytes += Buffer.byteLength(chunk);
88
91
  if (bytes > RESPONSE_LIMIT_BYTES) {
89
- response.destroy();
90
92
  finish({ error: new Error("Web Push provider response is too large") });
91
93
  return;
92
94
  }
@@ -105,31 +107,36 @@ const sendPreparedWebPushRequest = async (request: RequestFactory, options: Http
105
107
  }),
106
108
  });
107
109
  });
108
- response.on("error", (error) => finish({ error }));
109
- });
110
- outgoing.on("timeout", () => outgoing.destroy(new Error("Web Push provider request timed out")));
111
- outgoing.on("error", (error) => finish({ error }));
112
- if (body) outgoing.write(body);
113
- outgoing.end();
110
+ },
111
+ }).catch((error) => {
112
+ throw responseReceived ? Object.assign(error, { providerResponse: true }) : error;
114
113
  });
114
+ };
115
115
 
116
116
  export const sendPinnedWebPush = async (
117
117
  subscription: BrowserPushSubscription,
118
118
  payload: string,
119
119
  options: WebPushRequestOptions,
120
120
  deps: WebPushTransportDeps = {},
121
- ): Promise<void> => {
122
- const request = deps.request ?? httpsRequest;
123
- const prepared = await buildPinnedWebPushRequests(subscription, payload, options, deps);
124
- let lastError: unknown;
125
- for (const attempt of prepared.attempts) {
126
- try {
127
- await sendPreparedWebPushRequest(request, attempt, prepared.body);
128
- return;
129
- } catch (error) {
130
- if (error && typeof error === "object" && "providerResponse" in error) throw error;
131
- lastError = error;
132
- }
133
- }
134
- throw lastError instanceof Error ? lastError : new Error("Web Push provider request failed");
135
- };
121
+ ): Promise<void> =>
122
+ withNetworkDeadline(
123
+ options.timeout ?? REQUEST_TIMEOUT_MS,
124
+ deps.signal,
125
+ new Error("Web Push provider request timed out"),
126
+ async (signal) => {
127
+ const request = deps.request ?? httpsRequest;
128
+ const prepared = await buildPinnedWebPushRequests(subscription, payload, options, { ...deps, signal });
129
+ let lastError: unknown;
130
+ for (const attempt of prepared.attempts) {
131
+ try {
132
+ await sendPreparedWebPushRequest(request, attempt, prepared.body, signal);
133
+ return;
134
+ } catch (error) {
135
+ signal.throwIfAborted();
136
+ if (error && typeof error === "object" && "providerResponse" in error) throw error;
137
+ lastError = error;
138
+ }
139
+ }
140
+ throw lastError instanceof Error ? lastError : new Error("Web Push provider request failed");
141
+ },
142
+ );
@@ -4,6 +4,7 @@ import { Readable } from "node:stream";
4
4
  import { sql } from "bun";
5
5
  import { z } from "zod";
6
6
  import { listApps } from "../../_internal/registry";
7
+ import { DownstreamBusyError, downstreamBudget } from "../downstream-budget";
7
8
  import { parsePgJsonValue } from "../postgres";
8
9
  import { get } from "../settings";
9
10
  import { deleteMailObjects, verifyMailAttachment } from "./attachments";
@@ -97,6 +98,7 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
97
98
  if (signal?.aborted) abort();
98
99
  const timer = setTimeout(abort, 60_000);
99
100
  const streams: Readable[] = [];
101
+ let releaseSmtp: (() => void) | undefined;
100
102
  try {
101
103
  for (const [index, ref] of refs.entries()) await verifyMailAttachment(ref, metadata[index]!, attempt.signal);
102
104
  const attachments = [];
@@ -131,6 +133,8 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
131
133
  }
132
134
  // Load the checked profile by ID: a profile deleted (or recreated under its key) since the check is profile_removed.
133
135
  credentials = await resolveMailCredentials({ id: row.profile_id });
136
+ releaseSmtp = await downstreamBudget.acquire({ downstream: "smtp", timeoutMs: 60_000, signal: attempt.signal });
137
+ attempt.signal.throwIfAborted();
134
138
  const socket = new Socket();
135
139
  // DNS can complete after cancellation; tear down a late connection too.
136
140
  socket.once("connect", () => {
@@ -182,6 +186,7 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
182
186
  signal?.removeEventListener("abort", abort);
183
187
  attempt.abort();
184
188
  for (const stream of streams) stream.destroy();
189
+ releaseSmtp?.();
185
190
  }
186
191
  } catch (error) {
187
192
  if (error instanceof OutgoingMailError && ["profile_unknown", "profile_removed"].includes(error.code)) {
@@ -196,7 +201,7 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
196
201
  ]
197
202
  : [];
198
203
  const message =
199
- credentials || error instanceof OutgoingMailError
204
+ credentials || error instanceof OutgoingMailError || error instanceof DownstreamBusyError
200
205
  ? smtpFailureMessage(error, secrets)
201
206
  : "Outgoing mail delivery is temporarily unavailable.";
202
207
  const lost = error instanceof OutgoingMailError && error.code === "attachment_lost";
@@ -1,7 +1,10 @@
1
1
  import { fail, ok, type Result } from "@k2b/stdlib";
2
+ import { z } from "zod";
2
3
  import { getProcessApplicationId, getProcessPlatformPermissions } from "../../_internal/process-identity";
3
4
  import {
4
5
  MailBatchSchema,
6
+ type MailContent,
7
+ type MailContentContext,
5
8
  type MailFilter,
6
9
  MailFilterSchema,
7
10
  type MailMessage,
@@ -13,6 +16,7 @@ import {
13
16
  type MailRecord,
14
17
  type MailServiceError,
15
18
  } from "../../contracts/outgoing-mail";
19
+ import { ServiceAccountSchema, UserSchema } from "../../contracts/shared";
16
20
  import { cancelMailStreams } from "./attachments";
17
21
  import { enqueueMail } from "./enqueue";
18
22
  import { outgoingMailMessages, recordMailBatch, recordMailSend } from "./messages";
@@ -36,13 +40,63 @@ const failure = (error: unknown): MailServiceError => {
36
40
  return {
37
41
  code: error.code,
38
42
  message: error.message,
39
- status: error.code === "mail_unavailable" || error.status === 502 ? 500 : error.status,
43
+ status: error.code === "mail_unavailable" || error.status === 502 || error.status === 503 ? 500 : error.status,
40
44
  ...("limit" in error && typeof error.limit === "number" ? { limit: error.limit } : {}),
41
45
  ...("used" in error && typeof error.used === "number" ? { used: error.used } : {}),
42
46
  ...("requested" in error && typeof error.requested === "number" ? { requested: error.requested } : {}),
43
47
  };
44
48
  };
49
+ // Runtime validation protects the transport boundary; authentication is owned by the caller's
50
+ // trusted server context. Unknown actor fields never reach the read audit.
51
+ const delegation = z.object({
52
+ kind: z.literal("invocation"),
53
+ callingAppId: z.string().min(1),
54
+ credentialKind: z.enum(["session", "oauth", "api_key", "mandate"]),
55
+ invocationId: z.string().min(1),
56
+ requestId: z.string().nullable().optional(),
57
+ mandateId: z.string().optional(),
58
+ mandateRevision: z.int().positive().optional(),
59
+ workloadType: z.string().optional(),
60
+ workloadId: z.string().optional(),
61
+ });
62
+ const contentContext = z.object({
63
+ actor: z.discriminatedUnion("kind", [
64
+ z.object({
65
+ kind: z.literal("user"),
66
+ user: UserSchema.refine((user) => z.uuid().safeParse(user.id).success && !!user.uid),
67
+ delegation: delegation.optional(),
68
+ }),
69
+ z.object({
70
+ kind: z.literal("service_account"),
71
+ serviceAccount: ServiceAccountSchema.refine((account) => account.status === "active"),
72
+ delegatedUser: UserSchema.nullable(),
73
+ scopes: z.array(z.string()),
74
+ delegation: delegation.optional(),
75
+ }),
76
+ ]),
77
+ requestId: z.string().optional(),
78
+ });
45
79
  export const mail = {
80
+ async content(id: string, context: MailContentContext): Promise<Result<MailContent, MailServiceError>> {
81
+ const caller = identity(true);
82
+ if (!caller.ok) return caller;
83
+ const parsedId = z.uuid().safeParse(id);
84
+ const parsedContext = contentContext.safeParse(context);
85
+ if (!parsedId.success || !parsedContext.success)
86
+ return fail({ code: "bad_input", message: "Provide a valid mail ID and authenticated actor context.", status: 400 });
87
+ try {
88
+ return ok(
89
+ await outgoingMailMessages.appContent(
90
+ parsedId.data,
91
+ caller.data,
92
+ getProcessPlatformPermissions().includes("mail:read"),
93
+ parsedContext.data,
94
+ ),
95
+ );
96
+ } catch (error) {
97
+ return fail(failure(error));
98
+ }
99
+ },
46
100
  async readableApps(): Promise<Result<string[], MailServiceError>> {
47
101
  const caller = identity(true);
48
102
  if (!caller.ok) return caller;
@@ -4,6 +4,8 @@ import { z } from "zod";
4
4
  import {
5
5
  type AdminMailFilter,
6
6
  type AdminMailRecord,
7
+ type MailContent,
8
+ type MailContentContext,
7
9
  type MailFilter,
8
10
  type MailMessage,
9
11
  type MailPage,
@@ -17,7 +19,7 @@ import { audit } from "../audit";
17
19
  import { escapeLikePattern, parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
18
20
  import type { UploadedAttachments } from "./attachments";
19
21
  import { mailActorSnapshot, mailMessageId } from "./message";
20
- import { type MailAuditContext, OutgoingMailError } from "./store";
22
+ import { type MailAuditContext, mailAppIdSha256, OutgoingMailError } from "./store";
21
23
 
22
24
  export type MessageRow = {
23
25
  id: string;
@@ -109,7 +111,7 @@ export const messageRecord = (row: LogRow): MailRecord => ({
109
111
  ...(row.error_message ? { error: row.error_message } : {}),
110
112
  failures: MailRecordSchema.shape.failures.parse(parsePgJsonValue(row.failures)),
111
113
  attempts: row.attempt_count,
112
- ...(row.actor_id ? { actor: { id: row.actor_id, name: row.actor_name ?? row.actor_id } } : {}),
114
+ ...(row.actor_id ? { actor: { id: row.actor_id, name: row.actor_name ?? "" } } : {}),
113
115
  createdAt: date(row.created_at),
114
116
  ...(row.sent_at ? { sentAt: date(row.sent_at) } : {}),
115
117
  ...(row.content_purged_at ? { contentPurgedAt: date(row.content_purged_at) } : {}),
@@ -433,7 +435,84 @@ const content = async (id: string, context: MailAuditContext) =>
433
435
  headers: z.record(z.string(), z.string()).nullable().parse(parsePgJsonValue(row.headers)),
434
436
  };
435
437
  });
436
- export const outgoingMailMessages = { read, metadata, known, accept, list, content };
438
+ /** One budget for database work, from transaction start through audit. Pool acquisition and commit are outside it. */
439
+ const CONTENT_BUDGET_MS = 5_000;
440
+ const appContent = async (id: string, readingAppId: string, canReadOtherApps: boolean, context: MailContentContext): Promise<MailContent> =>
441
+ sql.begin(async (tx) => {
442
+ const deadline = Date.now() + CONTENT_BUDGET_MS;
443
+ const limit = async () => {
444
+ const remaining = Math.floor(deadline - Date.now());
445
+ if (remaining < 1) throw new Error("Outgoing mail content DB budget exhausted.");
446
+ await tx`SELECT set_config('statement_timeout', ${`${remaining}ms`}, true)`;
447
+ };
448
+ await limit();
449
+ // Writers take this same key exclusively. Check grants in a fresh statement after acquiring it.
450
+ await tx`SELECT pg_advisory_xact_lock_shared(hashtextextended('outgoing_mail.policy', 0))`;
451
+ await limit();
452
+ const [row] = await tx<{ app_id: string; content_purged_at: Date | string | null; level: string | null }[]>`
453
+ SELECT m.app_id, m.content_purged_at, g.level FROM outgoing_mail.messages m
454
+ LEFT JOIN outgoing_mail.app_log_access g ON g.app_id = ${readingAppId} AND g.source_app_id = m.app_id
455
+ WHERE m.id = ${id}::uuid AND (m.app_id = ${readingAppId} OR
456
+ (${canReadOtherApps} AND ${readingAppId} <> 'core' AND m.app_id <> 'core' AND g.app_id IS NOT NULL))
457
+ FOR SHARE OF m`;
458
+ if (!row) throw new OutgoingMailError("message_unknown", "Outgoing mail message does not exist.", 404);
459
+ if (row.app_id !== readingAppId && row.level !== "content")
460
+ throw new OutgoingMailError("app_not_allowed", "This application may not read this mail content.", 403);
461
+ let result: MailContent;
462
+ if (row.content_purged_at !== null) result = { purged: true, contentPurgedAt: date(row.content_purged_at) };
463
+ else {
464
+ await limit();
465
+ // No body fetch before authorization. Never fetch HTML, headers, object refs or attachment digests.
466
+ const [body] = await tx<{ text_body: string | null; attachment_names: unknown }[]>`
467
+ SELECT text_body, ARRAY(SELECT item->>'filename' FROM jsonb_array_elements(attachments) AS item) AS attachment_names
468
+ FROM outgoing_mail.messages WHERE id = ${id}::uuid`;
469
+ if (!body) throw new Error("Locked outgoing mail disappeared.");
470
+ result = { purged: false, text: body.text_body, attachmentNames: z.array(z.string()).parse(body.attachment_names) };
471
+ }
472
+ const actor = context.actor;
473
+ const user = actor.kind === "user" ? actor.user : undefined;
474
+ const provenance = actor.delegation;
475
+ await limit();
476
+ await audit.record(
477
+ {
478
+ action: "outgoing_mail.message.read",
479
+ outcome: "allowed",
480
+ target: { type: "outgoing_mail_message", id },
481
+ actor: user ? { userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles } : undefined,
482
+ requestId: context.requestId ?? provenance?.requestId,
483
+ metadata: {
484
+ appId: row.app_id,
485
+ appIdSha256: mailAppIdSha256(row.app_id),
486
+ readingAppId,
487
+ readingAppIdSha256: mailAppIdSha256(readingAppId),
488
+ actorKind: actor.kind,
489
+ ...(actor.kind === "service_account"
490
+ ? {
491
+ serviceAccountId: actor.serviceAccount.id,
492
+ ...(actor.delegatedUser ? { delegatedUserId: actor.delegatedUser.id } : {}),
493
+ }
494
+ : {}),
495
+ ...(provenance
496
+ ? {
497
+ delegation: {
498
+ kind: provenance.kind,
499
+ callingAppId: provenance.callingAppId,
500
+ credentialKind: provenance.credentialKind,
501
+ invocationId: provenance.invocationId,
502
+ mandateId: provenance.mandateId,
503
+ mandateRevision: provenance.mandateRevision,
504
+ workloadType: provenance.workloadType,
505
+ workloadId: provenance.workloadId,
506
+ },
507
+ }
508
+ : {}),
509
+ },
510
+ },
511
+ tx,
512
+ );
513
+ return result;
514
+ });
515
+ export const outgoingMailMessages = { read, metadata, known, accept, list, content, appContent };
437
516
 
438
517
  /** Serialize updates to preserve failures from overlapping reports and replicas. */
439
518
  export const applyOutgoingMailBounce = async (
@@ -5,7 +5,7 @@ import { NetworkTargetBlockedError, resolveAllowedNetworkAddresses } from "../ne
5
5
  export const outgoingMailAllowlist = () => parseNetworkAllowlistEnv("OUTGOING_MAIL_ALLOWED_TARGETS", env.OUTGOING_MAIL_ALLOWED_TARGETS);
6
6
 
7
7
  /** One bounded lookup shared by SMTP and bounce IMAP; clients use only the checked addresses. */
8
- export const resolveMailHost = async (host: string, signal?: AbortSignal, resolve = resolveAllowedNetworkAddresses) => {
8
+ export const resolveMailHost = async (host: string, signal?: AbortSignal, resolve = resolveAllowedNetworkAddresses, port?: number) => {
9
9
  const hostname = normalizeNetworkHostname(host);
10
10
  const deadline = new AbortController();
11
11
  const timer = setTimeout(() => deadline.abort(new Error("Outgoing mail DNS lookup timed out.")), 5000);
@@ -14,7 +14,7 @@ export const resolveMailHost = async (host: string, signal?: AbortSignal, resolv
14
14
  try {
15
15
  cancellation.throwIfAborted();
16
16
  const addresses = await Promise.race([
17
- resolve(hostname, outgoingMailAllowlist()),
17
+ resolve(hostname, outgoingMailAllowlist(), undefined, port),
18
18
  new Promise<never>((_, reject) => {
19
19
  abort = () => reject(cancellation.reason);
20
20
  cancellation.addEventListener("abort", abort, { once: true });
@@ -1,7 +1,7 @@
1
1
  import { sql } from "bun";
2
2
  import type { MailRetention } from "../../contracts/outgoing-mail";
3
3
  import { toPgUuidArray } from "../postgres";
4
- import { get } from "../settings";
4
+ import { get, read } from "../settings";
5
5
  import { deleteMailObjects } from "./attachments";
6
6
  import { messageAttachmentRefs } from "./messages";
7
7
 
@@ -38,6 +38,18 @@ export const readMailRetention = async (): Promise<MailRetention> => ({
38
38
  contentDays: await get<number>(MAIL_RETENTION_KEYS[0]),
39
39
  recordDays: await get<number>(MAIL_RETENTION_KEYS[1]),
40
40
  });
41
+ export const readMailRetentionSettings = async (db?: typeof sql) => {
42
+ const content = await read(MAIL_RETENTION_KEYS[0], db);
43
+ const record = await read(MAIL_RETENTION_KEYS[1], db);
44
+ return {
45
+ contentDays: Number(content.value),
46
+ recordDays: Number(record.value),
47
+ unreadable: {
48
+ [MAIL_RETENTION_KEYS[0]]: content.stored === "unreadable",
49
+ [MAIL_RETENTION_KEYS[1]]: record.stored === "unreadable",
50
+ },
51
+ };
52
+ };
41
53
  export const retainOutgoingMail = async (signal?: AbortSignal): Promise<void> => {
42
54
  const { contentDays, recordDays } = await readMailRetention();
43
55
  const deadline = Date.now() + 5 * 60_000;