@k2b/cloud 0.43.0 → 0.45.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 (308) hide show
  1. package/package.json +11 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/GroupCoverage.tsx +20 -5
  21. package/src/access/PermissionEditor.tsx +20 -5
  22. package/src/access/PrincipalAvatar.tsx +30 -21
  23. package/src/access/PrincipalPicker.tsx +14 -4
  24. package/src/access/ResourceApiKeys.tsx +3 -4
  25. package/src/access/app-access-messages.ts +6 -0
  26. package/src/access/messages.ts +4 -4
  27. package/src/account/EntitySearch.tsx +12 -0
  28. package/src/account/ProfilePictureDialog.tsx +699 -0
  29. package/src/account/profile-picture-messages.ts +269 -0
  30. package/src/account/ui.ts +7 -0
  31. package/src/ai/admin.ts +3 -0
  32. package/src/ai/app-frame.ts +12 -0
  33. package/src/ai/application-tasks.ts +101 -0
  34. package/src/ai/assistant-models.ts +126 -64
  35. package/src/ai/capabilities.ts +19 -4
  36. package/src/ai/chart-block.ts +63 -30
  37. package/src/ai/chat/chart-block.tsx +12 -3
  38. package/src/ai/chat/composer-adapter.ts +8 -7
  39. package/src/ai/chat/message-actions.tsx +1 -1
  40. package/src/ai/chat/message-utils.ts +2 -2
  41. package/src/ai/chat/presentation.tsx +22 -1
  42. package/src/ai/chat-quotas.ts +5 -4
  43. package/src/ai/chat-tasks.ts +2 -2
  44. package/src/ai/client/controller.ts +11 -10
  45. package/src/ai/client/projection.ts +3 -3
  46. package/src/ai/compaction.ts +3 -3
  47. package/src/ai/credentials.ts +13 -114
  48. package/src/ai/default-tool-contracts.ts +4 -3
  49. package/src/ai/dictation-runtime.ts +10 -6
  50. package/src/ai/enrich.ts +2 -2
  51. package/src/ai/executor.ts +40 -25
  52. package/src/ai/http.ts +17 -29
  53. package/src/ai/index.ts +12 -5
  54. package/src/ai/inference-calls.ts +5 -4
  55. package/src/ai/maintenance.ts +1 -1
  56. package/src/ai/memory-learning-runs.ts +5 -5
  57. package/src/ai/memory-learning.ts +3 -3
  58. package/src/ai/message-queue.ts +1 -2
  59. package/src/ai/migrate.ts +75 -42
  60. package/src/ai/model-access-migrate.ts +3 -17
  61. package/src/ai/model-access.ts +44 -85
  62. package/src/ai/model-card.tsx +134 -0
  63. package/src/ai/model-catalog.ts +359 -0
  64. package/src/ai/model-chooser-messages.ts +88 -0
  65. package/src/ai/model-chooser.tsx +577 -0
  66. package/src/ai/model-delete.ts +234 -0
  67. package/src/ai/model-fixture.ts +39 -0
  68. package/src/ai/model-icon.tsx +36 -0
  69. package/src/ai/model-overview.ts +53 -0
  70. package/src/ai/model-usage.ts +45 -0
  71. package/src/ai/openai-api-messages.ts +0 -4
  72. package/src/ai/openai-api.ts +26 -9
  73. package/src/ai/personal-agent.ts +1 -1
  74. package/src/ai/prefs.ts +12 -10
  75. package/src/ai/project-tool.ts +4 -4
  76. package/src/ai/projects-authorization.ts +53 -0
  77. package/src/ai/projects-routes.ts +58 -47
  78. package/src/ai/projects.ts +110 -88
  79. package/src/ai/protocol.ts +2 -2
  80. package/src/ai/provider-connections.ts +161 -0
  81. package/src/ai/provider-errors.ts +55 -6
  82. package/src/ai/provider-fetch.ts +4 -0
  83. package/src/ai/provider.ts +1 -1
  84. package/src/ai/quota-limits.ts +36 -0
  85. package/src/ai/quota-provider.ts +4 -2
  86. package/src/ai/quota-report.ts +7 -4
  87. package/src/ai/quotas.ts +19 -8
  88. package/src/ai/recent-resources.ts +9 -3
  89. package/src/ai/request-headers.ts +14 -87
  90. package/src/ai/resource-refs.ts +6 -2
  91. package/src/ai/routes.ts +47 -48
  92. package/src/ai/runtime.ts +50 -22
  93. package/src/ai/settings.ts +93 -580
  94. package/src/ai/skill-tool.ts +4 -4
  95. package/src/ai/skills-routes.ts +27 -26
  96. package/src/ai/skills.ts +120 -81
  97. package/src/ai/store.ts +45 -23
  98. package/src/ai/stream.ts +1 -1
  99. package/src/ai/structured-runs.ts +2 -2
  100. package/src/ai/structured.ts +21 -44
  101. package/src/ai/task-contracts.ts +25 -5
  102. package/src/ai/task-execution.ts +15 -6
  103. package/src/ai/task-model.ts +9 -0
  104. package/src/ai/transcription.ts +27 -22
  105. package/src/ai/types.ts +50 -62
  106. package/src/ai/ui.tsx +13 -0
  107. package/src/ai/usage.ts +8 -8
  108. package/src/ai/validate.ts +8 -7
  109. package/src/ai/view-block.ts +2 -2
  110. package/src/api/accounts-entities.ts +22 -3
  111. package/src/api/admin-ai-quotas.ts +34 -56
  112. package/src/api/admin-ai-skills.ts +4 -1
  113. package/src/api/admin-ai-usage.ts +1 -1
  114. package/src/api/admin-ai.ts +173 -0
  115. package/src/api/admin-core-settings.ts +68 -244
  116. package/src/api/app-approval.ts +3 -1
  117. package/src/api/app-event-delivery.ts +51 -0
  118. package/src/api/app-events.ts +123 -0
  119. package/src/api/app-resource-access.ts +48 -0
  120. package/src/api/auth/account-mismatch.ts +24 -0
  121. package/src/api/auth.ts +35 -5
  122. package/src/api/capabilities.ts +28 -4
  123. package/src/api/index.ts +7 -1
  124. package/src/api/mcp.ts +175 -2
  125. package/src/api/me.ts +6 -4
  126. package/src/api/pwa-phone.ts +75 -26
  127. package/src/api/pwa.ts +34 -8
  128. package/src/api/resource-links.ts +83 -6
  129. package/src/api/resource-resolve.ts +50 -0
  130. package/src/api/search/schemas.ts +3 -1
  131. package/src/api/search.ts +9 -7
  132. package/src/app-pwa/contracts.ts +4 -0
  133. package/src/app-pwa/core-query-error.ts +20 -0
  134. package/src/app-pwa/index.ts +1 -0
  135. package/src/app-pwa/service.ts +91 -0
  136. package/src/app-pwa/transport.ts +22 -0
  137. package/src/approvals/messages.ts +2 -2
  138. package/src/browser/cloud-id.ts +86 -0
  139. package/src/browser/resource-clipboard.ts +4 -0
  140. package/src/browser/resource-links.ts +52 -0
  141. package/src/browser/session.ts +39 -0
  142. package/src/browser/shared-text-history-validation.ts +151 -0
  143. package/src/browser/shared-text-history.ts +464 -0
  144. package/src/browser/shared-text-offline-authorized.ts +29 -0
  145. package/src/browser/shared-text-offline.ts +351 -0
  146. package/src/browser/shared-text-provider.ts +714 -0
  147. package/src/browser/shared-text-table-contract.ts +150 -0
  148. package/src/browser/shared-text.ts +19 -0
  149. package/src/capabilities/server-invoke.ts +2 -11
  150. package/src/capabilities/testing.ts +5 -2
  151. package/src/cli/access.ts +6 -5
  152. package/src/cli/admin/ai-models.ts +199 -0
  153. package/src/cli/admin/ai-quotas.ts +6 -102
  154. package/src/cli/admin/ai-usage.ts +4 -4
  155. package/src/cli/admin/app-credentials.ts +5 -3
  156. package/src/cli/admin/index.ts +5 -2
  157. package/src/cli/apps.ts +89 -26
  158. package/src/cli/capabilities.ts +22 -1
  159. package/src/cli/events.ts +320 -0
  160. package/src/config/env.ts +40 -0
  161. package/src/contracts/app-access.ts +26 -2
  162. package/src/contracts/app-details.ts +89 -0
  163. package/src/contracts/app-events.ts +51 -0
  164. package/src/contracts/app.ts +21 -5
  165. package/src/contracts/capabilities.ts +143 -3
  166. package/src/contracts/index.ts +2 -0
  167. package/src/contracts/notification-types.ts +36 -9
  168. package/src/contracts/pwa.ts +17 -1
  169. package/src/contracts/registry.ts +6 -0
  170. package/src/contracts/resource-clipboard.ts +35 -0
  171. package/src/contracts/resource-pages.ts +140 -0
  172. package/src/contracts/resource-resolution.ts +13 -0
  173. package/src/contracts/shared.ts +6 -1
  174. package/src/contracts/user-notifications.ts +2 -0
  175. package/src/events/app-event-delivery.ts +100 -0
  176. package/src/events/app-events.testing.ts +65 -0
  177. package/src/events/app-events.ts +134 -0
  178. package/src/events/core-user-created.ts +24 -0
  179. package/src/events/index.ts +9 -0
  180. package/src/events/testing.ts +2 -0
  181. package/src/index.ts +1 -1
  182. package/src/server/api/respond.ts +17 -4
  183. package/src/server/index.ts +2 -0
  184. package/src/server/middleware/app-access.ts +1 -1
  185. package/src/server/middleware/auth.ts +21 -7
  186. package/src/server/middleware/index.ts +1 -0
  187. package/src/server/middleware/invocation.ts +2 -0
  188. package/src/server/public-storage.ts +106 -0
  189. package/src/server/services/access.ts +31 -10
  190. package/src/server/services/index.ts +1 -0
  191. package/src/services/accounts/app.ts +36 -13
  192. package/src/services/accounts/entities.ts +43 -19
  193. package/src/services/accounts/group-sql.ts +23 -0
  194. package/src/services/accounts/groups.ts +19 -14
  195. package/src/services/accounts/identities.ts +70 -11
  196. package/src/services/accounts/identity-reconciliation.ts +3 -1
  197. package/src/services/accounts/local-groups.ts +69 -4
  198. package/src/services/accounts/users.ts +45 -14
  199. package/src/services/app-access.ts +25 -7
  200. package/src/services/app-details.ts +224 -0
  201. package/src/services/app-discovery.ts +2 -1
  202. package/src/services/app-event-shapes.ts +139 -0
  203. package/src/services/app-events/accept.ts +116 -0
  204. package/src/services/app-events/admin.ts +250 -0
  205. package/src/services/app-events/declarations.ts +187 -0
  206. package/src/services/app-events/deliver.ts +218 -0
  207. package/src/services/app-events.ts +92 -0
  208. package/src/services/config-conflict.ts +11 -0
  209. package/src/services/core-user-events.ts +1 -0
  210. package/src/services/freeipa-config.ts +9 -0
  211. package/src/services/geo/ledger.ts +28 -10
  212. package/src/services/geo/service.ts +43 -24
  213. package/src/services/identity/core-origin.ts +12 -0
  214. package/src/services/identity/invocation-actor.ts +20 -2
  215. package/src/services/identity/invocation-authority.ts +4 -0
  216. package/src/services/identity/invocation-token.ts +238 -56
  217. package/src/services/identity/issue-invocation.ts +6 -1
  218. package/src/services/image-metadata.ts +65 -1
  219. package/src/services/index.ts +13 -1
  220. package/src/services/ipa/sync.ts +6 -2
  221. package/src/services/ipa/users.ts +4 -1
  222. package/src/services/mandates/index.ts +2 -2
  223. package/src/services/mandates/policy.ts +20 -12
  224. package/src/services/notifications/browser.ts +4 -0
  225. package/src/services/notifications/catalog.ts +5 -2
  226. package/src/services/notifications/platform.ts +7 -2
  227. package/src/services/notifications/user.ts +18 -7
  228. package/src/services/oauth-tokens.ts +28 -2
  229. package/src/services/object-store.ts +217 -0
  230. package/src/services/providers/local/users.ts +5 -0
  231. package/src/services/pwa-devices.ts +147 -37
  232. package/src/services/session/index.ts +26 -3
  233. package/src/services/settings/core-settings.ts +71 -43
  234. package/src/services/settings/index.ts +8 -0
  235. package/src/services/settings/versions.ts +26 -0
  236. package/src/shared/ai-models.ts +274 -0
  237. package/src/shared/ai-quotas.ts +2 -2
  238. package/src/shared/ai-usage.ts +2 -2
  239. package/src/shared/email-html.ts +1 -0
  240. package/src/shared/index.ts +2 -0
  241. package/src/shared/markdown/extensions/katex.ts +8 -1
  242. package/src/shared/markdown/extensions/tables.ts +14 -1
  243. package/src/shared/markdown/index.ts +22 -11
  244. package/src/shared-text/anchors.ts +29 -0
  245. package/src/shared-text/awareness.ts +87 -0
  246. package/src/shared-text/changes.ts +27 -0
  247. package/src/shared-text/documents.ts +433 -65
  248. package/src/shared-text/events.ts +12 -2
  249. package/src/shared-text/index.ts +16 -1
  250. package/src/shared-text/log.ts +24 -5
  251. package/src/shared-text/snapshots.ts +69 -23
  252. package/src/shared-text/structural-history.ts +377 -0
  253. package/src/shared-text/table-actions.ts +432 -0
  254. package/src/shared-text/text-diff.ts +75 -0
  255. package/src/shared-text/types.ts +24 -5
  256. package/src/ssr/AdminArea.tsx +131 -0
  257. package/src/ssr/AdminLayout.tsx +9 -3
  258. package/src/ssr/AdminSidebar.tsx +7 -7
  259. package/src/ssr/AppAccessDenied.tsx +2 -0
  260. package/src/ssr/AppBadge.tsx +14 -0
  261. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  262. package/src/ssr/CloudShell.island.tsx +44 -7
  263. package/src/ssr/Layout.tsx +9 -7
  264. package/src/ssr/MobileProfileActions.tsx +2 -2
  265. package/src/ssr/ProfilePreferences.tsx +2 -1
  266. package/src/ssr/PwaLayout.tsx +16 -1
  267. package/src/ssr/PwaPartCount.island.tsx +23 -0
  268. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  269. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  270. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  271. package/src/ssr/admin-active-link.ts +16 -0
  272. package/src/ssr/admin-navigation.ts +26 -31
  273. package/src/ssr/app-badges.ts +31 -7
  274. package/src/ssr/app-launchpad.tsx +6 -1
  275. package/src/ssr/index.ts +8 -0
  276. package/src/ssr/page-trail-runtime.ts +3 -1
  277. package/src/ssr/platform-messages.ts +11 -1
  278. package/src/ssr/profile-actions.ts +3 -6
  279. package/src/ssr/pwa-messages.ts +2 -2
  280. package/src/ssr/rail-navigation.ts +5 -1
  281. package/src/ssr/settings-draft.ts +153 -0
  282. package/src/ssr/shell-data.tsx +3 -1
  283. package/src/styles/global.css +1 -0
  284. package/src/styles/profile-picture.css +270 -0
  285. package/src/styles/utilities-navigation.css +6 -0
  286. package/src/workflows/ai/policy.ts +49 -0
  287. package/src/workflows/ai/runtime.ts +131 -21
  288. package/src/workflows/ai/store.ts +283 -35
  289. package/src/workflows/ai/types.ts +8 -2
  290. package/src/workflows/ai-actions.ts +148 -120
  291. package/src/workflows/authoring.ts +31 -0
  292. package/src/workflows/contracts.ts +1 -0
  293. package/src/workflows/definition.ts +2 -0
  294. package/src/workflows/editor.ts +15 -0
  295. package/src/workflows/index.ts +2 -1
  296. package/src/workflows/language/compiler.ts +34 -3
  297. package/src/workflows/language/expressions.ts +3 -2
  298. package/src/workflows/runtime/executor.ts +134 -34
  299. package/src/workflows/runtime/index.ts +2 -0
  300. package/src/workflows/runtime/ports.ts +5 -1
  301. package/src/workflows/store/actions.ts +1 -1
  302. package/src/workflows/store/definitions.ts +5 -0
  303. package/src/workflows/store/runs.ts +5 -3
  304. package/src/workflows/store/worker.ts +7 -2
  305. package/src/ai/model-pricing.ts +0 -43
  306. package/src/ai/model-request-settings.ts +0 -98
  307. package/src/ai/settings-messages.ts +0 -129
  308. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -31,8 +31,6 @@ const listCanonical = async (params: {
31
31
  scope?: GroupListScope;
32
32
  search?: string;
33
33
  provider?: UserProvider;
34
- /** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
35
- includePersonal?: boolean;
36
34
  page?: number;
37
35
  perPage?: number;
38
36
  }): Promise<{
@@ -48,7 +46,6 @@ const listCanonical = async (params: {
48
46
  const scope = params.scope ?? (params.userId ? "member" : "all");
49
47
  const scopeUserId = params.userId ?? "00000000-0000-0000-0000-000000000000";
50
48
  const idsCondition = ids.length === 0 ? sql`TRUE` : sql`g.id = ANY(${toPgUuidArray(ids)}::uuid[])`;
51
- const includePersonal = params.includePersonal === true || params.ids !== undefined;
52
49
 
53
50
  if (params.ids && params.ids.length === 0) {
54
51
  return {
@@ -69,7 +66,7 @@ const listCanonical = async (params: {
69
66
  ${personalOwnerJoin()}
70
67
  WHERE (${params.provider ?? null}::text IS NULL OR g.provider = ${params.provider ?? null})
71
68
  AND ${idsCondition}
72
- AND (${includePersonal} = true OR personal_owner.personal_owner_id IS NULL)
69
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
73
70
  AND (
74
71
  ${scope === "all"} = true
75
72
  OR ${params.userId ?? null}::uuid IS NULL
@@ -86,7 +83,9 @@ const listCanonical = async (params: {
86
83
  OFFSET ${offset}
87
84
  `;
88
85
 
89
- const total = rows.length > 0 ? Number((rows[0] as Record<string, unknown>).total) : 0;
86
+ // A page past the end has no rows to carry the window count; the total still counts every match.
87
+ let total = rows.length > 0 ? Number((rows[0] as Record<string, unknown>).total) : 0;
88
+ if (!rows.length && page > 1) total = (await listCanonical({ ...params, page: 1, perPage: 1 })).total;
90
89
  return {
91
90
  groups: rows.map(buildBaseGroup),
92
91
  total,
@@ -105,8 +104,6 @@ export const list = async (params: {
105
104
  scope?: GroupListScope;
106
105
  search?: string;
107
106
  provider?: UserProvider;
108
- /** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
109
- includePersonal?: boolean;
110
107
  page?: number;
111
108
  perPage?: number;
112
109
  }) => {
@@ -120,13 +117,13 @@ export const get = async (params: { id: string }): Promise<BaseGroup | null> =>
120
117
  export const getMembers = async (params: {
121
118
  id: string;
122
119
  provider?: UserProvider;
123
- type?: "user" | "group";
120
+ type?: GroupMember["type"];
124
121
  recursive?: boolean;
125
122
  }): Promise<GroupMember[]> => {
126
123
  const provider = params.provider ?? (await getGroup(params.id))?.provider;
127
124
  if (provider === "local") return localGroups.getMembers(params);
128
- if (!provider) return [];
129
- return providers.ipa.groups.getMembers(params);
125
+ if (!provider || params.type === "service_account") return [];
126
+ return providers.ipa.groups.getMembers({ ...params, type: params.type });
130
127
  };
131
128
 
132
129
  export const getManagers = async (params: {
@@ -223,9 +220,13 @@ export const addMember = async (params: {
223
220
  provider?: UserProvider;
224
221
  user?: string;
225
222
  group?: string;
223
+ serviceAccount?: string;
226
224
  }): Promise<MutationResult<void>> => {
227
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
228
- if (provider === "local") return localGroups.addMember({ id: params.id, user: params.user, group: params.group });
225
+ const provider = (await getGroup(params.id))?.provider;
226
+ if (provider === "local")
227
+ return localGroups.addMember({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
228
+ if (params.serviceAccount)
229
+ return { ok: false, error: "Service accounts can only join local Cloud groups, not directory groups", status: 400 };
229
230
  const serviceSession = await getServiceIpaSession();
230
231
  if (!serviceSession.ok) return serviceSession;
231
232
  return providers.ipa.groups.addMember({
@@ -241,9 +242,13 @@ export const removeMember = async (params: {
241
242
  provider?: UserProvider;
242
243
  user?: string;
243
244
  group?: string;
245
+ serviceAccount?: string;
244
246
  }): Promise<MutationResult<void>> => {
245
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
246
- if (provider === "local") return localGroups.removeMember({ id: params.id, user: params.user, group: params.group });
247
+ const provider = (await getGroup(params.id))?.provider;
248
+ if (provider === "local")
249
+ return localGroups.removeMember({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
250
+ if (params.serviceAccount)
251
+ return { ok: false, error: "Service accounts can only join local Cloud groups, not directory groups", status: 400 };
247
252
  const serviceSession = await getServiceIpaSession();
248
253
  if (!serviceSession.ok) return serviceSession;
249
254
  return providers.ipa.groups.removeMember({
@@ -189,40 +189,99 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
189
189
  eligible: current.profile === "user",
190
190
  };
191
191
  }
192
+ const [ownership] = await db<{ personal: boolean }[]>`SELECT EXISTS (
193
+ SELECT 1 FROM auth.personal_groups p
194
+ WHERE (${input.identityId ?? null}::uuid IS NOT NULL AND p.group_id = ${input.identityId ?? null}::uuid)
195
+ OR (${input.identityId ?? null}::uuid IS NULL AND p.group_name = ${input.name})
196
+ ) AS personal`;
197
+ const personal = ownership?.personal === true;
192
198
  const rows = await groupRows(sql`(g.provider = 'local' AND g.name = ${input.name}) OR g.id = ${input.identityId ?? null}::uuid`);
193
199
  const expected = rows.find((row) => row.id === input.identityId);
194
200
  if (expected && (expected.provider !== "local" || expected.name !== input.name))
195
201
  return { state: "unknown", reason: "identity_conflict" };
196
202
  const matches = rows.filter((row) => row.provider === "local" && row.name === input.name);
197
- if (!matches.length) return { state: "absent" };
203
+ if (!matches.length) return personal ? { state: "absent", personal: true } : { state: "absent" };
198
204
  if (matches.length !== 1) return { state: "unknown", reason: "invalid_response" };
199
205
  const current = matches[0]!;
200
206
  return {
201
207
  state: "present",
202
208
  identity: { id: current.id, name: current.name, uidNumber: null, gidNumber: current.gidNumber },
203
209
  eligible: current.gidNumber !== null,
210
+ ...(personal ? { personal: true } : {}),
204
211
  };
205
212
  };
206
213
  const validateLookup = (input: { kind: "users" | "groups"; name: string }) => {
207
214
  if ((input.kind !== "users" && input.kind !== "groups") || typeof input.name !== "string" || !input.name || input.name.includes("\0"))
208
215
  throw new AccountIdentityError("invalid_identity_lookup", 400);
209
216
  };
217
+ const reconcileIdentity = async (
218
+ input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
219
+ state: AccountIdentityAvailability,
220
+ ): Promise<AccountIdentityReconciliation> => {
221
+ validateLookup(input);
222
+ if (input.provider !== "local" && input.provider !== "ipa") throw new AccountIdentityError("invalid_identity_lookup", 400);
223
+ if (input.identityId !== undefined) cursor(input.identityId);
224
+ if (input.signal?.aborted) return { state: "unknown", reason: "provider_unavailable" };
225
+ if (input.provider === "ipa") {
226
+ if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
227
+ return upstreamIdentity(input);
228
+ }
229
+ const result = await readLocalIdentity(input);
230
+ return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
231
+ };
210
232
  return {
233
+ /** Trusted application provisioning only; no profile, grants, credentials, or administrator impersonation. */
234
+ async provisioningUsers(input: { provider?: UserProvider; id?: string; after?: string }) {
235
+ if (input.provider !== undefined && input.provider !== "local" && input.provider !== "ipa")
236
+ throw new AccountIdentityError("invalid_provider", 400);
237
+ const id = cursor(input.id);
238
+ const after = cursor(input.after);
239
+ const state = await availability();
240
+ const [localAllowed, ipaAllowed] = await Promise.all([
241
+ isAccountCategoryAllowed({ provider: "local", profile: "user" }, db),
242
+ isAccountCategoryAllowed({ provider: "ipa", profile: "user" }, db),
243
+ ]);
244
+ const rows = await db<
245
+ {
246
+ id: string;
247
+ provider: UserProvider;
248
+ uid: string;
249
+ managed_by: UserProvider | null;
250
+ uid_number: number | null;
251
+ primary_gid_number: number | null;
252
+ }[]
253
+ >`SELECT u.id,u.provider,u.uid,p.managed_by,p.uid_number,p.primary_gid_number
254
+ FROM auth.users u LEFT JOIN auth.user_posix p ON p.user_id=u.id
255
+ WHERE u.provider IN ('local','ipa') AND (${input.provider ?? null}::text IS NULL OR u.provider=${input.provider ?? null})
256
+ AND u.profile='user'
257
+ AND (u.account_expires IS NULL OR u.account_expires>now())
258
+ AND (${id}::uuid IS NULL OR u.id=${id}::uuid)
259
+ AND (${after}::uuid IS NULL OR u.id>${after}::uuid)
260
+ ORDER BY u.id LIMIT ${PAGE_SIZE + 1}`;
261
+ const source = page(rows);
262
+ const items = source.items
263
+ .filter((row) => (row.provider === "local" ? localAllowed : ipaAllowed && state.freeipaEnabled))
264
+ .map((row) => ({
265
+ id: row.id,
266
+ provider: row.provider,
267
+ username: row.uid,
268
+ posix:
269
+ row.managed_by === row.provider && positiveId(row.uid_number) && positiveId(row.primary_gid_number)
270
+ ? { uidNumber: row.uid_number, primaryGidNumber: row.primary_gid_number }
271
+ : null,
272
+ }));
273
+ return { items, nextCursor: source.nextCursor, availability: state };
274
+ },
211
275
  async reconcile(
212
276
  actor: RequestActor,
213
277
  input: { kind: "users" | "groups"; provider: UserProvider; name: string; identityId?: string; signal?: AbortSignal },
214
278
  ): Promise<AccountIdentityReconciliation> {
215
279
  const { state } = await requireAdmin(actor);
216
- validateLookup(input);
217
- if (input.provider !== "local" && input.provider !== "ipa") throw new AccountIdentityError("invalid_identity_lookup", 400);
218
- if (input.identityId !== undefined) cursor(input.identityId);
219
- if (input.signal?.aborted) return { state: "unknown", reason: "provider_unavailable" };
220
- if (input.provider === "ipa") {
221
- if (!state.freeipaEnabled) return { state: "unknown", reason: "provider_disabled" };
222
- return upstreamIdentity(input);
223
- }
224
- const result = await readLocalIdentity(input);
225
- return input.signal?.aborted ? { state: "unknown", reason: "provider_unavailable" } : result;
280
+ return reconcileIdentity(input, state);
281
+ },
282
+ /** Trusted provisioning services use the same live identity check as administrator provisioning. */
283
+ async provisioningIdentity(input: { kind: "users"; provider: UserProvider; name: string; identityId: string; signal?: AbortSignal }) {
284
+ return reconcileIdentity(input, await availability());
226
285
  },
227
286
  /** Trusted application jobs only; this read does not authorize an HTTP caller or a filesystem mutation. */
228
287
  async localLifecycle(input: { kind: "users" | "groups"; identityId: string; name: string }) {
@@ -9,8 +9,10 @@ export type AccountIdentityReconciliation =
9
9
  state: "present";
10
10
  identity: { id: string | null; name: string; uidNumber: number | null; gidNumber: number | null };
11
11
  eligible: boolean;
12
+ /** Proven Cloud personal-group ownership, including retained directory bindings. */
13
+ personal?: boolean;
12
14
  }
13
- | { state: "absent" }
15
+ | { state: "absent"; personal?: boolean }
14
16
  | { state: "unknown"; reason: "provider_disabled" | "provider_unavailable" | "invalid_response" | "identity_conflict" };
15
17
  export type IdentityLookup = { kind: "users" | "groups"; name: string; signal?: AbortSignal };
16
18
 
@@ -2,6 +2,7 @@ import { sql } from "bun";
2
2
  import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
3
3
  import { escapeLikePattern, isUniqueViolation } from "../postgres";
4
4
  import { buildBaseGroup, personalOwnerJoin } from "./base-group";
5
+ import { recursiveServiceAccountGroupIdsSubquery } from "./group-sql";
5
6
 
6
7
  type DbRow = Record<string, unknown>;
7
8
 
@@ -122,6 +123,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
122
123
  SELECT COUNT(*)::int AS count
123
124
  FROM auth.groups g
124
125
  WHERE g.provider = 'local'
126
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
125
127
  AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
126
128
  `;
127
129
  const total = Number(countRow?.count ?? 0);
@@ -130,6 +132,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
130
132
  FROM auth.groups g
131
133
  ${personalOwnerJoin()}
132
134
  WHERE g.provider = 'local'
135
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
133
136
  AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
134
137
  ORDER BY g.name
135
138
  LIMIT ${perPage} OFFSET ${offset}
@@ -177,7 +180,7 @@ export const remove = async (params: { id: string }): Promise<MutationResult<voi
177
180
  return { ok: true, data: undefined };
178
181
  };
179
182
 
180
- export const getMembers = async (params: { id: string; type?: "user" | "group"; recursive?: boolean }): Promise<GroupMember[]> => {
183
+ export const getMembers = async (params: { id: string; type?: GroupMember["type"]; recursive?: boolean }): Promise<GroupMember[]> => {
181
184
  const group = await getLocalGroupById(params.id);
182
185
  if (!group) return [];
183
186
  const members: GroupMember[] = [];
@@ -231,6 +234,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
231
234
  SELECT DISTINCT g.id, g.name
232
235
  FROM local_group_tree tree
233
236
  JOIN auth.groups g ON g.id = tree.child_group_id
237
+ WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
234
238
  ORDER BY g.name
235
239
  `
236
240
  : await sql<DbRow[]>`
@@ -238,6 +242,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
238
242
  FROM auth.group_groups_v2 gg
239
243
  JOIN auth.groups g ON g.id = gg.child_group_id
240
244
  WHERE gg.parent_group_id = ${group.id} AND g.provider = 'local'
245
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
241
246
  ORDER BY g.name
242
247
  `;
243
248
  for (const row of groupRows) {
@@ -245,6 +250,25 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
245
250
  }
246
251
  }
247
252
 
253
+ if (!params.type || params.type === "service_account") {
254
+ const rows = await sql<{ id: string; name: string; kind: "standalone" | "agent" }[]>`
255
+ SELECT sa.id, sa.name, sa.kind
256
+ FROM auth.service_accounts sa
257
+ WHERE sa.kind IN ('standalone', 'agent') AND ${
258
+ params.recursive === true
259
+ ? sql`${group.id}::uuid IN (${recursiveServiceAccountGroupIdsSubquery(sql`sa.id`)})`
260
+ : sql`EXISTS (
261
+ SELECT 1 FROM auth.service_account_groups sg
262
+ WHERE sg.service_account_id = sa.id AND sg.group_id = ${group.id}::uuid
263
+ )`
264
+ }
265
+ ORDER BY sa.name, sa.id
266
+ `;
267
+ members.push(
268
+ ...rows.map((row) => ({ type: "service_account" as const, id: row.id, displayName: row.name, serviceAccountKind: row.kind })),
269
+ );
270
+ }
271
+
248
272
  return members;
249
273
  };
250
274
 
@@ -310,6 +334,7 @@ export const getManagers = async (params: { id: string; type?: "user" | "group";
310
334
  FROM auth.group_manager_groups_v2 gmg
311
335
  JOIN auth.groups g ON g.id = gmg.manager_group_id
312
336
  WHERE gmg.group_id = ${group.id} AND g.provider = 'local'
337
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
313
338
  ORDER BY g.name
314
339
  `;
315
340
  for (const row of groupRows) {
@@ -340,7 +365,8 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
340
365
  WHERE g.provider = 'local'
341
366
  )
342
367
  SELECT DISTINCT parent_group_id
343
- FROM local_parent_tree
368
+ FROM local_parent_tree tree
369
+ WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = tree.parent_group_id)
344
370
  `
345
371
  : await sql<DbRow[]>`
346
372
  SELECT gg.parent_group_id
@@ -348,6 +374,7 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
348
374
  JOIN auth.groups g ON g.id = gg.parent_group_id
349
375
  WHERE gg.child_group_id = ${group.id}
350
376
  AND g.provider = 'local'
377
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
351
378
  ORDER BY g.name
352
379
  `;
353
380
 
@@ -374,6 +401,7 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
374
401
  JOIN auth.group_manager_groups_v2 gmg ON gmg.manager_group_id = tree.manager_group_id
375
402
  JOIN auth.groups g ON g.id = gmg.group_id
376
403
  WHERE g.provider = 'local'
404
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
377
405
  `
378
406
  : await sql<DbRow[]>`
379
407
  SELECT gmg.group_id
@@ -381,15 +409,38 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
381
409
  JOIN auth.groups g ON g.id = gmg.group_id
382
410
  WHERE gmg.manager_group_id = ${group.id}
383
411
  AND g.provider = 'local'
412
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
384
413
  ORDER BY g.name
385
414
  `;
386
415
 
387
416
  return rows.map((row) => row.group_id as string);
388
417
  };
389
418
 
390
- export const addMember = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
419
+ export const addMember = async (params: {
420
+ id: string;
421
+ user?: string;
422
+ group?: string;
423
+ serviceAccount?: string;
424
+ }): Promise<MutationResult<void>> => {
391
425
  const group = await getLocalGroupById(params.id);
392
426
  if (!group) return { ok: false, error: "Group not found", status: 404 };
427
+ const [personal] = await sql`SELECT 1 FROM auth.personal_groups
428
+ WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
429
+ if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
430
+
431
+ if (params.serviceAccount) {
432
+ const [account] = await sql<{ id: string }[]>`
433
+ SELECT id FROM auth.service_accounts
434
+ WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')
435
+ `;
436
+ if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
437
+ const inserted = await sql`
438
+ INSERT INTO auth.service_account_groups (service_account_id, group_id)
439
+ VALUES (${account.id}::uuid, ${group.id}::uuid) ON CONFLICT DO NOTHING
440
+ `;
441
+ if (inserted.count === 0) return { ok: false, error: "Service account is already a direct member of this group", status: 409 };
442
+ return { ok: true, data: undefined };
443
+ }
393
444
 
394
445
  if (params.user) {
395
446
  const provider = await getUserProvider(params.user);
@@ -427,10 +478,21 @@ export const addMember = async (params: { id: string; user?: string; group?: str
427
478
  return { ok: false, error: "Missing member", status: 400 };
428
479
  };
429
480
 
430
- export const removeMember = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
481
+ export const removeMember = async (params: {
482
+ id: string;
483
+ user?: string;
484
+ group?: string;
485
+ serviceAccount?: string;
486
+ }): Promise<MutationResult<void>> => {
431
487
  const group = await getLocalGroupById(params.id);
432
488
  if (!group) return { ok: false, error: "Group not found", status: 404 };
433
489
 
490
+ if (params.serviceAccount) {
491
+ await sql`DELETE FROM auth.service_account_groups
492
+ WHERE service_account_id = ${params.serviceAccount}::uuid AND group_id = ${group.id}::uuid`;
493
+ return { ok: true, data: undefined };
494
+ }
495
+
434
496
  if (params.user) {
435
497
  await sql`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
436
498
  return { ok: true, data: undefined };
@@ -447,6 +509,9 @@ export const removeMember = async (params: { id: string; user?: string; group?:
447
509
  export const addManager = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
448
510
  const group = await getLocalGroupById(params.id);
449
511
  if (!group) return { ok: false, error: "Group not found", status: 404 };
512
+ const [personal] = await sql`SELECT 1 FROM auth.personal_groups
513
+ WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
514
+ if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
450
515
 
451
516
  if (params.user) {
452
517
  const provider = await getUserProvider(params.user);
@@ -1,10 +1,19 @@
1
1
  import { type SQL, sql } from "bun";
2
- import type { BaseUser, MutationResult, PaginationResponse, Role, User, UserProfile, UserProvider } from "../../contracts/shared";
2
+ import type {
3
+ BaseUser,
4
+ MutationResult,
5
+ PaginationResponse,
6
+ RequestActor,
7
+ Role,
8
+ User,
9
+ UserProfile,
10
+ UserProvider,
11
+ } from "../../contracts/shared";
3
12
  import { freeipa } from "../../server/services";
4
13
  import { createAuthLoginUrl } from "../../shared/redirect";
5
14
  import { isAccountCategoryAllowed } from "../account-category-policy";
6
15
  import type { AuditActor } from "../audit";
7
- import { getFreeIpaConfig } from "../freeipa-config";
16
+ import { getFreeIpaAdminGroups, getFreeIpaConfig } from "../freeipa-config";
8
17
  import { getServiceIpaSession } from "../ipa/service-account";
9
18
  import { toPgTextArray, toPgUuidArray } from "../postgres";
10
19
  import { providers } from "../providers";
@@ -168,11 +177,12 @@ const buildUser = (row: DbRow, groupsAdmin: string[]): User => {
168
177
  };
169
178
  };
170
179
 
171
- export const get = async (params: { id: string } | { uid: string }): Promise<User | null> => {
172
- const whereClause = "id" in params ? sql`u.id = ${params.id}` : sql`u.uid = ${params.uid}`;
173
- const userIdExpr = "id" in params ? sql`${params.id}` : sql`u.id`;
174
- const { groupsAdmin } = await getFreeIpaConfig();
175
- const rows = await sql<DbRow[]>`
180
+ export const get = async (params: { id: string } | { uid: string }, options: { db?: SQL } = {}): Promise<User | null> => {
181
+ const db = options.db ?? sql;
182
+ const whereClause = "id" in params ? db`u.id = ${params.id}` : db`u.uid = ${params.uid}`;
183
+ const userIdExpr = "id" in params ? db`${params.id}` : db`u.id`;
184
+ const groupsAdmin = options.db ? await getFreeIpaAdminGroups(db) : (await getFreeIpaConfig()).groupsAdmin;
185
+ const rows = await db<DbRow[]>`
176
186
  SELECT u.*,
177
187
  ${userIpaDataColumns()},
178
188
  CASE
@@ -224,9 +234,10 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
224
234
  };
225
235
 
226
236
  /** Audience roles for a resolved access subject, including delegated users and group managers. */
227
- export const getRoles = async (userId: string): Promise<Role[]> => {
228
- const { groupsAdmin } = await getFreeIpaConfig();
229
- const [row] = await sql<DbRow[]>`SELECT u.provider, u.profile,
237
+ export const getRoles = async (userId: string, options?: { db: typeof sql }): Promise<Role[]> => {
238
+ const db = options?.db ?? sql;
239
+ const groupsAdmin = options ? await getFreeIpaAdminGroups(db) : (await getFreeIpaConfig()).groupsAdmin;
240
+ const [row] = await db<DbRow[]>`SELECT u.provider, u.profile,
230
241
  CASE WHEN u.provider='local' THEN u.admin ELSE EXISTS (
231
242
  SELECT 1 FROM auth.ipa_user_effective_groups eg WHERE eg.user_id=u.id
232
243
  AND eg.group_name=ANY(${toPgTextArray(groupsAdmin)}::text[])
@@ -238,6 +249,26 @@ export const getRoles = async (userId: string): Promise<Role[]> => {
238
249
  return buildRoles({ provider, profile, memberofGroup: [], manages: stringArray(row.manages), admin: Boolean(row.effective_admin) });
239
250
  };
240
251
 
252
+ /**
253
+ * Revalidate installation authority and retain it until the caller commits.
254
+ * Lock users before grant/membership or mandate/profile locks, matching user
255
+ * deletion and provider sync. Projection and settings locks also cover rows
256
+ * inserted or replaced while lock acquisition was waiting.
257
+ */
258
+ export const hasInstallationAdminInTransaction = async (actor: RequestActor, db: typeof sql): Promise<boolean> => {
259
+ if (actor.kind !== "user" || actor.sessionKind === "app" || !actor.user.roles.includes("admin")) return false;
260
+ const [user] = await db<{ provider: UserProvider; profile: UserProfile; expired: boolean }[]>`
261
+ SELECT provider, profile, (account_expires IS NOT NULL AND account_expires <= statement_timestamp()) AS expired
262
+ FROM auth.users WHERE id=${actor.user.id}::uuid FOR SHARE`;
263
+ if (!user || user.expired || user.profile !== "user") return false;
264
+ await db`LOCK TABLE settings.entries IN SHARE MODE`;
265
+ if (!(await isAccountCategoryAllowed(user, db))) return false;
266
+ if (user.provider === "ipa") {
267
+ await db`LOCK TABLE auth.ipa_user_effective_groups IN SHARE MODE`;
268
+ }
269
+ return (await getRoles(actor.user.id, { db })).includes("admin");
270
+ };
271
+
241
272
  export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
242
273
  const { groupsAdmin } = await getFreeIpaConfig();
243
274
  const rows = await sql<DbRow[]>`
@@ -346,15 +377,15 @@ export const list = async (params: {
346
377
  };
347
378
  };
348
379
 
349
- /** Technical membership reads stay complete; human-facing lists exclude personal groups explicitly. */
350
- export const getGroups = async (params: { id: string; recursive?: boolean; includePersonal?: boolean }): Promise<string[]> => {
380
+ /** Exported group names exclude personal groups; authorization uses the complete group ID projection. */
381
+ export const getGroups = async (params: { id: string; recursive?: boolean }): Promise<string[]> => {
351
382
  if (params.recursive) {
352
383
  const rows = await sql<DbRow[]>`${recursiveUserGroupsSubquery({
353
384
  userId: params.id,
354
385
  select: sql`
355
386
  SELECT DISTINCT g.name FROM user_all_groups ag
356
387
  JOIN auth.groups g ON g.id = ag.group_id
357
- WHERE (${params.includePersonal !== false} OR NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id))
388
+ WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
358
389
  ORDER BY g.name
359
390
  `,
360
391
  })}`;
@@ -363,7 +394,7 @@ export const getGroups = async (params: { id: string; recursive?: boolean; inclu
363
394
  const rows = await sql<DbRow[]>`
364
395
  SELECT g.name FROM auth.groups g
365
396
  WHERE g.id IN (SELECT group_id FROM auth.user_groups_v2 WHERE user_id = ${params.id}::uuid)
366
- AND (${params.includePersonal !== false} OR NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id))
397
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
367
398
  ORDER BY g.name
368
399
  `;
369
400
  return rows.map((row) => row.name as string);
@@ -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);