@k2b/cloud 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/package.json +6 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-timeout.ts +71 -0
  6. package/src/_internal/define-app.ts +145 -89
  7. package/src/_internal/http-drain.ts +56 -0
  8. package/src/_internal/registry-validation.ts +3 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/status-preserving-ssr.ts +2 -0
  11. package/src/access/PermissionEditor.tsx +16 -17
  12. package/src/access/PrincipalPicker.tsx +45 -10
  13. package/src/access/messages.ts +8 -0
  14. package/src/access/principal-label.ts +18 -0
  15. package/src/access/ui.ts +1 -0
  16. package/src/account/EntitySearch.tsx +5 -4
  17. package/src/ai/action-review.ts +2 -1
  18. package/src/ai/app-skill-store.ts +1 -1
  19. package/src/ai/capabilities.ts +21 -3
  20. package/src/ai/capability-execution.ts +14 -12
  21. package/src/ai/chat/action-card.ts +7 -1
  22. package/src/ai/chat/action-messages.ts +14 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  24. package/src/ai/chat/action-question.tsx +202 -22
  25. package/src/ai/chat/action-text.ts +1 -1
  26. package/src/ai/chat-tasks.ts +2 -1
  27. package/src/ai/client/controller.ts +150 -32
  28. package/src/ai/client/transport.ts +14 -2
  29. package/src/ai/code-capability-routes.ts +27 -23
  30. package/src/ai/code-runtime-tools.ts +14 -16
  31. package/src/ai/code-source-tools.ts +16 -18
  32. package/src/ai/executor.ts +56 -21
  33. package/src/ai/fetch-file-tool.ts +67 -76
  34. package/src/ai/http.ts +1 -0
  35. package/src/ai/migrate.ts +13 -0
  36. package/src/ai/open-questions.ts +4 -8
  37. package/src/ai/projects.ts +1 -2
  38. package/src/ai/protocol.ts +2 -2
  39. package/src/ai/recent-resources.ts +12 -5
  40. package/src/ai/review-content.ts +77 -0
  41. package/src/ai/routes.ts +69 -31
  42. package/src/ai/runtime.ts +1 -0
  43. package/src/ai/skill-tool.ts +17 -6
  44. package/src/ai/skills-routes.ts +72 -27
  45. package/src/ai/skills.ts +139 -60
  46. package/src/ai/store.ts +168 -33
  47. package/src/ai/types.ts +10 -3
  48. package/src/ai/usage.ts +2 -2
  49. package/src/api/admin-core-settings.ts +15 -2
  50. package/src/api/admin-geo.ts +54 -0
  51. package/src/api/admin-linux-identities.ts +6 -3
  52. package/src/api/admin-outgoing-mail.ts +29 -15
  53. package/src/api/approvals-loader.ts +3 -3
  54. package/src/api/approvals.ts +51 -0
  55. package/src/api/apps.ts +4 -2
  56. package/src/api/capabilities.ts +258 -181
  57. package/src/api/capability-streams.ts +21 -13
  58. package/src/api/cli-plugins.ts +4 -1
  59. package/src/api/geo-glyphs.ts +11 -0
  60. package/src/api/geo.ts +128 -0
  61. package/src/api/help.ts +12 -4
  62. package/src/api/index.ts +5 -0
  63. package/src/api/mcp.ts +46 -38
  64. package/src/api/me-panel-consent.ts +8 -5
  65. package/src/api/search.ts +21 -14
  66. package/src/api/sync-ops.ts +19 -13
  67. package/src/api/widgets.ts +42 -18
  68. package/src/approvals/Approvals.tsx +5 -3
  69. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  70. package/src/browser/CloudResourceSearch.tsx +3 -13
  71. package/src/browser/PanelLeaveNotice.tsx +18 -0
  72. package/src/browser/navigation-search.ts +78 -25
  73. package/src/browser/panel-leave.ts +69 -0
  74. package/src/browser/panel-messages.ts +12 -0
  75. package/src/browser/panel-slot.ts +173 -24
  76. package/src/browser/panel.ts +3 -0
  77. package/src/browser/resource-search-messages.ts +2 -0
  78. package/src/capabilities/client.ts +28 -1
  79. package/src/capabilities/executions.ts +22 -3
  80. package/src/capabilities/geo-client.ts +57 -0
  81. package/src/capabilities/response.ts +2 -1
  82. package/src/capabilities/review-storage.ts +24 -0
  83. package/src/capabilities/reviews.ts +17 -3
  84. package/src/capabilities/server.ts +36 -16
  85. package/src/cli/access.ts +15 -23
  86. package/src/cli/admin/linux.ts +13 -2
  87. package/src/cli/admin/outgoing-mail.ts +26 -4
  88. package/src/cli/capabilities.ts +19 -0
  89. package/src/config/env.ts +0 -6
  90. package/src/contracts/app-access.ts +48 -0
  91. package/src/contracts/app.ts +4 -1
  92. package/src/contracts/approvals.ts +2 -2
  93. package/src/contracts/avatar.ts +186 -0
  94. package/src/contracts/capabilities.ts +148 -54
  95. package/src/contracts/geo-admin.ts +127 -0
  96. package/src/contracts/geo-map.ts +19 -0
  97. package/src/contracts/geo-route.ts +168 -0
  98. package/src/contracts/geo.ts +248 -0
  99. package/src/contracts/index.ts +4 -0
  100. package/src/contracts/outgoing-mail.ts +17 -4
  101. package/src/contracts/registry.ts +2 -0
  102. package/src/events/app-access.ts +68 -0
  103. package/src/events/live-delivery.ts +128 -0
  104. package/src/events/live.ts +14 -130
  105. package/src/geo/CloudMap.tsx +95 -0
  106. package/src/geo/map-messages.ts +17 -0
  107. package/src/geo/ui.ts +1 -0
  108. package/src/index.ts +2 -0
  109. package/src/server/client-ip.ts +36 -0
  110. package/src/server/index.ts +3 -4
  111. package/src/server/middleware/app-access.ts +312 -0
  112. package/src/server/middleware/auth.ts +1 -1
  113. package/src/server/middleware/invocation.ts +11 -0
  114. package/src/server/middleware/rate-limit.ts +4 -4
  115. package/src/server/services/access.ts +13 -9
  116. package/src/server/services/index.ts +0 -2
  117. package/src/server/services/services.ts +0 -2
  118. package/src/services/accounts/posix.ts +21 -7
  119. package/src/services/allowed-network-fetch.ts +16 -10
  120. package/src/services/app-access-metrics.ts +24 -0
  121. package/src/services/app-access.ts +123 -0
  122. package/src/services/app-discovery.ts +27 -0
  123. package/src/services/approvals/content.ts +40 -0
  124. package/src/services/approvals/index.ts +14 -6
  125. package/src/services/approvals/list.ts +6 -11
  126. package/src/services/approvals/question.ts +11 -7
  127. package/src/services/downstream-budget.ts +87 -0
  128. package/src/services/geo/admin.ts +262 -0
  129. package/src/services/geo/bucket.ts +67 -0
  130. package/src/services/geo/cache.ts +57 -0
  131. package/src/services/geo/costs.ts +5 -0
  132. package/src/services/geo/errors.ts +41 -0
  133. package/src/services/geo/fixtures/README.md +17 -0
  134. package/src/services/geo/fixtures/along.json +355 -0
  135. package/src/services/geo/fixtures/autocomplete.json +218 -0
  136. package/src/services/geo/fixtures/geocode.json +129 -0
  137. package/src/services/geo/fixtures/places.json +355 -0
  138. package/src/services/geo/fixtures/reverse.json +61 -0
  139. package/src/services/geo/fixtures/routing.json +57 -0
  140. package/src/services/geo/geoapify-route.ts +93 -0
  141. package/src/services/geo/geoapify.ts +281 -0
  142. package/src/services/geo/ledger.ts +122 -0
  143. package/src/services/geo/map.ts +160 -0
  144. package/src/services/geo/migrate.ts +25 -0
  145. package/src/services/geo/polyline.ts +184 -0
  146. package/src/services/geo/route-chain.ts +145 -0
  147. package/src/services/geo/route-costs.ts +9 -0
  148. package/src/services/geo/route-geometry.ts +39 -0
  149. package/src/services/geo/route-plan.ts +172 -0
  150. package/src/services/geo/route-service.ts +433 -0
  151. package/src/services/geo/service.ts +300 -0
  152. package/src/services/geo/settings.ts +49 -0
  153. package/src/services/help/index.ts +16 -7
  154. package/src/services/help/types.ts +6 -1
  155. package/src/services/identity/invocation-actor.ts +20 -2
  156. package/src/services/identity/issue-invocation.ts +67 -0
  157. package/src/services/identity/key-ring.ts +5 -3
  158. package/src/services/identity/runtime-config.ts +23 -0
  159. package/src/services/index.ts +9 -0
  160. package/src/services/mandates/index.ts +40 -3
  161. package/src/services/network-security.ts +6 -3
  162. package/src/services/network-transport.ts +99 -0
  163. package/src/services/notifications/catalog.ts +3 -2
  164. package/src/services/notifications/runtime.ts +1 -0
  165. package/src/services/notifications/web-push-transport.ts +45 -38
  166. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  167. package/src/services/outgoing-mail/index.ts +55 -1
  168. package/src/services/outgoing-mail/messages.ts +82 -3
  169. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  170. package/src/services/outgoing-mail/retention.ts +13 -1
  171. package/src/services/outgoing-mail/store.ts +36 -6
  172. package/src/services/outgoing-mail/test-send.ts +34 -4
  173. package/src/services/outgoing-mail/transport.ts +1 -1
  174. package/src/services/pdf/gotenberg.ts +11 -38
  175. package/src/services/pdf/template-preview.ts +1 -1
  176. package/src/services/public-http.ts +28 -40
  177. package/src/services/settings/app.ts +4 -1
  178. package/src/services/settings/core-settings.ts +88 -0
  179. package/src/services/settings/defaults.ts +8 -2
  180. package/src/services/settings/errors.ts +11 -0
  181. package/src/services/settings/geo-budget.ts +40 -0
  182. package/src/services/settings/index.ts +4 -3
  183. package/src/services/settings/store.ts +25 -17
  184. package/src/services/weather/geo.ts +29 -111
  185. package/src/services/weather/index.ts +4 -3
  186. package/src/services/weather/locations.ts +8 -5
  187. package/src/services/weather/migrate.ts +1 -0
  188. package/src/services/weather/settings.ts +0 -16
  189. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  190. package/src/shared/avatar-art.ts +400 -0
  191. package/src/shared/index.ts +2 -0
  192. package/src/shared/network-address.ts +22 -3
  193. package/src/shared/template-rendering.ts +36 -4
  194. package/src/shared-text/documents.ts +1 -1
  195. package/src/shared-text/index.ts +2 -0
  196. package/src/shared-text/log.ts +51 -3
  197. package/src/shared-text/snapshots.ts +340 -0
  198. package/src/ssr/AdminLayout.tsx +4 -2
  199. package/src/ssr/CloudShell.island.tsx +6 -0
  200. package/src/ssr/Layout.tsx +65 -15
  201. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  202. package/src/ssr/LayoutHelp.tsx +3 -2
  203. package/src/ssr/PanelRuntime.island.tsx +11 -7
  204. package/src/ssr/PwaLayout.tsx +2 -2
  205. package/src/ssr/RailApps.tsx +6 -0
  206. package/src/ssr/ShellRuntime.island.tsx +23 -7
  207. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  208. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
  209. package/src/ssr/admin-navigation.ts +1 -0
  210. package/src/ssr/app-badges.ts +6 -1
  211. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  212. package/src/ssr/help.ts +3 -2
  213. package/src/ssr/index.ts +1 -1
  214. package/src/ssr/layout-context.ts +2 -0
  215. package/src/ssr/mobile-menu-history.ts +4 -0
  216. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  217. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  218. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  219. package/src/ssr/page-swap.ts +28 -0
  220. package/src/ssr/page-trail-runtime.ts +32 -13
  221. package/src/ssr/panel-consent.ts +2 -2
  222. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  223. package/src/ssr/platform-messages.ts +8 -0
  224. package/src/ssr/runtime.ts +17 -0
  225. package/src/ssr/shell-data.tsx +10 -2
  226. package/src/styles/global.css +70 -6
  227. package/src/server/services/geo.ts +0 -215
@@ -1,9 +1,12 @@
1
1
  import { type AppSkillDefinition, appSkillManifestHash, registerAppSkills, validateAppSkillCatalog } from "../ai/app-skills";
2
+ import { type AppAccessDeclaration, compileAppAccess } from "../contracts/app-access";
2
3
  import { startLiveOutbox } from "../events/live";
3
4
  import { registerHelp } from "../services/help";
4
5
  import { preloadLayoutHelp } from "../ssr/help";
6
+ import { registerPageSwap } from "../ssr/page-swap";
5
7
  import { preloadPanelConsent } from "../ssr/panel-consent";
6
8
  import { invokeCapabilityStream } from "./capability-streams";
9
+ import { withCapabilityRequestTimeout } from "./capability-timeout";
7
10
  import { bindProcessApplicationId, clearProcessApplicationId } from "./process-identity";
8
11
  /**
9
12
  * defineApp() — The single entry point for every cloud app.
@@ -49,8 +52,10 @@ import type { AppSettingsMap, KindToType } from "../contracts/settings-types";
49
52
  import type { Role } from "../contracts/shared";
50
53
  import type { HelpDefinition } from "../server/help";
51
54
  import { getLocale, resolveLocale } from "../server/locale";
55
+ import { requireAppAccess, stopAppAccessConnections } from "../server/middleware/app-access";
52
56
  import { type AuthContext, auth } from "../server/middleware/auth";
53
57
  import { requireInvocation } from "../server/middleware/invocation";
58
+ import "../events/app-access";
54
59
  import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-template";
55
60
  import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
56
61
  import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
@@ -86,6 +91,7 @@ import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules }
86
91
  import { FONT_PRELOAD_LINKS } from "./font-preloads";
87
92
  import { createHeartbeat, createRegistryMembership } from "./heartbeat";
88
93
  import { compileHelp } from "./help";
94
+ import { createHttpDrain } from "./http-drain";
89
95
  import { createPageResponses } from "./page-responses";
90
96
  import { configurePostgresApplicationName } from "./postgres-application-name";
91
97
  import { getProcessSync, startProcessSync } from "./process-sync";
@@ -129,6 +135,7 @@ type ResolvedPageOptions = PageOptions & { performanceRoute?: string; themePrefe
129
135
  */
130
136
  export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefinitionMap = {}, AppId extends string = string> = {
131
137
  id: AppId;
138
+ access?: AppAccessDeclaration;
132
139
  name: string;
133
140
  icon: string;
134
141
  description: string;
@@ -137,6 +144,8 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
137
144
  appearance?: AppAppearance;
138
145
  /** URL prefix for SSR asset isolation. Omit for the global `/_ssr/` path (core). */
139
146
  basePath?: string;
147
+ /** Swap Layout content within this app while keeping the rail, tab bar and panel document. Default: false; focusMode pages still load a document. */
148
+ pageSwap?: boolean;
140
149
  /** Base URL as seen by other containers (e.g. "http://app-notebooks:3000"). */
141
150
  baseUrl: string;
142
151
  adminHref?: string;
@@ -530,6 +539,8 @@ export const defineApp = <
530
539
  // cannot override.
531
540
  const ssr = Object.assign(
532
541
  createStatusPreservingSsrHandler<ResolvedPageOptions>(html, async (c) => {
542
+ if (opts.pageSwap)
543
+ registerPageSwap(c, isDevelopment ? String(developmentStamp) : `${buildMetadata.version}:${buildMetadata.release}`);
533
544
  c.get("page").lang = getLocale(c);
534
545
  c.get("page").themePreference = readThemePreferenceFromCookieHeader(c.req.header("Cookie"));
535
546
  const user = c.get("user");
@@ -578,13 +589,19 @@ export const defineApp = <
578
589
  openapi: opts.openapi,
579
590
  cli: opts.cli,
580
591
  };
581
- const meta: AppMeta = { ...baseMeta, presentation: compileAppPresentation(baseMeta, opts.presentation) };
592
+ const meta: AppMeta = {
593
+ ...baseMeta,
594
+ access: compileAppAccess(opts.access),
595
+ presentation: compileAppPresentation(baseMeta, opts.presentation),
596
+ };
582
597
 
583
598
  // ── 3. start() — builds and boots the Hono server ────────────────────
584
599
  const start = async (startOpts: StartOptions): Promise<StartResult> => {
585
600
  const port = startOpts.port ?? 3000;
586
601
  const baseUrl = opts.baseUrl;
587
602
  const log = logger("app");
603
+ const http = createHttpDrain();
604
+ if (!meta.access) log.warn("Application has no access declaration; legacy entry and discovery rules remain active", { appId: meta.id });
588
605
 
589
606
  // Settings are encrypted at rest, so every container needs the secret. It
590
607
  // is only read on the first decrypt, which meant a container without it
@@ -632,6 +649,7 @@ export const defineApp = <
632
649
  // Registry entry
633
650
  const entry: AppRegistryEntry = {
634
651
  id: meta.id,
652
+ access: meta.access,
635
653
  name: meta.name,
636
654
  icon: meta.icon,
637
655
  description: meta.description,
@@ -766,6 +784,7 @@ export const defineApp = <
766
784
  // Framework-owned mounts answer before the app's router ever runs, so the
767
785
  // app middleware cannot report their template. Without this, every hashed
768
786
  // island chunk would land in telemetry as its own route.
787
+ const invocationApp = { getApp: async (id: string) => (id === meta.id ? meta : null) };
769
788
  const server = new Hono<AuthContext>()
770
789
  .use("*", routeTemplate)
771
790
  .get(APP_READINESS_PATH, () => appReadinessResponse(meta.id))
@@ -774,11 +793,14 @@ export const defineApp = <
774
793
  .all("/public/*", servePublicAsset(isDevelopment, sharedStylesheetVersion(buildMetadata.release)))
775
794
  .use(
776
795
  "/_internal/sync/*",
777
- requireInvocation((c) => ({
778
- targetAppId: meta.id,
779
- operation: syncInvocationOperation(c.req.method, new URL(c.req.url).pathname.slice("/_internal/sync".length)),
780
- schemaHash: null,
781
- })),
796
+ requireInvocation(
797
+ (c) => ({
798
+ targetAppId: meta.id,
799
+ operation: syncInvocationOperation(c.req.method, new URL(c.req.url).pathname.slice("/_internal/sync".length)),
800
+ schemaHash: null,
801
+ }),
802
+ invocationApp,
803
+ ),
782
804
  )
783
805
  .use("/_internal/sync/*", auth.requireOAuthScope("admin"))
784
806
  .use("/_internal/sync/*", async (c, next) => {
@@ -787,79 +809,73 @@ export const defineApp = <
787
809
  })
788
810
  .route("/_internal/sync", createSyncOpsRoutes(getProcessSync));
789
811
 
790
- if (opts.cli && cliModules.length > 0) server.route(CLOUD_CLI_PLUGIN_ROUTE, createCliPluginRoutes(meta.id, opts.cli));
791
-
792
- if (compiledHelp) {
793
- const pageBase = compiledHelp.summary.pageBase;
794
- const centralPageBase = `/help/apps/${encodeURIComponent(meta.id)}`;
795
- server.get(pageBase, (c) => c.redirect(centralPageBase, 302));
796
- server.get(`${pageBase}/:topic`, (c) => c.redirect(`${centralPageBase}/${encodeURIComponent(c.req.param("topic"))}`, 302));
797
- }
798
-
799
812
  if (compiledCapabilities) {
800
- const invoke = async (c: Context<AuthContext>, kind: "query" | "action" | "review") => {
801
- // Framework-owned endpoints run before the app's settings middleware,
802
- // so the operator default is read directly instead of via a snapshot.
803
- const requestLocale = resolveLocale(c.req.raw.headers, await get<string>("app.locale"));
804
- const messages = capabilityMessages(requestLocale);
805
- const parsedBody = await readBoundedJson(c.req.raw, CAPABILITY_MAX_REQUEST_BYTES);
806
- if (!parsedBody.ok) {
807
- const message = parsedBody.reason === "too_large" ? messages.requestTooLarge : messages.requestBodyJson;
808
- return c.json({ code: CAPABILITY_FRAMEWORK_ERROR_CODES.validationFailed, message }, 400);
809
- }
810
- if (
811
- typeof parsedBody.data !== "object" ||
812
- parsedBody.data === null ||
813
- Array.isArray(parsedBody.data) ||
814
- !Object.hasOwn(parsedBody.data, "input") ||
815
- Object.keys(parsedBody.data).length !== 1
816
- ) {
817
- return c.json(
818
- {
819
- code: CAPABILITY_FRAMEWORK_ERROR_CODES.validationFailed,
820
- message: messages.requestInputOnly,
813
+ const invoke = async (c: Context<AuthContext>, kind: "query" | "action" | "review") =>
814
+ withCapabilityRequestTimeout(c, kind, async () => {
815
+ // Framework-owned endpoints run before the app's settings middleware,
816
+ // so the operator default is read directly instead of via a snapshot.
817
+ const requestLocale = resolveLocale(c.req.raw.headers, await get<string>("app.locale"));
818
+ const messages = capabilityMessages(requestLocale);
819
+ const parsedBody = await readBoundedJson(c.req.raw, CAPABILITY_MAX_REQUEST_BYTES);
820
+ if (!parsedBody.ok) {
821
+ const message = parsedBody.reason === "too_large" ? messages.requestTooLarge : messages.requestBodyJson;
822
+ return c.json({ code: CAPABILITY_FRAMEWORK_ERROR_CODES.validationFailed, message }, 400);
823
+ }
824
+ if (
825
+ typeof parsedBody.data !== "object" ||
826
+ parsedBody.data === null ||
827
+ Array.isArray(parsedBody.data) ||
828
+ !Object.hasOwn(parsedBody.data, "input") ||
829
+ Object.keys(parsedBody.data).length !== 1
830
+ ) {
831
+ return c.json(
832
+ {
833
+ code: CAPABILITY_FRAMEWORK_ERROR_CODES.validationFailed,
834
+ message: messages.requestInputOnly,
835
+ },
836
+ 400,
837
+ );
838
+ }
839
+ const body = parsedBody.data as { input: unknown };
840
+ const actor = c.get("actor");
841
+ const user = actor.kind === "user" ? actor.user : actor.delegatedUser;
842
+ const idempotencyKey = c.req.header("idempotency-key") || undefined;
843
+ const invocation = {
844
+ compiled: compiledCapabilities,
845
+ localId: c.req.param("capabilityId") ?? "",
846
+ input: body.input,
847
+ expectedSchemaHash: c.req.header("x-cloud-capability-schema-hash") ?? null,
848
+ context: {
849
+ authority: auth.getAuthority(c),
850
+ actor,
851
+ accessSubject: c.get("accessSubject"),
852
+ user,
853
+ idempotencyKey,
854
+ locale: requestLocale,
855
+ requestId: normalizeInvocationRequestId(c.req.header("x-request-id")) ?? crypto.randomUUID(),
856
+ origin: CapabilityOriginSchema.catch("http").parse(c.req.header(CAPABILITY_ORIGIN_HEADER)),
857
+ signal: c.req.raw.signal,
821
858
  },
822
- 400,
823
- );
824
- }
825
- const body = parsedBody.data as { input: unknown };
826
- const actor = c.get("actor");
827
- const user = actor.kind === "user" ? actor.user : actor.delegatedUser;
828
- const idempotencyKey = c.req.header("idempotency-key") || undefined;
829
- const invocation = {
830
- compiled: compiledCapabilities,
831
- localId: c.req.param("capabilityId") ?? "",
832
- input: body.input,
833
- expectedSchemaHash: c.req.header("x-cloud-capability-schema-hash") ?? null,
834
- context: {
835
- actor,
836
- accessSubject: c.get("accessSubject"),
837
- user,
838
- idempotencyKey,
839
- locale: requestLocale,
840
- requestId: normalizeInvocationRequestId(c.req.header("x-request-id")) ?? crypto.randomUUID(),
841
- origin: CapabilityOriginSchema.catch("http").parse(c.req.header(CAPABILITY_ORIGIN_HEADER)),
842
- signal: c.req.raw.signal,
843
- },
844
- onUnexpectedError: (error: unknown) =>
845
- log.error(kind === "review" ? "Capability review failed" : "Capability execution failed", {
846
- appId: meta.id,
847
- kind,
848
- capabilityId: c.req.param("capabilityId") ?? "",
849
- error: error instanceof Error ? error.message : String(error),
850
- }),
851
- };
852
- const result =
853
- kind === "review" ? await reviewCompiledCapability(invocation) : await invokeCompiledCapability({ ...invocation, kind });
854
- const operation = kind === "action" ? compiledCapabilities.actions.get(invocation.localId) : undefined;
855
- const serialized = serializeCapabilityProviderResult(result, {
856
- nonIdempotentAction: kind === "action" && operation?.manifest.idempotency === "none",
857
- });
858
- return new Response(serialized.body, {
859
- status: serialized.status,
860
- headers: { "content-type": "application/json" },
859
+ onUnexpectedError: (error: unknown) =>
860
+ log.error(kind === "review" ? "Capability review failed" : "Capability execution failed", {
861
+ appId: meta.id,
862
+ kind,
863
+ capabilityId: c.req.param("capabilityId") ?? "",
864
+ error: error instanceof Error ? error.message : String(error),
865
+ }),
866
+ };
867
+ const result =
868
+ kind === "review" ? await reviewCompiledCapability(invocation) : await invokeCompiledCapability({ ...invocation, kind });
869
+ const operation = kind === "action" ? compiledCapabilities.actions.get(invocation.localId) : undefined;
870
+ const serialized = serializeCapabilityProviderResult(result, {
871
+ review: kind === "review",
872
+ nonIdempotentAction: kind === "action" && operation?.manifest.idempotency === "none",
873
+ });
874
+ return new Response(serialized.body, {
875
+ status: serialized.status,
876
+ headers: { "content-type": "application/json" },
877
+ });
861
878
  });
862
- };
863
879
  const capabilityAuth = (kind: "queries" | "actions", review = false) =>
864
880
  requireInvocation((c) => {
865
881
  const localId = c.req.param("capabilityId") ?? "";
@@ -878,7 +894,7 @@ export const defineApp = <
878
894
  operation: invocationOperation,
879
895
  schemaHash: operation.manifest.schemaHash,
880
896
  };
881
- });
897
+ }, invocationApp);
882
898
  for (const kind of ["queries", "actions"] as const) {
883
899
  server.post(
884
900
  `/api/_internal/capabilities/v1/streams/${kind}/:capabilityId/:verb`,
@@ -893,6 +909,7 @@ export const defineApp = <
893
909
  verb: c.req.param("verb") ?? "",
894
910
  request: c.req.raw,
895
911
  context: {
912
+ authority: auth.getAuthority(c),
896
913
  actor,
897
914
  accessSubject: c.get("accessSubject"),
898
915
  user: actor.kind === "user" ? actor.user : actor.delegatedUser,
@@ -932,7 +949,7 @@ export const defineApp = <
932
949
  const widgetId = c.req.param("widgetId") ?? "";
933
950
  if (!declaredWidgetIds.has(widgetId) || !startOpts.widgets?.[widgetId]) return null;
934
951
  return { targetAppId: meta.id, operation: widgetInvocationOperation(widgetId), schemaHash: null };
935
- }),
952
+ }, invocationApp),
936
953
  auth.requireOAuthScope("read", "admin"),
937
954
  runtimeMiddleware(),
938
955
  settingsMiddleware(),
@@ -947,10 +964,31 @@ export const defineApp = <
947
964
  );
948
965
  }
949
966
 
967
+ cleanup.push(() => stopAppAccessConnections());
968
+ server.use(
969
+ "*",
970
+ requireAppAccess(meta, (c, message) =>
971
+ ssr.error(c, 403, {
972
+ title: "No access",
973
+ description: message,
974
+ layout: "minimal",
975
+ }),
976
+ ),
977
+ );
978
+
979
+ if (opts.cli && cliModules.length > 0) server.route(CLOUD_CLI_PLUGIN_ROUTE, createCliPluginRoutes(meta.id, opts.cli));
980
+
981
+ if (compiledHelp) {
982
+ const pageBase = compiledHelp.summary.pageBase;
983
+ const centralPageBase = `/help/apps/${encodeURIComponent(meta.id)}`;
984
+ server.get(pageBase, (c) => c.redirect(centralPageBase, 302));
985
+ server.get(`${pageBase}/:topic`, (c) => c.redirect(`${centralPageBase}/${encodeURIComponent(c.req.param("topic"))}`, 302));
986
+ }
987
+
950
988
  // OpenAPI spec mount. Registered on the framework server (before the
951
989
  // user-fetch catch-all below) so it bypasses any auth / rate-limit
952
990
  // middleware on the api router — the spec must stay reachable without
953
- // a session for `app-api-docs` to render it.
991
+ // a session for legacy apps. Declared app access is enforced above.
954
992
  //
955
993
  // The `servers` override is load-bearing: hono-openapi walks the
956
994
  // BARE api router and emits paths relative to its own root (e.g.
@@ -1030,18 +1068,36 @@ export const defineApp = <
1030
1068
  log.info(`Registered "${meta.id}"`, { baseUrl });
1031
1069
 
1032
1070
  // Graceful shutdown
1033
- let stopping = false;
1034
- const shutdown = async () => {
1035
- if (stopping) return;
1036
- stopping = true;
1037
- log.info(`Stopping: ${meta.id}`);
1038
- await stopStartedResources();
1039
- };
1071
+ let shutdownPromise: Promise<void> | undefined;
1072
+ const shutdown = () =>
1073
+ (shutdownPromise ??= (async () => {
1074
+ const drained = http.stop();
1075
+ log.info(`Stopping: ${meta.id}`);
1076
+ try {
1077
+ await drained;
1078
+ } finally {
1079
+ await stopStartedResources();
1080
+ }
1081
+ })());
1040
1082
 
1041
- process.on("SIGTERM", () => void shutdown().then(() => process.exit(0)));
1042
- process.on("SIGINT", () => void shutdown().then(() => process.exit(0)));
1083
+ const stopAndExit = () =>
1084
+ void shutdown().then(
1085
+ () => process.exit(0),
1086
+ (error: unknown) => {
1087
+ log.error("HTTP shutdown failed", { appId: meta.id, error: error instanceof Error ? error.message : String(error) });
1088
+ process.exit(1);
1089
+ },
1090
+ );
1091
+ process.on("SIGTERM", stopAndExit);
1092
+ process.on("SIGINT", stopAndExit);
1043
1093
 
1044
- return { hostname: "0.0.0.0", port, development: isDevelopment, idleTimeout: REQUEST_IDLE_TIMEOUT_SECONDS, fetch: server.fetch };
1094
+ return {
1095
+ hostname: "0.0.0.0",
1096
+ port,
1097
+ development: isDevelopment,
1098
+ idleTimeout: REQUEST_IDLE_TIMEOUT_SECONDS,
1099
+ fetch: (request, bunServer) => http.fetch(request, bunServer, (request) => server.fetch(request, bunServer)),
1100
+ };
1045
1101
  } catch (error) {
1046
1102
  await stopStartedResources();
1047
1103
  throw error;
@@ -0,0 +1,56 @@
1
+ import type { Server } from "bun";
2
+
3
+ /** Matches the existing runtime worker drain deadline. */
4
+ export const HTTP_DRAIN_TIMEOUT_MS = 30_000;
5
+
6
+ /** Bun supplies the listening server to fetch, including for default-export entrypoints. */
7
+ export const createHttpDrain = (options: { timeoutMs?: number } = {}) => {
8
+ type HttpServer = Pick<Server<unknown>, "stop">;
9
+ const isHttpServer = (value: unknown): value is HttpServer =>
10
+ typeof value === "object" && value !== null && "stop" in value && typeof value.stop === "function";
11
+ const servers = new Set<HttpServer>();
12
+ let stopping = false;
13
+ let draining: Promise<void> | undefined;
14
+ return {
15
+ get stopping() {
16
+ return stopping;
17
+ },
18
+ fetch: async (request: Request, transport: unknown, handle: (request: Request) => Response | Promise<Response>): Promise<Response> => {
19
+ const server = isHttpServer(transport) ? transport : undefined;
20
+ if (server) servers.add(server);
21
+ if (stopping) {
22
+ // A listener that had not served a request before the signal was not
23
+ // captured yet. Reject that first request and close it as well.
24
+ void server?.stop(false);
25
+ return Response.json({ status: "stopping" }, { status: 503, headers: { "Retry-After": "3", Connection: "close" } });
26
+ }
27
+ // Preserve Bun request identity for websocket upgrades, requestIP and
28
+ // per-request timeouts. Forced stop aborts the native request signal.
29
+ return handle(request);
30
+ },
31
+ stop: (): Promise<void> => {
32
+ stopping = true;
33
+ draining ??= (async () => {
34
+ let timer: ReturnType<typeof setTimeout> | undefined;
35
+ let timedOut = false;
36
+ try {
37
+ await Promise.race([
38
+ Promise.all([...servers].map((server) => server.stop(false))),
39
+ new Promise<void>((resolve) => {
40
+ timer = setTimeout(() => {
41
+ // Cancel application work as well as the transport, before
42
+ // lifecycle hooks can tear down NATS or other dependencies.
43
+ timedOut = true;
44
+ resolve();
45
+ }, options.timeoutMs ?? HTTP_DRAIN_TIMEOUT_MS);
46
+ }),
47
+ ]);
48
+ if (timedOut) await Promise.all([...servers].map((server) => server.stop(true)));
49
+ } finally {
50
+ clearTimeout(timer);
51
+ }
52
+ })();
53
+ return draining;
54
+ },
55
+ };
56
+ };
@@ -1,4 +1,5 @@
1
1
  import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
2
+ import { AppAccessRuleSchema } from "../contracts/app-access";
2
3
  import { PlatformPermissionSchema } from "../contracts/outgoing-mail";
3
4
  import type { AppRegistryEntry } from "../contracts/registry";
4
5
  import { isOwnAppPath, SAME_ORIGIN_PATH } from "./app-path";
@@ -28,6 +29,8 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
28
29
  if (!isStringArray(value.routes) || value.routes.some((route) => !route.startsWith("/"))) {
29
30
  return invalid("routes", "an array of absolute paths");
30
31
  }
32
+ if (value.access !== undefined && !AppAccessRuleSchema.safeParse(value.access).success)
33
+ return invalid("access", "a declarative app access rule");
31
34
  if (value.startedAt !== undefined && !(typeof value.startedAt === "number" && Number.isFinite(value.startedAt))) {
32
35
  return invalid("startedAt", "a finite number");
33
36
  }
@@ -15,6 +15,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
15
15
  const searchTags = searchQueries?.flatMap((query) => query.universalSearch!.tags.flatMap((tag) => [tag.tag, ...(tag.aliases ?? [])]));
16
16
  return {
17
17
  id: e.id,
18
+ access: e.access,
18
19
  platformPermissions: e.platformPermissions ? [...e.platformPermissions] : undefined,
19
20
  name: e.name,
20
21
  icon: e.icon,
@@ -7,6 +7,7 @@ import type { RailSnapshot } from "../contracts/rail-preferences";
7
7
  import type { SettingsFlat, Unflatten } from "../contracts/settings-types";
8
8
  import type { User } from "../contracts/shared";
9
9
  import type { CORE_SETTINGS } from "../services/settings/core-settings";
10
+ import { preloadDisplayApps } from "../ssr/runtime";
10
11
  import { measureServerPhase } from "./server-timing";
11
12
 
12
13
  type PageEnv<T extends object> = {
@@ -45,6 +46,7 @@ export const createStatusPreservingSsrHandler = <T extends object>(
45
46
  const handler = args[args.length - 1] as SsrHandler<E, T>;
46
47
 
47
48
  return createHandler<E>(...middlewares, async (context) => {
49
+ await preloadDisplayApps(context);
48
50
  const result = await measureServerPhase(context, "ssr_data", () => handler(context as Context<E & PageEnv<T>>));
49
51
  if (result instanceof Response) return result;
50
52
  await measureServerPhase(context, "ssr_finalize", () => finalizePage?.(context as unknown as Context<PageEnv<T>>));
@@ -4,11 +4,11 @@ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "so
4
4
  import { createStore, reconcile } from "solid-js/store";
5
5
  import { CloudAvatar } from "../account/Avatar";
6
6
  import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
7
- import { groupDisplayName } from "../shared/account-display";
8
7
  import { createGroupCoverage, type GroupCoverage, GroupCoveragePanel, GroupCoverageToggle } from "./GroupCoverage";
9
8
  import { isManagerEntry } from "./managers";
10
9
  import { accessMessages } from "./messages";
11
10
  import PrincipalPicker, { principalKey } from "./PrincipalPicker";
11
+ import { principalLabel } from "./principal-label";
12
12
  import { serviceAccountKindDisplay } from "./service-account-kind";
13
13
 
14
14
  // ─────────────────────────────────────────────────────────────────────────
@@ -43,7 +43,7 @@ type PermissionEditorProps = {
43
43
  grantAccess: (
44
44
  principal: Principal,
45
45
  permission: GrantableLevel,
46
- display?: { displayName: string; serviceAccountKind?: ServiceAccountKind },
46
+ display?: { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind },
47
47
  scope?: string,
48
48
  ) => Promise<AccessEntry>;
49
49
 
@@ -145,16 +145,6 @@ const resolveEntryDisplay = (
145
145
  return defaults[permission] ?? defaults.none;
146
146
  };
147
147
 
148
- const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"], locale: string): string => {
149
- // Audiences always use the localized label; a server-supplied name is not translated.
150
- if (entry.principal.type === "authenticated") return t.allUsers;
151
- if (entry.principal.type === "public") return t.public;
152
- if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
153
- if (entry.principal.type === "user") return entry.principal.userId;
154
- if (entry.principal.type === "service_account") return entry.principal.serviceAccountId;
155
- return entry.principal.groupId;
156
- };
157
-
158
148
  const getPrincipalIcon = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"]): string => {
159
149
  switch (entry.principal.type) {
160
150
  case "user":
@@ -204,7 +194,10 @@ export default function PermissionEditor(props: PermissionEditorProps) {
204
194
  }
205
195
  }
206
196
 
207
- const grant = async (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => {
197
+ const grant = async (
198
+ principal: Principal,
199
+ display: { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind },
200
+ ) => {
208
201
  if (disposed || grantPending() || updateMut.loading() || revokeMut.loading()) return;
209
202
  const key = principalKey(principal);
210
203
  if (entries.some((entry) => principalKey(entry.principal) === key)) return;
@@ -224,8 +217,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
224
217
  const entry = await props.grantAccess(principal, first.level, display, first.scope);
225
218
  if (disposed) return;
226
219
  const index = entries.findIndex((entry) => entry.id === id);
227
- if (index !== -1) setEntries(index, reconcile({ ...entry }));
228
- announce(t().accessGranted({ name: display.displayName }));
220
+ const saved = {
221
+ ...entry,
222
+ displayName: entry.displayName ?? display.displayName,
223
+ avatarHash: entry.avatarHash !== undefined ? entry.avatarHash : display.avatarHash,
224
+ serviceAccountKind: entry.serviceAccountKind ?? display.serviceAccountKind,
225
+ };
226
+ if (index !== -1) setEntries(index, reconcile(saved));
227
+ announce(t().accessGranted({ name: principalLabel(saved, locale()) }));
229
228
  } catch (err) {
230
229
  if (!disposed) setEntries((current) => current.filter((entry) => entry.id !== id));
231
230
  // Prompts are global, so a grant that fails after its dialog closed still says so.
@@ -264,7 +263,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
264
263
 
265
264
  const revokeMut = mutation.create<string | null, AccessEntry>({
266
265
  mutation: async (entry) => {
267
- const displayName = getEntryDisplayName(entry, t(), locale());
266
+ const displayName = principalLabel(entry, locale());
268
267
  const confirmed = await prompts.confirm(t().removeAccessConfirm({ name: displayName }), {
269
268
  title: t().removeAccess,
270
269
  variant: "danger",
@@ -395,7 +394,7 @@ function AccessEntryRow(props: {
395
394
  }) {
396
395
  const locale = useLocale();
397
396
  const t = () => accessMessages.resolve([locale()]).t;
398
- const displayName = () => getEntryDisplayName(props.entry, t(), locale());
397
+ const displayName = () => principalLabel(props.entry, locale());
399
398
  const entryKey = () => levelKey(props.entry.permission, props.entry.scope);
400
399
  const display = () => resolveEntryDisplay(props.entry.permission, props.entry.scope, props.allowed, t());
401
400
  const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
@@ -1,7 +1,7 @@
1
1
  import { Combobox, type ComboboxOption, useLocale } from "@k2b/ui";
2
2
  import type { Principal, ServiceAccountKind } from "../contracts/shared";
3
- import { groupDisplayName } from "../shared/account-display";
4
3
  import { accessMessages } from "./messages";
4
+ import { principalLabel } from "./principal-label";
5
5
  import { serviceAccountKindDisplay } from "./service-account-kind";
6
6
 
7
7
  export const principalKey = (p: Principal) =>
@@ -13,7 +13,7 @@ export const principalKey = (p: Principal) =>
13
13
  ? `s:${p.serviceAccountId}`
14
14
  : p.type;
15
15
  type Entity =
16
- | { kind: "user"; user: { id: string; displayName: string; uid: string; mail: string | null } }
16
+ | { kind: "user"; user: { id: string; displayName: string; uid: string; mail: string | null; avatarHash?: string | null } }
17
17
  | { kind: "group"; group: { id: string; name: string; description: string | null } }
18
18
  | {
19
19
  kind: "service_account";
@@ -35,17 +35,36 @@ export default function PrincipalPicker(props: {
35
35
  disabled?: boolean;
36
36
  readOnly?: boolean;
37
37
  /** `serviceAccountKind` is set for service accounts so a new row can show the same kind as the result. */
38
- onSelect: (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => void;
38
+ onSelect: (
39
+ principal: Principal,
40
+ display: { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind },
41
+ ) => void;
39
42
  }) {
40
43
  const locale = useLocale(),
41
44
  t = () => accessMessages.resolve([locale()]).t;
42
- type Entry = { principal: Principal; label: string; icon: string; description?: string; serviceAccountKind?: ServiceAccountKind };
45
+ type Entry = {
46
+ principal: Principal;
47
+ label: string;
48
+ icon: string;
49
+ description?: string;
50
+ avatarHash?: string | null;
51
+ serviceAccountKind?: ServiceAccountKind;
52
+ };
43
53
  let selectable = new Map<string, Entry>();
44
54
  const load = async (search: string, signal: AbortSignal): Promise<ComboboxOption[]> => {
45
55
  const entries: Entry[] = [];
46
56
  if (props.allowAuthenticated !== false)
47
- entries.push({ principal: { type: "authenticated" }, label: t().allUsers, icon: "ti ti-lock-open-2" });
48
- if (props.allowPublic) entries.push({ principal: { type: "public" }, label: t().public, icon: "ti ti-world" });
57
+ entries.push({
58
+ principal: { type: "authenticated" },
59
+ label: principalLabel({ principal: { type: "authenticated" } }, locale()),
60
+ icon: "ti ti-lock-open-2",
61
+ });
62
+ if (props.allowPublic)
63
+ entries.push({
64
+ principal: { type: "public" },
65
+ label: principalLabel({ principal: { type: "public" } }, locale()),
66
+ icon: "ti ti-world",
67
+ });
49
68
  if (search.length >= 2) {
50
69
  const url = new URL("/api/accounts/entities", window.location.origin);
51
70
  url.searchParams.set("search", search);
@@ -64,14 +83,18 @@ export default function PrincipalPicker(props: {
64
83
  if (e.kind === "user")
65
84
  entries.push({
66
85
  principal: { type: "user", userId: e.user.id },
67
- label: e.user.displayName || e.user.uid,
86
+ label: principalLabel(
87
+ { principal: { type: "user", userId: e.user.id }, displayName: e.user.displayName || e.user.uid },
88
+ locale(),
89
+ ),
90
+ avatarHash: e.user.avatarHash,
68
91
  icon: "ti ti-user",
69
92
  description: e.user.mail ?? e.user.uid,
70
93
  });
71
94
  else if (e.kind === "group")
72
95
  entries.push({
73
96
  principal: { type: "group", groupId: e.group.id },
74
- label: groupDisplayName(e.group.name, locale()),
97
+ label: principalLabel({ principal: { type: "group", groupId: e.group.id }, displayName: e.group.name }, locale()),
75
98
  icon: "ti ti-users-group",
76
99
  description: e.group.description ?? undefined,
77
100
  });
@@ -80,7 +103,14 @@ export default function PrincipalPicker(props: {
80
103
  const kind = serviceAccountKindDisplay(account.kind, t());
81
104
  entries.push({
82
105
  principal: { type: "service_account", serviceAccountId: account.id },
83
- label: account.name,
106
+ label: principalLabel(
107
+ {
108
+ principal: { type: "service_account", serviceAccountId: account.id },
109
+ displayName: account.name,
110
+ serviceAccountKind: account.kind,
111
+ },
112
+ locale(),
113
+ ),
84
114
  // Resource-bound accounts often share a name; their binding tells them apart.
85
115
  description:
86
116
  account.kind === "resource_bound"
@@ -114,7 +144,12 @@ export default function PrincipalPicker(props: {
114
144
  fetchData={load}
115
145
  onSelect={(option) => {
116
146
  const entry = selectable.get(option.id);
117
- if (entry) props.onSelect(entry.principal, { displayName: entry.label, serviceAccountKind: entry.serviceAccountKind });
147
+ if (entry)
148
+ props.onSelect(entry.principal, {
149
+ displayName: entry.label,
150
+ avatarHash: entry.avatarHash,
151
+ serviceAccountKind: entry.serviceAccountKind,
152
+ });
118
153
  }}
119
154
  />
120
155
  );