@k2b/cloud 0.42.0 → 0.44.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 (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,519 @@
1
+ import { type SQL, sql } from "bun";
2
+ import type { AppMeta } from "../contracts/app";
3
+ import type { AppAccessDeclaration, AppAccessSnapshot } from "../contracts/app-access";
4
+ import { publishAppAccessChanged, wakeAppAccessChanged } from "../events/app-access";
5
+ import { isAppSessionActor, userFromActor } from "../server/actor";
6
+ import type { RequestAuthority } from "../server/middleware/auth";
7
+ import {
8
+ type AccessEntry,
9
+ buildAccessPrincipalCondition,
10
+ createAccess,
11
+ getEffectivePermission,
12
+ type Principal,
13
+ resolveDisplayNames,
14
+ } from "../server/services/access";
15
+ import { isAccountExpired } from "./account-model";
16
+ import { recursiveGroupIdsSubquery } from "./accounts/group-sql";
17
+ import { canUseApp } from "./app-access";
18
+ import { audit } from "./audit";
19
+ import { getIdentityRuntimeConfig } from "./identity/runtime-config";
20
+ import type { ServiceAccount } from "./service-accounts";
21
+ import { loadJwtSession } from "./session/user";
22
+
23
+ type ManagedApp = Pick<AppMeta, "id" | "name" | "access">;
24
+
25
+ export type { AppAccessSnapshot } from "../contracts/app-access";
26
+ export type AppAccessChange =
27
+ | { action: "grant"; principal: Principal; permission: "read" | "admin" }
28
+ | { action: "update"; accessId: string; permission: "read" | "admin" }
29
+ | { action: "revoke"; accessId: string };
30
+ export class AppAccessManagementError extends Error {
31
+ constructor(
32
+ readonly code: string,
33
+ message: string,
34
+ readonly status: 400 | 403 | 404 | 409 = 403,
35
+ ) {
36
+ super(message);
37
+ }
38
+ }
39
+
40
+ const credentialIsEligible = (authority: RequestAuthority): boolean => {
41
+ if (authority.scopes.includes("ai:inference")) return false;
42
+ const user = userFromActor(authority.actor);
43
+ if (user && isAccountExpired(user.accountExpires)) return false;
44
+ if (authority.actor.kind !== "service_account") return true;
45
+ const { serviceAccount, credentialExpiresAt } = authority.actor;
46
+ return (
47
+ serviceAccount.status === "active" &&
48
+ serviceAccount.kind !== "resource_bound" &&
49
+ (!credentialExpiresAt || new Date(credentialExpiresAt).getTime() > Date.now())
50
+ );
51
+ };
52
+
53
+ /** Repair is administration authority, never an implicit usage grant. */
54
+ export const canRepairAppAccess = (authority: RequestAuthority): boolean => {
55
+ if (!credentialIsEligible(authority) || isAppSessionActor(authority.actor) || !userFromActor(authority.actor)?.roles.includes("admin"))
56
+ return false;
57
+ const kind = authority.credentialKind === "invocation" ? authority.actor.delegation?.credentialKind : authority.credentialKind;
58
+ return (
59
+ kind === "session" ||
60
+ (kind === "api_key" &&
61
+ authority.actor.kind === "service_account" &&
62
+ authority.actor.serviceAccount.kind === "user_delegated" &&
63
+ authority.scopes.length === 0) ||
64
+ authority.scopes.includes("admin")
65
+ );
66
+ };
67
+ const canWriteList = (authority: RequestAuthority, appId: string): boolean => {
68
+ if (!credentialIsEligible(authority)) return false;
69
+ const kind = authority.credentialKind === "invocation" ? authority.actor.delegation?.credentialKind : authority.credentialKind;
70
+ return (
71
+ kind === "session" ||
72
+ (kind === "api_key" &&
73
+ authority.actor.kind === "service_account" &&
74
+ authority.actor.serviceAccount.kind === "user_delegated" &&
75
+ authority.scopes.length === 0) ||
76
+ authority.scopes.some((scope) => ["write", "admin", `${appId}:write`, `${appId}:admin`, `${appId}:*`].includes(scope))
77
+ );
78
+ };
79
+
80
+ const listEntries = async (appId: string, db: SQL = sql): Promise<AccessEntry[]> => {
81
+ const rows = await db<
82
+ {
83
+ id: string;
84
+ user_id: string | null;
85
+ group_id: string | null;
86
+ service_account_id: string | null;
87
+ authenticated_only: boolean;
88
+ permission: "read" | "admin";
89
+ created_at: Date;
90
+ }[]
91
+ >`
92
+ SELECT a.* FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
93
+ WHERE j.app_id = ${appId} ORDER BY a.created_at, a.id
94
+ `;
95
+ return rows.map((row) => ({
96
+ id: row.id,
97
+ permission: row.permission,
98
+ createdAt: row.created_at.toISOString(),
99
+ principal: row.user_id
100
+ ? { type: "user", userId: row.user_id }
101
+ : row.group_id
102
+ ? { type: "group", groupId: row.group_id }
103
+ : row.service_account_id
104
+ ? { type: "service_account", serviceAccountId: row.service_account_id }
105
+ : row.authenticated_only
106
+ ? { type: "authenticated" }
107
+ : { type: "public" },
108
+ }));
109
+ };
110
+ /** Shared eligibility predicate for the last-manager guard and its presentation. */
111
+ const managerEligibility = (db: SQL) => db`
112
+ EXISTS (SELECT 1 FROM auth.users u WHERE (u.account_expires IS NULL OR u.account_expires > now()) AND
113
+ (u.id = a.user_id OR (a.group_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(db`u.id`)}))))
114
+ OR EXISTS (SELECT 1 FROM auth.service_accounts sa WHERE sa.id = a.service_account_id
115
+ AND sa.status = 'active' AND sa.kind IN ('standalone', 'agent'))
116
+ `;
117
+
118
+ const entryPresentation = async (appId: string): Promise<Map<string, { groupMemberCount?: number; effectiveManager: boolean }>> => {
119
+ const rows = await sql<{ id: string; group_member_count: number | null; effective_manager: boolean }[]>`
120
+ SELECT a.id,
121
+ CASE WHEN a.group_id IS NOT NULL THEN (
122
+ SELECT count(*)::integer FROM auth.users u
123
+ WHERE a.group_id IN (${recursiveGroupIdsSubquery(sql`u.id`)})
124
+ ) END AS group_member_count,
125
+ a.permission = 'admin' AND (${managerEligibility(sql)}) AS effective_manager
126
+ FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
127
+ WHERE j.app_id = ${appId}
128
+ `;
129
+ return new Map(
130
+ rows.map((row) => [
131
+ row.id,
132
+ {
133
+ ...(row.group_member_count === null ? {} : { groupMemberCount: row.group_member_count }),
134
+ effectiveManager: row.effective_manager,
135
+ },
136
+ ]),
137
+ );
138
+ };
139
+
140
+ const requireConfigurable = (app: ManagedApp) => {
141
+ if (app.access?.kind !== "grants")
142
+ throw new AppAccessManagementError("APP_ACCESS_FIXED", "This application uses a fixed access rule.", 409);
143
+ return app.access;
144
+ };
145
+
146
+ export const requireAppAccessManager = async (
147
+ authority: RequestAuthority,
148
+ app: ManagedApp,
149
+ options: { repair?: boolean; canManage?: AppAccessDeclaration["canManage"]; db?: SQL } = {},
150
+ ): Promise<void> => {
151
+ requireConfigurable(app);
152
+ if (options.repair) {
153
+ if (!canRepairAppAccess(authority))
154
+ throw new AppAccessManagementError("FORBIDDEN", "Cloud administration authority is required to repair app access.");
155
+ return;
156
+ }
157
+ if (!canWriteList(authority, app.id)) throw new AppAccessManagementError("FORBIDDEN", "This credential cannot manage app access.");
158
+ const entries = await listEntries(app.id, options.db);
159
+ const permission = await getEffectivePermission(
160
+ {
161
+ accessIds: entries
162
+ .filter((entry) => entry.principal.type !== "public" && entry.principal.type !== "authenticated")
163
+ .map((entry) => entry.id),
164
+ subject: authority.accessSubject,
165
+ },
166
+ options.db,
167
+ );
168
+ if (permission !== "admin" || (options.canManage && !(await options.canManage(authority, { db: options.db ?? sql })))) {
169
+ throw new AppAccessManagementError("FORBIDDEN", "A person who manages this application's access can change the list.");
170
+ }
171
+ };
172
+
173
+ /** Whether the app should show its access settings to this caller. Unexpected failures remain visible to the caller. */
174
+ export const canManageAppAccess = async (
175
+ authority: RequestAuthority,
176
+ app: ManagedApp,
177
+ canManage?: AppAccessDeclaration["canManage"],
178
+ ): Promise<boolean> => {
179
+ if (app.access?.kind !== "grants") return false;
180
+ try {
181
+ await requireAppAccessManager(authority, app, { canManage });
182
+ return true;
183
+ } catch (error) {
184
+ if (error instanceof AppAccessManagementError && error.status === 403) return false;
185
+ throw error;
186
+ }
187
+ };
188
+
189
+ export const readAppAccess = async (params: {
190
+ app: ManagedApp;
191
+ authority: RequestAuthority;
192
+ repair?: boolean;
193
+ canManage?: AppAccessDeclaration["canManage"];
194
+ }): Promise<AppAccessSnapshot> => {
195
+ await requireAppAccessManager(params.authority, params.app, params);
196
+ const [state] = await sql<
197
+ { configured_at: Date | null }[]
198
+ >`SELECT configured_at FROM auth.app_access_state WHERE app_id = ${params.app.id}`;
199
+ const entries = await listEntries(params.app.id);
200
+ const rule = requireConfigurable(params.app);
201
+ const inheritedSignedIn = !state?.configured_at && rule.until?.kind === "signedIn";
202
+ if (inheritedSignedIn && !entries.some((entry) => entry.principal.type === "authenticated"))
203
+ entries.unshift({
204
+ id: "inherited-authenticated",
205
+ principal: { type: "authenticated" },
206
+ permission: "read",
207
+ createdAt: "1970-01-01T00:00:00.000Z",
208
+ });
209
+ const matches = buildAccessPrincipalCondition({
210
+ subject: params.authority.accessSubject,
211
+ columns: {
212
+ userId: sql`a.user_id`,
213
+ groupId: sql`a.group_id`,
214
+ serviceAccountId: sql`a.service_account_id`,
215
+ authenticatedOnly: sql`a.authenticated_only`,
216
+ },
217
+ });
218
+ const own = await sql<{ id: string }[]>`SELECT a.id FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
219
+ WHERE j.app_id = ${params.app.id} AND ${matches}`;
220
+ const groupsAdmin = params.canManage && !params.repair ? (await getIdentityRuntimeConfig()).groupsAdmin : [];
221
+ const presentation = await entryPresentation(params.app.id);
222
+ return {
223
+ ownEntryIds: [...own.map((entry) => entry.id), ...(inheritedSignedIn ? ["inherited-authenticated"] : [])],
224
+ appId: params.app.id,
225
+ name: params.app.name,
226
+ entries: (await resolveDisplayNames(entries)).map((entry) => ({ ...entry, ...presentation.get(entry.id) })),
227
+ configured: !!state?.configured_at,
228
+ hasEffectiveManager: await hasEffectiveManager(
229
+ params.app,
230
+ sql,
231
+ groupsAdmin,
232
+ params.repair ? undefined : params.canManage,
233
+ params.authority,
234
+ ),
235
+ inheritedSignedIn,
236
+ canUse: await canUseApp(params.authority, params.app),
237
+ };
238
+ };
239
+
240
+ /**
241
+ * Restrictions are identity/domain predicates evaluated with canonical, currently usable
242
+ * session or API-key authority. Never manufacture roles, session kinds or broader scopes.
243
+ * OAuth/invocation-only eligibility cannot be established from durable credential metadata;
244
+ * no supported candidate within one maximum identity page means fail closed.
245
+ */
246
+ const hasRestrictedManager = async (
247
+ appId: string,
248
+ db: SQL,
249
+ canManage: NonNullable<AppAccessDeclaration["canManage"]>,
250
+ groupsAdmin: string[],
251
+ ): Promise<boolean> => {
252
+ // The maximum page size of the public PaginationQuerySchema bounds callback work,
253
+ // including multiple sessions/keys belonging to one principal, under the app row lock.
254
+ const budget = 100;
255
+ const sessions = await db<{ sid: string; user_id: string; auth_epoch: number }[]>`
256
+ SELECT sf.sid, sf.user_id, sf.auth_epoch FROM auth.session_families sf
257
+ JOIN auth.users u ON u.id = sf.user_id
258
+ JOIN auth.signing_keys sk ON sk.kid = sf.signing_kid AND sk.state <> 'revoked'
259
+ WHERE sf.revoked_at IS NULL AND sf.expires_at > now() AND sf.auth_epoch = u.auth_epoch
260
+ AND (u.account_expires IS NULL OR u.account_expires > now())
261
+ AND EXISTS (
262
+ SELECT 1 FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
263
+ WHERE j.app_id = ${appId} AND a.permission = 'admin'
264
+ AND (a.user_id = u.id OR (a.group_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(sql`u.id`)})))
265
+ )
266
+ ORDER BY sf.sid LIMIT ${budget}
267
+ `;
268
+ if (sessions.length) {
269
+ for (const candidate of sessions) {
270
+ // The same loader as HTTP authentication checks category, consent, key/family
271
+ // revocation and auth epoch, and strips admin for mobile app sessions.
272
+ const loaded = await loadJwtSession(
273
+ { userId: candidate.user_id, sid: candidate.sid, authEpoch: candidate.auth_epoch, groupsAdmin },
274
+ db,
275
+ );
276
+ if (!loaded) continue;
277
+ const authority: RequestAuthority = {
278
+ actor: { kind: "user", user: loaded.user, ...(loaded.pwaDeviceId ? { sessionKind: "app" } : {}) },
279
+ accessSubject: { type: "user", userId: loaded.user.id },
280
+ credentialKind: "session",
281
+ scopes: [],
282
+ };
283
+ if (canWriteList(authority, appId) && (await canManage(authority, { db }))) return true;
284
+ }
285
+ }
286
+ const remaining = budget - sessions.length;
287
+ if (!remaining) return false;
288
+ const credentials = await db<
289
+ {
290
+ id: string;
291
+ scopes: string[];
292
+ expires_at: Date | null;
293
+ service_account_id: string;
294
+ name: string;
295
+ kind: ServiceAccount["kind"];
296
+ status: ServiceAccount["status"];
297
+ delegated_user_id: string | null;
298
+ app_id: string | null;
299
+ resource_type: string | null;
300
+ resource_id: string | null;
301
+ created_by: string | null;
302
+ created_at: Date;
303
+ }[]
304
+ >`
305
+ SELECT c.id, c.scopes, c.expires_at, sa.id AS service_account_id,
306
+ sa.name, sa.kind, sa.status, sa.delegated_user_id, sa.app_id, sa.resource_type, sa.resource_id, sa.created_by, sa.created_at
307
+ FROM auth.service_account_credentials c JOIN auth.service_accounts sa ON sa.id = c.service_account_id
308
+ WHERE c.kind = 'api_token' AND c.status = 'active' AND (c.expires_at IS NULL OR c.expires_at > now())
309
+ AND sa.status = 'active' AND sa.kind IN ('standalone', 'agent')
310
+ AND EXISTS (
311
+ SELECT 1 FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
312
+ WHERE j.app_id = ${appId} AND a.permission = 'admin' AND a.service_account_id = sa.id
313
+ )
314
+ ORDER BY c.id LIMIT ${remaining}
315
+ `;
316
+ for (const candidate of credentials) {
317
+ // Only non-secret metadata is selected. Keep both scope fields and the credential
318
+ // identity/expiry exactly as the API-key middleware exposes them.
319
+ const authority: RequestAuthority = {
320
+ actor: {
321
+ kind: "service_account",
322
+ serviceAccount: {
323
+ id: candidate.service_account_id,
324
+ name: candidate.name,
325
+ kind: candidate.kind,
326
+ status: candidate.status,
327
+ delegatedUserId: candidate.delegated_user_id,
328
+ appId: candidate.app_id,
329
+ resourceType: candidate.resource_type,
330
+ resourceId: candidate.resource_id,
331
+ createdBy: candidate.created_by,
332
+ createdAt: candidate.created_at.toISOString(),
333
+ },
334
+ delegatedUser: null,
335
+ scopes: candidate.scopes,
336
+ credentialId: candidate.id,
337
+ credentialExpiresAt: candidate.expires_at?.toISOString() ?? null,
338
+ },
339
+ accessSubject: { type: "service_account", serviceAccountId: candidate.service_account_id },
340
+ credentialKind: "api_key",
341
+ scopes: candidate.scopes,
342
+ };
343
+ if (canWriteList(authority, appId) && (await canManage(authority, { db }))) return true;
344
+ }
345
+ return false;
346
+ };
347
+
348
+ /** An empty group cannot keep the list manageable. User-delegated and resource-bound keys cannot be its last manager. */
349
+ const hasEffectiveManager = async (
350
+ app: ManagedApp,
351
+ db: SQL,
352
+ groupsAdmin: string[],
353
+ canManage?: AppAccessDeclaration["canManage"],
354
+ current?: RequestAuthority,
355
+ ): Promise<boolean> => {
356
+ if (canManage) {
357
+ // The real request can also prove eligibility (including OAuth/invocations),
358
+ // but only if its management grant still exists after the proposed mutation.
359
+ let currentHasGrant = false;
360
+ if (current) {
361
+ try {
362
+ await requireAppAccessManager(current, app, { db });
363
+ currentHasGrant = true;
364
+ } catch (error) {
365
+ if (!(error instanceof AppAccessManagementError && error.code === "FORBIDDEN")) throw error;
366
+ }
367
+ if (currentHasGrant && (await canManage(current, { db }))) return true;
368
+ }
369
+ return hasRestrictedManager(app.id, db, canManage, groupsAdmin);
370
+ }
371
+ const [row] = await db<{ exists: boolean }[]>`
372
+ SELECT EXISTS (
373
+ SELECT 1 FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
374
+ WHERE j.app_id = ${app.id} AND a.permission = 'admin' AND (${managerEligibility(db)})
375
+ ) AS exists
376
+ `;
377
+ return row?.exists ?? false;
378
+ };
379
+
380
+ const samePrincipal = (left: Principal, right: Principal): boolean => {
381
+ switch (left.type) {
382
+ case "user":
383
+ return right.type === "user" && left.userId === right.userId;
384
+ case "group":
385
+ return right.type === "group" && left.groupId === right.groupId;
386
+ case "service_account":
387
+ return right.type === "service_account" && left.serviceAccountId === right.serviceAccountId;
388
+ default:
389
+ return left.type === right.type;
390
+ }
391
+ };
392
+
393
+ export const changeAppAccess = async (params: {
394
+ app: ManagedApp;
395
+ authority: RequestAuthority;
396
+ change: AppAccessChange;
397
+ repair?: boolean;
398
+ canManage?: AppAccessDeclaration["canManage"];
399
+ requestId?: string;
400
+ }): Promise<{ entry?: AccessEntry; canUse: boolean }> => {
401
+ const rule = requireConfigurable(params.app);
402
+ const { app, authority, change } = params;
403
+ // Runtime settings use the global pool. Resolve them before reserving a connection;
404
+ // all grant checks, callbacks and candidate domain reads below must share db under the app lock.
405
+ const groupsAdmin =
406
+ params.canManage && !params.repair && canWriteList(authority, app.id) ? (await getIdentityRuntimeConfig()).groupsAdmin : [];
407
+ const result = await sql.begin(async (db) => {
408
+ await db`INSERT INTO auth.app_access_state (app_id) VALUES (${app.id}) ON CONFLICT DO NOTHING`;
409
+ const [state] = await db<
410
+ { configured_at: Date | null }[]
411
+ >`SELECT configured_at FROM auth.app_access_state WHERE app_id = ${app.id} FOR UPDATE`;
412
+ await requireAppAccessManager(authority, app, { ...params, db });
413
+ const user = userFromActor(authority.actor);
414
+ const record = (action: string, before: AccessEntry | null, after: AccessEntry | null) =>
415
+ audit.record(
416
+ {
417
+ action,
418
+ outcome: "allowed",
419
+ actor: user ? { userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles } : null,
420
+ target: { type: "app", id: app.id, label: app.name },
421
+ requestId: params.requestId,
422
+ metadata: {
423
+ before,
424
+ after,
425
+ via: params.repair ? "platform-admin" : "app",
426
+ actorKind: authority.actor.kind,
427
+ serviceAccountId: authority.actor.kind === "service_account" ? authority.actor.serviceAccount.id : null,
428
+ },
429
+ },
430
+ db,
431
+ );
432
+ let entries = await listEntries(app.id, db);
433
+ // Preserve signed-in use when saving the first manager, as shown in the initial list.
434
+ if (!state?.configured_at && rule.until?.kind === "signedIn" && !entries.some((entry) => entry.principal.type === "authenticated")) {
435
+ const created = await createAccess({ principal: { type: "authenticated" }, permission: "read" }, db);
436
+ if (!created.ok) throw new AppAccessManagementError(created.error.code, created.error.message, 409);
437
+ await db`INSERT INTO auth.app_access (app_id, access_id) VALUES (${app.id}, ${created.data.id}::uuid)`;
438
+ entries = await listEntries(app.id, db);
439
+ const seed = entries.find((entry) => entry.id === created.data.id)!;
440
+ await record("app.access.granted", null, seed);
441
+ }
442
+ let changedId: string | undefined;
443
+ if (change.action === "grant") {
444
+ if (change.principal.type === "public" || (change.principal.type === "authenticated" && change.permission !== "read"))
445
+ throw new AppAccessManagementError(
446
+ "BAD_INPUT",
447
+ "Grant Use to signed-in identities; public app access and audience managers are not supported.",
448
+ 400,
449
+ );
450
+ if (entries.some((entry) => samePrincipal(entry.principal, change.principal)))
451
+ throw new AppAccessManagementError("CONFLICT", "This principal already has access.", 409);
452
+ const created = await createAccess(change, db);
453
+ if (!created.ok) throw new AppAccessManagementError(created.error.code, created.error.message, 400);
454
+ changedId = created.data.id;
455
+ await db`INSERT INTO auth.app_access (app_id, access_id) VALUES (${app.id}, ${changedId}::uuid)`;
456
+ } else {
457
+ const accessId =
458
+ change.accessId === "inherited-authenticated"
459
+ ? entries.find((entry) => entry.principal.type === "authenticated")?.id
460
+ : change.accessId;
461
+ const before = entries.find((entry) => entry.id === accessId);
462
+ if (!before) throw new AppAccessManagementError("NOT_FOUND", "Access entry not found.", 404);
463
+ if (change.action === "update") {
464
+ if (before.principal.type === "public") throw new AppAccessManagementError("BAD_INPUT", "Public app access is not supported.", 400);
465
+ if (before.principal.type === "authenticated" && change.permission !== "read")
466
+ throw new AppAccessManagementError("BAD_INPUT", "Signed-in identities may only receive Use.", 400);
467
+ await db`UPDATE auth.access SET permission = ${change.permission}::auth.permission_level WHERE id = ${before.id}::uuid`;
468
+ changedId = before.id;
469
+ } else await db`DELETE FROM auth.access WHERE id = ${before.id}::uuid`;
470
+ await record(
471
+ change.action === "update" ? "app.access.updated" : "app.access.revoked",
472
+ before,
473
+ change.action === "update" ? { ...before, permission: change.permission } : null,
474
+ );
475
+ }
476
+ if (!(await hasEffectiveManager(app, db, groupsAdmin, params.repair ? undefined : params.canManage, authority)))
477
+ throw new AppAccessManagementError("LAST_MANAGER", "At least one person must manage access. Add another manager first.", 409);
478
+ await db`UPDATE auth.app_access_state SET configured_at = COALESCE(configured_at, now()) WHERE app_id = ${app.id}`;
479
+ const after = changedId ? (await listEntries(app.id, db)).find((entry) => entry.id === changedId) : undefined;
480
+ if (change.action === "grant") await record("app.access.granted", null, after ?? null);
481
+ await publishAppAccessChanged(db, app.id);
482
+ return after;
483
+ });
484
+ wakeAppAccessChanged();
485
+ return { entry: result ? (await resolveDisplayNames([result]))[0] : undefined, canUse: await canUseApp(authority, app) };
486
+ };
487
+
488
+ /** Only manager names, for the intentionally limited direct-link denial page. */
489
+ export const appAccessManagers = async (appId: string): Promise<AccessEntry[]> => {
490
+ const presentation = await entryPresentation(appId);
491
+ const entries = (await listEntries(appId)).filter((entry) => presentation.get(entry.id)?.effectiveManager);
492
+ return resolveDisplayNames(entries);
493
+ };
494
+
495
+ /** Batched grant counts for the administration repair overview. */
496
+ export const appAccessGrantCounts = async (authority: RequestAuthority, apps: readonly ManagedApp[]): Promise<Map<string, number>> => {
497
+ if (!canRepairAppAccess(authority)) throw new AppAccessManagementError("FORBIDDEN", "Cloud administration authority is required.");
498
+ const ids = apps.filter((app) => app.access?.kind === "grants").map((app) => app.id);
499
+ if (!ids.length) return new Map();
500
+ const rows = await sql<{ app_id: string; count: number; authenticated: boolean; configured: boolean }[]>`
501
+ SELECT COALESCE(aa.app_id, s.app_id) AS app_id, count(aa.access_id)::integer AS count,
502
+ COALESCE(bool_or(a.authenticated_only), false) AS authenticated,
503
+ s.configured_at IS NOT NULL AS configured
504
+ FROM auth.app_access aa
505
+ FULL JOIN auth.app_access_state s ON s.app_id = aa.app_id
506
+ LEFT JOIN auth.access a ON a.id = aa.access_id
507
+ WHERE COALESCE(aa.app_id, s.app_id) IN ${sql(ids)} GROUP BY aa.app_id, s.app_id, s.configured_at
508
+ `;
509
+ return new Map(
510
+ apps
511
+ .filter((app) => app.access?.kind === "grants")
512
+ .map((app) => {
513
+ const row = rows.find((row) => row.app_id === app.id);
514
+ // The editor includes this inherited audience before the first save.
515
+ const inherited = app.access?.kind === "grants" && app.access.until?.kind === "signedIn" && !row?.configured && !row?.authenticated;
516
+ return [app.id, (row?.count ?? 0) + (inherited ? 1 : 0)];
517
+ }),
518
+ );
519
+ };
@@ -1,9 +1,10 @@
1
1
  import { type SQL, sql } from "bun";
2
2
  import type { Context } from "hono";
3
+ import { RESOURCE_ACCESS_TIMEOUT_MS, resourceAppAccess } from "../_internal/app-access-resources";
3
4
  import type { AppMeta } from "../contracts/app";
4
5
  import { AppAccessRuleSchema, type FixedAppAccessRule } from "../contracts/app-access";
5
6
  import { userFromActor } from "../server/actor";
6
- import type { AuthContext, RequestAuthority } from "../server/middleware/auth";
7
+ import { type AuthContext, auth, type RequestAuthority } from "../server/middleware/auth";
7
8
  import { buildAccessPrincipalCondition } from "../server/services/access";
8
9
  import { logger } from "./logging";
9
10
  import { toPgTextArray } from "./postgres";
@@ -17,7 +18,7 @@ export const appAccessAuthority = <E extends AuthContext>(c: Context<E>): Reques
17
18
  const accessSubject = c.get("accessSubject");
18
19
  const credentialKind = c.get("credentialKind");
19
20
  if (!actor || !accessSubject || !credentialKind) return null;
20
- return { actor, accessSubject, credentialKind, scopes: c.get("credentialScopes") ?? [] };
21
+ return auth.getAuthority(c);
21
22
  };
22
23
 
23
24
  const credentialAllows = (authority: RequestAuthority, appId: string): boolean => {
@@ -65,6 +66,7 @@ export const evaluateAppAccess = (authority: RequestAuthority | null, app: Acces
65
66
  const parsed = AppAccessRuleSchema.safeParse(app.access);
66
67
  if (!parsed.success) return false;
67
68
  const rule = parsed.data;
69
+ if (rule.kind === "resources") return false; // Asynchronous adapter decision only.
68
70
  if (rule.kind !== "grants") return fixedAllows(authority, rule);
69
71
  if (!state?.configured) return !!rule.until && fixedAllows(authority, rule.until);
70
72
  return state.permission === "read" || state.permission === "admin";
@@ -74,21 +76,34 @@ let lastFailureLog = -Infinity;
74
76
  const reportFailure = () => {
75
77
  if (Date.now() - lastFailureLog < 60_000) return;
76
78
  lastFailureLog = Date.now();
77
- logger("app-access").error("Application access could not be resolved; configurable applications are unavailable");
79
+ logger("app-access").error("Application access could not be resolved; affected applications are unavailable");
78
80
  };
79
81
 
80
82
  /**
81
- * One authoritative SQL read for all configurable apps. No grant cache: revocation applies on the next request.
83
+ * One authoritative SQL read for all configurable lists; resource rules query the owning adapters.
84
+ * No cross-request grant cache: revocation applies on the next request.
82
85
  * Pass the reserved transaction as `db` when checking access inside a guarded operation.
86
+ * A supplied `signal` replaces the default resource budget; the caller must bound it.
83
87
  */
84
88
  export const usableAppIds = async (
85
89
  authority: RequestAuthority | null,
86
90
  apps: readonly AccessApp[],
87
- options: { db?: SQL } = {},
91
+ options: { db?: SQL; signal?: AbortSignal } = {},
88
92
  ): Promise<Set<string>> => {
89
93
  const db = options.db ?? sql;
90
94
  const allowed = new Set(apps.filter((app) => app.access?.kind !== "grants" && evaluateAppAccess(authority, app)).map((app) => app.id));
91
95
  if (!authority) return allowed;
96
+ let resourceSignal = options.signal;
97
+ // Sequential: one existence probe in flight per decision, no resource enumeration.
98
+ for (const app of apps) {
99
+ if (app.access?.kind !== "resources" || !credentialAllows(authority, app.id)) continue;
100
+ resourceSignal ??= AbortSignal.timeout(RESOURCE_ACCESS_TIMEOUT_MS);
101
+ try {
102
+ if (await resourceAppAccess(authority, app, { ...options, signal: resourceSignal })) allowed.add(app.id);
103
+ } catch {
104
+ if (!options.signal?.aborted) reportFailure();
105
+ }
106
+ }
92
107
  const configurable = apps.filter((app) => app.access?.kind === "grants" && credentialAllows(authority, app.id));
93
108
  if (configurable.length === 0) return allowed;
94
109
  try {
@@ -119,5 +134,8 @@ export const usableAppIds = async (
119
134
  return allowed;
120
135
  };
121
136
 
122
- export const canUseApp = async (authority: RequestAuthority | null, app: AccessApp, options: { db?: SQL } = {}): Promise<boolean> =>
123
- (await usableAppIds(authority, [app], options)).has(app.id);
137
+ export const canUseApp = async (
138
+ authority: RequestAuthority | null,
139
+ app: AccessApp,
140
+ options: { db?: SQL; signal?: AbortSignal } = {},
141
+ ): Promise<boolean> => (await usableAppIds(authority, [app], options)).has(app.id);
@@ -55,11 +55,11 @@ const { create: secret, hash, matches, code: comparison } = pairingSecret;
55
55
  const iso = (value: Date | string) => new Date(value).toISOString();
56
56
  const future = (seconds: number) => new Date(Date.now() + seconds * 1000);
57
57
 
58
- export type AppApprovalConfig = { issuer: string; appOrigin: string; enabled: boolean; adminPairing: boolean };
58
+ export type AppApprovalConfig = { issuer: string; appOrigin: string; enabled: boolean; adminPairing: boolean; label?: string };
59
59
  export const readAppApprovalConfig = async (db: SQL = sql, requireAppOrigin = true): Promise<AppApprovalConfig> => {
60
60
  const rows = await db<
61
61
  { key: string; value: string }[]
62
- >`SELECT key, value FROM settings.entries WHERE key IN ('app.url','user.app_approval.enabled','user.app_approval.origin','user.app_approval.admin_pairing')`;
62
+ >`SELECT key, value FROM settings.entries WHERE key IN ('app.url','app.name','user.app_approval.label','user.app_approval.enabled','user.app_approval.origin','user.app_approval.admin_pairing')`;
63
63
  const values = new Map(await Promise.all(rows.map(async (row) => [row.key, await decryptValue(row.value)] as const)));
64
64
  const enabled = values.get("user.app_approval.enabled") ?? false;
65
65
  const adminPairing = values.get("user.app_approval.admin_pairing") ?? false;
@@ -93,6 +93,10 @@ export const readAppApprovalConfig = async (db: SQL = sql, requireAppOrigin = tr
93
93
  } catch {
94
94
  return reject("UNAVAILABLE", 503);
95
95
  }
96
+ const label = [values.get("user.app_approval.label"), values.get("app.name"), CORE_SETTINGS["app.name"].default]
97
+ .find((value): value is string => typeof value === "string" && !!value.trim())!
98
+ .trim()
99
+ .slice(0, 80);
96
100
  if (!enabled) return { issuer, appOrigin: "", enabled: false, adminPairing };
97
101
  let appOrigin = "";
98
102
  try {
@@ -102,7 +106,7 @@ export const readAppApprovalConfig = async (db: SQL = sql, requireAppOrigin = tr
102
106
  // Pairing, discovery and sign-in still require a valid trusted origin.
103
107
  if (requireAppOrigin) throw error;
104
108
  }
105
- return { issuer, appOrigin, enabled, adminPairing };
109
+ return { issuer, appOrigin, enabled, adminPairing, label };
106
110
  };
107
111
 
108
112
  type AccountRow = {
@@ -532,7 +536,14 @@ export const createAppApprovalService = (
532
536
  await tx`INSERT INTO auth.app_pairings(id, issuer, user_id, initiated_by, initiator_sid, assisted, auth_epoch, secret_hash, expires_at)
533
537
  VALUES (${id}::uuid,${cfg.issuer},${targetId}::uuid,${actor.userId}::uuid,${actor.sid}::uuid,${targetId !== actor.userId},${target.auth_epoch},${hash(token)},${expiresAt})`;
534
538
  await record(tx, "pairing.start", actor.userId, id, { targetUserId: targetId, assisted: targetId !== actor.userId });
535
- return { protocol: APP_APPROVAL_PROTOCOL, issuer: cfg.issuer, pairingId: id, secret: token, expiresAt: iso(expiresAt) };
539
+ return {
540
+ protocol: APP_APPROVAL_PROTOCOL,
541
+ issuer: cfg.issuer,
542
+ pairingId: id,
543
+ secret: token,
544
+ expiresAt: iso(expiresAt),
545
+ ...(cfg.label ? { label: cfg.label } : {}),
546
+ };
536
547
  });
537
548
  },
538
549
  claimPairing: async (claim: { pairingId: string; secret: string; publicKey: AppDevicePublicKey; name: string; signature: string }) => {