@k2b/cloud 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/package.json +11 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/GroupCoverage.tsx +20 -5
  21. package/src/access/PermissionEditor.tsx +20 -5
  22. package/src/access/PrincipalAvatar.tsx +30 -21
  23. package/src/access/PrincipalPicker.tsx +14 -4
  24. package/src/access/ResourceApiKeys.tsx +3 -4
  25. package/src/access/app-access-messages.ts +6 -0
  26. package/src/access/messages.ts +4 -4
  27. package/src/account/EntitySearch.tsx +12 -0
  28. package/src/account/ProfilePictureDialog.tsx +699 -0
  29. package/src/account/profile-picture-messages.ts +269 -0
  30. package/src/account/ui.ts +7 -0
  31. package/src/ai/admin.ts +3 -0
  32. package/src/ai/app-frame.ts +12 -0
  33. package/src/ai/application-tasks.ts +101 -0
  34. package/src/ai/assistant-models.ts +126 -64
  35. package/src/ai/capabilities.ts +19 -4
  36. package/src/ai/chart-block.ts +63 -30
  37. package/src/ai/chat/chart-block.tsx +12 -3
  38. package/src/ai/chat/composer-adapter.ts +8 -7
  39. package/src/ai/chat/message-actions.tsx +1 -1
  40. package/src/ai/chat/message-utils.ts +2 -2
  41. package/src/ai/chat/presentation.tsx +22 -1
  42. package/src/ai/chat-quotas.ts +5 -4
  43. package/src/ai/chat-tasks.ts +2 -2
  44. package/src/ai/client/controller.ts +11 -10
  45. package/src/ai/client/projection.ts +3 -3
  46. package/src/ai/compaction.ts +3 -3
  47. package/src/ai/credentials.ts +13 -114
  48. package/src/ai/default-tool-contracts.ts +4 -3
  49. package/src/ai/dictation-runtime.ts +10 -6
  50. package/src/ai/enrich.ts +2 -2
  51. package/src/ai/executor.ts +40 -25
  52. package/src/ai/http.ts +17 -29
  53. package/src/ai/index.ts +12 -5
  54. package/src/ai/inference-calls.ts +5 -4
  55. package/src/ai/maintenance.ts +1 -1
  56. package/src/ai/memory-learning-runs.ts +5 -5
  57. package/src/ai/memory-learning.ts +3 -3
  58. package/src/ai/message-queue.ts +1 -2
  59. package/src/ai/migrate.ts +75 -42
  60. package/src/ai/model-access-migrate.ts +3 -17
  61. package/src/ai/model-access.ts +44 -85
  62. package/src/ai/model-card.tsx +134 -0
  63. package/src/ai/model-catalog.ts +359 -0
  64. package/src/ai/model-chooser-messages.ts +88 -0
  65. package/src/ai/model-chooser.tsx +577 -0
  66. package/src/ai/model-delete.ts +234 -0
  67. package/src/ai/model-fixture.ts +39 -0
  68. package/src/ai/model-icon.tsx +36 -0
  69. package/src/ai/model-overview.ts +53 -0
  70. package/src/ai/model-usage.ts +45 -0
  71. package/src/ai/openai-api-messages.ts +0 -4
  72. package/src/ai/openai-api.ts +26 -9
  73. package/src/ai/personal-agent.ts +1 -1
  74. package/src/ai/prefs.ts +12 -10
  75. package/src/ai/project-tool.ts +4 -4
  76. package/src/ai/projects-authorization.ts +53 -0
  77. package/src/ai/projects-routes.ts +58 -47
  78. package/src/ai/projects.ts +110 -88
  79. package/src/ai/protocol.ts +2 -2
  80. package/src/ai/provider-connections.ts +161 -0
  81. package/src/ai/provider-errors.ts +55 -6
  82. package/src/ai/provider-fetch.ts +4 -0
  83. package/src/ai/provider.ts +1 -1
  84. package/src/ai/quota-limits.ts +36 -0
  85. package/src/ai/quota-provider.ts +4 -2
  86. package/src/ai/quota-report.ts +7 -4
  87. package/src/ai/quotas.ts +19 -8
  88. package/src/ai/recent-resources.ts +9 -3
  89. package/src/ai/request-headers.ts +14 -87
  90. package/src/ai/resource-refs.ts +6 -2
  91. package/src/ai/routes.ts +47 -48
  92. package/src/ai/runtime.ts +50 -22
  93. package/src/ai/settings.ts +93 -580
  94. package/src/ai/skill-tool.ts +4 -4
  95. package/src/ai/skills-routes.ts +27 -26
  96. package/src/ai/skills.ts +120 -81
  97. package/src/ai/store.ts +45 -23
  98. package/src/ai/stream.ts +1 -1
  99. package/src/ai/structured-runs.ts +2 -2
  100. package/src/ai/structured.ts +21 -44
  101. package/src/ai/task-contracts.ts +25 -5
  102. package/src/ai/task-execution.ts +15 -6
  103. package/src/ai/task-model.ts +9 -0
  104. package/src/ai/transcription.ts +27 -22
  105. package/src/ai/types.ts +50 -62
  106. package/src/ai/ui.tsx +13 -0
  107. package/src/ai/usage.ts +8 -8
  108. package/src/ai/validate.ts +8 -7
  109. package/src/ai/view-block.ts +2 -2
  110. package/src/api/accounts-entities.ts +22 -3
  111. package/src/api/admin-ai-quotas.ts +34 -56
  112. package/src/api/admin-ai-skills.ts +4 -1
  113. package/src/api/admin-ai-usage.ts +1 -1
  114. package/src/api/admin-ai.ts +173 -0
  115. package/src/api/admin-core-settings.ts +68 -244
  116. package/src/api/app-approval.ts +3 -1
  117. package/src/api/app-event-delivery.ts +51 -0
  118. package/src/api/app-events.ts +123 -0
  119. package/src/api/app-resource-access.ts +48 -0
  120. package/src/api/auth/account-mismatch.ts +24 -0
  121. package/src/api/auth.ts +35 -5
  122. package/src/api/capabilities.ts +28 -4
  123. package/src/api/index.ts +7 -1
  124. package/src/api/mcp.ts +175 -2
  125. package/src/api/me.ts +6 -4
  126. package/src/api/pwa-phone.ts +75 -26
  127. package/src/api/pwa.ts +34 -8
  128. package/src/api/resource-links.ts +83 -6
  129. package/src/api/resource-resolve.ts +50 -0
  130. package/src/api/search/schemas.ts +3 -1
  131. package/src/api/search.ts +9 -7
  132. package/src/app-pwa/contracts.ts +4 -0
  133. package/src/app-pwa/core-query-error.ts +20 -0
  134. package/src/app-pwa/index.ts +1 -0
  135. package/src/app-pwa/service.ts +91 -0
  136. package/src/app-pwa/transport.ts +22 -0
  137. package/src/approvals/messages.ts +2 -2
  138. package/src/browser/cloud-id.ts +86 -0
  139. package/src/browser/resource-clipboard.ts +4 -0
  140. package/src/browser/resource-links.ts +52 -0
  141. package/src/browser/session.ts +39 -0
  142. package/src/browser/shared-text-history-validation.ts +151 -0
  143. package/src/browser/shared-text-history.ts +464 -0
  144. package/src/browser/shared-text-offline-authorized.ts +29 -0
  145. package/src/browser/shared-text-offline.ts +351 -0
  146. package/src/browser/shared-text-provider.ts +714 -0
  147. package/src/browser/shared-text-table-contract.ts +150 -0
  148. package/src/browser/shared-text.ts +19 -0
  149. package/src/capabilities/server-invoke.ts +2 -11
  150. package/src/capabilities/testing.ts +5 -2
  151. package/src/cli/access.ts +6 -5
  152. package/src/cli/admin/ai-models.ts +199 -0
  153. package/src/cli/admin/ai-quotas.ts +6 -102
  154. package/src/cli/admin/ai-usage.ts +4 -4
  155. package/src/cli/admin/app-credentials.ts +5 -3
  156. package/src/cli/admin/index.ts +5 -2
  157. package/src/cli/apps.ts +89 -26
  158. package/src/cli/capabilities.ts +22 -1
  159. package/src/cli/events.ts +320 -0
  160. package/src/config/env.ts +40 -0
  161. package/src/contracts/app-access.ts +26 -2
  162. package/src/contracts/app-details.ts +89 -0
  163. package/src/contracts/app-events.ts +51 -0
  164. package/src/contracts/app.ts +21 -5
  165. package/src/contracts/capabilities.ts +143 -3
  166. package/src/contracts/index.ts +2 -0
  167. package/src/contracts/notification-types.ts +36 -9
  168. package/src/contracts/pwa.ts +17 -1
  169. package/src/contracts/registry.ts +6 -0
  170. package/src/contracts/resource-clipboard.ts +35 -0
  171. package/src/contracts/resource-pages.ts +140 -0
  172. package/src/contracts/resource-resolution.ts +13 -0
  173. package/src/contracts/shared.ts +6 -1
  174. package/src/contracts/user-notifications.ts +2 -0
  175. package/src/events/app-event-delivery.ts +100 -0
  176. package/src/events/app-events.testing.ts +65 -0
  177. package/src/events/app-events.ts +134 -0
  178. package/src/events/core-user-created.ts +24 -0
  179. package/src/events/index.ts +9 -0
  180. package/src/events/testing.ts +2 -0
  181. package/src/index.ts +1 -1
  182. package/src/server/api/respond.ts +17 -4
  183. package/src/server/index.ts +2 -0
  184. package/src/server/middleware/app-access.ts +1 -1
  185. package/src/server/middleware/auth.ts +21 -7
  186. package/src/server/middleware/index.ts +1 -0
  187. package/src/server/middleware/invocation.ts +2 -0
  188. package/src/server/public-storage.ts +106 -0
  189. package/src/server/services/access.ts +31 -10
  190. package/src/server/services/index.ts +1 -0
  191. package/src/services/accounts/app.ts +36 -13
  192. package/src/services/accounts/entities.ts +43 -19
  193. package/src/services/accounts/group-sql.ts +23 -0
  194. package/src/services/accounts/groups.ts +19 -14
  195. package/src/services/accounts/identities.ts +70 -11
  196. package/src/services/accounts/identity-reconciliation.ts +3 -1
  197. package/src/services/accounts/local-groups.ts +69 -4
  198. package/src/services/accounts/users.ts +45 -14
  199. package/src/services/app-access.ts +25 -7
  200. package/src/services/app-details.ts +224 -0
  201. package/src/services/app-discovery.ts +2 -1
  202. package/src/services/app-event-shapes.ts +139 -0
  203. package/src/services/app-events/accept.ts +116 -0
  204. package/src/services/app-events/admin.ts +250 -0
  205. package/src/services/app-events/declarations.ts +187 -0
  206. package/src/services/app-events/deliver.ts +218 -0
  207. package/src/services/app-events.ts +92 -0
  208. package/src/services/config-conflict.ts +11 -0
  209. package/src/services/core-user-events.ts +1 -0
  210. package/src/services/freeipa-config.ts +9 -0
  211. package/src/services/geo/ledger.ts +28 -10
  212. package/src/services/geo/service.ts +43 -24
  213. package/src/services/identity/core-origin.ts +12 -0
  214. package/src/services/identity/invocation-actor.ts +20 -2
  215. package/src/services/identity/invocation-authority.ts +4 -0
  216. package/src/services/identity/invocation-token.ts +238 -56
  217. package/src/services/identity/issue-invocation.ts +6 -1
  218. package/src/services/image-metadata.ts +65 -1
  219. package/src/services/index.ts +13 -1
  220. package/src/services/ipa/sync.ts +6 -2
  221. package/src/services/ipa/users.ts +4 -1
  222. package/src/services/mandates/index.ts +2 -2
  223. package/src/services/mandates/policy.ts +20 -12
  224. package/src/services/notifications/browser.ts +4 -0
  225. package/src/services/notifications/catalog.ts +5 -2
  226. package/src/services/notifications/platform.ts +7 -2
  227. package/src/services/notifications/user.ts +18 -7
  228. package/src/services/oauth-tokens.ts +28 -2
  229. package/src/services/object-store.ts +217 -0
  230. package/src/services/providers/local/users.ts +5 -0
  231. package/src/services/pwa-devices.ts +147 -37
  232. package/src/services/session/index.ts +26 -3
  233. package/src/services/settings/core-settings.ts +71 -43
  234. package/src/services/settings/index.ts +8 -0
  235. package/src/services/settings/versions.ts +26 -0
  236. package/src/shared/ai-models.ts +274 -0
  237. package/src/shared/ai-quotas.ts +2 -2
  238. package/src/shared/ai-usage.ts +2 -2
  239. package/src/shared/email-html.ts +1 -0
  240. package/src/shared/index.ts +2 -0
  241. package/src/shared/markdown/extensions/katex.ts +8 -1
  242. package/src/shared/markdown/extensions/tables.ts +14 -1
  243. package/src/shared/markdown/index.ts +22 -11
  244. package/src/shared-text/anchors.ts +29 -0
  245. package/src/shared-text/awareness.ts +87 -0
  246. package/src/shared-text/changes.ts +27 -0
  247. package/src/shared-text/documents.ts +433 -65
  248. package/src/shared-text/events.ts +12 -2
  249. package/src/shared-text/index.ts +16 -1
  250. package/src/shared-text/log.ts +24 -5
  251. package/src/shared-text/snapshots.ts +69 -23
  252. package/src/shared-text/structural-history.ts +377 -0
  253. package/src/shared-text/table-actions.ts +432 -0
  254. package/src/shared-text/text-diff.ts +75 -0
  255. package/src/shared-text/types.ts +24 -5
  256. package/src/ssr/AdminArea.tsx +131 -0
  257. package/src/ssr/AdminLayout.tsx +9 -3
  258. package/src/ssr/AdminSidebar.tsx +7 -7
  259. package/src/ssr/AppAccessDenied.tsx +2 -0
  260. package/src/ssr/AppBadge.tsx +14 -0
  261. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  262. package/src/ssr/CloudShell.island.tsx +44 -7
  263. package/src/ssr/Layout.tsx +9 -7
  264. package/src/ssr/MobileProfileActions.tsx +2 -2
  265. package/src/ssr/ProfilePreferences.tsx +2 -1
  266. package/src/ssr/PwaLayout.tsx +16 -1
  267. package/src/ssr/PwaPartCount.island.tsx +23 -0
  268. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  269. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  270. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  271. package/src/ssr/admin-active-link.ts +16 -0
  272. package/src/ssr/admin-navigation.ts +26 -31
  273. package/src/ssr/app-badges.ts +31 -7
  274. package/src/ssr/app-launchpad.tsx +6 -1
  275. package/src/ssr/index.ts +8 -0
  276. package/src/ssr/page-trail-runtime.ts +3 -1
  277. package/src/ssr/platform-messages.ts +11 -1
  278. package/src/ssr/profile-actions.ts +3 -6
  279. package/src/ssr/pwa-messages.ts +2 -2
  280. package/src/ssr/rail-navigation.ts +5 -1
  281. package/src/ssr/settings-draft.ts +153 -0
  282. package/src/ssr/shell-data.tsx +3 -1
  283. package/src/styles/global.css +1 -0
  284. package/src/styles/profile-picture.css +270 -0
  285. package/src/styles/utilities-navigation.css +6 -0
  286. package/src/workflows/ai/policy.ts +49 -0
  287. package/src/workflows/ai/runtime.ts +131 -21
  288. package/src/workflows/ai/store.ts +283 -35
  289. package/src/workflows/ai/types.ts +8 -2
  290. package/src/workflows/ai-actions.ts +148 -120
  291. package/src/workflows/authoring.ts +31 -0
  292. package/src/workflows/contracts.ts +1 -0
  293. package/src/workflows/definition.ts +2 -0
  294. package/src/workflows/editor.ts +15 -0
  295. package/src/workflows/index.ts +2 -1
  296. package/src/workflows/language/compiler.ts +34 -3
  297. package/src/workflows/language/expressions.ts +3 -2
  298. package/src/workflows/runtime/executor.ts +134 -34
  299. package/src/workflows/runtime/index.ts +2 -0
  300. package/src/workflows/runtime/ports.ts +5 -1
  301. package/src/workflows/store/actions.ts +1 -1
  302. package/src/workflows/store/definitions.ts +5 -0
  303. package/src/workflows/store/runs.ts +5 -3
  304. package/src/workflows/store/worker.ts +7 -2
  305. package/src/ai/model-pricing.ts +0 -43
  306. package/src/ai/model-request-settings.ts +0 -98
  307. package/src/ai/settings-messages.ts +0 -129
  308. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -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
 
@@ -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);
@@ -634,11 +648,20 @@ export const session = {
634
648
 
635
649
  revoke: revokeToken,
636
650
 
637
- /** Signs out the web session (or an explicit bearer session). The mobile app's session is unaffected. */
638
- delete: async (c: Context): Promise<void> => {
651
+ /** Signs out the web session (or an explicit bearer). False when an expected account no longer owns the request's credential. */
652
+ delete: async (c: Context, expectedUserId?: string | null): Promise<boolean> => {
653
+ // Capture once: another tab may replace the browser cookie while this
654
+ // request runs, but both the comparison and revocation concern this token.
639
655
  const token = getWebOrBearerToken(c);
656
+ if (token && expectedUserId !== undefined) {
657
+ const claims = await verifySessionToken(token);
658
+ if (claims && claims.sub !== expectedUserId) return false;
659
+ }
640
660
  if (token) await revokeToken(token, c.req.header("x-request-id"));
641
- deleteCookie(c, "session_token", { path: "/" });
661
+ // Bound browser logout revokes the captured family without mutating the
662
+ // shared cookie: its response may arrive after another tab signs in.
663
+ if (expectedUserId === undefined) deleteCookie(c, "session_token", { path: "/" });
664
+ return true;
642
665
  },
643
666
 
644
667
  issueInTransaction,
@@ -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)",
@@ -315,48 +385,6 @@ export const CORE_SETTINGS = {
315
385
  default: false,
316
386
  description: "Enable Cloud AI features including personal conversations, Assistant, and app Capabilities.",
317
387
  },
318
- "ai.default_model_id": {
319
- kind: "string",
320
- label: "Default Model ID",
321
- default: "",
322
- description: "Model profile id used when an app asks for the platform default AI model.",
323
- placeholder: "e.g. openrouter-fast",
324
- },
325
- "ai.model_profiles_json": {
326
- kind: "text",
327
- label: "Model Profiles",
328
- default: "[]",
329
- description: "JSON array of model profiles. Provider API keys are stored on the profile they belong to.",
330
- placeholder:
331
- '[{"id":"openrouter-fast","label":"OpenRouter Fast","provider":"openrouter","model":"openai/gpt-4.1-mini","enabled":true,"dataBoundary":"hosted","capabilities":["streaming"],"apiKey":"sk-or-..."}]',
332
- },
333
- "ai.background_model_id": {
334
- kind: "string",
335
- label: "Background Model ID",
336
- default: "",
337
- description: "Model profile id used for background AI jobs. Empty = platform default model.",
338
- placeholder: "e.g. openrouter-fast",
339
- },
340
- "ai.audio_model_id": {
341
- kind: "string",
342
- label: "Audio Model ID",
343
- default: "",
344
- description: "Audio transcription model profile for dictation and audio files. Empty disables transcription.",
345
- },
346
- "ai.vision_model_id": {
347
- kind: "string",
348
- label: "Vision Model ID",
349
- default: "",
350
- description: "Vision-capable model profile used by the view_image tool. Empty disables the tool fallback.",
351
- placeholder: "e.g. openrouter-vision",
352
- },
353
- "ai.workflow_model_id": {
354
- kind: "string",
355
- label: "Workflow Model ID",
356
- default: "",
357
- description: "Model profile id used for AI workflow actions. Empty = background model, then platform default.",
358
- placeholder: "e.g. openrouter-fast",
359
- },
360
388
  "ai.enrich_cron": {
361
389
  kind: "cron",
362
390
  label: "Chat Enrichment Schedule",
@@ -828,7 +856,7 @@ export const CORE_SETTINGS = {
828
856
  label: "Requests Per Second",
829
857
  default: 60,
830
858
  min: 1,
831
- 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",
832
860
  },
833
861
 
834
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
+ };