@k2b/cloud 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/package.json +9 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -0,0 +1,312 @@
1
+ import type { Context, MiddlewareHandler } from "hono";
2
+ import { Hono } from "hono";
3
+ import { createMiddleware } from "hono/factory";
4
+ import { TrieRouter } from "hono/router/trie-router";
5
+ import type { WSEvents } from "hono/ws";
6
+ import { getApp } from "../../_internal/registry";
7
+ import type { AppMeta } from "../../contracts/app";
8
+ import { appAccessAuthority, canUseApp } from "../../services/app-access";
9
+ import { recordAppAccessDenied } from "../../services/app-access-metrics";
10
+ import { createLoginRedirectUrl } from "../../shared/redirect";
11
+ import { type AuthContext, auth, type RequestAuthority } from "./auth";
12
+
13
+ type AccessApp = Pick<AppMeta, "id" | "access"> & { name?: string };
14
+ export const APP_ACCESS_RECHECK_MS = 50_000;
15
+ export const APP_ACCESS_CHECK_TIMEOUT_MS = 10_000;
16
+ const connections = new Set<() => void>();
17
+ /** app.start() closes sockets and SSE before draining the process's shared services. */
18
+ export const stopAppAccessConnections = (): void => {
19
+ for (const close of [...connections]) close();
20
+ };
21
+
22
+ export const appAccessDeniedBody = (app: AccessApp) => ({
23
+ code: "app_access_denied" as const,
24
+ app: app.id,
25
+ message: `You cannot use ${app.name ?? app.id}. ${app.access?.kind === "grants" ? "Ask someone who manages access to this app." : "Ask your Cloud administrator about access."}`,
26
+ });
27
+
28
+ export class AppAccessDeniedError extends Error {
29
+ readonly code = "app_access_denied";
30
+ constructor(readonly app: AccessApp) {
31
+ super(appAccessDeniedBody(app).message);
32
+ }
33
+ }
34
+
35
+ /** Public paths bypass this gate only; the route still owns its authentication. */
36
+ export const appPublicRouteMatcher = (app: AccessApp): ((path: string) => boolean) => {
37
+ const router = new TrieRouter<boolean>();
38
+ for (const path of app.access?.publicRoutes ?? []) router.add("ALL", path, true);
39
+ return (path) => router.match("ALL", path)[0].length > 0;
40
+ };
41
+
42
+ /** Resolve afresh rather than reuse the original Context's authentication cache. */
43
+ export const appAccessRevalidator = (c: Context<AuthContext>, app: AccessApp): (() => Promise<boolean>) => {
44
+ const original = appAccessAuthority(c);
45
+ const sessionToken = c.get("sessionToken");
46
+ const sessionKind = c.get("sessionKind");
47
+ const samePrincipal = (current: RequestAuthority): boolean => {
48
+ if (!original || current.accessSubject.type !== original.accessSubject.type) return false;
49
+ if (
50
+ current.accessSubject.type === "user" &&
51
+ original.accessSubject.type === "user" &&
52
+ current.accessSubject.userId !== original.accessSubject.userId
53
+ )
54
+ return false;
55
+ if (
56
+ current.accessSubject.type === "service_account" &&
57
+ original.accessSubject.type === "service_account" &&
58
+ current.accessSubject.serviceAccountId !== original.accessSubject.serviceAccountId
59
+ )
60
+ return false;
61
+ if (current.credentialKind !== original.credentialKind || current.actor.kind !== original.actor.kind) return false;
62
+ if (current.actor.kind === "user" && original.actor.kind === "user") return current.actor.user.id === original.actor.user.id;
63
+ return (
64
+ current.actor.kind === "service_account" &&
65
+ original.actor.kind === "service_account" &&
66
+ current.actor.serviceAccount.id === original.actor.serviceAccount.id &&
67
+ current.actor.credentialId === original.actor.credentialId
68
+ );
69
+ };
70
+ return async () => {
71
+ if (!original || original.credentialKind === "invocation") return false;
72
+ let allowed = false;
73
+ const probe = new Hono<AuthContext>()
74
+ .onError((error) => {
75
+ throw error;
76
+ })
77
+ .use(auth.requireRole("*"))
78
+ .all("*", async (fresh) => {
79
+ const authority = appAccessAuthority(fresh);
80
+ if (!authority || !samePrincipal(authority)) return fresh.body(null, 401);
81
+ if (
82
+ original.credentialKind === "session" &&
83
+ (fresh.get("sessionToken") !== sessionToken || fresh.get("sessionKind") !== sessionKind)
84
+ )
85
+ return fresh.body(null, 401);
86
+ // A stream retains its initial domain context. Reconnect on changed scopes so
87
+ // neither a narrowed key nor an empty-scope personal key keeps stale authority.
88
+ if (JSON.stringify([...new Set(authority.scopes)].sort()) !== JSON.stringify([...new Set(original.scopes)].sort()))
89
+ return fresh.body(null, 401);
90
+ allowed = await canUseApp(authority, app);
91
+ return fresh.body(null, 204);
92
+ });
93
+ await probe.fetch(new Request(c.req.url, { headers: c.req.raw.headers }));
94
+ return allowed;
95
+ };
96
+ };
97
+
98
+ type WatchOptions = {
99
+ check: () => Promise<boolean>;
100
+ denied: () => void;
101
+ subscribe: (changed: () => void) => () => void;
102
+ intervalMs?: number;
103
+ timeoutMs?: number;
104
+ };
105
+
106
+ /** One check in flight, coalesced invalidations, and a deadline that fails closed. */
107
+ export const watchAppAccess = (options: WatchOptions): (() => void) => {
108
+ let stopped = false;
109
+ let checking = false;
110
+ let again = false;
111
+ let deadline: ReturnType<typeof setTimeout> | undefined;
112
+ let unsubscribe = () => {};
113
+ const stop = () => {
114
+ if (stopped) return;
115
+ stopped = true;
116
+ clearInterval(timer);
117
+ clearTimeout(deadline);
118
+ unsubscribe();
119
+ };
120
+ const deny = () => {
121
+ if (stopped) return;
122
+ stop();
123
+ options.denied();
124
+ };
125
+ const check = async () => {
126
+ if (stopped) return;
127
+ if (checking) {
128
+ again = true;
129
+ return;
130
+ }
131
+ checking = true;
132
+ deadline = setTimeout(deny, options.timeoutMs ?? APP_ACCESS_CHECK_TIMEOUT_MS);
133
+ try {
134
+ if (!(await options.check())) deny();
135
+ } catch {
136
+ deny();
137
+ } finally {
138
+ clearTimeout(deadline);
139
+ checking = false;
140
+ if (again && !stopped) {
141
+ again = false;
142
+ void check();
143
+ }
144
+ }
145
+ };
146
+ const timer = setInterval(() => void check(), options.intervalMs ?? APP_ACCESS_RECHECK_MS);
147
+ timer.unref?.();
148
+ unsubscribe = options.subscribe(() => void check());
149
+ // Close the race between the handshake's check and subscribing to changes.
150
+ void check();
151
+ return stop;
152
+ };
153
+
154
+ const subscribeApp = async (appId: string): Promise<(changed: () => void) => () => void> => {
155
+ const { subscribeAppAccessChanged } = await import("../../events/app-access");
156
+ return (changed) => subscribeAppAccessChanged(appId, changed);
157
+ };
158
+
159
+ /** Used by shared live updates and custom sockets; close always releases the watcher. */
160
+ export const guardAppAccessSocket = async (c: Context<AuthContext>, appId: string, events: WSEvents): Promise<WSEvents> => {
161
+ let app: AccessApp | null;
162
+ try {
163
+ app = await getApp(appId);
164
+ } catch {
165
+ app = null;
166
+ }
167
+ if (app && (!app.access || appPublicRouteMatcher(app)(c.req.path))) return events;
168
+ const authority = appAccessAuthority(c);
169
+ if (!app || !(await canUseApp(authority, app))) {
170
+ if (app) recordAppAccessDenied(app.id);
171
+ return { onOpen: (_event, ws) => ws.close(1008, "app_access_denied") };
172
+ }
173
+ const revalidate = appAccessRevalidator(c, app);
174
+ const subscribe = await subscribeApp(appId);
175
+ let stop: (() => void) | undefined;
176
+ let denied = false;
177
+ let shutdown = () => {};
178
+ return {
179
+ ...events,
180
+ onOpen: (event, ws) => {
181
+ shutdown = () => {
182
+ stop?.();
183
+ connections.delete(shutdown);
184
+ denied = true;
185
+ ws.close(1012, "server_restarting");
186
+ };
187
+ connections.add(shutdown);
188
+ stop = watchAppAccess({
189
+ check: revalidate,
190
+ subscribe,
191
+ denied: () => {
192
+ connections.delete(shutdown);
193
+ denied = true;
194
+ ws.close(1008, "app_access_denied");
195
+ },
196
+ });
197
+ return events.onOpen?.(event, ws);
198
+ },
199
+ onMessage: (event, ws) => {
200
+ if (!denied) return events.onMessage?.(event, ws);
201
+ },
202
+ onClose: (event, ws) => {
203
+ connections.delete(shutdown);
204
+ stop?.();
205
+ return events.onClose?.(event, ws);
206
+ },
207
+ onError: (event, ws) => {
208
+ connections.delete(shutdown);
209
+ stop?.();
210
+ ws.close(1011, "app_access_check_failed");
211
+ return events.onError?.(event, ws);
212
+ },
213
+ };
214
+ };
215
+
216
+ const guardSse = async (c: Context<AuthContext>, app: AccessApp, response: Response): Promise<Response> => {
217
+ if (!response.body) return response;
218
+ const subscribe = await subscribeApp(app.id);
219
+ const reader = response.body.getReader();
220
+ let ended = false;
221
+ let stop = () => {};
222
+ let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
223
+ const abort = () => {
224
+ if (!ended) streamController?.close();
225
+ finish();
226
+ };
227
+ const finish = () => {
228
+ if (ended) return;
229
+ ended = true;
230
+ stop();
231
+ connections.delete(abort);
232
+ c.req.raw.signal.removeEventListener("abort", abort);
233
+ void reader.cancel().catch(() => {});
234
+ };
235
+ const body = new ReadableStream<Uint8Array>({
236
+ start(controller) {
237
+ streamController = controller;
238
+ connections.add(abort);
239
+ stop = watchAppAccess({
240
+ check: appAccessRevalidator(c, app),
241
+ subscribe,
242
+ denied: () => {
243
+ if (ended) return;
244
+ controller.enqueue(new TextEncoder().encode(`event: error\ndata: ${JSON.stringify(appAccessDeniedBody(app))}\n\n`));
245
+ controller.close();
246
+ finish();
247
+ },
248
+ });
249
+ c.req.raw.signal.addEventListener("abort", abort, { once: true });
250
+ if (c.req.raw.signal.aborted) {
251
+ controller.close();
252
+ finish();
253
+ }
254
+ },
255
+ async pull(controller) {
256
+ try {
257
+ const item = await reader.read();
258
+ if (ended) return;
259
+ if (item.done) {
260
+ controller.close();
261
+ finish();
262
+ } else controller.enqueue(item.value);
263
+ } catch (error) {
264
+ if (!ended) {
265
+ controller.error(error);
266
+ finish();
267
+ }
268
+ }
269
+ },
270
+ cancel: finish,
271
+ });
272
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
273
+ };
274
+
275
+ /** Framework gate: install after static and internal invocation mounts, before every app-owned handler. */
276
+ export const requireAppAccess = (
277
+ app: AccessApp,
278
+ renderDenied: (c: Context<AuthContext>, message: string) => Promise<Response>,
279
+ ): MiddlewareHandler<AuthContext> => {
280
+ const isPublic = appPublicRouteMatcher(app);
281
+ return createMiddleware<AuthContext>(async (c, next) => {
282
+ if (!app.access || isPublic(c.req.path)) return next();
283
+ let resolved: RequestAuthority | null = null;
284
+ await auth.requireRole("*")(c, async () => {
285
+ resolved = appAccessAuthority(c);
286
+ });
287
+ const browser =
288
+ c.req.method === "GET" && c.req.header("accept")?.includes("text/html") && c.req.header("upgrade")?.toLowerCase() !== "websocket";
289
+ if (!resolved) {
290
+ // Let the established mobile launch flow renew an app session.
291
+ if (browser && c.req.path.startsWith("/pwa/")) {
292
+ const url = new URL(c.req.url);
293
+ return c.redirect(
294
+ url.searchParams.has("pwa_launch")
295
+ ? "/pwa/?pwa=unavailable"
296
+ : `/pwa/_auth/session/launch?to=${encodeURIComponent(url.pathname + url.search)}`,
297
+ );
298
+ }
299
+ return browser ? c.redirect(createLoginRedirectUrl(c.req.url)) : c.json({ message: "Authentication required" }, 401);
300
+ }
301
+ if (!(await canUseApp(resolved, app))) {
302
+ recordAppAccessDenied(app.id);
303
+ c.header("Cache-Control", "private, no-store");
304
+ const body = appAccessDeniedBody(app);
305
+ const response = browser ? await renderDenied(c, body.message) : c.json(body, 403);
306
+ response.headers.set("Cache-Control", "private, no-store");
307
+ return response;
308
+ }
309
+ await next();
310
+ if (c.res.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream")) c.res = await guardSse(c, app, c.res);
311
+ });
312
+ };
@@ -394,7 +394,7 @@ const requireOAuthScope = (...requiredScopes: string[]) =>
394
394
  return c.json({ code: "FORBIDDEN", message: `OAuth scope ${requiredScopes.join(" or ")} is required` }, 403);
395
395
  });
396
396
 
397
- const getAuthority = (c: Context<AuthContext>): RequestAuthority => {
397
+ const getAuthority = <E extends AuthContext>(c: Context<E>): RequestAuthority => {
398
398
  const actor = c.get("actor");
399
399
  const accessSubject = c.get("accessSubject");
400
400
  const credentialKind = c.get("credentialKind");
@@ -414,12 +414,14 @@ const getAuthority = (c: Context<AuthContext>): RequestAuthority => {
414
414
  const isAppSession = <E extends AuthContext>(c: Context<E>): boolean => isAppSessionActor(c.get("actor"));
415
415
 
416
416
  /** Returns only the browser session identity authenticated for this request. */
417
- const browserSignIn = async <E extends AuthContext>(c: Context<E>): Promise<{ userId: string; sid: string } | null> => {
417
+ const browserSignIn = async <E extends AuthContext>(c: Context<E>): Promise<{ userId: string; sid: string; authEpoch: number } | null> => {
418
418
  if (c.get("credentialKind") !== "session" || c.get("actor")?.kind !== "user" || isAppSession(c)) return null;
419
419
  const token = c.get("sessionToken");
420
420
  if (!token) return null;
421
421
  const authenticated = await session.authenticateRequest(c, token);
422
- return authenticated?.data.kind === "web" ? { userId: authenticated.data.userId, sid: authenticated.data.sid } : null;
422
+ return authenticated?.data.kind === "web"
423
+ ? { userId: authenticated.data.userId, sid: authenticated.data.sid, authEpoch: authenticated.data.authEpoch }
424
+ : null;
423
425
  };
424
426
 
425
427
  /** The standard answer for actions the mobile app may not take. */
@@ -1,7 +1,12 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
+ import { getApp } from "../../_internal/registry";
4
+ import type { AppMeta } from "../../contracts/app";
5
+ import { canUseApp } from "../../services/app-access";
6
+ import { recordAppAccessDenied } from "../../services/app-access-metrics";
3
7
  import { type ResolvedInvocationAuthority, resolveInvocationAuthority } from "../../services/identity/invocation-actor";
4
8
  import { type CloudInvocationClaims, isInvocationJwtCandidate, verifyInvocationToken } from "../../services/identity/invocation-token";
9
+ import { appAccessDeniedBody } from "./app-access";
5
10
  import type { AuthContext } from "./auth";
6
11
 
7
12
  export type InvocationExpectation = {
@@ -11,6 +16,7 @@ export type InvocationExpectation = {
11
16
  };
12
17
 
13
18
  type InvocationMiddlewareDependencies = {
19
+ getApp?: (appId: string) => Promise<Pick<AppMeta, "id" | "access"> | null>;
14
20
  verify?: typeof verifyInvocationToken;
15
21
  resolve?: typeof resolveInvocationAuthority;
16
22
  };
@@ -47,6 +53,11 @@ export const requireInvocation = (
47
53
  if (!claims) return reject(c);
48
54
  const authority = await (dependencies.resolve ?? resolveInvocationAuthority)(claims);
49
55
  if (!authority) return reject(c);
56
+ const app = await (dependencies.getApp ?? getApp)(expectation.targetAppId).catch(() => null);
57
+ if (!app || !(await canUseApp(authority, app))) {
58
+ if (app) recordAppAccessDenied(app.id);
59
+ return c.json(appAccessDeniedBody(app ?? { id: expectation.targetAppId }), 403);
60
+ }
50
61
  if (claims.schema_hash !== expectation.schemaHash) {
51
62
  return c.json({ code: "SCHEMA_MISMATCH", message: "The invocation schema no longer matches the target" }, 409);
52
63
  }
@@ -2,7 +2,7 @@ import type { Context, MiddlewareHandler } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
3
  import type { MessageResponse } from "../../contracts/shared";
4
4
  import * as settings from "../../services/settings";
5
- import { clientIp } from "../client-ip";
5
+ import { clientIp, clientIpBucket } from "../client-ip";
6
6
  import { ratelimit } from "../ratelimit";
7
7
  import { type AuthContext, auth } from "./auth";
8
8
 
@@ -81,13 +81,13 @@ const getLimiter = (limitPerSecond: number, windowSecs: number) => {
81
81
  };
82
82
 
83
83
  const resolveIdentifier = async (c: Context<AuthContext>, keyBy: RateLimitConfig["keyBy"]): Promise<string> => {
84
- if (keyBy === "ip") return `ip:${clientIp(c)}`;
84
+ if (keyBy === "ip") return clientIpBucket(clientIp(c));
85
85
 
86
86
  const token = auth.session.getToken(c);
87
- if (!token) return `ip:${clientIp(c)}`;
87
+ if (!token) return clientIpBucket(clientIp(c));
88
88
 
89
89
  const userId = await auth.session.getRequestSubject(c, token);
90
- if (!userId) return `ip:${clientIp(c)}`;
90
+ if (!userId) return clientIpBucket(clientIp(c));
91
91
 
92
92
  return `user:${userId}`;
93
93
  };
@@ -655,12 +655,18 @@ export const listUsersWithAccess = async (params: {
655
655
 
656
656
  /**
657
657
  * Resolve display names for access entries.
658
- * Populates the displayName field based on principal type, and the
659
- * serviceAccountKind field for service-account principals.
658
+ * Missing principals and audiences have no displayName; render with principalLabel.
659
+ * Includes serviceAccountKind for existing service-account principals.
660
660
  */
661
661
  export const resolveDisplayNames = async <T extends { principal: Principal }>(
662
662
  entries: T[],
663
- ): Promise<(T & { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind })[]> => {
663
+ ): Promise<
664
+ (Omit<T, "displayName" | "avatarHash" | "serviceAccountKind"> & {
665
+ displayName?: string;
666
+ avatarHash?: string | null;
667
+ serviceAccountKind?: ServiceAccountKind;
668
+ })[]
669
+ > => {
664
670
  const userIds = entries.filter((e) => e.principal.type === "user").map((e) => (e.principal as { type: "user"; userId: string }).userId);
665
671
 
666
672
  const groupIds = entries
@@ -709,30 +715,28 @@ export const resolveDisplayNames = async <T extends { principal: Principal }>(
709
715
  }
710
716
 
711
717
  return entries.map((entry) => {
712
- let displayName: string;
718
+ let displayName: string | undefined;
713
719
  let avatarHash: string | null | undefined;
714
720
  let serviceAccountKind: ServiceAccountKind | undefined;
715
721
  switch (entry.principal.type) {
716
722
  case "user": {
717
723
  const user = userNames.get(entry.principal.userId);
718
- displayName = user?.displayName ?? "Unknown User";
724
+ displayName = user?.displayName;
719
725
  avatarHash = user?.avatarHash ?? null;
720
726
  break;
721
727
  }
722
728
  case "group":
723
- displayName = groupNames.get(entry.principal.groupId) ?? "Unknown Group";
729
+ displayName = groupNames.get(entry.principal.groupId);
724
730
  break;
725
731
  case "service_account": {
726
732
  const serviceAccount = serviceAccountsById.get(entry.principal.serviceAccountId);
727
- displayName = serviceAccount?.name ?? "Unknown Service Account";
733
+ displayName = serviceAccount?.name;
728
734
  serviceAccountKind = serviceAccount?.kind;
729
735
  break;
730
736
  }
731
737
  case "authenticated":
732
- displayName = "All users (incl. guests)";
733
738
  break;
734
739
  case "public":
735
- displayName = "Public";
736
740
  break;
737
741
  }
738
742
  return { ...entry, displayName, avatarHash, serviceAccountKind };
@@ -32,9 +32,7 @@ export {
32
32
  updateAccess,
33
33
  } from "./access";
34
34
  export { freeipa } from "./freeipa";
35
- export type { GeoPlace, GeoService } from "./geo";
36
35
 
37
- export { geo, geoService } from "./geo";
38
36
  export { paginateItems } from "./pagination";
39
37
  export { services } from "./services";
40
38
 
@@ -1,12 +1,10 @@
1
1
  import { err, fail, isServiceError, ok, okMany, paginate, password, svg, tryCatch, unwrap } from "@k2b/stdlib";
2
2
  import * as access from "./access";
3
3
  import { freeipa } from "./freeipa";
4
- import { geo } from "./geo";
5
4
 
6
5
  export const services = {
7
6
  access,
8
7
  freeipa,
9
- geo,
10
8
  images: { generateFallback: svg.generateAvatar, parseWebpDataUrl: svg.parseWebpDataUrl },
11
9
  password,
12
10
  result: { ok, okMany, fail, err, unwrap, paginate, tryCatch, isServiceError },
@@ -31,6 +31,7 @@ import { getFreeIpaConfig } from "../freeipa-config";
31
31
  import { type LogEntry, logger, logging } from "../logging";
32
32
  import { isUniqueViolation } from "../postgres";
33
33
  import { providers } from "../providers";
34
+ import { session } from "../session";
34
35
  import { get as getSetting } from "../settings";
35
36
  import { type AccountsActor, canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget } from "./authz";
36
37
  import { listDuplicateEmails } from "./duplicate-emails";
@@ -838,7 +839,12 @@ export const accountsAppService = {
838
839
  * admin app is UI only and must not dispatch on provider or speak to
839
840
  * FreeIPA directly.
840
841
  */
841
- changeOwnPassword: async (config: { user: User; currentPassword: string; newPassword: string }): Promise<Result<void>> => {
842
+ changeOwnPassword: async (config: {
843
+ user: User;
844
+ currentPassword: string;
845
+ newPassword: string;
846
+ keep?: { sid: string; authEpoch: number } | null;
847
+ }): Promise<Result<{ kept: { token: string; expiresAt: Date } | null }>> => {
842
848
  const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
843
849
  if (!(await getFreeIpaConfig()).enabled) {
844
850
  const result = fail(err.badInput("FreeIPA is disabled."));
@@ -875,8 +881,22 @@ export const accountsAppService = {
875
881
  uid: config.user.uid,
876
882
  newPassword: config.newPassword,
877
883
  });
878
- const serviceResult = result.ok ? ok(undefined) : fail(toServiceError(result.status, result.error));
879
- return recordCompletedMutation({
884
+ let serviceResult: Result<{ kept: { token: string; expiresAt: Date } | null }>;
885
+ if (!result.ok) {
886
+ serviceResult = fail(toServiceError(result.status, result.error));
887
+ } else {
888
+ try {
889
+ serviceResult = ok(await session.endOtherSignIns(config.user.id, { keep: config.keep, reason: "password_change" }));
890
+ } catch {
891
+ serviceResult = fail({
892
+ code: "SIGN_OUT_OTHERS_FAILED",
893
+ status: 500,
894
+ message: "Password changed. Signing out your other sign-ins did not work.",
895
+ });
896
+ }
897
+ }
898
+ const record = result.ok ? audit.recordResultAfterSideEffect : audit.recordResult;
899
+ return record({
880
900
  action: "accounts.user.change_own_password",
881
901
  actor: auditActor(actor),
882
902
  target: userTarget(config.user),
@@ -132,8 +132,8 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
132
132
  const readConfig = async (connection = db): Promise<LinuxIdentityConfiguration> => {
133
133
  // Provisioning must not use a stale cache after an operator disables or changes the range.
134
134
  const [row] = await connection<{ value: string }[]>`SELECT value FROM settings.entries WHERE key = ${CONFIG_KEY}`;
135
- const raw = row ? await decryptValue(row.value) : JSON.stringify(DEFAULT_LINUX_IDENTITY_CONFIGURATION);
136
135
  try {
136
+ const raw = row ? await decryptValue(row.value) : JSON.stringify(DEFAULT_LINUX_IDENTITY_CONFIGURATION);
137
137
  return LinuxIdentityConfigurationSchema.parse(typeof raw === "string" ? JSON.parse(raw) : raw);
138
138
  } catch {
139
139
  throw new PosixError("invalid_configuration", 400);
@@ -304,15 +304,21 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
304
304
  };
305
305
 
306
306
  const service = {
307
- async configuration(actor: Actor) {
307
+ async configuration(actor: Actor): Promise<LinuxIdentityConfiguration & { unreadable?: boolean }> {
308
308
  requireAdmin(actor);
309
- return readConfig();
309
+ try {
310
+ return { ...(await readConfig()), unreadable: false };
311
+ } catch (error) {
312
+ // Provisioning still fails on this row. Administration alone may display the default for recovery.
313
+ if (!(error instanceof PosixError) || error.code !== "invalid_configuration") throw error;
314
+ return { ...DEFAULT_LINUX_IDENTITY_CONFIGURATION, unreadable: true };
315
+ }
310
316
  },
311
317
  async overview(actor: Actor, after: string | null = null, filters: { search?: string; scope?: "ready" | "all" } = {}) {
312
318
  requireAdmin(actor);
313
319
  const result = await rows(db, null, after, filters);
314
320
  const items = result.slice(0, POSIX_PAGE_SIZE).map(candidate);
315
- return { config: await readConfig(), items, nextCursor: result.length > POSIX_PAGE_SIZE ? items.at(-1)!.id : null };
321
+ return { config: await service.configuration(actor), items, nextCursor: result.length > POSIX_PAGE_SIZE ? items.at(-1)!.id : null };
316
322
  },
317
323
  async get(actor: Actor, id: string) {
318
324
  requireAdmin(actor);
@@ -320,7 +326,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
320
326
  if (!row) throw new PosixError("user_not_found", 404);
321
327
  return { config: await readConfig(), user: candidate(row) };
322
328
  },
323
- async configure(actor: Actor, input: LinuxIdentityConfiguration) {
329
+ async configure(actor: Actor, input: LinuxIdentityConfiguration, options?: { replaceUnreadable?: boolean }) {
324
330
  requireAdmin(actor);
325
331
  const parsed = LinuxIdentityConfigurationSchema.safeParse(input);
326
332
  if (!parsed.success) throw new PosixError("invalid_configuration", 400);
@@ -328,9 +334,17 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
328
334
  await db.begin(async (tx) => {
329
335
  // Same order as provisioning; settings write upgrades this lock in the same transaction.
330
336
  await lock(tx);
337
+ if (options?.replaceUnreadable !== true) {
338
+ try {
339
+ await readConfig(tx);
340
+ } catch (error) {
341
+ if (error instanceof PosixError && error.code === "invalid_configuration")
342
+ throw new settings.SettingUnreadableError(CONFIG_KEY);
343
+ throw error;
344
+ }
345
+ }
331
346
  if (parsed.data.enabled) await checkRange(tx, parsed.data, ranges);
332
- // complete value from explicit admin input; nothing is merged over a read (#1303)
333
- await settings.set(CONFIG_KEY, JSON.stringify(parsed.data), tx, { replaceUnreadable: true });
347
+ await settings.set(CONFIG_KEY, JSON.stringify(parsed.data), tx, { replaceUnreadable: options?.replaceUnreadable === true });
334
348
  await audit.record(
335
349
  {
336
350
  action: "accounts.linux.configure",
@@ -675,10 +675,21 @@ export const resetPassword = async (params: { id: string }): Promise<MutationRes
675
675
  const serviceSession = await getServiceIpaSession();
676
676
  if (!serviceSession.ok) return serviceSession;
677
677
 
678
- return providers.ipa.users.resetPassword({
678
+ const result = await providers.ipa.users.resetPassword({
679
679
  ipaSession: serviceSession.data,
680
680
  id: params.id,
681
681
  });
682
+ if (!result.ok) return result;
683
+ try {
684
+ await session.endOtherSignIns(params.id, { reason: "password_reset" });
685
+ } catch {
686
+ return {
687
+ ok: false,
688
+ error: "The password was reset, but signing out the person's other sign-ins did not work. Reset the password again.",
689
+ status: 500,
690
+ };
691
+ }
692
+ return result;
682
693
  };
683
694
 
684
695
  export const switchProvider = async (params: { id: string; provider: UserProvider }): Promise<MutationResult<void>> => {
@@ -2,6 +2,7 @@ import { type ClientRequest, request as httpRequest, type IncomingMessage, type
2
2
  import { request as httpsRequest } from "node:https";
3
3
  import { type NetworkAllowlist, networkAddressFamily, normalizeNetworkHostname } from "../shared/network-address";
4
4
  import { type NetworkLookup, pinnedNetworkLookup, resolveAllowedNetworkAddresses } from "./network-security";
5
+ import { awaitNetworkOperation } from "./network-transport";
5
6
 
6
7
  type RequestFactory = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
7
8
  type AllowedNetworkFetchDeps = {
@@ -25,18 +26,19 @@ export const createAllowedNetworkFetch =
25
26
  if (url.username || url.password) throw new TypeError("Network target must not include user information");
26
27
  const signal = request.signal;
27
28
  signal.throwIfAborted();
28
- const addresses = await new Promise<Awaited<ReturnType<typeof resolveAllowedNetworkAddresses>>>((resolve, reject) => {
29
- const abort = () => reject(signal.reason);
30
- signal.addEventListener("abort", abort, { once: true });
31
- (deps.resolve ?? resolveAllowedNetworkAddresses)(url.hostname, allowlist, deps.lookup)
32
- .then(resolve, reject)
33
- .finally(() => signal.removeEventListener("abort", abort));
34
- if (signal.aborted) abort();
35
- });
29
+ const addresses = await awaitNetworkOperation(
30
+ (deps.resolve ?? resolveAllowedNetworkAddresses)(
31
+ url.hostname,
32
+ allowlist,
33
+ deps.lookup,
34
+ Number(url.port || (url.protocol === "https:" ? 443 : 80)),
35
+ ),
36
+ signal,
37
+ );
36
38
  signal.throwIfAborted();
37
39
  // A body of known size goes out with Content-Length; only a caller's stream (in init or an input Request) is chunked.
38
40
  const streamed = init?.body != null ? init.body instanceof ReadableStream : input instanceof Request && input.body !== null;
39
- const payload = request.body && !streamed ? new Uint8Array(await request.arrayBuffer()) : undefined;
41
+ const payload = request.body && !streamed ? new Uint8Array(await awaitNetworkOperation(request.arrayBuffer(), signal)) : undefined;
40
42
  signal.throwIfAborted();
41
43
  return new Promise<Response>((resolve, reject) => {
42
44
  const headers = new Headers(request.headers);
@@ -125,7 +127,11 @@ export const createAllowedNetworkFetch =
125
127
  });
126
128
  try {
127
129
  resolve(new Response(body, { status, statusText: incoming.statusMessage, headers: responseHeaders }));
128
- if (empty) incoming.resume();
130
+ if (empty) {
131
+ incoming.destroy();
132
+ outgoing.destroy();
133
+ cleanup();
134
+ }
129
135
  } catch (error) {
130
136
  fail(error instanceof Error ? error : new Error(String(error)));
131
137
  }