@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
@@ -2,6 +2,7 @@ import type { Context, Env, MiddlewareHandler } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
3
  import { matchedRoutes } from "hono/route";
4
4
  import { findTargetHandler } from "hono/utils/handler";
5
+ import { bindAppAccessRequest } from "../../_internal/app-access-request";
5
6
  import { measureServerPhase } from "../../_internal/server-timing";
6
7
  import {
7
8
  AI_INFERENCE_SCOPE,
@@ -58,6 +59,8 @@ export type ServiceAccountRequestActor =
58
59
  delegatedUser: User;
59
60
  scopes: string[];
60
61
  credentialId?: string | null;
62
+ /** Public OAuth client identifier; safe to retain, never the bearer token. */
63
+ oauthClientId?: string | null;
61
64
  credentialExpiresAt?: string | null;
62
65
  delegation?: InvocationProvenance;
63
66
  }
@@ -67,6 +70,8 @@ export type ServiceAccountRequestActor =
67
70
  delegatedUser: null;
68
71
  scopes: string[];
69
72
  credentialId?: string | null;
73
+ /** Public OAuth client identifier; safe to retain, never the bearer token. */
74
+ oauthClientId?: string | null;
70
75
  credentialExpiresAt?: string | null;
71
76
  delegation?: InvocationProvenance;
72
77
  };
@@ -248,6 +253,7 @@ const loadAuthenticatedActorUncached = async (
248
253
  delegatedUser: authResult.delegatedUser,
249
254
  scopes: authResult.scopes,
250
255
  credentialId: null,
256
+ oauthClientId: authResult.oauthClientId,
251
257
  credentialExpiresAt: typeof authResult.payload.exp === "number" ? new Date(authResult.payload.exp * 1_000).toISOString() : null,
252
258
  };
253
259
  c.set("actor", actor);
@@ -482,13 +488,21 @@ const getAuthority = <E extends AuthContext>(c: Context<E>): RequestAuthority =>
482
488
  const accessSubject = c.get("accessSubject");
483
489
  const credentialKind = c.get("credentialKind");
484
490
  if (!actor || !accessSubject || !credentialKind) throw new Error("Request authority has not been resolved");
485
- return {
486
- actor,
487
- accessSubject,
488
- credentialKind,
489
- scopes: [...(c.get("credentialScopes") ?? [])],
490
- ...(c.get("credentialProof") ? { credentialProof: c.get("credentialProof") } : {}),
491
- };
491
+ return bindAppAccessRequest(
492
+ {
493
+ actor,
494
+ accessSubject,
495
+ credentialKind,
496
+ scopes: [...(c.get("credentialScopes") ?? [])],
497
+ ...(c.get("credentialProof") ? { credentialProof: c.get("credentialProof") } : {}),
498
+ },
499
+ c.req.raw,
500
+ credentialKind === "session"
501
+ ? c.get("sessionToken")
502
+ : credentialKind !== "invocation"
503
+ ? (session.getBearerToken(c) ?? undefined)
504
+ : undefined,
505
+ );
492
506
  };
493
507
 
494
508
  /**
@@ -4,6 +4,7 @@ export {
4
4
  type AuthContext,
5
5
  auth,
6
6
  type InvocationProvenance,
7
+ isRequestCredentialCurrent,
7
8
  type RequestActor,
8
9
  type RequestAuthority,
9
10
  type RequestCredentialKind,
@@ -1,5 +1,6 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
+ import { bindAppAccessRequest } from "../../_internal/app-access-request";
3
4
  import { getApp } from "../../_internal/registry";
4
5
  import type { AppMeta } from "../../contracts/app";
5
6
  import { canUseApp } from "../../services/app-access";
@@ -53,6 +54,7 @@ export const requireInvocation = (
53
54
  if (!claims) return reject(c);
54
55
  const authority = await (dependencies.resolve ?? resolveInvocationAuthority)(claims);
55
56
  if (!authority) return reject(c);
57
+ bindAppAccessRequest(authority, c.req.raw);
56
58
  const app = await (dependencies.getApp ?? getApp)(expectation.targetAppId).catch(() => null);
57
59
  if (!app || !(await canUseApp(authority, app))) {
58
60
  if (app) recordAppAccessDenied(app.id);
@@ -0,0 +1,106 @@
1
+ import { objectStoreBackend } from "../_internal/object-store-config";
2
+ import { validateObjectKey } from "../_internal/object-store-key";
3
+ import { get } from "../services/settings";
4
+ import { clientIpBucket } from "./client-ip";
5
+ import { ratelimit } from "./ratelimit";
6
+
7
+ const notFound = () => new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
8
+
9
+ /** Platform-owned anonymous delivery. Never accepts a bucket, private prefix or listing. */
10
+ export const servePublicStorage = async (request: Request, clientAddress = "unknown"): Promise<Response> => {
11
+ let path: string;
12
+ try {
13
+ // Validate before application decoding; Bun has already resolved transport
14
+ // dot segments. The S3 key below always starts in the public area.
15
+ const raw = request.url.replace(/^[a-z]+:\/\/[^/]+/i, "").split(/[?#]/, 1)[0] ?? "";
16
+ if (/[\\]|%(?:2e|2f|5c)/i.test(raw)) return notFound();
17
+ const match = /^\/public\/storage\/([a-z][a-z0-9-]{0,79})\/(.+)$/.exec(raw);
18
+ if (!match || match[1] === "storage") return notFound();
19
+ const area = `public/${match[1]}/`;
20
+ path = area + validateObjectKey(decodeURIComponent(match[2] ?? ""), area);
21
+ } catch {
22
+ return notFound();
23
+ }
24
+ if (request.method !== "GET" && request.method !== "HEAD") return notFound();
25
+ try {
26
+ const perSecond = Math.max(1, Math.floor(await get<number>("security.rate_limit_per_second")));
27
+ const budget = await ratelimit({ id: "public-storage", limit: perSecond }).check(clientIpBucket(clientAddress));
28
+ if (budget.limited)
29
+ return new Response("Rate limit exceeded.", {
30
+ status: 429,
31
+ headers: { "Cache-Control": "no-store", "Retry-After": String(Math.max(1, Math.ceil(budget.resetIn / 1000))) },
32
+ });
33
+ const { client, limit } = await objectStoreBackend();
34
+ const stat = await client.stat(path);
35
+ if (stat.size > limit) return notFound();
36
+ const headers = new Headers({
37
+ "Content-Type": stat.type || "application/octet-stream",
38
+ "Content-Length": String(stat.size),
39
+ ETag: stat.etag,
40
+ "Last-Modified": stat.lastModified.toUTCString(),
41
+ "Cache-Control": "public, max-age=300",
42
+ "X-Content-Type-Options": "nosniff",
43
+ // Apply to every type, including mislabelled or future active formats.
44
+ "Content-Security-Policy": "sandbox; default-src 'none'; frame-ancestors 'none'",
45
+ });
46
+ const essence = stat.type.split(";", 1)[0]?.trim().toLowerCase() ?? "";
47
+ const inline =
48
+ /^(?:image\/(?:png|jpeg|gif|webp|avif|bmp|x-icon)|audio\/(?:mpeg|ogg|wav|webm|mp4)|video\/(?:mp4|webm|ogg)|text\/plain)$/;
49
+ if (!inline.test(essence)) headers.set("Content-Disposition", "attachment");
50
+ const ifNoneMatch = request.headers.get("if-none-match");
51
+ const modifiedSince = request.headers.get("if-modified-since");
52
+ if (
53
+ ifNoneMatch
54
+ ? ifNoneMatch.split(",").some((value) => value.trim() === stat.etag || value.trim() === "*")
55
+ : modifiedSince && stat.lastModified.getTime() <= Date.parse(modifiedSince)
56
+ ) {
57
+ headers.delete("Content-Length");
58
+ return new Response(null, { status: 304, headers });
59
+ }
60
+ if (request.method === "HEAD") return new Response(null, { headers });
61
+ const reader = client.file(path).stream().getReader();
62
+ let size = 0;
63
+ const abort = () => {
64
+ void reader.cancel(request.signal.reason).catch(() => {});
65
+ };
66
+ request.signal.addEventListener("abort", abort, { once: true });
67
+ const release = () => {
68
+ request.signal.removeEventListener("abort", abort);
69
+ reader.releaseLock();
70
+ };
71
+ if (request.signal.aborted) abort();
72
+ const stream = new ReadableStream<Uint8Array>(
73
+ {
74
+ async pull(controller) {
75
+ try {
76
+ const next = await reader.read();
77
+ if (next.done) {
78
+ release();
79
+ controller.close();
80
+ return;
81
+ }
82
+ size += next.value.byteLength;
83
+ if (size > limit) throw new RangeError("Public object exceeds the configured size limit.");
84
+ controller.enqueue(next.value);
85
+ } catch (error) {
86
+ await reader.cancel(error).catch(() => {});
87
+ release();
88
+ controller.error(error);
89
+ }
90
+ },
91
+ async cancel(reason) {
92
+ try {
93
+ await reader.cancel(reason);
94
+ } finally {
95
+ release();
96
+ }
97
+ },
98
+ },
99
+ { highWaterMark: 0 },
100
+ );
101
+ return new Response(stream, { headers });
102
+ } catch (error) {
103
+ if (error instanceof Error && "code" in error && ["NoSuchKey", "NotFound", "ENOENT"].includes(String(error.code))) return notFound();
104
+ return new Response("Storage is unavailable.", { status: 503, headers: { "Cache-Control": "no-store" } });
105
+ }
106
+ };
@@ -2,9 +2,10 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
2
2
  import { type SQLQuery, sql } from "bun";
3
3
  import { type ManagerCandidate, removesLastManager } from "../../access/managers";
4
4
  import { accessMessages } from "../../access/messages";
5
- import { recursiveGroupIdsSubquery } from "../../services/accounts/group-sql";
5
+ import { recursiveGroupIdsSubquery, recursiveServiceAccountGroupIdsSubquery } from "../../services/accounts/group-sql";
6
6
  import { toPgUuidArray } from "../../services/postgres";
7
7
  import type { ServiceAccountKind } from "../../services/service-accounts";
8
+ import type { RequestAuthority } from "../middleware/auth";
8
9
 
9
10
  export type AccessDb = typeof sql;
10
11
 
@@ -265,19 +266,26 @@ export const ensureManagerRemains = (params: {
265
266
  return fail({ code: "LAST_MANAGER", message: t.lastManager({ level: params.level ?? t.manage }), status: 409 });
266
267
  };
267
268
 
268
- /** Resolve direct and nested group memberships from the authoritative database mirror. */
269
- export const getEffectiveGroups = async (params: { userId: string | null }, db: AccessDb = sql): Promise<EffectiveGroup[]> => {
270
- if (!params.userId) return [];
269
+ type EffectiveGroupParams = { userId: string | null; serviceAccountId?: never } | { serviceAccountId: string | null; userId?: never };
270
+
271
+ /** Resolve authoritative memberships; machine recipients inherit local groups only. */
272
+ export const getEffectiveGroups = async (params: EffectiveGroupParams, db: AccessDb = sql): Promise<EffectiveGroup[]> => {
273
+ const groupIds = params.userId
274
+ ? recursiveGroupIdsSubquery(params.userId)
275
+ : params.serviceAccountId
276
+ ? recursiveServiceAccountGroupIdsSubquery(params.serviceAccountId)
277
+ : null;
278
+ if (!groupIds) return [];
271
279
  const rows = await db<{ id: string; name: string }[]>`
272
280
  SELECT g.id, g.name
273
281
  FROM auth.groups g
274
- WHERE g.id IN (${recursiveGroupIdsSubquery(params.userId)})
282
+ WHERE g.id IN (${groupIds})
275
283
  ORDER BY g.name
276
284
  `;
277
285
  return rows;
278
286
  };
279
287
 
280
- export const getEffectiveGroupIds = async (params: { userId: string | null }, db: AccessDb = sql): Promise<string[]> =>
288
+ export const getEffectiveGroupIds = async (params: EffectiveGroupParams, db: AccessDb = sql): Promise<string[]> =>
281
289
  (await getEffectiveGroups(params, db)).map((group) => group.id);
282
290
 
283
291
  /**
@@ -305,6 +313,13 @@ export const deleteAccess = async (params: { id: string }, db: AccessDb = sql):
305
313
  * Each app implements this to connect their resources to auth.access.
306
314
  */
307
315
  export type ResourceAccessAdapter<TResourceId = string> = {
316
+ /**
317
+ * Whether any resource in this adapter has an effective read/write/admin right.
318
+ * Use an existence query, never enumerate resources. Preserve domain eligibility,
319
+ * principal semantics, resource binding and credential ceilings. Use db and signal.
320
+ * Optional for existing adapters; required when selected by appAccess.resources().
321
+ */
322
+ hasAnyRight?: (subject: AccessSubject, context: { authority: RequestAuthority; db: AccessDb; signal: AbortSignal }) => Promise<boolean>;
308
323
  /** Get all access entries for a resource */
309
324
  list: (resourceId: TResourceId) => Promise<AccessEntry[]>;
310
325
  /** Add an access entry to a resource */
@@ -321,7 +336,7 @@ export type ResourceAccessAdapter<TResourceId = string> = {
321
336
  *
322
337
  * Apps provide column fragments because access rows are commonly joined under
323
338
  * different aliases. The subject is authoritative: group membership is always
324
- * resolved recursively from its user id and never accepted from a caller.
339
+ * resolved recursively from its user or service-account id and never accepted from a caller.
325
340
  * Resource binding, credential scopes, and permission precedence remain the
326
341
  * responsibility of the app using the predicate.
327
342
  */
@@ -338,8 +353,13 @@ export const buildAccessPrincipalTierConditions = (params: {
338
353
  serviceAccount: sql`${columns.serviceAccountId} = ${serviceAccountId}::uuid`,
339
354
  user: sql`${columns.userId} = ${userId}::uuid`,
340
355
  group: sql`(
341
- ${userId}::uuid IS NOT NULL
342
- AND ${columns.groupId} IN (${userId ? recursiveGroupIdsSubquery(userId) : sql`SELECT NULL::uuid WHERE FALSE`})
356
+ ${columns.groupId} IN (${
357
+ userId
358
+ ? recursiveGroupIdsSubquery(userId)
359
+ : serviceAccountId
360
+ ? recursiveServiceAccountGroupIdsSubquery(serviceAccountId)
361
+ : sql`SELECT NULL::uuid WHERE FALSE`
362
+ })
343
363
  )`,
344
364
  authenticated: sql`${authenticated} AND ${columns.authenticatedOnly} = true`,
345
365
  public: sql`(
@@ -361,7 +381,7 @@ export const buildAccessPrincipalCondition = (params: { subject: AccessSubject |
361
381
  * Get the effective permission level for an actor on a resource.
362
382
  * Returns the highest permission from:
363
383
  * - Direct user or service-account access
364
- * - Direct and nested user group memberships resolved from the database
384
+ * - Direct and nested group memberships for users and active standalone/agent accounts
365
385
  * - Authenticated access for every authenticated subject
366
386
  * - Public access for every subject, including anonymous callers
367
387
  *
@@ -457,6 +477,7 @@ export const getEffectivePermissions = async (params: {
457
477
  a.service_account_id = s.service_account_id
458
478
  OR a.user_id = s.user_id
459
479
  OR (s.user_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(sql`s.user_id`)}))
480
+ OR (s.service_account_id IS NOT NULL AND a.group_id IN (${recursiveServiceAccountGroupIdsSubquery(sql`s.service_account_id`)}))
460
481
  OR a.authenticated_only = true
461
482
  OR (a.user_id IS NULL AND a.group_id IS NULL AND a.service_account_id IS NULL AND a.authenticated_only = false)
462
483
  )
@@ -1,4 +1,5 @@
1
1
  // Cloud-specific server services
2
+ export { recursiveServiceAccountGroupIdsSubquery } from "../../services/accounts/group-sql";
2
3
 
3
4
  export type {
4
5
  AccessDb,
@@ -8,6 +8,8 @@ import type {
8
8
  EntityListItem,
9
9
  GroupMember,
10
10
  MutationResult,
11
+ ServiceAccountKind,
12
+ ServiceAccountStatus,
11
13
  User,
12
14
  UserProfile,
13
15
  UserProvider,
@@ -413,7 +415,6 @@ export const accountsAppService = {
413
415
  const userGroups = await users.getGroups({
414
416
  id: config.userId,
415
417
  recursive: config.recursive,
416
- includePersonal: false,
417
418
  });
418
419
  const query = config.filter?.query?.trim().toLowerCase();
419
420
  const filtered = query ? userGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : userGroups;
@@ -968,7 +969,7 @@ export const accountsAppService = {
968
969
  group: {
969
970
  list: async (config: {
970
971
  pagination?: PageParams;
971
- filter?: { search?: string; includePersonal?: boolean };
972
+ filter?: { search?: string };
972
973
  scope?: { userId?: string; ids?: string[]; provider?: UserProvider; mode?: "all" | "member" | "managed" };
973
974
  }): Promise<Paginated<BaseGroup>> => {
974
975
  const { page, perPage } = paginate(config.pagination);
@@ -980,7 +981,6 @@ export const accountsAppService = {
980
981
  scope: config.scope?.mode,
981
982
  ids: config.scope?.ids,
982
983
  provider: config.scope?.provider,
983
- includePersonal: config.filter?.includePersonal,
984
984
  });
985
985
 
986
986
  return {
@@ -991,7 +991,10 @@ export const accountsAppService = {
991
991
  hasNext: result.pagination.hasNext,
992
992
  };
993
993
  },
994
- get: async (config: { id: string }) => groups.get(config),
994
+ get: async (config: { id: string }) => {
995
+ const result = await groups.list({ ids: [config.id], perPage: 1 });
996
+ return result.groups[0] ?? null;
997
+ },
995
998
  member: {
996
999
  list: async (config: {
997
1000
  id: string;
@@ -1014,7 +1017,14 @@ export const accountsAppService = {
1014
1017
  });
1015
1018
  return paginateItems(filtered, config.pagination);
1016
1019
  },
1017
- add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1020
+ add: async (config: {
1021
+ actor: AccountsActor;
1022
+ id: string;
1023
+ provider?: UserProvider;
1024
+ userId?: string;
1025
+ groupId?: string;
1026
+ serviceAccountId?: string;
1027
+ }) => {
1018
1028
  const group = await groups.get({ id: config.id });
1019
1029
  if (!group) {
1020
1030
  const result = fail(err.notFound("Group not found"));
@@ -1033,17 +1043,25 @@ export const accountsAppService = {
1033
1043
  provider: config.provider,
1034
1044
  user: config.userId,
1035
1045
  group: config.groupId,
1046
+ serviceAccount: config.serviceAccountId,
1036
1047
  }),
1037
1048
  );
1038
1049
  return recordCompletedMutation({
1039
1050
  action: "accounts.group.member.add",
1040
1051
  actor: auditActor(config.actor),
1041
1052
  target: groupTarget(group),
1042
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1053
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1043
1054
  result,
1044
1055
  });
1045
1056
  },
1046
- remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1057
+ remove: async (config: {
1058
+ actor: AccountsActor;
1059
+ id: string;
1060
+ provider?: UserProvider;
1061
+ userId?: string;
1062
+ groupId?: string;
1063
+ serviceAccountId?: string;
1064
+ }) => {
1047
1065
  const group = await groups.get({ id: config.id });
1048
1066
  if (!group) {
1049
1067
  const result = fail(err.notFound("Group not found"));
@@ -1062,13 +1080,14 @@ export const accountsAppService = {
1062
1080
  provider: config.provider,
1063
1081
  user: config.userId,
1064
1082
  group: config.groupId,
1083
+ serviceAccount: config.serviceAccountId,
1065
1084
  }),
1066
1085
  );
1067
1086
  return recordCompletedMutation({
1068
1087
  action: "accounts.group.member.remove",
1069
1088
  actor: auditActor(config.actor),
1070
1089
  target: groupTarget(group),
1071
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1090
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1072
1091
  result,
1073
1092
  });
1074
1093
  },
@@ -1253,6 +1272,9 @@ export const accountsAppService = {
1253
1272
  profile?: UserProfile;
1254
1273
  userIds?: string[];
1255
1274
  groupIds?: string[];
1275
+ serviceAccountIds?: string[];
1276
+ serviceAccountKinds?: ServiceAccountKind[];
1277
+ serviceAccountStatus?: ServiceAccountStatus;
1256
1278
  excludeUserIds?: string[];
1257
1279
  excludeGroupIds?: string[];
1258
1280
  excludeServiceAccountIds?: string[];
@@ -1262,7 +1284,6 @@ export const accountsAppService = {
1262
1284
  parentGroupId?: string;
1263
1285
  managedByUserId?: string;
1264
1286
  recursive?: boolean;
1265
- includePersonal?: boolean;
1266
1287
  }): Promise<Paginated<EntityListItem>> => {
1267
1288
  const canSearchDirectory = config.actor.roles.includes("user");
1268
1289
  const usesRelationFilter = Boolean(
@@ -1284,6 +1305,9 @@ export const accountsAppService = {
1284
1305
  profile: config.profile,
1285
1306
  userIds: config.userIds,
1286
1307
  groupIds: config.groupIds,
1308
+ serviceAccountIds: config.serviceAccountIds,
1309
+ serviceAccountKinds: config.serviceAccountKinds,
1310
+ serviceAccountStatus: config.serviceAccountStatus,
1287
1311
  excludeUserIds: config.excludeUserIds,
1288
1312
  excludeGroupIds: config.excludeGroupIds,
1289
1313
  excludeServiceAccountIds: config.excludeServiceAccountIds,
@@ -1293,7 +1317,6 @@ export const accountsAppService = {
1293
1317
  parentGroupId: config.parentGroupId,
1294
1318
  managedByUserId: config.managedByUserId,
1295
1319
  recursive: config.recursive,
1296
- includePersonal: config.includePersonal,
1297
1320
  page,
1298
1321
  perPage,
1299
1322
  });
@@ -1655,9 +1678,9 @@ export const accountsAppService = {
1655
1678
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local') AS local_accounts_total,
1656
1679
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'user') AS local_user_accounts_total,
1657
1680
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'guest') AS local_guest_accounts_total,
1658
- (SELECT COUNT(*)::int FROM auth.groups g WHERE NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS groups_total,
1659
- (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS ipa_groups_total,
1660
- (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS local_groups_total,
1681
+ (SELECT COUNT(*)::int FROM auth.groups g WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS groups_total,
1682
+ (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS ipa_groups_total,
1683
+ (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS local_groups_total,
1661
1684
  (SELECT COUNT(*)::int FROM auth.account_requests WHERE status = 'pending') AS open_requests,
1662
1685
  (
1663
1686
  SELECT COUNT(*)::int
@@ -1,14 +1,14 @@
1
- import { sql } from "bun";
1
+ import { type SQLQuery, sql } from "bun";
2
2
  import type { EntityKind, EntityListItem, UserProfile, UserProvider } from "../../contracts/shared";
3
3
  import { getFreeIpaConfig } from "../freeipa-config";
4
4
  import { escapeLikePattern, toPgTextArray, toPgUuidArray } from "../postgres";
5
- import type { ServiceAccountKind } from "../service-accounts";
5
+ import type { ServiceAccountKind, ServiceAccountStatus } from "../service-accounts";
6
6
  import { buildBaseGroup, personalOwnerJoin } from "./base-group";
7
7
  import { buildBaseUser } from "./base-user";
8
- import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
8
+ import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery, recursiveServiceAccountGroupIdsSubquery } from "./group-sql";
9
9
 
10
10
  type DbRow = Record<string, unknown>;
11
- type SqlFragment = any;
11
+ type SqlFragment = SQLQuery;
12
12
 
13
13
  export type EntityListParams = {
14
14
  visibility:
@@ -21,6 +21,9 @@ export type EntityListParams = {
21
21
  profile?: UserProfile;
22
22
  userIds?: string[];
23
23
  groupIds?: string[];
24
+ serviceAccountIds?: string[];
25
+ serviceAccountKinds?: ServiceAccountKind[];
26
+ serviceAccountStatus?: ServiceAccountStatus;
24
27
  excludeUserIds?: string[];
25
28
  excludeGroupIds?: string[];
26
29
  excludeServiceAccountIds?: string[];
@@ -30,11 +33,6 @@ export type EntityListParams = {
30
33
  parentGroupId?: string;
31
34
  managedByUserId?: string;
32
35
  recursive?: boolean;
33
- /**
34
- * Personal Linux groups are left out of directory browsing and search by
35
- * default. Exact `groupIds` lookups and relation filters always return them.
36
- */
37
- includePersonal?: boolean;
38
36
  page?: number;
39
37
  perPage?: number;
40
38
  };
@@ -427,11 +425,7 @@ export const list = async (
427
425
  const groupsAdmin = (await getFreeIpaConfig()).groupsAdmin;
428
426
  const groupsAdminLiteral = toPgTextArray(groupsAdmin);
429
427
  const spec = buildQuerySpec(params);
430
- const includePersonal =
431
- params.includePersonal === true ||
432
- params.groupIds !== undefined ||
433
- Boolean(params.memberOfGroupId || params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
434
- const personalCondition = includePersonal ? sql`TRUE` : sql`(kind <> 'group' OR personal_owner_id IS NULL)`;
428
+ const personalCondition = sql`(kind <> 'group' OR NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = entity_rows.id))`;
435
429
  const visibilityCondition =
436
430
  params.visibility.type === "directory"
437
431
  ? sql`TRUE`
@@ -453,12 +447,13 @@ export const list = async (
453
447
  )
454
448
  )`;
455
449
  const kindsCondition = (params.kinds?.length ?? 0) === 0 ? sql`TRUE` : sql`kind = ANY(${toPgTextArray(params.kinds ?? [])}::text[])`;
456
- const hasIdFilter = params.userIds !== undefined || params.groupIds !== undefined;
450
+ const hasIdFilter = params.userIds !== undefined || params.groupIds !== undefined || params.serviceAccountIds !== undefined;
457
451
  const includeIdCondition = !hasIdFilter
458
452
  ? sql`TRUE`
459
453
  : sql`(
460
454
  (kind = 'user' AND id = ANY(${toPgUuidArray(params.userIds ?? [])}::uuid[]))
461
455
  OR (kind = 'group' AND id = ANY(${toPgUuidArray(params.groupIds ?? [])}::uuid[]))
456
+ OR (kind = 'service_account' AND id = ANY(${toPgUuidArray(params.serviceAccountIds ?? [])}::uuid[]))
462
457
  )`;
463
458
  const excludeUserCondition =
464
459
  (params.excludeUserIds?.length ?? 0) === 0
@@ -472,6 +467,13 @@ export const list = async (
472
467
  (params.excludeServiceAccountIds?.length ?? 0) === 0
473
468
  ? sql`TRUE`
474
469
  : sql`(kind <> 'service_account' OR id <> ALL(${toPgUuidArray(params.excludeServiceAccountIds ?? [])}::uuid[]))`;
470
+ const serviceAccountKindsCondition =
471
+ (params.serviceAccountKinds?.length ?? 0) === 0
472
+ ? sql`TRUE`
473
+ : sql`(kind <> 'service_account' OR service_account_kind = ANY(${toPgTextArray(params.serviceAccountKinds ?? [])}::text[]))`;
474
+ const serviceAccountStatusCondition = params.serviceAccountStatus
475
+ ? sql`(kind <> 'service_account' OR status = ${params.serviceAccountStatus})`
476
+ : sql`TRUE`;
475
477
  const userMemberOfGroupCondition =
476
478
  (params.userMemberOfGroupIds?.length ?? 0) === 0
477
479
  ? sql`TRUE`
@@ -482,15 +484,36 @@ export const list = async (
482
484
  AND ug.group_id = ANY(${toPgUuidArray(params.userMemberOfGroupIds ?? [])}::uuid[])
483
485
  ))`;
484
486
 
487
+ // Machine recipients have local membership, but no directory management/parent relation.
488
+ const otherRelation = Boolean(params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
489
+ const machineRelation = params.memberOfGroupId
490
+ ? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
491
+ SELECT 1 FROM auth.groups target
492
+ WHERE target.id = ${params.memberOfGroupId}::uuid AND target.provider = 'local'
493
+ AND ${
494
+ params.recursive === true
495
+ ? sql`target.id IN (${recursiveServiceAccountGroupIdsSubquery(sql`sa.id`)})`
496
+ : sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
497
+ WHERE sg.service_account_id = sa.id AND sg.group_id = target.id)`
498
+ }
499
+ )`
500
+ : sql`TRUE`;
501
+ const machineDirect = params.memberOfGroupId
502
+ ? sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
503
+ WHERE sg.service_account_id = sa.id AND sg.group_id = ${params.memberOfGroupId}::uuid)`
504
+ : sql`NULL::boolean`;
485
505
  const where = sql`
486
506
  ${visibilityCondition}
487
507
  AND ${kindsCondition}
488
508
  AND ${includeIdCondition}
489
- AND (${params.provider ?? null}::text IS NULL OR provider = ${params.provider ?? null})
509
+ AND (${params.provider ?? null}::text IS NULL OR provider = ${params.provider ?? null}
510
+ OR (kind = 'service_account' AND ${params.provider ?? null}::text = 'local'))
490
511
  AND (${params.profile ?? null}::text IS NULL OR kind = 'group' OR profile = ${params.profile ?? null})
491
512
  AND ${excludeUserCondition}
492
513
  AND ${excludeGroupCondition}
493
514
  AND ${excludeServiceAccountCondition}
515
+ AND ${serviceAccountKindsCondition}
516
+ AND ${serviceAccountStatusCondition}
494
517
  AND ${userMemberOfGroupCondition}
495
518
  AND ${personalCondition}
496
519
  AND (
@@ -601,7 +624,7 @@ export const list = async (
601
624
  service_account_rows AS (
602
625
  SELECT
603
626
  'service_account'::text AS kind,
604
- NULL::boolean AS direct,
627
+ ${machineDirect} AS direct,
605
628
  sa.id,
606
629
  NULL::text AS provider,
607
630
  NULL::text AS profile,
@@ -633,6 +656,7 @@ export const list = async (
633
656
  NULL::text AS personal_owner_display_name,
634
657
  LOWER(sa.name) AS sort_label
635
658
  FROM auth.service_accounts sa
659
+ WHERE NOT ${otherRelation} AND ${machineRelation}
636
660
  ),
637
661
  entity_rows AS (
638
662
  SELECT * FROM user_rows
@@ -649,9 +673,9 @@ export const list = async (
649
673
  OFFSET ${offset}
650
674
  `;
651
675
 
676
+ // A page past the end has no rows to carry the window count; the total still counts every match.
652
677
  let total = rows.length > 0 ? Number(rows[0]!.total) : 0;
653
- if (params.visibility.type === "directory_shares" && !rows.length && page > 1)
654
- total = (await list({ ...params, page: 1, perPage: 1 })).total;
678
+ if (!rows.length && page > 1) total = (await list({ ...params, page: 1, perPage: 1 })).total;
655
679
  return {
656
680
  items: rows.map((row) => {
657
681
  const entity = mapEntityRow(row);
@@ -104,3 +104,26 @@ export const buildManagedGroupScopeCondition = (params: { userId: SqlValue; grou
104
104
  })}
105
105
  )
106
106
  `;
107
+
108
+ /** Local recipient membership only; never supplies user roles or directory identity. */
109
+ export const recursiveServiceAccountGroupIdsSubquery = (serviceAccountId: SqlValue) => sql`
110
+ WITH RECURSIVE account_subject AS (
111
+ -- Resolve a correlated column before introducing aliases used by directory callers (for example sa.id).
112
+ SELECT ${serviceAccountId}::uuid AS id
113
+ ), account_groups AS (
114
+ SELECT sg.group_id
115
+ FROM auth.service_account_groups sg
116
+ JOIN auth.service_accounts sa ON sa.id = sg.service_account_id
117
+ JOIN account_subject subject ON subject.id = sa.id
118
+ JOIN auth.groups g ON g.id = sg.group_id
119
+ WHERE sa.status = 'active' AND sa.kind IN ('standalone', 'agent')
120
+ AND g.provider = 'local'
121
+ UNION
122
+ SELECT gg.parent_group_id
123
+ FROM auth.group_groups_v2 gg
124
+ JOIN auth.groups parent ON parent.id = gg.parent_group_id
125
+ JOIN account_groups ag ON ag.group_id = gg.child_group_id
126
+ WHERE parent.provider = 'local'
127
+ )
128
+ SELECT group_id FROM account_groups
129
+ `;