@k2b/cloud 0.6.0 → 0.8.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 (349) hide show
  1. package/README.md +2 -2
  2. package/package.json +22 -11
  3. package/scripts/README.md +10 -0
  4. package/scripts/browser-performance.ts +14 -0
  5. package/scripts/build-pdf-renderer.ts +39 -0
  6. package/scripts/build.ts +20 -35
  7. package/scripts/preload.ts +16 -12
  8. package/scripts/sync-recovery-smoke.ts +22 -6
  9. package/src/_internal/app-presentation.ts +14 -2
  10. package/src/_internal/build-metadata.ts +8 -1
  11. package/src/_internal/capabilities.ts +131 -7
  12. package/src/_internal/capability-streams.ts +81 -0
  13. package/src/_internal/define-app.ts +80 -37
  14. package/src/_internal/heartbeat.ts +4 -0
  15. package/src/_internal/help-catalog.ts +27 -172
  16. package/src/_internal/help.ts +13 -76
  17. package/src/_internal/page-responses.ts +2 -0
  18. package/src/_internal/postgres-application-name.ts +30 -0
  19. package/src/_internal/process-identity.ts +15 -0
  20. package/src/_internal/process-sync.ts +1 -1
  21. package/src/_internal/readiness.ts +4 -1
  22. package/src/_internal/registry-validation.ts +29 -25
  23. package/src/_internal/registry.ts +2 -53
  24. package/src/_internal/runtime-context.ts +5 -16
  25. package/src/_internal/server-timing.ts +13 -0
  26. package/src/_internal/status-preserving-ssr.ts +18 -6
  27. package/src/_internal/web-vitals-asset.ts +10 -0
  28. package/src/access/PermissionEditor.tsx +18 -151
  29. package/src/access/PrincipalPicker.tsx +110 -0
  30. package/src/access/ResourceApiKeys.tsx +1 -1
  31. package/src/access/ui.ts +1 -0
  32. package/src/ai/admin.ts +14 -13
  33. package/src/ai/assistant-models.ts +12 -1
  34. package/src/ai/audio-format.ts +34 -0
  35. package/src/ai/audio-tool.ts +90 -0
  36. package/src/ai/browser-code-contracts.ts +201 -0
  37. package/src/ai/browser.ts +4 -0
  38. package/src/ai/capabilities.ts +55 -36
  39. package/src/ai/capability-execution.ts +47 -12
  40. package/src/ai/chat/blocks.tsx +229 -90
  41. package/src/ai/chat/builtin-tools.tsx +2 -4
  42. package/src/ai/chat/capability-result.ts +15 -0
  43. package/src/ai/chat/capability-table.tsx +2 -10
  44. package/src/ai/chat/composer-adapter.ts +83 -3
  45. package/src/ai/chat/message-actions.tsx +8 -7
  46. package/src/ai/chat/message-utils.ts +25 -1
  47. package/src/ai/chat/messages.ts +2 -0
  48. package/src/ai/chat/presentation.tsx +31 -47
  49. package/src/ai/chat/primitives.tsx +43 -1
  50. package/src/ai/chat/tool-disclosure.tsx +21 -2
  51. package/src/ai/chat/tool-groups.ts +58 -0
  52. package/src/ai/chat-quotas.ts +25 -0
  53. package/src/ai/chat-task-contracts.ts +14 -10
  54. package/src/ai/chat-tasks.ts +297 -43
  55. package/src/ai/client/controller.ts +165 -39
  56. package/src/ai/client/file-source.ts +12 -6
  57. package/src/ai/client/live-connection.ts +1 -0
  58. package/src/ai/client/projection.ts +29 -11
  59. package/src/ai/client/transport.ts +6 -0
  60. package/src/ai/code-capability-routes.ts +105 -0
  61. package/src/ai/code-capability-transport.ts +39 -0
  62. package/src/ai/code-execution.ts +53 -0
  63. package/src/ai/code-mode-skill.ts +118 -0
  64. package/src/ai/code-runtime-tools.ts +179 -0
  65. package/src/ai/code-source-contracts.ts +270 -0
  66. package/src/ai/code-source-tools.ts +188 -0
  67. package/src/ai/compaction.ts +27 -6
  68. package/src/ai/data-analysis-skill.ts +10 -0
  69. package/src/ai/default-tools.ts +99 -3
  70. package/src/ai/dictation-runtime.ts +171 -0
  71. package/src/ai/dictations.ts +188 -0
  72. package/src/ai/draft-content.ts +49 -0
  73. package/src/ai/enrich.ts +6 -0
  74. package/src/ai/executor.ts +189 -33
  75. package/src/ai/fetch-file-tool.ts +2 -2
  76. package/src/ai/file-content-version.ts +27 -0
  77. package/src/ai/file-context.ts +2 -1
  78. package/src/ai/file-media-type.ts +12 -0
  79. package/src/ai/file-reference-contracts.ts +15 -0
  80. package/src/ai/file-tools.ts +8 -1
  81. package/src/ai/files-store.ts +212 -71
  82. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  83. package/src/ai/grids-skill.ts +42 -10
  84. package/src/ai/http.ts +26 -8
  85. package/src/ai/index.ts +26 -3
  86. package/src/ai/inference-calls.ts +197 -0
  87. package/src/ai/live-events.ts +5 -1
  88. package/src/ai/live.ts +1 -1
  89. package/src/ai/markdown-pdf-tool.ts +2 -2
  90. package/src/ai/memories.ts +30 -27
  91. package/src/ai/memory-learning-runs.ts +1 -1
  92. package/src/ai/memory-learning.ts +64 -32
  93. package/src/ai/memory-tool.ts +1 -5
  94. package/src/ai/memory-workflow-evidence.ts +9 -4
  95. package/src/ai/message-queue.ts +178 -0
  96. package/src/ai/migrate.ts +189 -11
  97. package/src/ai/model-pricing.ts +43 -0
  98. package/src/ai/pdf-render-worker.ts +75 -0
  99. package/src/ai/pdf-render.fixture.ts +28 -0
  100. package/src/ai/pdf-render.ts +77 -0
  101. package/src/ai/projects-routes.ts +18 -43
  102. package/src/ai/projects.ts +33 -14
  103. package/src/ai/protocol.ts +3 -0
  104. package/src/ai/provider.ts +1 -8
  105. package/src/ai/quota-provider.ts +192 -0
  106. package/src/ai/quota-report.ts +152 -0
  107. package/src/ai/quotas-migrate.ts +45 -0
  108. package/src/ai/quotas.ts +168 -0
  109. package/src/ai/routes.ts +241 -11
  110. package/src/ai/run-timeout.ts +12 -0
  111. package/src/ai/runtime-tools.ts +7 -5
  112. package/src/ai/runtime.ts +74 -17
  113. package/src/ai/settings.ts +36 -8
  114. package/src/ai/short-id.ts +4 -5
  115. package/src/ai/skill-catalog.ts +0 -10
  116. package/src/ai/skill-search.ts +58 -0
  117. package/src/ai/skill-seeds.ts +114 -20
  118. package/src/ai/skill-tool.ts +21 -5
  119. package/src/ai/skills-routes.ts +49 -4
  120. package/src/ai/skills.ts +358 -163
  121. package/src/ai/store.ts +416 -201
  122. package/src/ai/stream.ts +5 -0
  123. package/src/ai/structured-runs.ts +4 -3
  124. package/src/ai/structured.ts +12 -1
  125. package/src/ai/system-prompt.ts +14 -2
  126. package/src/ai/task-contracts.ts +84 -0
  127. package/src/ai/task-execution.ts +92 -0
  128. package/src/ai/timeline.ts +2 -5
  129. package/src/ai/todo-contracts.ts +32 -0
  130. package/src/ai/todo-tool.ts +19 -0
  131. package/src/ai/tool-audit.ts +30 -40
  132. package/src/ai/tools.ts +6 -0
  133. package/src/ai/transcription.ts +202 -0
  134. package/src/ai/turn-timing.ts +114 -0
  135. package/src/ai/types.ts +58 -10
  136. package/src/ai/ui.tsx +3 -0
  137. package/src/ai/usage.ts +118 -118
  138. package/src/ai/vision-tool.ts +60 -10
  139. package/src/api/admin-ai-projects.ts +2 -2
  140. package/src/api/admin-ai-quotas.ts +80 -0
  141. package/src/api/admin-ai-skills.ts +48 -5
  142. package/src/api/admin-ai-usage.ts +2 -2
  143. package/src/api/admin-core-settings.ts +25 -0
  144. package/src/api/admin-identity.ts +91 -51
  145. package/src/api/admin-linux-identities.ts +10 -4
  146. package/src/api/admin-rail.ts +58 -0
  147. package/src/api/announcements.ts +14 -0
  148. package/src/api/app-approval.ts +2 -2
  149. package/src/api/auth.ts +1 -1
  150. package/src/api/capabilities.ts +324 -43
  151. package/src/api/capability-streams.ts +262 -0
  152. package/src/api/help.ts +8 -44
  153. package/src/api/index.ts +8 -2
  154. package/src/api/mcp.ts +45 -34
  155. package/src/api/me-web-vitals.ts +35 -0
  156. package/src/api/me.ts +2 -0
  157. package/src/api/search/schemas.ts +26 -15
  158. package/src/api/search.bench.ts +1 -1
  159. package/src/api/search.ts +54 -17
  160. package/src/browser/CloudResourceSearch.tsx +640 -702
  161. package/src/browser/app-approval-vault.ts +19 -7
  162. package/src/browser/command-bridge.ts +109 -0
  163. package/src/browser/command-shortcuts.ts +76 -0
  164. package/src/browser/commands.ts +67 -0
  165. package/src/browser/navigation-search.ts +48 -0
  166. package/src/browser/notifications.ts +1 -1
  167. package/src/browser/resource-picker.tsx +5 -18
  168. package/src/browser/resource-search-dialog.ts +9 -0
  169. package/src/browser/resource-search-input.ts +46 -0
  170. package/src/browser/resource-search-messages.ts +76 -40
  171. package/src/browser/resource-search.ts +7 -0
  172. package/src/browser/search-bridge.ts +102 -0
  173. package/src/browser/search-commands.ts +59 -0
  174. package/src/browser/search.ts +13 -0
  175. package/src/browser/spotlight-position.ts +208 -0
  176. package/src/browser/testing.ts +6 -0
  177. package/src/browser/web-vitals.ts +25 -0
  178. package/src/capabilities/claims.ts +179 -0
  179. package/src/capabilities/client.ts +34 -0
  180. package/src/capabilities/command-link.ts +16 -0
  181. package/src/capabilities/executions.ts +298 -0
  182. package/src/capabilities/migrate.ts +81 -0
  183. package/src/capabilities/server.ts +116 -35
  184. package/src/capabilities/stream-body.ts +16 -0
  185. package/src/capabilities/streams.ts +28 -0
  186. package/src/cli/admin/ai-quotas.ts +161 -0
  187. package/src/cli/admin/ai-skills.ts +64 -0
  188. package/src/cli/admin/ai-usage.ts +53 -30
  189. package/src/cli/admin/app-credentials.ts +77 -0
  190. package/src/cli/admin/data.ts +17 -0
  191. package/src/cli/admin/index.ts +10 -1
  192. package/src/cli/admin/instance.ts +2 -2
  193. package/src/cli/admin/linux.ts +4 -2
  194. package/src/cli/admin/sync.ts +1 -1
  195. package/src/cli/capabilities.ts +42 -0
  196. package/src/cli/capability-streams.ts +25 -0
  197. package/src/cli/index.ts +3 -2
  198. package/src/config/define-env.ts +97 -0
  199. package/src/config/env.ts +147 -51
  200. package/src/config/index.ts +3 -1
  201. package/src/contracts/app.ts +23 -3
  202. package/src/contracts/capabilities.ts +75 -5
  203. package/src/contracts/capability-streams.ts +46 -0
  204. package/src/contracts/commands.ts +49 -0
  205. package/src/contracts/index.ts +5 -2
  206. package/src/contracts/rail-admin.ts +38 -0
  207. package/src/contracts/rail-preferences.ts +16 -0
  208. package/src/contracts/registry.ts +5 -27
  209. package/src/contracts/settings-types.ts +1 -1
  210. package/src/contracts/shared.ts +18 -5
  211. package/src/contracts/web-vitals.ts +27 -0
  212. package/src/desktop/index.ts +1 -0
  213. package/src/desktop/solid.tsx +1 -1
  214. package/src/index.ts +8 -6
  215. package/src/server/index.ts +3 -0
  216. package/src/server/middleware/auth.ts +3 -2
  217. package/src/server/middleware/route-template.ts +9 -3
  218. package/src/server/middleware/runtime.ts +2 -1
  219. package/src/server/middleware/settings.ts +17 -11
  220. package/src/server/ratelimit.ts +1 -1
  221. package/src/server/services/access-revision.ts +10 -0
  222. package/src/server/services/access.ts +3 -1
  223. package/src/server/services/index.ts +1 -0
  224. package/src/services/account-category-policy.ts +9 -0
  225. package/src/services/account-lifecycle/audit.ts +1 -1
  226. package/src/services/accounts/app.ts +12 -2
  227. package/src/services/accounts/email-write.ts +23 -0
  228. package/src/services/accounts/groups.ts +15 -4
  229. package/src/services/accounts/identities.ts +249 -0
  230. package/src/services/accounts/identity-reconciliation.ts +87 -0
  231. package/src/services/accounts/ipa-data.ts +2 -2
  232. package/src/services/accounts/local-groups.ts +2 -2
  233. package/src/services/accounts/notification-sender.ts +12 -2
  234. package/src/services/accounts/posix.ts +45 -29
  235. package/src/services/accounts/users.ts +2 -2
  236. package/src/services/announcements/index.ts +69 -8
  237. package/src/services/app-approval.ts +11 -10
  238. package/src/services/audit/index.ts +1 -1
  239. package/src/services/cache-fill.ts +35 -0
  240. package/src/services/help/index.ts +109 -0
  241. package/src/services/help/maintenance.ts +25 -0
  242. package/src/services/help/store.ts +180 -0
  243. package/src/services/help/types.ts +46 -0
  244. package/src/services/identity/invocation-actor.ts +2 -2
  245. package/src/services/identity/key-config.ts +8 -5
  246. package/src/services/identity/key-ring.ts +1 -1
  247. package/src/services/identity/runtime-config.ts +6 -5
  248. package/src/services/index.ts +15 -0
  249. package/src/services/ipa/posix.ts +1 -1
  250. package/src/services/ipa/sync.ts +1 -1
  251. package/src/services/ipa/users.ts +268 -221
  252. package/src/services/legal-consent.ts +1 -1
  253. package/src/services/logging/index.ts +15 -13
  254. package/src/services/logging/trace.ts +2 -1
  255. package/src/services/logging/web-vitals.ts +81 -0
  256. package/src/services/mandates/index.ts +21 -4
  257. package/src/services/mandates/policy.ts +165 -2
  258. package/src/services/notifications/batches.ts +125 -57
  259. package/src/services/notifications/browser.ts +12 -0
  260. package/src/services/notifications/index.ts +0 -2
  261. package/src/services/notifications/platform.ts +0 -4
  262. package/src/services/oauth-tokens.ts +3 -3
  263. package/src/services/pdf/gotenberg.ts +131 -72
  264. package/src/services/pdf/index.ts +11 -8
  265. package/src/services/providers/local/users.ts +25 -16
  266. package/src/services/public-http.ts +101 -0
  267. package/src/services/rail-shortcuts.ts +95 -0
  268. package/src/services/rail-snapshot.ts +53 -0
  269. package/src/services/request-cache-redis.ts +29 -0
  270. package/src/services/session/index.ts +15 -2
  271. package/src/services/session/user.ts +62 -37
  272. package/src/services/settings/app.ts +5 -1
  273. package/src/services/settings/core-settings.ts +26 -35
  274. package/src/services/settings/defaults.ts +5 -0
  275. package/src/services/settings/index.ts +3 -3
  276. package/src/services/settings/store.ts +58 -84
  277. package/src/services/webauthn.ts +1 -1
  278. package/src/shared/ai-costs.ts +42 -0
  279. package/src/shared/ai-platform-prompt.ts +1 -1
  280. package/src/shared/ai-quotas.ts +136 -0
  281. package/src/shared/ai-usage.ts +6 -16
  282. package/src/shared/app-presentation.ts +21 -15
  283. package/src/shared/capability-messages.ts +14 -0
  284. package/src/shared/help.ts +6 -42
  285. package/src/shared/index.ts +3 -0
  286. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  287. package/src/ssr/AdminLayout.tsx +7 -5
  288. package/src/ssr/AdminSidebar.tsx +30 -16
  289. package/src/ssr/AppLaunchpad.island.tsx +33 -136
  290. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  291. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  292. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  293. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  294. package/src/ssr/Layout.tsx +17 -6
  295. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  296. package/src/ssr/LayoutFooter.tsx +1 -1
  297. package/src/ssr/LayoutHeader.tsx +46 -14
  298. package/src/ssr/LayoutHelp.tsx +22 -23
  299. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  300. package/src/ssr/LayoutRail.tsx +17 -9
  301. package/src/ssr/MinimalLayout.tsx +1 -1
  302. package/src/ssr/MobileNavigation.tsx +71 -0
  303. package/src/ssr/MobileProfileActions.tsx +49 -0
  304. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  305. package/src/ssr/RailApps.island.tsx +2 -2
  306. package/src/ssr/RailEditor.tsx +17 -2
  307. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  308. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  309. package/src/ssr/admin-navigation.ts +11 -1
  310. package/src/ssr/help.ts +29 -0
  311. package/src/ssr/islands/SearchBar.tsx +67 -0
  312. package/src/ssr/islands/index.ts +3 -1
  313. package/src/ssr/layout-context.ts +2 -2
  314. package/src/ssr/layout-help-search.ts +15 -0
  315. package/src/ssr/mobile-menu-history.ts +70 -0
  316. package/src/ssr/platform-messages.ts +16 -8
  317. package/src/ssr/profile-actions.ts +42 -0
  318. package/src/ssr/rail-context.ts +8 -4
  319. package/src/ssr/rail-messages.ts +2 -0
  320. package/src/ssr/rail-navigation.ts +3 -3
  321. package/src/ssr/workspace-navigation.ts +75 -0
  322. package/src/styles/effects.css +6 -6
  323. package/src/styles/global.css +54 -0
  324. package/src/styles/input.css +43 -41
  325. package/src/styles/resource-search.css +505 -0
  326. package/src/styles/utilities-feedback.css +13 -0
  327. package/src/styles/utilities-layout.css +1 -5
  328. package/src/styles/utilities-navigation.css +3 -39
  329. package/src/types/ambient.d.ts +5 -0
  330. package/src/workflows/ai/index.ts +2 -2
  331. package/src/workflows/ai/runtime.ts +19 -90
  332. package/src/workflows/ai/store.ts +2 -0
  333. package/src/workflows/ai/types.ts +6 -82
  334. package/src/workflows/ai-actions.ts +16 -13
  335. package/src/workflows/definition.ts +10 -5
  336. package/src/workflows/store/actions.ts +44 -20
  337. package/src/workflows/store/index.ts +4 -2
  338. package/src/workflows/store/runs.ts +36 -0
  339. package/src/workflows/store/worker-pool.ts +55 -0
  340. package/src/workflows/store/worker-runtime.ts +90 -0
  341. package/src/ai/kit-skill.ts +0 -22
  342. package/src/ai/usage-migrate.ts +0 -81
  343. package/src/browser/notification-target.ts +0 -27
  344. package/src/contracts/notification-live.ts +0 -66
  345. package/src/server/services/freeipa/test-certificates.ts +0 -44
  346. package/src/services/notifications/live.ts +0 -50
  347. package/src/services/session/test-fixture.ts +0 -18
  348. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  349. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -0,0 +1,30 @@
1
+ /** Name Bun's default pool before its first use without changing connection ownership. */
2
+ export const configurePostgresApplicationName = (
3
+ applicationId: string,
4
+ environment: Record<string, string | undefined> = process.env,
5
+ ): void => {
6
+ // Match Bun's precedence and preserve TLS_* variables, which imply required TLS.
7
+ const variable = ["DATABASE_URL", "DATABASEURL", "TLS_DATABASE_URL", "POSTGRES_URL", "PGURL", "PG_URL", "TLS_POSTGRES_DATABASE_URL"].find(
8
+ (key) => environment[key],
9
+ );
10
+ if (!variable) return;
11
+
12
+ let url: URL;
13
+ try {
14
+ url = new URL(environment[variable]!);
15
+ } catch {
16
+ // Diagnostics must not change how Bun handles an existing connection configuration.
17
+ return;
18
+ }
19
+ if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return;
20
+
21
+ let operatorConfigured = false;
22
+ url.searchParams.forEach((_value, key) => {
23
+ // options can contain an operator-owned `-c application_name=...` setting.
24
+ if (key.toLowerCase() === "application_name" || key.toLowerCase() === "options") operatorConfigured = true;
25
+ });
26
+ if (operatorConfigured) return;
27
+
28
+ url.searchParams.set("application_name", `cloud:${applicationId}`);
29
+ environment[variable] = url.toString();
30
+ };
@@ -0,0 +1,15 @@
1
+ /** Identity of the application started in this process, derived from its declaration. */
2
+ let applicationId: string | undefined;
3
+
4
+ export const getProcessApplicationId = (): string | undefined => applicationId;
5
+
6
+ export const bindProcessApplicationId = (id: string): void => {
7
+ if (applicationId !== undefined && applicationId !== id) {
8
+ throw new Error(`This process already hosts application "${applicationId}"`);
9
+ }
10
+ applicationId = id;
11
+ };
12
+
13
+ export const clearProcessApplicationId = (): void => {
14
+ applicationId = undefined;
15
+ };
@@ -82,7 +82,7 @@ export const startProcessSync = async ({ application }: { application: string })
82
82
  connection,
83
83
  namespace: env.SYNC_NAMESPACE,
84
84
  application,
85
- defaults: { replicas: 3 },
85
+ defaults: { replicas: env.SYNC_REPLICAS },
86
86
  observe: (event) => observeSyncEvent(event, application),
87
87
  });
88
88
  bindProcessSync(sync);
@@ -1,3 +1,6 @@
1
+ import { buildMetadata } from "./build-metadata";
2
+
1
3
  export const APP_READINESS_PATH = "/_cloud/ready";
2
4
 
3
- export const appReadinessResponse = (appId: string): Response => Response.json({ status: "ready", appId });
5
+ export const appReadinessResponse = (appId: string): Response =>
6
+ Response.json({ status: "ready", appId, version: buildMetadata.version, release: buildMetadata.release });
@@ -39,11 +39,12 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
39
39
  }
40
40
  for (const [locale, translation] of Object.entries(value.presentation.translations)) {
41
41
  if (!isRecord(translation)) return invalid(`presentation.translations.${locale}`, "an object");
42
- if (translation.name !== undefined && !isString(translation.name)) return invalid(`presentation.translations.${locale}.name`, "a string");
42
+ if (translation.name !== undefined && !isString(translation.name))
43
+ return invalid(`presentation.translations.${locale}.name`, "a string");
43
44
  if (translation.description !== undefined && !isString(translation.description)) {
44
45
  return invalid(`presentation.translations.${locale}.description`, "a string");
45
46
  }
46
- for (const field of ["adminGroups", "adminLinks", "legalLinks"] as const) {
47
+ for (const field of ["adminGroups", "adminLinks", "legalLinks", "searchLinks", "searchLinkDescriptions"] as const) {
47
48
  if (translation[field] !== undefined && !isStringRecord(translation[field])) {
48
49
  return invalid(`presentation.translations.${locale}.${field}`, "a string map");
49
50
  }
@@ -66,6 +67,7 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
66
67
  (group) =>
67
68
  !isRecord(group) ||
68
69
  (group.id !== undefined && !isString(group.id)) ||
70
+ (group.section !== undefined && group.section !== "ai") ||
69
71
  !isString(group.label) ||
70
72
  !Array.isArray(group.links) ||
71
73
  group.links.some((link) => !isRecord(link) || !isString(link.label) || !isString(link.href) || !isString(link.icon)),
@@ -84,6 +86,23 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
84
86
  return invalid("legalLinks", "an array of valid links");
85
87
  }
86
88
  }
89
+ if (value.searchLinks !== undefined) {
90
+ if (
91
+ !Array.isArray(value.searchLinks) ||
92
+ value.searchLinks.some(
93
+ (link) =>
94
+ !isRecord(link) ||
95
+ !isString(link.label) ||
96
+ !link.label.trim() ||
97
+ (link.description !== undefined && !isString(link.description)) ||
98
+ !isString(link.href) ||
99
+ !/^\/(?![\/\\])[^\\\s]*$/.test(link.href) ||
100
+ (link.icon !== undefined && !isString(link.icon)) ||
101
+ (link.keywords !== undefined && !isStringArray(link.keywords)),
102
+ )
103
+ )
104
+ return invalid("searchLinks", "an array of same-origin navigation links");
105
+ }
87
106
  if (value.widgets !== undefined) {
88
107
  if (
89
108
  !Array.isArray(value.widgets) ||
@@ -102,32 +121,17 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
102
121
  return invalid("capabilities", "a valid capability summary");
103
122
  }
104
123
  }
105
- if (value.help !== undefined) {
106
- if (
107
- !isRecord(value.help) ||
124
+ if (
125
+ value.help !== undefined &&
126
+ (!isRecord(value.help) ||
108
127
  !isString(value.help.manifestHash) ||
128
+ !isString(value.help.baseLocale) ||
109
129
  !isString(value.help.pageBase) ||
110
- !Array.isArray(value.help.documents)
111
- ) {
112
- return invalid("help", "a valid Help summary");
113
- }
114
- if (
115
- !value.help.pageBase.startsWith("/") ||
116
- value.help.documents.some(
117
- (document) =>
118
- !isRecord(document) ||
119
- !isString(document.id) ||
120
- !isString(document.title) ||
121
- typeof document.order !== "number" ||
122
- !isString(document.searchUrl) ||
123
- !document.searchUrl.startsWith("/") ||
124
- !isString(document.url) ||
125
- !document.url.startsWith("/"),
126
- )
127
- ) {
128
- return invalid("help", "a valid Help summary");
129
- }
130
+ !value.help.pageBase.startsWith("/"))
131
+ ) {
132
+ return invalid("help", "a valid Help reference");
130
133
  }
134
+
131
135
  if (value.openapi !== undefined && !isString(value.openapi)) return invalid("openapi", "a string");
132
136
  return null;
133
137
  };
@@ -1,7 +1,7 @@
1
1
  import type { EphemeralConfig } from "@k2b/sync";
2
2
  import type { AppAppearanceColor } from "../contracts/app";
3
3
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
4
- import type { AppRegistryEntry, CapabilityRegistryEntry, HelpRegistryEntry } from "../contracts/registry";
4
+ import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
5
5
  import type { DashboardWidgetPresentation } from "../contracts/widgets";
6
6
  import { resolveAppPresentations } from "../shared/app-presentation";
7
7
  import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
@@ -10,7 +10,7 @@ import { validateAppRegistryEntry } from "./registry-validation";
10
10
  import { watchRegistryChanges } from "./registry-watch";
11
11
 
12
12
  /**
13
- * Shared app registry: three @k2b/sync ephemerals on the process Sync
13
+ * Shared app registry: two @k2b/sync ephemerals on the process Sync
14
14
  * instance. Apps, the gateway, and gateway-ops all declare them through the
15
15
  * functions below, so id, owner, TTL, and value bound stay identical across
16
16
  * the fleet (a differing declaration fails with ResourceDriftError).
@@ -34,20 +34,12 @@ export const CAPABILITY_REGISTRY_CONFIG = {
34
34
  maxValueBytes: 512 * 1024,
35
35
  } as const satisfies EphemeralConfig;
36
36
 
37
- export const HELP_REGISTRY_CONFIG = {
38
- id: "cloud-help",
39
- owner: REGISTRY_OWNER,
40
- ttlMs: APP_REGISTRY_TTL_MS,
41
- maxValueBytes: 512 * 1024,
42
- } as const satisfies EphemeralConfig;
43
-
44
37
  export const APP_REGISTRY_PREFIX = "apps/";
45
38
 
46
39
  export type CapabilityRegistryRecord = { appId: string; manifest: CapabilityManifest; presentation?: CapabilityPresentationCatalog };
47
40
 
48
41
  export const appRegistry = lazySync((sync) => sync.ephemeral<AppRegistryEntry>(APP_REGISTRY_CONFIG));
49
42
  export const capabilityRegistry = lazySync((sync) => sync.ephemeral<CapabilityRegistryRecord>(CAPABILITY_REGISTRY_CONFIG));
50
- export const helpRegistry = lazySync((sync) => sync.ephemeral<HelpRegistryEntry>(HELP_REGISTRY_CONFIG));
51
43
 
52
44
  /**
53
45
  * Follow the app registry until `signal` aborts. The watch first replays the
@@ -232,49 +224,6 @@ export const getCapability = async (appId: string): Promise<CapabilityRegistryEn
232
224
  return entry ? resolveLiveCapabilityRegistryEntry(entry.key, entry.value, app ?? undefined) : null;
233
225
  };
234
226
 
235
- const isHelpRegistryEntry = (value: unknown): value is HelpRegistryEntry => {
236
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
237
- const entry = value as Partial<HelpRegistryEntry>;
238
- const validDocument = (document: unknown) =>
239
- !!document &&
240
- typeof document === "object" &&
241
- typeof (document as { id?: unknown }).id === "string" &&
242
- typeof (document as { title?: unknown }).title === "string" &&
243
- typeof (document as { order?: unknown }).order === "number" &&
244
- typeof (document as { markdown?: unknown }).markdown === "string" &&
245
- ((document as { searchText?: unknown }).searchText === undefined ||
246
- typeof (document as { searchText?: unknown }).searchText === "string") &&
247
- ((document as { icon?: unknown }).icon === undefined || typeof (document as { icon?: unknown }).icon === "string") &&
248
- ((document as { description?: unknown }).description === undefined ||
249
- typeof (document as { description?: unknown }).description === "string");
250
- return (
251
- typeof entry.appId === "string" &&
252
- typeof entry.appName === "string" &&
253
- typeof entry.appIcon === "string" &&
254
- typeof entry.manifestHash === "string" &&
255
- (entry.baseLocale === undefined || typeof entry.baseLocale === "string") &&
256
- Array.isArray(entry.documents) &&
257
- entry.documents.every(validDocument) &&
258
- (entry.documentsByLocale === undefined ||
259
- (!!entry.documentsByLocale &&
260
- typeof entry.documentsByLocale === "object" &&
261
- !Array.isArray(entry.documentsByLocale) &&
262
- Object.values(entry.documentsByLocale).every((documents) => Array.isArray(documents) && documents.every(validDocument))))
263
- );
264
- };
265
-
266
- export const listHelp = async (): Promise<HelpRegistryEntry[]> => {
267
- const snap = await helpRegistry().snapshot({ prefix: "help/" });
268
- return snap.entries.map((entry) => entry.value).filter(isHelpRegistryEntry);
269
- };
270
-
271
- export const getHelp = async (appId: string): Promise<HelpRegistryEntry | null> => {
272
- const key = `help/${appId}`;
273
- const snap = await helpRegistry().snapshot({ prefix: key });
274
- const value = snap.entries.find((entry) => entry.key === key)?.value;
275
- return isHelpRegistryEntry(value) ? value : null;
276
- };
277
-
278
227
  /**
279
228
  * Same as `listApps` but returns registry metadata for admin observability.
280
229
  */
@@ -29,6 +29,8 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
29
29
  adminGroups: translation.adminGroups ? { ...translation.adminGroups } : undefined,
30
30
  adminLinks: translation.adminLinks ? { ...translation.adminLinks } : undefined,
31
31
  legalLinks: translation.legalLinks ? { ...translation.legalLinks } : undefined,
32
+ searchLinks: translation.searchLinks ? { ...translation.searchLinks } : undefined,
33
+ searchLinkDescriptions: translation.searchLinkDescriptions ? { ...translation.searchLinkDescriptions } : undefined,
32
34
  },
33
35
  ]),
34
36
  ),
@@ -37,6 +39,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
37
39
  appearance: e.appearance,
38
40
  adminHref: e.nav?.adminHref,
39
41
  adminNav: e.adminNav?.map((group) => ({
42
+ ...(group.section ? { section: group.section } : {}),
40
43
  id: group.id,
41
44
  label: group.label,
42
45
  links: group.links.map((link) => ({ ...link })),
@@ -53,22 +56,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
53
56
  requiresRoles: e.nav.requiresRoles as Role[] | undefined,
54
57
  }
55
58
  : undefined,
56
- help: e.help
57
- ? {
58
- manifestHash: e.help.manifestHash,
59
- pageBase: e.help.pageBase,
60
- baseLocale: e.help.baseLocale,
61
- documentsByLocale: e.help.documentsByLocale
62
- ? Object.fromEntries(
63
- Object.entries(e.help.documentsByLocale).map(([locale, documents]) => [
64
- locale,
65
- documents.map((document) => ({ ...document })),
66
- ]),
67
- )
68
- : undefined,
69
- documents: e.help.documents.map((document) => ({ ...document })),
70
- }
71
- : undefined,
59
+ help: e.help ? { ...e.help } : undefined,
72
60
  searchTags,
73
61
  searchHelp: searchQueries?.map((query) => query.description).join(" "),
74
62
  searchTagHelp: searchQueries?.flatMap((query) =>
@@ -78,6 +66,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
78
66
  ]),
79
67
  ),
80
68
  legalLinks: e.legalLinks ? e.legalLinks.map((l) => ({ ...l })) : undefined,
69
+ searchLinks: e.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
81
70
  openapi: e.openapi,
82
71
  };
83
72
  }),
@@ -0,0 +1,13 @@
1
+ import type { Context } from "hono";
2
+
3
+ type Phase = "auth" | "settings" | "runtime" | "ssr_data" | "ssr_finalize" | "ssr_render";
4
+
5
+ /** Measure the owning operation, not downstream middleware or another request. */
6
+ export async function measureServerPhase<T>(c: Context, phase: Phase, operation: () => T | Promise<T>): Promise<T> {
7
+ const start = performance.now();
8
+ try {
9
+ return await operation();
10
+ } finally {
11
+ c.header("Server-Timing", `${phase};dur=${(performance.now() - start).toFixed(2)}`, { append: true });
12
+ }
13
+ }
@@ -2,10 +2,20 @@ import type { HtmlFn, RenderFn } from "@k2b/ssr";
2
2
  import { createSSRHandler } from "@k2b/ssr/hono";
3
3
  import type { Context, Env, MiddlewareHandler, TypedResponse } from "hono";
4
4
  import type { StatusCode } from "hono/utils/http-status";
5
- import type { RailPreferences } from "../contracts/rail-preferences";
5
+ import type { RailSnapshot } from "../contracts/rail-preferences";
6
+ import type { SettingsFlat, Unflatten } from "../contracts/settings-types";
6
7
  import type { User } from "../contracts/shared";
8
+ import type { CORE_SETTINGS } from "../services/settings/core-settings";
9
+ import { measureServerPhase } from "./server-timing";
7
10
 
8
- type PageEnv<T extends object> = { Variables: { page: Partial<T>; user?: User; railPreferences?: RailPreferences } };
11
+ type PageEnv<T extends object> = {
12
+ Variables: {
13
+ page: Partial<T>;
14
+ user?: User;
15
+ railPreferences?: RailSnapshot;
16
+ settings?: Unflatten<SettingsFlat<typeof CORE_SETTINGS>>;
17
+ };
18
+ };
9
19
  type SsrHandlerResult = RenderFn | Response | TypedResponse;
10
20
  type SsrHandler<E extends Env, T extends object> = (context: Context<E & PageEnv<T>>) => SsrHandlerResult | Promise<SsrHandlerResult>;
11
21
 
@@ -30,15 +40,17 @@ export const createStatusPreservingSsrHandler = <T extends object>(
30
40
  const handler = args[args.length - 1] as SsrHandler<E, T>;
31
41
 
32
42
  return createHandler<E>(...middlewares, async (context) => {
33
- const result = await handler(context as Context<E & PageEnv<T>>);
43
+ const result = await measureServerPhase(context, "ssr_data", () => handler(context as Context<E & PageEnv<T>>));
34
44
  if (result instanceof Response) return result;
35
- await finalizePage?.(context as unknown as Context<PageEnv<T>>);
45
+ await measureServerPhase(context, "ssr_finalize", () => finalizePage?.(context as unknown as Context<PageEnv<T>>));
36
46
  if (typeof result !== "function") return result;
37
47
 
38
48
  const status = context.newResponse(null).status;
39
- if (status === 200) return result;
49
+ if (result.constructor.name === "AsyncFunction") {
50
+ throw new Error("[ssr] ssr() render functions must be synchronous: return () => <Page />");
51
+ }
40
52
 
41
- const response = await html(result, context.get("page") as T);
53
+ const response = await measureServerPhase(context, "ssr_render", () => html(result, context.get("page") as T));
42
54
  const headers: Record<string, string> = {};
43
55
  response.headers.forEach((value, key) => {
44
56
  headers[key] = value;
@@ -0,0 +1,10 @@
1
+ import { createHash } from "node:crypto";
2
+ import { dependencies } from "../../package.json";
3
+ import * as collectorModule from "../browser/web-vitals.ts" with { type: "text" };
4
+
5
+ // All apps using the same collector share one Core asset and browser cache key.
6
+ // Both inputs are bundled, so production servers need no source files on disk.
7
+ if (!("default" in collectorModule) || typeof collectorModule.default !== "string") throw new Error("Expected collector source text");
8
+ const version = createHash("sha256").update(collectorModule.default).update(dependencies["web-vitals"]).digest("hex").slice(0, 16);
9
+ export const WEB_VITALS_ASSET_NAME = `web-vitals-${version}.js`;
10
+ export const WEB_VITALS_ASSET_HREF = `/public/${WEB_VITALS_ASSET_NAME}`;
@@ -1,9 +1,10 @@
1
1
  import { mutation } from "@k2b/stdlib/solid";
2
- import { Button, Combobox, type ComboboxOption, IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
2
+ import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
3
3
  import { createSignal, For, Show } from "solid-js";
4
4
  import { CloudAvatar } from "../account/Avatar";
5
5
  import type { AccessEntry, PermissionLevel, Principal } from "../contracts/shared";
6
6
  import { accessMessages } from "./messages";
7
+ import PrincipalPicker from "./PrincipalPicker";
7
8
 
8
9
  // ─────────────────────────────────────────────────────────────────────────
9
10
  // Public API
@@ -56,8 +57,8 @@ type PermissionEditorProps = {
56
57
  * write "Use", views call read "View"). When undefined, all three
57
58
  * are offered with default labels. New entries are granted
58
59
  * `allowedLevels[0]` on pick — the user upgrades via the row pill
59
- * afterwards. */
60
- allowedLevels?: AllowedLevel[];
60
+ * afterwards. A function may restrict levels by principal (for example public read-only). */
61
+ allowedLevels?: AllowedLevel[] | ((principal: Principal) => AllowedLevel[]);
61
62
  };
62
63
 
63
64
  // ─────────────────────────────────────────────────────────────────────────
@@ -135,22 +136,6 @@ const getPrincipalIcon = (principal: Principal): string => {
135
136
  }
136
137
  };
137
138
 
138
- // Backend `/api/accounts/entities` shape (the subset we consume).
139
- type ApiEntity =
140
- | { kind: "user"; user: { id: string; uid: string; displayName: string; mail: string | null } }
141
- | { kind: "group"; group: { id: string; name: string; description: string | null } }
142
- | {
143
- kind: "service_account";
144
- serviceAccount: {
145
- id: string;
146
- name: string;
147
- kind: "user_delegated" | "resource_bound";
148
- appId: string | null;
149
- resourceType: string | null;
150
- resourceId: string | null;
151
- };
152
- };
153
-
154
139
  // ─────────────────────────────────────────────────────────────────────────
155
140
  // PermissionEditor
156
141
  // ─────────────────────────────────────────────────────────────────────────
@@ -162,12 +147,12 @@ export default function PermissionEditor(props: PermissionEditorProps) {
162
147
  const canEdit = () => props.canEdit !== false;
163
148
  const allowPublic = () => props.allowPublic === true;
164
149
  const allowAuthenticated = () => props.allowAuthenticated !== false;
165
- const allowed = () => resolveAllowedLevels(props.allowedLevels, t());
166
- const isSinglePicker = () => allowed().length === 1;
150
+ const allowed = (principal: Principal) =>
151
+ resolveAllowedLevels(typeof props.allowedLevels === "function" ? props.allowedLevels(principal) : props.allowedLevels, t());
167
152
 
168
153
  // Defensive dev-warning: an empty allowedLevels array makes the editor
169
154
  // unable to grant anything.
170
- if (props.allowedLevels && props.allowedLevels.length === 0) {
155
+ if (Array.isArray(props.allowedLevels) && props.allowedLevels.length === 0) {
171
156
  if (typeof console !== "undefined") {
172
157
  console.warn(
173
158
  "[PermissionEditor] `allowedLevels=[]` — the editor cannot grant any permission. Pass at least one level or omit the prop for the default View / Edit / Manage set.",
@@ -175,21 +160,6 @@ export default function PermissionEditor(props: PermissionEditorProps) {
175
160
  }
176
161
  }
177
162
 
178
- const existingUserIds = () =>
179
- entries()
180
- .filter((e) => e.principal.type === "user")
181
- .map((e) => (e.principal as { type: "user"; userId: string }).userId);
182
- const existingGroupIds = () =>
183
- entries()
184
- .filter((e) => e.principal.type === "group")
185
- .map((e) => (e.principal as { type: "group"; groupId: string }).groupId);
186
- const existingServiceAccountIds = () =>
187
- entries()
188
- .filter((e) => e.principal.type === "service_account")
189
- .map((e) => (e.principal as { type: "service_account"; serviceAccountId: string }).serviceAccountId);
190
- const hasAuthenticatedEntry = () => entries().some((entry) => entry.principal.type === "authenticated");
191
- const hasPublicEntry = () => entries().some((entry) => entry.principal.type === "public");
192
-
193
163
  const grantMut = mutation.create({
194
164
  mutation: async (data: { principal: Principal; permission: GrantableLevel; display: { displayName: string } }) =>
195
165
  props.grantAccess(data.principal, data.permission, data.display),
@@ -230,106 +200,6 @@ export default function PermissionEditor(props: PermissionEditorProps) {
230
200
  });
231
201
  const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
232
202
 
233
- // ── Combobox add-flow ─────────────────────────────────────────────────
234
- // The Combobox is a fire-and-forget input: type → pick → granted at the
235
- // lowest allowed level. The `principalsByOptId` map carries the original
236
- // discriminated principal across the ComboboxOption boundary so onSelect
237
- // can route it to grantAccess without re-parsing prefixed ids.
238
- let principalsByOptId = new Map<string, Principal>();
239
-
240
- const fetchPrincipals = async (q: string, signal: AbortSignal): Promise<ComboboxOption[]> => {
241
- const map = new Map<string, Principal>();
242
- const opts: ComboboxOption[] = [];
243
-
244
- // Synthetic principals — only when allowed AND not already granted.
245
- // Placed first so they're visible immediately on focus, before any
246
- // typing kicks off a backend request.
247
- if (allowAuthenticated() && !hasAuthenticatedEntry()) {
248
- map.set("auth", { type: "authenticated" });
249
- opts.push({
250
- id: "auth",
251
- label: t().allUsers,
252
- description: t().signedInDescription,
253
- icon: "ti-lock-open-2",
254
- });
255
- }
256
- if (allowPublic() && !hasPublicEntry()) {
257
- map.set("public", { type: "public" });
258
- opts.push({
259
- id: "public",
260
- label: t().public,
261
- description: t().publicDescription,
262
- icon: "ti-world",
263
- });
264
- }
265
-
266
- // Real entities require a query — avoid a wide listing on every focus.
267
- if (q.length >= 2) {
268
- const url = new URL("/api/accounts/entities", window.location.origin);
269
- url.searchParams.set("search", q);
270
- url.searchParams.set("kinds", props.allowServiceAccounts ? "user,group,service_account" : "user,group");
271
- url.searchParams.set("per_page", "10");
272
- const userIds = existingUserIds();
273
- if (userIds.length) url.searchParams.set("exclude_user_ids", userIds.join(","));
274
- const groupIds = existingGroupIds();
275
- if (groupIds.length) url.searchParams.set("exclude_group_ids", groupIds.join(","));
276
- const serviceAccountIds = existingServiceAccountIds();
277
- if (serviceAccountIds.length) url.searchParams.set("exclude_service_account_ids", serviceAccountIds.join(","));
278
-
279
- const res = await fetch(url.toString(), { credentials: "same-origin", signal });
280
- if (res.ok) {
281
- const data = (await res.json()) as { items?: ApiEntity[] };
282
- for (const item of data.items ?? []) {
283
- if (item.kind === "user") {
284
- const id = `u:${item.user.id}`;
285
- map.set(id, { type: "user", userId: item.user.id });
286
- opts.push({
287
- id,
288
- label: item.user.displayName,
289
- description: item.user.mail ?? item.user.uid,
290
- icon: "ti ti-user",
291
- });
292
- } else if (item.kind === "group") {
293
- const id = `g:${item.group.id}`;
294
- map.set(id, { type: "group", groupId: item.group.id });
295
- opts.push({
296
- id,
297
- label: item.group.name,
298
- description: item.group.description ?? undefined,
299
- icon: "ti ti-users-group",
300
- });
301
- } else if (item.kind === "service_account") {
302
- const id = `sa:${item.serviceAccount.id}`;
303
- map.set(id, { type: "service_account", serviceAccountId: item.serviceAccount.id });
304
- opts.push({
305
- id,
306
- label: item.serviceAccount.name,
307
- description:
308
- item.serviceAccount.kind === "user_delegated"
309
- ? t().userBoundServiceAccount
310
- : [item.serviceAccount.appId, item.serviceAccount.resourceType, item.serviceAccount.resourceId]
311
- .filter(Boolean)
312
- .join(" · "),
313
- icon: "ti ti-key",
314
- });
315
- }
316
- }
317
- }
318
- }
319
-
320
- principalsByOptId = map;
321
- return opts;
322
- };
323
-
324
- const handleSelect = (option: ComboboxOption) => {
325
- if (busy()) return;
326
- const principal = principalsByOptId.get(option.id);
327
- if (!principal) return;
328
- const firstLevel = allowed()[0]?.level;
329
- if (!firstLevel) return; // dev-warned above; bail silently
330
- grantMut.mutate({ principal, permission: firstLevel, display: { displayName: option.label } });
331
- };
332
-
333
203
  return (
334
204
  <div class="flex flex-col gap-3">
335
205
  {/* Existing entries */}
@@ -340,8 +210,8 @@ export default function PermissionEditor(props: PermissionEditorProps) {
340
210
  entry={entry}
341
211
  canEdit={canEdit()}
342
212
  disabled={busy()}
343
- allowed={allowed()}
344
- singlePicker={isSinglePicker()}
213
+ allowed={allowed(entry.principal)}
214
+ singlePicker={allowed(entry.principal).length === 1}
345
215
  onUpdatePermission={(permission) => {
346
216
  if (!busy()) void updateMut.mutate({ accessId: entry.id, permission });
347
217
  }}
@@ -360,19 +230,16 @@ export default function PermissionEditor(props: PermissionEditorProps) {
360
230
  level on pick. The user upgrades via the row pill if they want
361
231
  a higher level. KISS: one decision per step. */}
362
232
  <Show when={canEdit()}>
363
- <Combobox
364
- placeholder={
365
- props.allowServiceAccounts
366
- ? allowAuthenticated()
367
- ? t().addAll
368
- : t().addService
369
- : allowAuthenticated()
370
- ? t().addAudience
371
- : t().addBasic
372
- }
373
- fetchData={fetchPrincipals}
374
- onSelect={handleSelect}
233
+ <PrincipalPicker
234
+ existing={entries().map((e) => e.principal)}
235
+ allowPublic={allowPublic()}
236
+ allowAuthenticated={allowAuthenticated()}
237
+ allowServiceAccounts={props.allowServiceAccounts}
375
238
  disabled={busy()}
239
+ onSelect={(principal, display) => {
240
+ const permission = allowed(principal)[0]?.level;
241
+ if (permission && !busy()) grantMut.mutate({ principal, permission, display });
242
+ }}
376
243
  />
377
244
  </Show>
378
245
  </div>