@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
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import type { MutationResult, UserProfile } from "../../../contracts/shared";
3
+ import { coreUserEventPublisher } from "../../../events/core-user-created";
3
4
  import { isAccountCategoryAllowed } from "../../account-category-policy";
4
5
  import { writeDeletedAccountAudit } from "../../account-lifecycle/audit";
5
6
  import { emailAlreadyUsed, findAccountsByEmail, lockAccountEmail, normalizeAccountEmail } from "../../accounts/email-write";
@@ -96,6 +97,10 @@ export const create = async (params: {
96
97
  )
97
98
  RETURNING id
98
99
  `;
100
+ await coreUserEventPublisher().publish(tx, "user.created", {
101
+ subject: rows[0]!.id,
102
+ data: { userId: rows[0]!.id, provider: "local" },
103
+ });
99
104
  return { ok: true, data: { id: rows[0]!.id } };
100
105
  }, params.actor);
101
106
  } catch (error) {
@@ -52,6 +52,7 @@ export type PwaDevicePairedNotice = {
52
52
  platform: PwaPlatform;
53
53
  pairedAt: string;
54
54
  locale: string | null;
55
+ pairedBy?: { userId: string | null; name: string };
55
56
  };
56
57
  export type PwaDeviceNotifier = (notice: PwaDevicePairedNotice) => Promise<unknown>;
57
58
 
@@ -59,6 +60,8 @@ type PairingRow = {
59
60
  id: string;
60
61
  user_id: string;
61
62
  initiator_sid: string;
63
+ initiator_user_id: string | null;
64
+ device_confirmation_hash: string | null;
62
65
  auth_epoch: string;
63
66
  comparison: string | null;
64
67
  platform: PwaPlatform | null;
@@ -83,6 +86,8 @@ type DeviceRow = {
83
86
  revoked_at: Date | null;
84
87
  locale: string | null;
85
88
  notified_at: Date | null;
89
+ paired_by_user_id: string | null;
90
+ paired_by_name: string | null;
86
91
  };
87
92
  type UserRow = {
88
93
  id: string;
@@ -126,7 +131,7 @@ export const parseDeviceKey = (value: string | null | undefined): { deviceId: st
126
131
 
127
132
  /** Untranslated default names; the person renames the phone in the app. */
128
133
  export const defaultDeviceName = (platform: PwaPlatform, userAgent: string | undefined): string =>
129
- platform === "ios" ? (/\biPad\b/.test(userAgent ?? "") ? "iPad" : "iPhone") : platform === "android" ? "Android" : "Phone";
134
+ platform === "ios" ? (/\biPad\b/.test(userAgent ?? "") ? "iPad" : "iPhone") : platform === "android" ? "Android" : "Device";
130
135
 
131
136
  const accountName = (user: Pick<UserRow, "display_name" | "uid">) => user.display_name?.trim() || user.uid;
132
137
 
@@ -160,10 +165,17 @@ const initiatorValid = async (tx: SQL, pairing: PairingRow): Promise<boolean> =>
160
165
  SELECT 1 FROM auth.session_families sf
161
166
  JOIN auth.users u ON u.id = sf.user_id
162
167
  JOIN auth.signing_keys sk ON sk.kid = sf.signing_kid AND sk.state <> 'revoked'
163
- WHERE sf.sid = ${pairing.initiator_sid}::uuid AND sf.user_id = ${pairing.user_id}::uuid
168
+ WHERE sf.sid = ${pairing.initiator_sid}::uuid AND sf.user_id = ${pairing.initiator_user_id ?? pairing.user_id}::uuid
164
169
  AND sf.revoked_at IS NULL AND sf.expires_at > now() AND sf.auth_epoch = u.auth_epoch AND sf.pwa_device_id IS NULL
170
+ AND (${pairing.initiator_user_id === null} OR u.admin = true)
171
+ AND (${pairing.initiator_user_id === null} OR u.account_expires IS NULL OR u.account_expires > now())
165
172
  `;
166
- return Boolean(row);
173
+ if (!row) return false;
174
+ if (pairing.initiator_user_id) {
175
+ const [admin] = await tx<UserRow[]>`SELECT * FROM auth.users WHERE id = ${pairing.initiator_user_id}::uuid`;
176
+ return Boolean(admin && (await isAccountCategoryAllowed(admin, tx)));
177
+ }
178
+ return true;
167
179
  };
168
180
 
169
181
  const accountExpired = (user: UserRow) => Boolean(user.account_expires && passed(user.account_expires));
@@ -176,6 +188,7 @@ const pairedNotice = (device: DeviceRow, name: string): PwaDevicePairedNotice =>
176
188
  platform: device.platform,
177
189
  pairedAt: iso(device.created_at),
178
190
  locale: device.locale,
191
+ ...(device.paired_by_name ? { pairedBy: { userId: device.paired_by_user_id, name: device.paired_by_name } } : {}),
179
192
  });
180
193
 
181
194
  /**
@@ -268,6 +281,14 @@ export type PwaRenewal =
268
281
 
269
282
  export type PwaCompletion =
270
283
  | { state: "paired"; credentials: PwaCredentials | null; platform: PwaPlatform }
284
+ | { state: "rejected" }
285
+ | {
286
+ state: "confirmation_required";
287
+ account: { name: string };
288
+ administrator: { name: string };
289
+ confirmationToken: string;
290
+ expiresAt: string;
291
+ }
271
292
  | { state: "waiting"; code: string; account: { name: string }; expiresAt: string };
272
293
 
273
294
  export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairingClaimHints) => {
@@ -336,28 +357,48 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
336
357
  return value;
337
358
  };
338
359
 
360
+ const startPairing = async (actor: PwaWebActor, targetUserId?: string) => {
361
+ const id = crypto.randomUUID();
362
+ const secret = pairingSecret.create();
363
+ const now = Date.now();
364
+ const claimUntil = new Date(now + limits.linkClaimSeconds * 1000);
365
+ const expiresAt = new Date(now + limits.pairingSeconds * 1000);
366
+ await sql.begin(async (tx) => {
367
+ const userId = targetUserId ?? actor.userId;
368
+ await tx`SELECT id FROM auth.users WHERE id IN (${actor.userId}::uuid, ${userId}::uuid) ORDER BY id FOR UPDATE`;
369
+ const account = await requireRecentWebSession(tx, actor, limits.recentSessionSeconds, (code) => fail(code, 403));
370
+ if (targetUserId) {
371
+ const [admin] = await tx`SELECT admin FROM auth.users WHERE id = ${actor.userId}::uuid`;
372
+ if (!admin?.admin) fail("FORBIDDEN", 403);
373
+ }
374
+ const target = targetUserId ? await lockUser(tx, targetUserId) : account;
375
+ if (!target) return fail("NOT_FOUND", 404);
376
+ if (targetUserId && (accountExpired(target) || !(await isAccountCategoryAllowed(target, tx)))) fail("ACCOUNT_BLOCKED", 403);
377
+ const [open] = await tx<{ count: number }[]>`
378
+ SELECT count(*)::int AS count FROM auth.pwa_pairings
379
+ WHERE user_id = ${userId}::uuid AND expires_at > now() AND state IN ('pending', 'claimed', 'awaiting_device', 'confirmed')
380
+ `;
381
+ if ((open?.count ?? 0) >= limits.pendingPerAccount) fail("LIMIT_REACHED", 429);
382
+ await tx`INSERT INTO auth.pwa_pairings (id, user_id, initiator_sid, initiator_user_id, auth_epoch, link_secret_hash, state, claim_until, expires_at)
383
+ VALUES (${id}::uuid, ${userId}::uuid, ${actor.sid}::uuid, ${targetUserId ? actor.userId : null}::uuid, ${target.auth_epoch}, ${pairingSecret.hash(secret)}, 'pending', ${claimUntil}, ${expiresAt})`;
384
+ await record(
385
+ tx,
386
+ "pairing.start",
387
+ { type: "pwa_pairing", id },
388
+ actor.userId,
389
+ targetUserId ? { targetUserId, administratorUserId: actor.userId } : {},
390
+ );
391
+ });
392
+ return { id, secret, claimUntil: iso(claimUntil), expiresAt: iso(expiresAt) };
393
+ };
394
+
339
395
  return {
340
396
  // ---------- Web side: the initiating web session ----------
341
397
 
342
- startPairing: async (actor: PwaWebActor) => {
343
- const id = crypto.randomUUID();
344
- const secret = pairingSecret.create();
345
- const now = Date.now();
346
- const claimUntil = new Date(now + limits.linkClaimSeconds * 1000);
347
- const expiresAt = new Date(now + limits.pairingSeconds * 1000);
348
- await sql.begin(async (tx) => {
349
- await lockUser(tx, actor.userId);
350
- const account = await requireRecentWebSession(tx, actor, limits.recentSessionSeconds, (code) => fail(code, 403));
351
- const [open] = await tx<{ count: number }[]>`
352
- SELECT count(*)::int AS count FROM auth.pwa_pairings
353
- WHERE user_id = ${actor.userId}::uuid AND expires_at > now() AND state IN ('pending', 'claimed', 'confirmed')
354
- `;
355
- if ((open?.count ?? 0) >= limits.pendingPerAccount) fail("LIMIT_REACHED", 429);
356
- await tx`INSERT INTO auth.pwa_pairings (id, user_id, initiator_sid, auth_epoch, link_secret_hash, state, claim_until, expires_at)
357
- VALUES (${id}::uuid, ${actor.userId}::uuid, ${actor.sid}::uuid, ${account.auth_epoch}, ${pairingSecret.hash(secret)}, 'pending', ${claimUntil}, ${expiresAt})`;
358
- await record(tx, "pairing.start", { type: "pwa_pairing", id }, actor.userId);
359
- });
360
- return { id, secret, claimUntil: iso(claimUntil), expiresAt: iso(expiresAt) };
398
+ startPairing: (actor: PwaWebActor) => startPairing(actor),
399
+ startUserPairing: (actor: PwaWebActor & PwaDeviceAdministrator, userId: string) => {
400
+ if (!actor.admin) return fail("FORBIDDEN", 403);
401
+ return startPairing(actor, userId);
361
402
  },
362
403
 
363
404
  /** Never returns the code: the person types what the phone shows. */
@@ -365,12 +406,12 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
365
406
  const read = async (validateInitiator = false) => {
366
407
  const [p] = await sql<(PairingRow & { current_epoch: string })[]>`
367
408
  SELECT p.*, u.auth_epoch AS current_epoch FROM auth.pwa_pairings p JOIN auth.users u ON u.id = p.user_id
368
- WHERE p.id = ${id}::uuid AND p.user_id = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
409
+ WHERE p.id = ${id}::uuid AND COALESCE(p.initiator_user_id, p.user_id) = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
369
410
  `;
370
411
  if (!p) return fail("NOT_FOUND", 404);
371
412
  if (passed(p.expires_at) || p.current_epoch !== p.auth_epoch || (p.state === "pending" && passed(p.claim_until)))
372
413
  return fail("EXPIRED", 410);
373
- if (validateInitiator && !(await initiatorValid(sql, p))) return fail("EXPIRED", 410);
414
+ if ((validateInitiator || p.initiator_user_id !== null) && !(await initiatorValid(sql, p))) return fail("EXPIRED", 410);
374
415
  return {
375
416
  state: p.state,
376
417
  claimUntil: iso(p.claim_until),
@@ -406,10 +447,10 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
406
447
  confirmPairing: async (actor: PwaWebActor, id: string, code: string) => {
407
448
  type Outcome = { ok: true } | { ok: false; code: PwaErrorCode; status: PwaError["status"]; attemptsLeft?: number };
408
449
  const outcome = await sql.begin(async (tx): Promise<Outcome> => {
409
- const user = await lockUser(tx, actor.userId);
410
- const [p] = await tx<PairingRow[]>`
411
- SELECT * FROM auth.pwa_pairings WHERE id = ${id}::uuid AND user_id = ${actor.userId}::uuid AND initiator_sid = ${actor.sid}::uuid FOR UPDATE
412
- `;
450
+ const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${id}::uuid
451
+ AND COALESCE(initiator_user_id, user_id) = ${actor.userId}::uuid AND initiator_sid = ${actor.sid}::uuid`;
452
+ const user = snapshot ? await lockUser(tx, snapshot.user_id) : null;
453
+ const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot?.id ?? null}::uuid FOR UPDATE`;
413
454
  if (!user || !p) return { ok: false, code: "NOT_FOUND", status: 404 };
414
455
  if (p.state === "cancelled" || passed(p.expires_at) || user.auth_epoch !== p.auth_epoch || !(await initiatorValid(tx, p)))
415
456
  return { ok: false, code: "EXPIRED", status: 410 };
@@ -426,8 +467,11 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
426
467
  await tx`UPDATE auth.pwa_pairings SET failed_attempts = ${failed} WHERE id = ${p.id}::uuid`;
427
468
  return { ok: false, code: "WRONG_CODE", status: 409, attemptsLeft: limits.confirmAttempts - failed };
428
469
  }
429
- await tx`UPDATE auth.pwa_pairings SET state = 'confirmed' WHERE id = ${p.id}::uuid`;
430
- await record(tx, "pairing.confirm", { type: "pwa_pairing", id: p.id }, actor.userId, { platform: p.platform });
470
+ await tx`UPDATE auth.pwa_pairings SET state = ${p.initiator_user_id ? "awaiting_device" : "confirmed"} WHERE id = ${p.id}::uuid`;
471
+ await record(tx, "pairing.confirm", { type: "pwa_pairing", id: p.id }, actor.userId, {
472
+ platform: p.platform,
473
+ ...(p.initiator_user_id ? { targetUserId: p.user_id, administratorUserId: p.initiator_user_id } : {}),
474
+ });
431
475
  return { ok: true };
432
476
  });
433
477
  if (!outcome.ok) fail(outcome.code, outcome.status, outcome.attemptsLeft);
@@ -435,10 +479,10 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
435
479
 
436
480
  cancelPairing: async (actor: PwaWebActor, id: string) => {
437
481
  await sql.begin(async (tx) => {
438
- await lockUser(tx, actor.userId);
439
- const [p] = await tx<PairingRow[]>`
440
- SELECT * FROM auth.pwa_pairings WHERE id = ${id}::uuid AND user_id = ${actor.userId}::uuid AND initiator_sid = ${actor.sid}::uuid FOR UPDATE
441
- `;
482
+ const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${id}::uuid
483
+ AND COALESCE(initiator_user_id, user_id) = ${actor.userId}::uuid AND initiator_sid = ${actor.sid}::uuid`;
484
+ if (snapshot) await lockUser(tx, snapshot.user_id);
485
+ const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot?.id ?? null}::uuid FOR UPDATE`;
442
486
  if (!p) return fail("NOT_FOUND", 404);
443
487
  if (p.state === "completed") return fail("CONFLICT", 409);
444
488
  if (p.state === "cancelled") return;
@@ -517,6 +561,28 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
517
561
  SELECT * FROM auth.pwa_pairings WHERE completion_secret_hash = ${pairingSecret.hash(input.completionSecret)}
518
562
  `;
519
563
  if (!snapshot || snapshot.state === "cancelled" || passed(snapshot.expires_at)) return fail("EXPIRED", 410);
564
+ if (snapshot.state === "rejected") return { state: "rejected" };
565
+ if (snapshot.state === "awaiting_device") {
566
+ return sql.begin(async (tx) => {
567
+ const user = await lockUser(tx, snapshot.user_id);
568
+ const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot.id}::uuid FOR UPDATE`;
569
+ if (!user || !p || passed(p.expires_at) || user.auth_epoch !== p.auth_epoch || !(await initiatorValid(tx, p)))
570
+ return fail("EXPIRED", 410);
571
+ if (p.state !== "awaiting_device" || !p.initiator_user_id) return fail("CONFLICT", 409);
572
+ if (accountExpired(user) || !(await isAccountCategoryAllowed(user, tx))) return fail("ACCOUNT_BLOCKED", 403);
573
+ const [admin] = await tx<UserRow[]>`SELECT * FROM auth.users WHERE id = ${p.initiator_user_id}::uuid`;
574
+ if (!admin) return fail("EXPIRED", 410);
575
+ const confirmationToken = pairingSecret.create();
576
+ await tx`UPDATE auth.pwa_pairings SET device_confirmation_hash = ${pairingSecret.hash(confirmationToken)} WHERE id = ${p.id}::uuid`;
577
+ return {
578
+ state: "confirmation_required" as const,
579
+ account: { name: accountName(user) },
580
+ administrator: { name: accountName(admin) },
581
+ confirmationToken,
582
+ expiresAt: iso(p.expires_at),
583
+ };
584
+ });
585
+ }
520
586
  if (snapshot.state === "claimed") {
521
587
  const [user] = await sql<UserRow[]>`SELECT * FROM auth.users WHERE id = ${snapshot.user_id}::uuid`;
522
588
  // Signing out on the web, or everywhere, voids the pairing at once.
@@ -570,12 +636,17 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
570
636
  // An unusually long locale tag is dropped; the notice then uses the operator's locale.
571
637
  const locale = input.locale && input.locale.length <= 35 ? input.locale : null;
572
638
  const [device] = await tx<DeviceRow[]>`
573
- INSERT INTO auth.pwa_devices (id, user_id, name, platform, auth_epoch, secret_hash, rotated_at, locale)
574
- VALUES (${deviceId}::uuid, ${user.id}::uuid, ${p.device_name}, ${p.platform}, ${user.auth_epoch}, ${pairingSecret.hash(secret)}, now(), ${locale})
639
+ INSERT INTO auth.pwa_devices (id, user_id, name, platform, auth_epoch, secret_hash, rotated_at, locale, paired_by_user_id, paired_by_name)
640
+ VALUES (${deviceId}::uuid, ${user.id}::uuid, ${p.device_name}, ${p.platform}, ${user.auth_epoch}, ${pairingSecret.hash(secret)}, now(), ${locale}, ${p.initiator_user_id}::uuid,
641
+ (SELECT COALESCE(NULLIF(trim(display_name), ''), uid) FROM auth.users WHERE id = ${p.initiator_user_id}::uuid))
575
642
  RETURNING *`;
576
643
  await issue(user.id, appSession(deviceId));
577
644
  await tx`UPDATE auth.pwa_pairings SET state = 'completed', device_id = ${deviceId}::uuid WHERE id = ${p.id}::uuid`;
578
- await record(tx, "device.enroll", { type: "pwa_device", id: deviceId }, user.id, { platform: p.platform, pairingId: p.id });
645
+ await record(tx, "device.enroll", { type: "pwa_device", id: deviceId }, p.initiator_user_id ?? user.id, {
646
+ platform: p.platform,
647
+ pairingId: p.id,
648
+ ...(p.initiator_user_id ? { targetUserId: user.id, administratorUserId: p.initiator_user_id } : {}),
649
+ });
579
650
  return { kind: "issued", deviceKey: `${deviceId}.${secret}`, notice: pairedNotice(device!, p.device_name) };
580
651
  });
581
652
  const platform = snapshot.platform ?? "other";
@@ -585,6 +656,45 @@ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairing
585
656
  return { state: "paired", credentials: { deviceKey: value.deviceKey, sessionToken: token }, platform };
586
657
  },
587
658
 
659
+ decidePairing: async (
660
+ completionSecret: string | null | undefined,
661
+ input: { confirmationToken: string; decision: "confirm" | "reject" },
662
+ ) => {
663
+ if (!completionSecret) return fail("EXPIRED", 410);
664
+ const [snapshot] = await sql<
665
+ PairingRow[]
666
+ >`SELECT * FROM auth.pwa_pairings WHERE completion_secret_hash = ${pairingSecret.hash(completionSecret)}`;
667
+ if (!snapshot) return fail("EXPIRED", 410);
668
+ await sql.begin(async (tx) => {
669
+ const user = await lockUser(tx, snapshot.user_id);
670
+ const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot.id}::uuid FOR UPDATE`;
671
+ if (
672
+ !user ||
673
+ !p ||
674
+ !p.initiator_user_id ||
675
+ passed(p.expires_at) ||
676
+ user.auth_epoch !== p.auth_epoch ||
677
+ !(await initiatorValid(tx, p))
678
+ )
679
+ return fail("EXPIRED", 410);
680
+ if (!p.device_confirmation_hash || !pairingSecret.matches(input.confirmationToken, p.device_confirmation_hash))
681
+ return fail("FORBIDDEN", 403);
682
+ const next = input.decision === "confirm" ? "confirmed" : "rejected";
683
+ if (p.state === next || (next === "confirmed" && p.state === "completed")) return;
684
+ if (p.state !== "awaiting_device") return fail("CONFLICT", 409);
685
+ if (accountExpired(user) || !(await isAccountCategoryAllowed(user, tx))) return fail("ACCOUNT_BLOCKED", 403);
686
+ await tx`UPDATE auth.pwa_pairings SET state = ${next} WHERE id = ${p.id}::uuid`;
687
+ await record(
688
+ tx,
689
+ input.decision === "confirm" ? "pairing.device_confirm" : "pairing.reject",
690
+ { type: "pwa_pairing", id: p.id },
691
+ user.id,
692
+ { targetUserId: user.id, administratorUserId: p.initiator_user_id },
693
+ );
694
+ });
695
+ hints.publish(snapshot.id);
696
+ },
697
+
588
698
  /** Renewal with device-key rotation; `launch` and `renew` share it. */
589
699
  renew: renewal,
590
700
 
@@ -491,8 +491,10 @@ export const createStandaloneApiToken = async (params: {
491
491
  name: string;
492
492
  expiresAt?: string | null;
493
493
  scopes?: string[];
494
+ access?: "full" | "models";
494
495
  }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
495
- if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
496
+ if (params.scopes?.includes(AI_INFERENCE_SCOPE) || (params.access === "models" && params.scopes?.length))
497
+ return fail(err.badInput("Models-only access cannot be combined with general credential scopes"));
496
498
  const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
497
499
  if (!serviceAccount || !isStandaloneServiceAccountKind(serviceAccount.kind)) return fail(err.notFound("Service account"));
498
500
  if (serviceAccount.status !== "active") return fail(err.badInput("Service account is disabled"));
@@ -503,7 +505,7 @@ export const createStandaloneApiToken = async (params: {
503
505
  name: params.name,
504
506
  expiresAt: params.expiresAt,
505
507
  createdBy: params.actor.id,
506
- scopes: params.scopes,
508
+ scopes: params.access === "models" ? [AI_INFERENCE_SCOPE] : params.scopes,
507
509
  });
508
510
 
509
511
  return audit.recordResult({
@@ -514,6 +516,7 @@ export const createStandaloneApiToken = async (params: {
514
516
  serviceAccountId: serviceAccount.id,
515
517
  kind: "api_token",
516
518
  serviceAccountKind: serviceAccount.kind,
519
+ access: params.access ?? "full",
517
520
  expiresAt: params.expiresAt ?? null,
518
521
  },
519
522
  result,
@@ -31,6 +31,14 @@ import { loadJwtSession, loadJwtSessionIdentity } from "./user";
31
31
 
32
32
  const log = logger("auth:session");
33
33
 
34
+ /** A browser already signed in must prove that same account before continuing. */
35
+ export class SessionAccountMismatchError extends Error {
36
+ constructor() {
37
+ super("Sign in with the account already open in this browser.");
38
+ this.name = "SessionAccountMismatchError";
39
+ }
40
+ }
41
+
34
42
  export type SessionKind = "web" | "app";
35
43
 
36
44
  export type SessionData = {
@@ -585,6 +593,12 @@ export const session = {
585
593
  appDeviceId?: string;
586
594
  },
587
595
  ): Promise<string> => {
596
+ if (c.req.header("X-Cloud-Reauthenticate") === "1") {
597
+ const currentToken = session.getWebToken(c);
598
+ const current = currentToken ? await session.authenticateRequest(c, currentToken) : null;
599
+ if (!current || current.user.id !== userId) throw new SessionAccountMismatchError();
600
+ }
601
+
588
602
  const expiryHours = await settings.get<number>("user.session.expiry_hours");
589
603
  if (!Number.isFinite(expiryHours) || expiryHours < 1) throw new Error("user.session.expiry_hours must be a positive number");
590
604
  const ttlSeconds = Math.floor(expiryHours * 60 * 60);
@@ -30,6 +30,76 @@ const IPA_ACCOUNT_TRANSITION_OPTIONS = [
30
30
  ] as const;
31
31
 
32
32
  export const CORE_SETTINGS = {
33
+ "storage.endpoint": {
34
+ kind: "url",
35
+ label: "Garage S3 endpoint",
36
+ description: "Garage S3 API origin, such as https://s3.example.org.",
37
+ default: "",
38
+ presentation: {
39
+ translations: { de: { label: "Garage-S3-Endpunkt", description: "Origin der Garage-S3-API, etwa https://s3.example.org." } },
40
+ },
41
+ },
42
+ "storage.region": {
43
+ kind: "string",
44
+ label: "Signing region",
45
+ description: "Must match the Garage S3 region.",
46
+ default: "garage",
47
+ presentation: { translations: { de: { label: "Signaturregion", description: "Muss der S3-Region von Garage entsprechen." } } },
48
+ },
49
+ "storage.bucket": {
50
+ kind: "string",
51
+ label: "Bucket",
52
+ description: "One private Garage bucket for application objects; keep website mode disabled.",
53
+ default: "",
54
+ presentation: {
55
+ translations: {
56
+ de: { label: "Bucket", description: "Ein privater Garage-Bucket für App-Dateien; Website-Modus ausgeschaltet lassen." },
57
+ },
58
+ },
59
+ },
60
+ "storage.access_key_id": {
61
+ kind: "secret",
62
+ label: "Access key ID",
63
+ description: "Installation-wide key with read and write access to the configured bucket.",
64
+ default: "",
65
+ presentation: {
66
+ translations: {
67
+ de: {
68
+ label: "Access-Key-ID",
69
+ description: "Installationsweiter Schlüssel mit Lese- und Schreibzugriff auf den eingetragenen Bucket.",
70
+ },
71
+ },
72
+ },
73
+ },
74
+ "storage.secret_access_key": {
75
+ kind: "secret",
76
+ label: "Secret access key",
77
+ description: "Secret belonging to the installation-wide Garage key.",
78
+ default: "",
79
+ presentation: {
80
+ translations: {
81
+ de: { label: "Geheimer Zugriffsschlüssel", description: "Geheimer Teil des installationsweiten Garage-Schlüssels." },
82
+ },
83
+ },
84
+ },
85
+ "storage.max_object_bytes": {
86
+ kind: "number",
87
+ label: "Maximum object size (bytes)",
88
+ description: "Default 32 MiB covers 25 MB documents and 1–5 MB images. Budget memory for concurrent operations.",
89
+ default: 32 * 1024 ** 2,
90
+ min: 1,
91
+ max: 5 * 1024 ** 3,
92
+ integer: true,
93
+ presentation: {
94
+ translations: {
95
+ de: {
96
+ label: "Maximale Objektgröße (Bytes)",
97
+ description: "32 MiB reichen für Dokumente bis 25 MB und Bilder von 1–5 MB. Speicher für gleichzeitige Vorgänge einplanen.",
98
+ },
99
+ },
100
+ },
101
+ },
102
+
33
103
  "outgoing_mail.content_retention_days": {
34
104
  kind: "number",
35
105
  label: "Mail content retention (days)",
@@ -69,6 +139,22 @@ export const CORE_SETTINGS = {
69
139
  "Trusted HTTPS origin of the authenticator, used for app installation and pairing links. Changing this origin requires reviewing existing devices; never use a wildcard.",
70
140
  default: "https://cloud-login.pwa.k2b.dev",
71
141
  },
142
+ "user.app_approval.label": {
143
+ kind: "string",
144
+ label: "Name in Cloud Login",
145
+ description:
146
+ "Suggested label for new Cloud Login connections. Empty uses the application display name. People can change it; existing connections keep their names.",
147
+ default: "",
148
+ presentation: {
149
+ translations: {
150
+ de: {
151
+ label: "Name in Cloud Login",
152
+ description:
153
+ "Namensvorschlag für neue Cloud-Login-Verbindungen. Leer übernimmt den Anzeigenamen der Anwendung. Bestehende Verbindungen behalten ihren Namen; Nutzer können den Vorschlag ändern.",
154
+ },
155
+ },
156
+ },
157
+ },
72
158
  "user.app_approval.admin_pairing": {
73
159
  kind: "boolean",
74
160
  label: "Allow administrator-assisted pairing",
@@ -299,48 +385,6 @@ export const CORE_SETTINGS = {
299
385
  default: false,
300
386
  description: "Enable Cloud AI features including personal conversations, Assistant, and app Capabilities.",
301
387
  },
302
- "ai.default_model_id": {
303
- kind: "string",
304
- label: "Default Model ID",
305
- default: "",
306
- description: "Model profile id used when an app asks for the platform default AI model.",
307
- placeholder: "e.g. openrouter-fast",
308
- },
309
- "ai.model_profiles_json": {
310
- kind: "text",
311
- label: "Model Profiles",
312
- default: "[]",
313
- description: "JSON array of model profiles. Provider API keys are stored on the profile they belong to.",
314
- placeholder:
315
- '[{"id":"openrouter-fast","label":"OpenRouter Fast","provider":"openrouter","model":"openai/gpt-4.1-mini","enabled":true,"dataBoundary":"hosted","capabilities":["streaming"],"apiKey":"sk-or-..."}]',
316
- },
317
- "ai.background_model_id": {
318
- kind: "string",
319
- label: "Background Model ID",
320
- default: "",
321
- description: "Model profile id used for background AI jobs. Empty = platform default model.",
322
- placeholder: "e.g. openrouter-fast",
323
- },
324
- "ai.audio_model_id": {
325
- kind: "string",
326
- label: "Audio Model ID",
327
- default: "",
328
- description: "Audio transcription model profile for dictation and audio files. Empty disables transcription.",
329
- },
330
- "ai.vision_model_id": {
331
- kind: "string",
332
- label: "Vision Model ID",
333
- default: "",
334
- description: "Vision-capable model profile used by the view_image tool. Empty disables the tool fallback.",
335
- placeholder: "e.g. openrouter-vision",
336
- },
337
- "ai.workflow_model_id": {
338
- kind: "string",
339
- label: "Workflow Model ID",
340
- default: "",
341
- description: "Model profile id used for AI workflow actions. Empty = background model, then platform default.",
342
- placeholder: "e.g. openrouter-fast",
343
- },
344
388
  "ai.enrich_cron": {
345
389
  kind: "cron",
346
390
  label: "Chat Enrichment Schedule",
@@ -428,20 +472,20 @@ export const CORE_SETTINGS = {
428
472
  label: "OpenAI-compatible API",
429
473
  default: false,
430
474
  description:
431
- "Let people create API keys with the access “AI models only” and call the Assistant models they may use. Checked on every request: turning this off stops existing keys too. Models without reference prices have no usage limit; only the rate limit and the concurrent request limit protect them.",
475
+ "Let people create personal models-only API keys and administrators issue them to standalone accounts and agents. Keys use only models granted to their account. Checked on every request: turning this off stops existing keys too. Models without reference prices have no usage limit; only the rate limit and the concurrent request limit protect them.",
432
476
  },
433
477
  // Default 4: one process runs at most 8 chat turns at once (AI_TURN_WORKER_CONCURRENCY in ai/runtime.ts), and this
434
- // synchronous API bypasses that queue, so one person may hold at most half of it. Typical clients need 1-2 (one
478
+ // synchronous API bypasses that queue, so one account may hold at most half of it. Typical clients need 1-2 (one
435
479
  // request after another plus a connection test); 4 leaves room for a second client without letting a script in a
436
480
  // loop crowd out everyone else. Operators with more provider capacity raise it.
437
481
  "ai.openai_api_max_concurrent_requests": {
438
482
  kind: "number",
439
- label: "OpenAI API concurrent requests per person",
483
+ label: "OpenAI API concurrent requests per account",
440
484
  integer: true,
441
485
  min: 1,
442
486
  max: 64,
443
487
  default: 4,
444
- description: "Maximum simultaneous OpenAI-compatible API requests per person across all Cloud instances.",
488
+ description: "Maximum simultaneous OpenAI-compatible API requests per account across all Cloud instances.",
445
489
  },
446
490
  // ── PDF rendering ─────────────────────────────────────────────────────
447
491
  "gotenberg.url": {
@@ -812,7 +856,7 @@ export const CORE_SETTINGS = {
812
856
  label: "Requests Per Second",
813
857
  default: 60,
814
858
  min: 1,
815
- description: "Maximum API requests per second per IP address",
859
+ description: "Maximum API and public storage requests per second per IP address, with separate budgets",
816
860
  },
817
861
 
818
862
  // ── Command line ────────────────────────────────────────────────────────
@@ -209,6 +209,9 @@ export { invalidateSettingsCache, readStates, SettingUnreadableError } from "./s
209
209
  export async function getAll(locale?: string): Promise<SettingEntry[]> {
210
210
  const allKeys = SETTINGS.map((def) => def.key);
211
211
  const values = await readStates(allKeys);
212
+ const versions = await sql<
213
+ { key: string; updated_at: Date; updated_by: string | null; name: string | null }[]
214
+ >`SELECT e.key,e.updated_at,e.updated_by,COALESCE(NULLIF(u.display_name,''),u.uid) AS name FROM settings.entries e LEFT JOIN auth.users u ON u.id=e.updated_by`;
212
215
 
213
216
  return SETTINGS.map((def) => {
214
217
  const presentation = resolveSettingPresentation(def, locale);
@@ -218,6 +221,11 @@ export async function getAll(locale?: string): Promise<SettingEntry[]> {
218
221
  const resetValue = envFallback ?? def.default;
219
222
 
220
223
  return {
224
+ updatedAt: versions.find((row) => row.key === def.key)?.updated_at.toISOString() ?? null,
225
+ updatedBy: (() => {
226
+ const row = versions.find((row) => row.key === def.key);
227
+ return row?.updated_by ? { id: row.updated_by, name: row.name ?? row.updated_by } : null;
228
+ })(),
221
229
  key: def.key,
222
230
  label: presentation.label,
223
231
  kind: def.kind,
@@ -0,0 +1,26 @@
1
+ import { sql } from "bun";
2
+ import { ConfigConflict } from "../config-conflict";
3
+ import { SETTINGS_MAP } from "./defaults";
4
+ import { readKeyState } from "./store";
5
+
6
+ /** Lock missing keys as well as existing rows, so first writes have the same conflict guarantee. */
7
+ export const assertSettingVersions = async (expected: Record<string, string | null>, db = sql): Promise<void> => {
8
+ for (const key of Object.keys(expected).sort()) {
9
+ await db`SELECT pg_advisory_xact_lock(hashtext(${`settings:${key}`}))`;
10
+ const [row] = await db<
11
+ { updated_at: Date; updated_by: string | null }[]
12
+ >`SELECT updated_at,updated_by FROM settings.entries WHERE key=${key} FOR UPDATE`;
13
+ if ((row?.updated_at.toISOString() ?? null) === expected[key]) continue;
14
+ const [who] = row?.updated_by
15
+ ? await db<
16
+ { id: string; name: string }[]
17
+ >`SELECT id,COALESCE(NULLIF(display_name,''),uid) AS name FROM auth.users WHERE id=${row.updated_by}::uuid`
18
+ : [];
19
+ throw new ConfigConflict(
20
+ SETTINGS_MAP.has(key) && SETTINGS_MAP.get(key)?.kind !== "secret" ? (await readKeyState(key, db)).value : "",
21
+ who ?? null,
22
+ row?.updated_at.toISOString() ?? new Date(0).toISOString(),
23
+ key,
24
+ );
25
+ }
26
+ };