@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
@@ -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");
@@ -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 },
@@ -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",
@@ -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
  }
@@ -0,0 +1,24 @@
1
+ import { logger } from "./logging";
2
+ import { requestCacheRedis } from "./request-cache-redis";
3
+
4
+ const KEY = "cloud:app-access:denied";
5
+ let lastFailureLog = -Infinity;
6
+
7
+ /** Count denied entry/invocation attempts, never discovery filtering or audit rows. Call only for registered app IDs. */
8
+ export const recordAppAccessDenied = (appId: string): void => {
9
+ void requestCacheRedis()
10
+ .then((redis) => redis.send("HINCRBY", [KEY, appId, "1"]))
11
+ .catch(() => {
12
+ if (Date.now() - lastFailureLog < 60_000) return;
13
+ lastFailureLog = Date.now();
14
+ logger("app-access").warn("Application access denial metrics are unavailable");
15
+ });
16
+ };
17
+
18
+ /** The metric collector reads only the current registry's bounded set of app labels. */
19
+ export const readAppAccessDenials = async (appIds: readonly string[]): Promise<{ app: string; count: number }[]> => {
20
+ if (!appIds.length) return [];
21
+ const values: unknown = await (await requestCacheRedis()).send("HMGET", [KEY, ...appIds]);
22
+ if (!Array.isArray(values)) throw new Error("Invalid application access metrics response");
23
+ return appIds.map((app, index) => ({ app, count: Number(values[index] ?? 0) }));
24
+ };