@k2b/cloud 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,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
+ };
@@ -5,6 +5,7 @@ import { accessMessages } from "../../access/messages";
5
5
  import { recursiveGroupIdsSubquery } 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
 
@@ -305,6 +306,13 @@ export const deleteAccess = async (params: { id: string }, db: AccessDb = sql):
305
306
  * Each app implements this to connect their resources to auth.access.
306
307
  */
307
308
  export type ResourceAccessAdapter<TResourceId = string> = {
309
+ /**
310
+ * Whether any resource in this adapter has an effective read/write/admin right.
311
+ * Use an existence query, never enumerate resources. Preserve domain eligibility,
312
+ * principal semantics, resource binding and credential ceilings. Use db and signal.
313
+ * Optional for existing adapters; required when selected by appAccess.resources().
314
+ */
315
+ hasAnyRight?: (subject: AccessSubject, context: { authority: RequestAuthority; db: AccessDb; signal: AbortSignal }) => Promise<boolean>;
308
316
  /** Get all access entries for a resource */
309
317
  list: (resourceId: TResourceId) => Promise<AccessEntry[]>;
310
318
  /** Add an access entry to a resource */
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import { accountCategory } from "../../contracts/account-categories";
3
+ import type { PersonAvatar } from "../../contracts/avatar";
3
4
  import type {
4
5
  BaseGroup,
5
6
  BaseUser,
@@ -7,6 +8,8 @@ import type {
7
8
  EntityListItem,
8
9
  GroupMember,
9
10
  MutationResult,
11
+ ServiceAccountKind,
12
+ ServiceAccountStatus,
10
13
  User,
11
14
  UserProfile,
12
15
  UserProvider,
@@ -34,6 +37,7 @@ import { providers } from "../providers";
34
37
  import { session } from "../session";
35
38
  import { get as getSetting } from "../settings";
36
39
  import { type AccountsActor, canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget } from "./authz";
40
+ import type { AvatarChange, AvatarState } from "./avatar";
37
41
  import { listDuplicateEmails } from "./duplicate-emails";
38
42
  import * as entities from "./entities";
39
43
  import * as groups from "./groups";
@@ -378,6 +382,29 @@ export const accountsAppService = {
378
382
  result,
379
383
  });
380
384
  },
385
+ getAvatarState: async (config: { id: string }) => users.getAvatarState(config),
386
+ /** One save of the profile picture dialog: a photo, a built avatar, or which stored picture others see. */
387
+ updateAvatar: async (config: { actor: AccountsActor; id: string; change: AvatarChange }) => {
388
+ const target = await users.getMinimal({ id: config.id });
389
+ const targetInfo = userTarget(target);
390
+ const selfService = config.actor.userId === config.id;
391
+ if (!selfService) {
392
+ const adminError = await requireAdminActor<AvatarState<PersonAvatar>>({
393
+ actor: config.actor,
394
+ action: "accounts.user.avatar.update",
395
+ target: targetInfo,
396
+ });
397
+ if (adminError) return adminError;
398
+ }
399
+ const result = fromMutationResult(await users.updateAvatar({ id: config.id, change: config.change }));
400
+ return recordCompletedMutation({
401
+ action: "accounts.user.avatar.update",
402
+ actor: auditActor(config.actor),
403
+ target: targetInfo,
404
+ metadata: { selfService, kind: result.ok ? result.data.kind : null, avatarHash: result.ok ? result.data.avatarHash : null },
405
+ result,
406
+ });
407
+ },
381
408
  group: {
382
409
  list: async (config: {
383
410
  userId: string;
@@ -388,6 +415,7 @@ export const accountsAppService = {
388
415
  const userGroups = await users.getGroups({
389
416
  id: config.userId,
390
417
  recursive: config.recursive,
418
+ includePersonal: false,
391
419
  });
392
420
  const query = config.filter?.query?.trim().toLowerCase();
393
421
  const filtered = query ? userGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : userGroups;
@@ -1227,6 +1255,8 @@ export const accountsAppService = {
1227
1255
  profile?: UserProfile;
1228
1256
  userIds?: string[];
1229
1257
  groupIds?: string[];
1258
+ serviceAccountKinds?: ServiceAccountKind[];
1259
+ serviceAccountStatus?: ServiceAccountStatus;
1230
1260
  excludeUserIds?: string[];
1231
1261
  excludeGroupIds?: string[];
1232
1262
  excludeServiceAccountIds?: string[];
@@ -1258,6 +1288,8 @@ export const accountsAppService = {
1258
1288
  profile: config.profile,
1259
1289
  userIds: config.userIds,
1260
1290
  groupIds: config.groupIds,
1291
+ serviceAccountKinds: config.serviceAccountKinds,
1292
+ serviceAccountStatus: config.serviceAccountStatus,
1261
1293
  excludeUserIds: config.excludeUserIds,
1262
1294
  excludeGroupIds: config.excludeGroupIds,
1263
1295
  excludeServiceAccountIds: config.excludeServiceAccountIds,
@@ -1629,9 +1661,9 @@ export const accountsAppService = {
1629
1661
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local') AS local_accounts_total,
1630
1662
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'user') AS local_user_accounts_total,
1631
1663
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'guest') AS local_guest_accounts_total,
1632
- (SELECT COUNT(*)::int FROM auth.groups) AS groups_total,
1633
- (SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'ipa') AS ipa_groups_total,
1634
- (SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'local') AS local_groups_total,
1664
+ (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,
1665
+ (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,
1666
+ (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,
1635
1667
  (SELECT COUNT(*)::int FROM auth.account_requests WHERE status = 'pending') AS open_requests,
1636
1668
  (
1637
1669
  SELECT COUNT(*)::int
@@ -1,7 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { sql } from "bun";
3
+ import {
4
+ type AvatarConfig,
5
+ type AvatarKind,
6
+ canonicalAvatarConfig,
7
+ type PersonAvatar,
8
+ PersonAvatarSchema,
9
+ type RobotAvatar,
10
+ RobotAvatarSchema,
11
+ robotAvatarFromSeed,
12
+ } from "../../contracts/avatar";
3
13
  import { MAX_AVATAR_BYTES, MAX_AVATAR_DATA_URL_LENGTH } from "../../contracts/profile";
4
14
  import type { MutationResult } from "../../contracts/shared";
15
+ import { AVATAR_RENDERER_REVISION, renderAvatarSvg } from "../../shared/avatar-art";
5
16
  import { stripImageMetadata } from "../image-metadata";
6
17
 
7
18
  export { MAX_AVATAR_BYTES, MAX_AVATAR_DATA_URL_LENGTH } from "../../contracts/profile";
@@ -78,46 +89,342 @@ export const parseAvatarDataUrl = (input: string): MutationResult<ParsedAvatarDa
78
89
  };
79
90
  };
80
91
 
81
- export const getAvatar = async (params: { id: string }): Promise<StoredAvatar | null> => {
82
- const [row] = await sql<{ avatar_data_url: string | null; avatar_hash: string | null }[]>`
83
- SELECT avatar_data_url, avatar_hash
84
- FROM auth.users
85
- WHERE id = ${params.id}::uuid
86
- `;
87
- if (!row?.avatar_data_url || !row.avatar_hash) return null;
92
+ /** The image others see under an account's avatar URL. */
93
+ export type ServedAvatar = {
94
+ hash: string;
95
+ contentType: ParsedAvatarDataUrl["contentType"] | "image/svg+xml";
96
+ bytes: Buffer;
97
+ };
98
+
99
+ /**
100
+ * What the profile picture dialog shows: the active kind, the built config, and the stored photo. Only the account
101
+ * holder (or an administrator for a service account) reads this; others see only the active picture.
102
+ */
103
+ export type AvatarState<Built extends AvatarConfig = AvatarConfig> = {
104
+ kind: AvatarKind;
105
+ built: Built | null;
106
+ hasPhoto: boolean;
107
+ photo: string | null;
108
+ avatarHash: string | null;
109
+ };
110
+
111
+ /** One save in the dialog: store and show a photo or a built avatar, or switch to what is stored. */
112
+ export type AvatarChange<Built extends AvatarConfig = AvatarConfig> = { dataUrl: string } | { built: Built } | { use: AvatarKind };
113
+
114
+ type AvatarRow = {
115
+ id: string;
116
+ avatar_kind: AvatarKind | null;
117
+ avatar_config: unknown;
118
+ avatar_data_url: string | null;
119
+ avatar_hash: string | null;
120
+ };
121
+
122
+ /** The hash of a built avatar; it changes with the renderer revision, so caches refetch a redrawn avatar. */
123
+ export const builtAvatarHash = (config: AvatarConfig): string =>
124
+ createHash("sha256")
125
+ .update(`built:${AVATAR_RENDERER_REVISION}:${canonicalAvatarConfig(config)}`)
126
+ .digest("hex");
88
127
 
128
+ const svgAvatar = (config: AvatarConfig, hash: string): ServedAvatar => ({
129
+ hash,
130
+ contentType: "image/svg+xml",
131
+ bytes: Buffer.from(renderAvatarSvg(config)),
132
+ });
133
+
134
+ const photoAvatar = (row: AvatarRow): ServedAvatar | null => {
135
+ if (!row.avatar_data_url || !row.avatar_hash) return null;
89
136
  const parsed = parseAvatarDataUrl(row.avatar_data_url);
90
- if (!parsed.ok) return null;
91
- if (parsed.data.hash !== row.avatar_hash) return null;
137
+ if (!parsed.ok || parsed.data.hash !== row.avatar_hash) return null;
92
138
  return parsed.data;
93
139
  };
94
140
 
95
- export const setAvatar = async (params: { id: string; dataUrl: string }): Promise<MutationResult<{ avatarHash: string }>> => {
96
- const parsed = parseAvatarDataUrl(params.dataUrl);
97
- if (!parsed.ok) return parsed;
141
+ const parsePerson = (value: unknown): PersonAvatar | null => {
142
+ const parsed = PersonAvatarSchema.safeParse(value);
143
+ return parsed.success ? parsed.data : null;
144
+ };
145
+ const parseRobot = (value: unknown): RobotAvatar | null => {
146
+ const parsed = RobotAvatarSchema.safeParse(value);
147
+ return parsed.success ? parsed.data : null;
148
+ };
149
+
150
+ /** A row from before built avatars has no kind: a stored photo is active, otherwise initials. */
151
+ const userKind = (row: AvatarRow): AvatarKind => row.avatar_kind ?? (row.avatar_data_url ? "photo" : "initials");
152
+
153
+ /**
154
+ * A service account always shows a picture: its photo, or its robot, drawn from the account id until customised. The
155
+ * seed is the id as stored, so every spelling of the id in a URL draws the same robot.
156
+ */
157
+ const serviceAccountRobot = (row: AvatarRow): RobotAvatar => parseRobot(row.avatar_config) ?? robotAvatarFromSeed(row.id);
158
+
159
+ const readUserRow = async (db: typeof sql, id: string, lock = false): Promise<AvatarRow | undefined> => {
160
+ const [row] = lock
161
+ ? await db<AvatarRow[]>`
162
+ SELECT id, avatar_kind, avatar_config, avatar_data_url, avatar_hash FROM auth.users WHERE id = ${id}::uuid FOR UPDATE
163
+ `
164
+ : await db<AvatarRow[]>`SELECT id, avatar_kind, avatar_config, avatar_data_url, avatar_hash FROM auth.users WHERE id = ${id}::uuid`;
165
+ return row;
166
+ };
167
+
168
+ const readServiceAccountRow = async (db: typeof sql, id: string, lock = false): Promise<AvatarRow | undefined> => {
169
+ const [row] = lock
170
+ ? await db<AvatarRow[]>`
171
+ SELECT id, avatar_kind, avatar_config, avatar_data_url, avatar_hash FROM auth.service_accounts WHERE id = ${id}::uuid FOR UPDATE
172
+ `
173
+ : await db<AvatarRow[]>`
174
+ SELECT id, avatar_kind, avatar_config, avatar_data_url, avatar_hash FROM auth.service_accounts WHERE id = ${id}::uuid
175
+ `;
176
+ return row;
177
+ };
98
178
 
179
+ /**
180
+ * The image others see for a person or a service account, or null when a person shows initials. An inactive photo is
181
+ * never served.
182
+ */
183
+ export const getAvatar = async (params: { id: string }): Promise<ServedAvatar | null> => {
184
+ const user = await readUserRow(sql, params.id);
185
+ if (user) {
186
+ const kind = userKind(user);
187
+ if (kind === "photo") return photoAvatar(user);
188
+ if (kind === "built") {
189
+ // The served hash comes from the renderer drawing these bytes, not from storage: while a rollout mixes renderer
190
+ // revisions, a stored hash of another revision never marks these bytes as immutable.
191
+ const config = parsePerson(user.avatar_config);
192
+ return config ? svgAvatar(config, builtAvatarHash(config)) : null;
193
+ }
194
+ return null;
195
+ }
196
+ const account = await readServiceAccountRow(sql, params.id);
197
+ if (!account) return null;
198
+ if (account.avatar_kind === "photo") return photoAvatar(account);
199
+ const robot = serviceAccountRobot(account);
200
+ return svgAvatar(robot, builtAvatarHash(robot));
201
+ };
202
+
203
+ const userState = (row: AvatarRow): AvatarState<PersonAvatar> => ({
204
+ kind: userKind(row),
205
+ built: parsePerson(row.avatar_config),
206
+ hasPhoto: row.avatar_data_url !== null,
207
+ photo: row.avatar_data_url,
208
+ avatarHash: row.avatar_hash,
209
+ });
210
+
211
+ const serviceAccountState = (row: AvatarRow): AvatarState<RobotAvatar> => {
212
+ const robot = serviceAccountRobot(row);
213
+ return {
214
+ kind: row.avatar_kind === "photo" ? "photo" : "built",
215
+ built: robot,
216
+ hasPhoto: row.avatar_data_url !== null,
217
+ photo: row.avatar_data_url,
218
+ avatarHash: row.avatar_kind === "photo" ? row.avatar_hash : (row.avatar_hash ?? builtAvatarHash(robot)),
219
+ };
220
+ };
221
+
222
+ export const getAvatarState = async (params: { id: string }): Promise<AvatarState<PersonAvatar> | null> => {
223
+ const row = await readUserRow(sql, params.id);
224
+ return row ? userState(row) : null;
225
+ };
226
+
227
+ export const getServiceAccountAvatarState = async (params: { id: string }): Promise<AvatarState<RobotAvatar> | null> => {
228
+ const row = await readServiceAccountRow(sql, params.id);
229
+ return row ? serviceAccountState(row) : null;
230
+ };
231
+
232
+ const strippedPhoto = (dataUrl: string): MutationResult<{ dataUrl: string; hash: string }> => {
233
+ const parsed = parseAvatarDataUrl(dataUrl);
234
+ if (!parsed.ok) return parsed;
99
235
  const bytes = Buffer.from(stripImageMetadata(parsed.data.bytes));
100
- const dataUrl = `data:${parsed.data.contentType};base64,${bytes.toString("base64")}`;
101
- const rows = await sql<{ avatar_hash: string }[]>`
102
- UPDATE auth.users
103
- SET avatar_data_url = ${dataUrl},
104
- avatar_hash = ${hashAvatar(dataUrl)}
105
- WHERE id = ${params.id}::uuid
106
- RETURNING avatar_hash
107
- `;
108
- const avatarHash = rows[0]?.avatar_hash;
109
- if (!avatarHash) return { ok: false, error: "User not found", status: 404 };
110
- return { ok: true, data: { avatarHash } };
111
- };
112
-
113
- export const clearAvatar = async (params: { id: string }): Promise<MutationResult<void>> => {
114
- const rows = await sql<{ id: string }[]>`
115
- UPDATE auth.users
116
- SET avatar_data_url = NULL,
117
- avatar_hash = NULL
118
- WHERE id = ${params.id}::uuid
119
- RETURNING id
120
- `;
121
- if (!rows[0]) return { ok: false, error: "User not found", status: 404 };
122
- return { ok: true, data: undefined };
236
+ const stripped = `data:${parsed.data.contentType};base64,${bytes.toString("base64")}`;
237
+ return { ok: true, data: { dataUrl: stripped, hash: hashAvatar(stripped) } };
238
+ };
239
+
240
+ const notFound = { ok: false, error: "User not found", status: 404 } as const;
241
+ const missing = (what: string) => ({ ok: false, error: `No ${what} is stored to show.`, status: 409 }) as const;
242
+
243
+ /** A change whose photo is already stripped; stripping happens before any storage access and throws a typed 422. */
244
+ type PreparedChange = { photo: { dataUrl: string; hash: string } } | Exclude<AvatarChange, { dataUrl: string }>;
245
+
246
+ const prepareChange = (change: AvatarChange): MutationResult<PreparedChange> => {
247
+ if (!("dataUrl" in change)) return { ok: true, data: change };
248
+ const photo = strippedPhoto(change.dataUrl);
249
+ return photo.ok ? { ok: true, data: { photo: photo.data } } : photo;
250
+ };
251
+
252
+ /** Applies one dialog save to a person: the new values for the avatar columns, or why the save cannot happen. */
253
+ const nextUserColumns = (row: AvatarRow, change: PreparedChange): MutationResult<AvatarRow> => {
254
+ if ("photo" in change) {
255
+ return { ok: true, data: { ...row, avatar_kind: "photo", avatar_data_url: change.photo.dataUrl, avatar_hash: change.photo.hash } };
256
+ }
257
+ if ("built" in change) {
258
+ const config = parsePerson(change.built);
259
+ if (!config) return { ok: false, error: "A person's avatar must use the person shape set.", status: 400 };
260
+ return { ok: true, data: { ...row, avatar_kind: "built", avatar_config: config, avatar_hash: builtAvatarHash(config) } };
261
+ }
262
+ if (change.use === "photo") {
263
+ if (!row.avatar_data_url) return missing("photo");
264
+ return { ok: true, data: { ...row, avatar_kind: "photo", avatar_hash: hashAvatar(row.avatar_data_url) } };
265
+ }
266
+ if (change.use === "built") {
267
+ const config = parsePerson(row.avatar_config);
268
+ if (!config) return missing("avatar");
269
+ return { ok: true, data: { ...row, avatar_kind: "built", avatar_hash: builtAvatarHash(config) } };
270
+ }
271
+ return { ok: true, data: { ...row, avatar_kind: "initials", avatar_hash: null } };
272
+ };
273
+
274
+ const nextServiceAccountColumns = (row: AvatarRow, change: PreparedChange): MutationResult<AvatarRow> => {
275
+ if ("photo" in change) {
276
+ return { ok: true, data: { ...row, avatar_kind: "photo", avatar_data_url: change.photo.dataUrl, avatar_hash: change.photo.hash } };
277
+ }
278
+ if ("built" in change) {
279
+ const config = parseRobot(change.built);
280
+ if (!config) return { ok: false, error: "A service account's avatar must use the robot shape set.", status: 400 };
281
+ return { ok: true, data: { ...row, avatar_kind: "built", avatar_config: config, avatar_hash: builtAvatarHash(config) } };
282
+ }
283
+ if (change.use === "photo") {
284
+ if (!row.avatar_data_url) return missing("photo");
285
+ return { ok: true, data: { ...row, avatar_kind: "photo", avatar_hash: hashAvatar(row.avatar_data_url) } };
286
+ }
287
+ if (change.use === "built") {
288
+ const config = parseRobot(row.avatar_config);
289
+ return { ok: true, data: { ...row, avatar_kind: "built", avatar_hash: config ? builtAvatarHash(config) : null } };
290
+ }
291
+ return { ok: false, error: "A service account always shows a picture: a photo or its robot.", status: 400 };
292
+ };
293
+
294
+ const writeUserColumns = (db: typeof sql, id: string, row: AvatarRow) => db`
295
+ UPDATE auth.users
296
+ SET avatar_kind = ${row.avatar_kind},
297
+ avatar_config = ${row.avatar_config === null ? null : JSON.stringify(row.avatar_config)}::text::jsonb,
298
+ avatar_data_url = ${row.avatar_data_url},
299
+ avatar_hash = ${row.avatar_hash}
300
+ WHERE id = ${id}::uuid
301
+ `;
302
+
303
+ const writeServiceAccountColumns = (db: typeof sql, id: string, row: AvatarRow) => db`
304
+ UPDATE auth.service_accounts
305
+ SET avatar_kind = ${row.avatar_kind},
306
+ avatar_config = ${row.avatar_config === null ? null : JSON.stringify(row.avatar_config)}::text::jsonb,
307
+ avatar_data_url = ${row.avatar_data_url},
308
+ avatar_hash = ${row.avatar_hash}
309
+ WHERE id = ${id}::uuid
310
+ `;
311
+
312
+ /** Saves a person's profile picture: a photo, a built avatar, or which stored picture others see. */
313
+ export const updateAvatar = async (params: { id: string; change: AvatarChange }): Promise<MutationResult<AvatarState<PersonAvatar>>> => {
314
+ const change = prepareChange(params.change);
315
+ if (!change.ok) return change;
316
+ return sql.begin(async (tx) => {
317
+ const row = await readUserRow(tx, params.id, true);
318
+ if (!row) return notFound;
319
+ const next = nextUserColumns(row, change.data);
320
+ if (!next.ok) return next;
321
+ await writeUserColumns(tx, params.id, next.data);
322
+ return { ok: true, data: userState(next.data) };
323
+ });
324
+ };
325
+
326
+ /** Saves a service account's picture: a photo, a customised robot, or which of them others see. */
327
+ export const updateServiceAccountAvatar = async (params: {
328
+ id: string;
329
+ change: AvatarChange;
330
+ }): Promise<MutationResult<AvatarState<RobotAvatar>>> => {
331
+ const change = prepareChange(params.change);
332
+ if (!change.ok) return change;
333
+ return sql.begin(async (tx) => {
334
+ const row = await readServiceAccountRow(tx, params.id, true);
335
+ if (!row) return { ok: false, error: "Service account not found", status: 404 };
336
+ const next = nextServiceAccountColumns(row, change.data);
337
+ if (!next.ok) return next;
338
+ await writeServiceAccountColumns(tx, params.id, next.data);
339
+ return { ok: true, data: serviceAccountState(next.data) };
340
+ });
341
+ };
342
+
343
+ /** Stores and shows a photo. Kept for the photo-only callers; same as `updateAvatar` with `{ dataUrl }`. */
344
+ export const setAvatar = async (params: { id: string; dataUrl: string }): Promise<MutationResult<{ avatarHash: string }>> => {
345
+ const result = await updateAvatar({ id: params.id, change: { dataUrl: params.dataUrl } });
346
+ if (!result.ok) return result;
347
+ return { ok: true, data: { avatarHash: result.data.avatarHash ?? "" } };
348
+ };
349
+
350
+ /** Deletes a person's photo. When the photo was showing, initials show instead; a shown built avatar stays. */
351
+ export const clearAvatar = async (params: { id: string }): Promise<MutationResult<void>> =>
352
+ sql.begin(async (tx) => {
353
+ const row = await readUserRow(tx, params.id, true);
354
+ if (!row) return notFound;
355
+ const showingPhoto = userKind(row) === "photo";
356
+ await writeUserColumns(tx, params.id, {
357
+ ...row,
358
+ avatar_kind: showingPhoto ? "initials" : row.avatar_kind,
359
+ avatar_data_url: null,
360
+ avatar_hash: showingPhoto ? null : row.avatar_hash,
361
+ });
362
+ return { ok: true, data: undefined };
363
+ });
364
+
365
+ /** Deletes a service account's photo; its robot shows again. */
366
+ export const clearServiceAccountAvatar = async (params: { id: string }): Promise<MutationResult<AvatarState<RobotAvatar>>> =>
367
+ sql.begin(async (tx) => {
368
+ const row = await readServiceAccountRow(tx, params.id, true);
369
+ if (!row) return { ok: false, error: "Service account not found", status: 404 };
370
+ const config = parseRobot(row.avatar_config);
371
+ const next: AvatarRow = { ...row, avatar_kind: "built", avatar_data_url: null, avatar_hash: config ? builtAvatarHash(config) : null };
372
+ await writeServiceAccountColumns(tx, params.id, next);
373
+ return { ok: true, data: serviceAccountState(next) };
374
+ });
375
+
376
+ const REFRESH_BATCH = 500;
377
+
378
+ type RefreshRow = { id: string; avatar_config: unknown; avatar_hash: string | null };
379
+
380
+ /**
381
+ * Writes the current renderer's hash for one stored built avatar, only if the row still holds the config and hash
382
+ * that were read: a save on another replica in between wins and keeps its own hash.
383
+ */
384
+ export const writeRefreshedHash = async (db: typeof sql, table: "users" | "service_accounts", row: RefreshRow): Promise<boolean> => {
385
+ const config = table === "users" ? parsePerson(row.avatar_config) : parseRobot(row.avatar_config);
386
+ const hash = config ? builtAvatarHash(config) : null;
387
+ if (!hash || hash === row.avatar_hash) return false;
388
+ const configText = JSON.stringify(row.avatar_config);
389
+ const written =
390
+ table === "users"
391
+ ? await db`
392
+ UPDATE auth.users SET avatar_hash = ${hash}
393
+ WHERE id = ${row.id}::uuid AND avatar_kind = 'built'
394
+ AND avatar_config = ${configText}::text::jsonb AND avatar_hash IS NOT DISTINCT FROM ${row.avatar_hash}
395
+ `
396
+ : await db`
397
+ UPDATE auth.service_accounts SET avatar_hash = ${hash}
398
+ WHERE id = ${row.id}::uuid AND COALESCE(avatar_kind, 'built') = 'built'
399
+ AND avatar_config = ${configText}::text::jsonb AND avatar_hash IS NOT DISTINCT FROM ${row.avatar_hash}
400
+ `;
401
+ return written.count > 0;
402
+ };
403
+
404
+ /**
405
+ * Re-hashes stored built avatars after the renderer revision changed, so every client fetches the redrawn image. Runs
406
+ * at startup in bounded batches and writes only rows whose hash differs.
407
+ */
408
+ export const refreshBuiltAvatarHashes = async (db: typeof sql = sql): Promise<number> => {
409
+ let updated = 0;
410
+ for (const table of ["users", "service_accounts"] as const) {
411
+ let after = "00000000-0000-0000-0000-000000000000";
412
+ for (;;) {
413
+ const rows =
414
+ table === "users"
415
+ ? await db<RefreshRow[]>`
416
+ SELECT id, avatar_config, avatar_hash FROM auth.users
417
+ WHERE avatar_kind = 'built' AND id > ${after}::uuid ORDER BY id LIMIT ${REFRESH_BATCH}
418
+ `
419
+ : await db<RefreshRow[]>`
420
+ SELECT id, avatar_config, avatar_hash FROM auth.service_accounts
421
+ WHERE COALESCE(avatar_kind, 'built') = 'built' AND avatar_config IS NOT NULL AND id > ${after}::uuid
422
+ ORDER BY id LIMIT ${REFRESH_BATCH}
423
+ `;
424
+ for (const row of rows) if (await writeRefreshedHash(db, table, row)) updated++;
425
+ if (rows.length < REFRESH_BATCH) break;
426
+ after = rows.at(-1)?.id ?? after;
427
+ }
428
+ }
429
+ return updated;
123
430
  };
@@ -4,8 +4,7 @@ import type { BaseGroup, UserProvider } from "../../contracts/shared";
4
4
  type DbRow = Record<string, unknown>;
5
5
 
6
6
  /**
7
- * Joins the owner of a personal Linux group (the group stored as a user's
8
- * `auth.user_posix.primary_group_id`) onto `auth.groups g` as
7
+ * Joins the durable owner of a personal Linux group onto `auth.groups g` as
9
8
  * `personal_owner_id`, `personal_owner_uid`, and `personal_owner_display_name`.
10
9
  * A function, so importing this module never binds Bun's default `sql` handle.
11
10
  */
@@ -15,9 +14,9 @@ export const personalOwnerJoin = () => sql`
15
14
  owner.id AS personal_owner_id,
16
15
  owner.uid AS personal_owner_uid,
17
16
  COALESCE(NULLIF(owner.display_name, ''), NULLIF(owner.mail, ''), owner.uid) AS personal_owner_display_name
18
- FROM auth.user_posix owner_posix
19
- JOIN auth.users owner ON owner.id = owner_posix.user_id
20
- WHERE owner_posix.primary_group_id = g.id
17
+ FROM auth.personal_groups owner_group
18
+ JOIN auth.users owner ON owner.id = owner_group.owner_id
19
+ WHERE owner_group.group_id = g.id AND owner_group.retired_at IS NULL
21
20
  ORDER BY owner.id
22
21
  LIMIT 1
23
22
  ) personal_owner ON TRUE