@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -42,7 +42,7 @@ export const legalConsent = {
42
42
  groupsAdmin,
43
43
  allowPendingLegalConsent: true,
44
44
  });
45
- if (!user || isAccountExpired(user.accountExpires) || !(await isAccountCategoryAllowed(user))) return null;
45
+ if (!user || isAccountExpired(user.accountExpires)) return null;
46
46
  const [row] = await sql<{ pending: boolean }[]>`
47
47
  SELECT legal_pending AND NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${user.id}::uuid) AS pending
48
48
  FROM auth.session_families WHERE sid = ${claims.sid}::uuid
@@ -1,3 +1,4 @@
1
+ import { readWebVitals } from "./web-vitals";
1
2
  import { sql } from "bun";
2
3
  import type { PaginationParams } from "../../contracts/shared";
3
4
  import { escapeLikePattern, parsePgJsonRecord, toPgTextArray } from "../postgres";
@@ -129,7 +130,7 @@ function write(params: WriteParams): void {
129
130
  ${params.level},
130
131
  ${params.source},
131
132
  ${params.message},
132
- ${safeMetadata ? JSON.stringify(safeMetadata) : null}::jsonb
133
+ (${safeMetadata ? JSON.stringify(safeMetadata) : null}::text)::jsonb
133
134
  )
134
135
  `.catch((err: Error) => console.error("[logging] DB write failed:", err.message));
135
136
  }
@@ -194,7 +195,7 @@ const list = async (
194
195
  WHERE source = ${filterSource}
195
196
  AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
196
197
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
197
- AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
198
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
198
199
  `;
199
200
 
200
201
  dataRows = await sql`
@@ -203,7 +204,7 @@ const list = async (
203
204
  WHERE source = ${filterSource}
204
205
  AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
205
206
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
206
- AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
207
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
207
208
  ORDER BY created_at DESC
208
209
  LIMIT ${perPage} OFFSET ${offset}
209
210
  `;
@@ -214,7 +215,7 @@ const list = async (
214
215
  WHERE source = ANY(${filterSourcesLiteral}::text[])
215
216
  AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
216
217
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
217
- AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
218
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
218
219
  `;
219
220
 
220
221
  dataRows = await sql`
@@ -223,7 +224,7 @@ const list = async (
223
224
  WHERE source = ANY(${filterSourcesLiteral}::text[])
224
225
  AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
225
226
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
226
- AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
227
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
227
228
  ORDER BY created_at DESC
228
229
  LIMIT ${perPage} OFFSET ${offset}
229
230
  `;
@@ -233,7 +234,7 @@ const list = async (
233
234
  FROM logging.entries
234
235
  WHERE (${filterLevel}::text IS NULL OR level = ${filterLevel})
235
236
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
236
- AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
237
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
237
238
  `;
238
239
 
239
240
  dataRows = await sql`
@@ -241,7 +242,7 @@ const list = async (
241
242
  FROM logging.entries
242
243
  WHERE (${filterLevel}::text IS NULL OR level = ${filterLevel})
243
244
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
244
- AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\')
245
+ AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
245
246
  ORDER BY created_at DESC
246
247
  LIMIT ${perPage} OFFSET ${offset}
247
248
  `;
@@ -321,8 +322,8 @@ const timeseries = async (
321
322
  AND (${level}::text IS NULL OR entries.level = ${level})
322
323
  AND (
323
324
  ${searchPattern}::text IS NULL
324
- OR entries.message ILIKE ${searchPattern} ESCAPE '\'
325
- OR entries.metadata::text ILIKE ${searchPattern} ESCAPE '\'
325
+ OR entries.message ILIKE ${searchPattern} ESCAPE '\\'
326
+ OR entries.metadata::text ILIKE ${searchPattern} ESCAPE '\\'
326
327
  )
327
328
  GROUP BY at, entries.level
328
329
  )
@@ -369,8 +370,8 @@ const statsBy = async (
369
370
  AND (${level}::text IS NULL OR level = ${level})
370
371
  AND (
371
372
  ${searchPattern}::text IS NULL
372
- OR message ILIKE ${searchPattern} ESCAPE '\'
373
- OR metadata::text ILIKE ${searchPattern} ESCAPE '\'
373
+ OR message ILIKE ${searchPattern} ESCAPE '\\'
374
+ OR metadata::text ILIKE ${searchPattern} ESCAPE '\\'
374
375
  )
375
376
  GROUP BY level
376
377
  ORDER BY count DESC, key ASC
@@ -387,8 +388,8 @@ const statsBy = async (
387
388
  AND (${level}::text IS NULL OR level = ${level})
388
389
  AND (
389
390
  ${searchPattern}::text IS NULL
390
- OR message ILIKE ${searchPattern} ESCAPE '\'
391
- OR metadata::text ILIKE ${searchPattern} ESCAPE '\'
391
+ OR message ILIKE ${searchPattern} ESCAPE '\\'
392
+ OR metadata::text ILIKE ${searchPattern} ESCAPE '\\'
392
393
  )
393
394
  GROUP BY source
394
395
  ORDER BY count DESC, key ASC
@@ -466,6 +467,7 @@ const summary = async (): Promise<LogSummary> => {
466
467
 
467
468
  /** Admin service object for querying/managing logs. */
468
469
  export const logging = {
470
+ webVitals: readWebVitals,
469
471
  list,
470
472
  getById,
471
473
  getSources,
@@ -1,3 +1,4 @@
1
+ import { getProcessApplicationId } from "../../_internal/process-identity";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import type { SyncEvent } from "@k2b/sync";
3
4
  import { sql } from "bun";
@@ -1034,7 +1035,7 @@ const detailAttributes = (detail: Record<string, unknown>): TraceAttributes => {
1034
1035
  };
1035
1036
 
1036
1037
  /** The app hosting this process; Sync events carry no app name. */
1037
- const processAppId = (): string | undefined => process.env.APP_ID?.trim() || undefined;
1038
+ const processAppId = getProcessApplicationId;
1038
1039
 
1039
1040
  const traceSyncEvent = async (event: SyncEvent, application?: string): Promise<void> => {
1040
1041
  const resource = event.resource;
@@ -0,0 +1,81 @@
1
+ import { sql } from "bun";
2
+ import { z } from "zod";
3
+
4
+ const ranges = {
5
+ "1h": { hours: 1, bucket: 60 },
6
+ "6h": { hours: 6, bucket: 300 },
7
+ "24h": { hours: 24, bucket: 900 },
8
+ "7d": { hours: 168, bucket: 3600 },
9
+ "30d": { hours: 720, bucket: 21600 },
10
+ } as const;
11
+ const querySchema = z.object({
12
+ range: z.enum(["1h", "6h", "24h", "7d", "30d"]).default("24h"),
13
+ appId: z.string().max(64).default(""),
14
+ route: z.string().max(200).default(""),
15
+ page: z.number().int().min(1).max(100000).default(1),
16
+ });
17
+ export type WebVitalsQuery = z.input<typeof querySchema>;
18
+ export type WebVitalName = "LCP" | "INP" | "CLS";
19
+ export type WebVitalMetric = { name: WebVitalName; count: number; p75: number };
20
+ export type WebVitalsOverview = {
21
+ summary: WebVitalMetric[];
22
+ series: (WebVitalMetric & { bucket: number })[];
23
+ routes: (WebVitalMetric & { appId: string; route: string })[];
24
+ totalRoutes: number;
25
+ page: number;
26
+ perPage: number;
27
+ };
28
+
29
+ /** Admin diagnostics. Deduplicate the last valid report inside [since, until).
30
+ * Attribute it to its last receipt time; this is not navigation-start time.
31
+ * Only aggregates leave Postgres. Retention applies before this read.
32
+ */
33
+ export async function readWebVitals(input: WebVitalsQuery, until = new Date()): Promise<WebVitalsOverview> {
34
+ const query = querySchema.parse(input);
35
+ const { hours, bucket } = ranges[query.range];
36
+ const since = new Date(until.getTime() - hours * 3600000);
37
+ const perPage = 50;
38
+ const [result] = await sql<Omit<WebVitalsOverview, "page" | "perPage">[]>`
39
+ WITH stored AS MATERIALIZED (
40
+ SELECT id, created_at,
41
+ logging.object_metadata(metadata) AS metadata
42
+ FROM logging.entries
43
+ WHERE source = 'web-vitals' AND level = 'info' AND created_at >= ${since} AND created_at < ${until}
44
+ ), candidates AS MATERIALIZED (
45
+ SELECT id, created_at, metadata->>'appId' AS app, metadata->>'routeTemplate' AS route,
46
+ metadata->>'name' AS name, metadata->>'id' AS metric_id,
47
+ CASE WHEN jsonb_typeof(metadata->'value') = 'number' THEN (metadata->>'value')::numeric END AS value
48
+ FROM stored
49
+ WHERE jsonb_typeof(metadata->'appId') = 'string' AND metadata->>'appId' ~ '^[a-z0-9-]{1,64}$'
50
+ AND jsonb_typeof(metadata->'routeTemplate') = 'string'
51
+ AND length(metadata->>'routeTemplate') BETWEEN 1 AND 200 AND metadata->>'routeTemplate' ~ '^/[a-zA-Z0-9_/:.*-]*$'
52
+ AND jsonb_typeof(metadata->'id') = 'string' AND length(metadata->>'id') BETWEEN 1 AND 100
53
+ AND metadata->>'name' IN ('LCP','INP','CLS')
54
+ AND (${query.appId} = '' OR metadata->>'appId' = ${query.appId})
55
+ AND (${query.route} = '' OR metadata->>'routeTemplate' = ${query.route})
56
+ ), latest AS MATERIALIZED (
57
+ SELECT DISTINCT ON (app, route, name, metric_id) app, route, name, value::double precision AS value, created_at
58
+ FROM candidates WHERE value >= 0 AND value <= 1.7976931348623157e308::numeric
59
+ ORDER BY app, route, name, metric_id, created_at DESC, id DESC
60
+ ), route_stats AS MATERIALIZED (
61
+ SELECT app, route, name, count(*)::int AS count,
62
+ percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75
63
+ FROM latest GROUP BY app, route, name
64
+ ), route_keys AS MATERIALIZED (
65
+ SELECT DISTINCT app, route FROM route_stats ORDER BY app, route LIMIT ${perPage} OFFSET ${(query.page - 1) * perPage}
66
+ )
67
+ SELECT
68
+ COALESCE((SELECT jsonb_agg(s) FROM (SELECT name, count(*)::int AS count, percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75 FROM latest GROUP BY name ORDER BY name) s), '[]'::jsonb) AS summary,
69
+ COALESCE((SELECT jsonb_agg(s ORDER BY s.bucket, s.name) FROM (SELECT name,
70
+ floor(extract(epoch FROM created_at) / ${bucket}) * ${bucket} * 1000 AS bucket,
71
+ count(*)::int AS count, percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75
72
+ FROM latest GROUP BY name, bucket) s), '[]'::jsonb) AS series,
73
+ COALESCE((SELECT jsonb_agg(s ORDER BY s."appId", s.route, s.name) FROM (
74
+ SELECT l.app AS "appId", l.route, l.name, l.count, l.p75
75
+ FROM route_stats l JOIN route_keys k USING (app, route)
76
+ ) s), '[]'::jsonb) AS routes,
77
+ (SELECT count(*)::int FROM (SELECT DISTINCT app, route FROM route_stats) r) AS "totalRoutes"
78
+ `;
79
+ if (!result) throw new Error("Web Vitals aggregation returned no result");
80
+ return { ...result, page: query.page, perPage };
81
+ }
@@ -9,6 +9,10 @@ import { parsePgJsonValue } from "../postgres";
9
9
  import { isMandatePolicyNarrowing, type MandatePolicyV1, MandatePolicyV1Schema, mandatePolicyAllows, parseMandatePolicy } from "./policy";
10
10
 
11
11
  export {
12
+ CapabilityGrantsSchema,
13
+ CapabilityGrantSchema,
14
+ type CapabilityGrant,
15
+ capabilityGrantAllows,
12
16
  isMandatePolicyNarrowing,
13
17
  MANDATE_POLICY_MAX_IDENTIFIERS,
14
18
  MANDATE_POLICY_VERSION,
@@ -176,7 +180,7 @@ const mapMandate = (row: MandateRow): Mandate => ({
176
180
  revokeReason: row.revoke_reason,
177
181
  });
178
182
 
179
- const selectMandate = sql`
183
+ const selectMandate = () => sql`
180
184
  SELECT id, subject_kind, subject_user_id, subject_service_account_id,
181
185
  owner_app_id, workload_type, workload_id, policy, state, revision, expires_at,
182
186
  confirmed_at, confirmation_deadline,
@@ -224,7 +228,7 @@ const parsePolicyForWorkload = (
224
228
 
225
229
  const load = async (mandateId: string, db: SQL): Promise<Mandate | null> => {
226
230
  if (!UUID.safeParse(mandateId).success) return null;
227
- const [row] = await db<MandateRow[]>`${selectMandate} WHERE id = ${mandateId}::uuid`;
231
+ const [row] = await db<MandateRow[]>`${selectMandate()} WHERE id = ${mandateId}::uuid`;
228
232
  return row ? mapMandate(row) : null;
229
233
  };
230
234
 
@@ -437,7 +441,7 @@ export const listMandates = async (config: {
437
441
  const where = conditions.slice(1).reduce((combined, condition) => sql`${combined} AND ${condition}`, conditions[0]!);
438
442
  const [countRows, rows] = await Promise.all([
439
443
  db<{ count: number }[]>`SELECT COUNT(*)::int AS count FROM auth.mandates WHERE ${where}`,
440
- db<MandateRow[]>`${selectMandate} WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT ${perPage} OFFSET ${offset}`,
444
+ db<MandateRow[]>`${selectMandate()} WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT ${perPage} OFFSET ${offset}`,
441
445
  ]);
442
446
  const total = countRows[0]?.count ?? 0;
443
447
  return { items: rows.map(mapMandate), page, perPage, total, hasNext: page * perPage < total };
@@ -616,6 +620,8 @@ export const validateMandateIssueAuthority = async (
616
620
  targetAppId: string;
617
621
  operation: string;
618
622
  actionApproval?: "none" | "approved";
623
+ input?: unknown;
624
+ capabilityApproval?: "none" | "rememberable" | "always";
619
625
  expectedRevision?: number;
620
626
  requestId?: string;
621
627
  },
@@ -669,7 +675,15 @@ export const validateMandateIssueAuthority = async (
669
675
  } catch {
670
676
  return denyMandateIssue(err.forbidden("Mandate policy is invalid"));
671
677
  }
672
- if (!mandatePolicyAllows(policy, { appId: target.data, operation: operation.data, actionApproval: input.actionApproval ?? "none" })) {
678
+ if (
679
+ !mandatePolicyAllows(policy, {
680
+ appId: target.data,
681
+ operation: operation.data,
682
+ actionApproval: input.actionApproval ?? "none",
683
+ input: input.input,
684
+ capabilityApproval: input.capabilityApproval,
685
+ })
686
+ ) {
673
687
  return denyMandateIssue(err.forbidden("Mandate policy does not allow this operation"));
674
688
  }
675
689
  const mandate = mapMandate(row);
@@ -25,12 +25,48 @@ const operationSchema = z.union([
25
25
  }, "Expected a canonical mandate operation"),
26
26
  ]);
27
27
 
28
+ export const CapabilityGrantSchema = z
29
+ .object({
30
+ appId: CapabilityAppIdSchema.describe("Exact discovered application ID."),
31
+ capabilityId: CapabilityLocalIdSchema.describe("Exact local capability ID from discovery, without application prefix."),
32
+ kind: z.enum(["query", "action"]).describe("Discovered capability kind."),
33
+ fixedInput: z.record(z.string(), z.json()).default({}).describe("Exact top-level input values. Omitted fields remain unrestricted."),
34
+ })
35
+ .strict();
36
+ export const CapabilityGrantsSchema = z.array(CapabilityGrantSchema).max(MANDATE_POLICY_MAX_IDENTIFIERS / 2);
37
+ export type CapabilityGrant = z.output<typeof CapabilityGrantSchema>;
38
+
39
+ const equalJson = (a: unknown, b: unknown): boolean => {
40
+ if (a === b) return true;
41
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
42
+ if (Array.isArray(a) || Array.isArray(b))
43
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => equalJson(v, b[i]));
44
+ const ak = Object.keys(a),
45
+ bk = Object.keys(b);
46
+ return ak.length === bk.length && ak.every((key) => Object.hasOwn(b, key) && equalJson(Reflect.get(a, key), Reflect.get(b, key)));
47
+ };
48
+ export const capabilityGrantAllows = (
49
+ grant: CapabilityGrant,
50
+ input: { appId: string; capabilityId: string; kind: "query" | "action"; input: unknown },
51
+ ): boolean =>
52
+ grant.appId === input.appId &&
53
+ grant.capabilityId === input.capabilityId &&
54
+ grant.kind === input.kind &&
55
+ Object.entries(grant.fixedInput).every(
56
+ ([key, value]) =>
57
+ input.input !== null &&
58
+ typeof input.input === "object" &&
59
+ Object.hasOwn(input.input, key) &&
60
+ equalJson(value, Reflect.get(input.input, key)),
61
+ );
62
+
28
63
  export const MandatePolicyV1Schema = z
29
64
  .object({
30
65
  version: z.literal(MANDATE_POLICY_VERSION),
31
66
  apps: z.union([z.literal("*"), identifierList(CapabilityAppIdSchema)]),
32
67
  operations: z.union([z.literal("*"), identifierList(operationSchema)]),
33
68
  actions: z.enum(["deny", "require_approval", "preapproved"]),
69
+ grants: CapabilityGrantsSchema.optional(),
34
70
  })
35
71
  .strict()
36
72
  .superRefine((policy, context) => {
@@ -70,14 +106,41 @@ const ACTION_AUTHORITY = { deny: 0, require_approval: 1, preapproved: 2 } as con
70
106
  export const isMandatePolicyNarrowing = (current: MandatePolicyV1, next: MandatePolicyV1): boolean =>
71
107
  setAllows(current.apps, next.apps) &&
72
108
  setAllows(current.operations, next.operations) &&
73
- ACTION_AUTHORITY[next.actions] <= ACTION_AUTHORITY[current.actions];
109
+ ACTION_AUTHORITY[next.actions] <= ACTION_AUTHORITY[current.actions] &&
110
+ (current.grants === undefined ||
111
+ (next.grants !== undefined &&
112
+ next.grants.every((grant) =>
113
+ current.grants!.some((existing) => capabilityGrantAllows(existing, { ...grant, input: grant.fixedInput })),
114
+ )));
74
115
 
75
116
  export const mandatePolicyAllows = (
76
117
  policy: MandatePolicyV1,
77
- input: { appId: string; operation: string; actionApproval: "none" | "approved" },
118
+ input: {
119
+ appId: string;
120
+ operation: string;
121
+ actionApproval: "none" | "approved";
122
+ input?: unknown;
123
+ capabilityApproval?: "none" | "rememberable" | "always";
124
+ },
78
125
  ): boolean => {
79
126
  if (policy.apps !== "*" && !policy.apps.includes(input.appId)) return false;
80
127
  if (policy.operations !== "*" && !policy.operations.includes(input.operation)) return false;
128
+ if (policy.grants !== undefined) {
129
+ const [operation, capabilityId] = input.operation.split(":");
130
+ const kind =
131
+ operation === "capability.query"
132
+ ? "query"
133
+ : operation === "capability.action.run" || operation === "capability.action.review"
134
+ ? "action"
135
+ : null;
136
+ if (
137
+ !kind ||
138
+ !capabilityId ||
139
+ !policy.grants.some((grant) => capabilityGrantAllows(grant, { appId: input.appId, capabilityId, kind, input: input.input }))
140
+ )
141
+ return false;
142
+ if (kind === "action" && input.capabilityApproval !== "none" && input.capabilityApproval !== "rememberable") return false;
143
+ }
81
144
  if (!input.operation.startsWith("capability.action.run:")) return true;
82
145
  if (policy.actions === "deny") return false;
83
146
  if (policy.actions === "require_approval") return input.actionApproval === "approved";
@@ -4,6 +4,7 @@ import type { Worker } from "@k2b/sync";
4
4
  import { sql } from "bun";
5
5
  import { lazySync } from "../../_internal/process-sync";
6
6
  import { markdown } from "../../shared/markdown";
7
+ import { type AuditActor, audit } from "../audit";
7
8
  import { logger, trace } from "../logging";
8
9
  import { parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
9
10
 
@@ -406,7 +407,7 @@ export const createDraft = async (params: {
406
407
  subject: string;
407
408
  bodyMarkdown: string;
408
409
  selection: NotificationBatchSelection;
409
- createdBy: string;
410
+ actor: AuditActor & { userId: string };
410
411
  }): Promise<Result<NotificationBatch>> => {
411
412
  const subject = params.subject.trim();
412
413
  const bodyMarkdown = params.bodyMarkdown.trim();
@@ -422,12 +423,24 @@ export const createDraft = async (params: {
422
423
  }
423
424
  const hash = selectionHash(selection);
424
425
  const bodyHtml = markdown.renderSync(bodyMarkdown);
425
- const rows = await sql<BatchRow[]>`
426
- INSERT INTO notifications.batches (subject, body_markdown, body_html, selection, selection_hash, created_by)
427
- VALUES (${subject}, ${bodyMarkdown}, ${bodyHtml}, ${JSON.stringify(selection)}::jsonb, ${hash}, ${params.createdBy}::uuid)
428
- RETURNING *
429
- `;
430
- return ok(mapBatch(rows[0]!));
426
+ return sql.begin(async (tx) => {
427
+ const rows = await tx<BatchRow[]>`
428
+ INSERT INTO notifications.batches (subject, body_markdown, body_html, selection, selection_hash, created_by)
429
+ VALUES (${subject}, ${bodyMarkdown}, ${bodyHtml}, ${JSON.stringify(selection)}::text::jsonb, ${hash}, ${params.actor.userId}::uuid)
430
+ RETURNING *
431
+ `;
432
+ const batch = mapBatch(rows[0]!);
433
+ await audit.record(
434
+ {
435
+ action: "accounts.notification_batch.create",
436
+ outcome: "allowed",
437
+ actor: params.actor,
438
+ target: { type: "notification_batch", id: batch.id },
439
+ },
440
+ tx,
441
+ );
442
+ return ok(batch);
443
+ });
431
444
  };
432
445
 
433
446
  export const get = async (id: string): Promise<NotificationBatch | null> => {
@@ -504,7 +517,7 @@ export const listRecipients = async (params: {
504
517
 
505
518
  export const finalize = async (params: {
506
519
  id: string;
507
- actorUserId: string;
520
+ actor: AuditActor & { userId: string };
508
521
  expectedSelectionHash: string;
509
522
  expectedDeliverableCount: number;
510
523
  expectedRecipientHash: string;
@@ -568,7 +581,7 @@ export const finalize = async (params: {
568
581
  UPDATE notifications.batches
569
582
  SET
570
583
  status = 'ready',
571
- finalized_by = ${params.actorUserId}::uuid,
584
+ finalized_by = ${params.actor.userId}::uuid,
572
585
  finalized_at = now(),
573
586
  target_count = ${candidates.length},
574
587
  deliverable_count = ${deliverableCount},
@@ -576,6 +589,16 @@ export const finalize = async (params: {
576
589
  WHERE id = ${params.id}::uuid
577
590
  RETURNING *
578
591
  `;
592
+ await audit.record(
593
+ {
594
+ action: "accounts.notification_batch.finalize",
595
+ outcome: "allowed",
596
+ actor: params.actor,
597
+ target: { type: "notification_batch", id: params.id },
598
+ metadata: { targetCount: candidates.length, deliverableCount },
599
+ },
600
+ tx,
601
+ );
579
602
  return ok(mapBatch(updated[0]!));
580
603
  });
581
604
 
@@ -584,28 +607,45 @@ export const finalize = async (params: {
584
607
  return ok({ batch: result.data, jobId });
585
608
  };
586
609
 
587
- export const retryFailed = async (params: { id: string }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
610
+ export const retryFailed = async (params: {
611
+ id: string;
612
+ actor: AuditActor & { userId: string };
613
+ }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
588
614
  const batch = await get(params.id);
589
615
  if (!batch) return fail(err.notFound("Notification batch not found"));
590
616
  if (batch.status === "draft" || batch.status === "cancelled") {
591
617
  return fail(err.conflict("Only finalized notification batches can retry recipients"));
592
618
  }
593
619
 
594
- const updated = await sql<BatchRow[]>`
595
- UPDATE notifications.batch_recipients
596
- SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
597
- WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
598
- RETURNING user_id
599
- `;
600
- if (updated.length === 0) {
601
- return fail(err.conflict("No failed deliverable recipients can be retried"));
602
- }
620
+ const result = await sql.begin(async (tx) => {
621
+ const updated = await tx<BatchRow[]>`
622
+ UPDATE notifications.batch_recipients
623
+ SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
624
+ WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
625
+ RETURNING user_id
626
+ `;
627
+ if (updated.length === 0) {
628
+ return fail(err.conflict("No failed deliverable recipients can be retried"));
629
+ }
603
630
 
604
- await sql`
605
- UPDATE notifications.batches
606
- SET status = 'ready', completed_at = NULL, last_error = NULL
607
- WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
608
- `;
631
+ await tx`
632
+ UPDATE notifications.batches
633
+ SET status = 'ready', completed_at = NULL, last_error = NULL
634
+ WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
635
+ `;
636
+ await audit.record(
637
+ {
638
+ action: "accounts.notification_batch.retry_failed",
639
+ outcome: "allowed",
640
+ actor: params.actor,
641
+ target: { type: "notification_batch", id: params.id },
642
+ metadata: { recipientCount: updated.length },
643
+ },
644
+ tx,
645
+ );
646
+ return ok(null);
647
+ });
648
+ if (!result.ok) return result;
609
649
  const refreshed = await refreshBatchCounters(params.id);
610
650
  // A completing worker may already have sampled zero remaining recipients.
611
651
  // Explicit retries need their own wakeup even while that worker holds its key.
@@ -615,6 +655,7 @@ export const retryFailed = async (params: { id: string }): Promise<Result<{ batc
615
655
 
616
656
  export const retryRecipient = async (params: {
617
657
  id: string;
658
+ actor: AuditActor & { userId: string };
618
659
  userId: string;
619
660
  }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
620
661
  const batch = await get(params.id);
@@ -623,30 +664,44 @@ export const retryRecipient = async (params: {
623
664
  return fail(err.conflict("Only finalized notification batches can retry recipients"));
624
665
  }
625
666
 
626
- const updated = await sql<BatchRow[]>`
627
- UPDATE notifications.batch_recipients
628
- SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
629
- WHERE batch_id = ${params.id}::uuid
630
- AND user_id = ${params.userId}::uuid
631
- AND status = 'error'
632
- AND recipient IS NOT NULL
633
- RETURNING *
634
- `;
635
- if (!updated[0]) {
636
- const existing = await sql<BatchRow[]>`
637
- SELECT status, recipient
638
- FROM notifications.batch_recipients
639
- WHERE batch_id = ${params.id}::uuid AND user_id = ${params.userId}::uuid
667
+ const result = await sql.begin(async (tx) => {
668
+ const updated = await tx<BatchRow[]>`
669
+ UPDATE notifications.batch_recipients
670
+ SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
671
+ WHERE batch_id = ${params.id}::uuid
672
+ AND user_id = ${params.userId}::uuid
673
+ AND status = 'error'
674
+ AND recipient IS NOT NULL
675
+ RETURNING *
640
676
  `;
641
- if (!existing[0]) return fail(err.notFound("Notification recipient not found"));
642
- return fail(err.conflict("Only failed deliverable recipients can be retried"));
643
- }
677
+ if (!updated[0]) {
678
+ const existing = await tx<BatchRow[]>`
679
+ SELECT status, recipient
680
+ FROM notifications.batch_recipients
681
+ WHERE batch_id = ${params.id}::uuid AND user_id = ${params.userId}::uuid
682
+ `;
683
+ if (!existing[0]) return fail(err.notFound("Notification recipient not found"));
684
+ return fail(err.conflict("Only failed deliverable recipients can be retried"));
685
+ }
644
686
 
645
- await sql`
646
- UPDATE notifications.batches
647
- SET status = 'ready', completed_at = NULL, last_error = NULL
648
- WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
649
- `;
687
+ await tx`
688
+ UPDATE notifications.batches
689
+ SET status = 'ready', completed_at = NULL, last_error = NULL
690
+ WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
691
+ `;
692
+ await audit.record(
693
+ {
694
+ action: "accounts.notification_batch.retry_recipient",
695
+ outcome: "allowed",
696
+ actor: params.actor,
697
+ target: { type: "notification_batch", id: params.id },
698
+ metadata: { recipientUserId: params.userId },
699
+ },
700
+ tx,
701
+ );
702
+ return ok(null);
703
+ });
704
+ if (!result.ok) return result;
650
705
  const refreshed = await refreshBatchCounters(params.id);
651
706
  const { jobId } = await batchJob().submit({
652
707
  key: `${params.id}:retry:${crypto.randomUUID()}`,
@@ -655,17 +710,29 @@ export const retryRecipient = async (params: {
655
710
  return ok({ batch: refreshed ?? batch, jobId });
656
711
  };
657
712
 
658
- export const removeDraft = async (params: { id: string }): Promise<Result<{ id: string }>> => {
659
- const rows = await sql<BatchRow[]>`
660
- DELETE FROM notifications.batches
661
- WHERE id = ${params.id}::uuid AND status = 'draft'
662
- RETURNING id
663
- `;
664
- if (rows[0]) return ok({ id: rows[0].id as string });
665
-
666
- const batch = await get(params.id);
667
- if (!batch) return fail(err.notFound("Notification batch not found"));
668
- return fail(err.conflict("Only draft notification batches can be deleted"));
713
+ export const removeDraft = async (params: { id: string; actor: AuditActor & { userId: string } }): Promise<Result<{ id: string }>> => {
714
+ return sql.begin(async (tx) => {
715
+ const rows = await tx<BatchRow[]>`
716
+ DELETE FROM notifications.batches
717
+ WHERE id = ${params.id}::uuid AND status = 'draft'
718
+ RETURNING id
719
+ `;
720
+ if (rows[0]) {
721
+ await audit.record(
722
+ {
723
+ action: "accounts.notification_batch.delete",
724
+ outcome: "allowed",
725
+ actor: params.actor,
726
+ target: { type: "notification_batch", id: params.id },
727
+ },
728
+ tx,
729
+ );
730
+ return ok({ id: params.id });
731
+ }
732
+ const existing = await tx`SELECT id FROM notifications.batches WHERE id = ${params.id}::uuid`;
733
+ if (!existing[0]) return fail(err.notFound("Notification batch not found"));
734
+ return fail(err.conflict("Only draft notification batches can be deleted"));
735
+ });
669
736
  };
670
737
 
671
738
  export const start = async (): Promise<void> => {
@@ -152,6 +152,18 @@ const browserDriver = {
152
152
  },
153
153
  deliver: async (value: unknown) => {
154
154
  const payload: BrowserDeliveryPayload = BrowserDeliveryPayloadSchema.parse(value);
155
+ // A queued payload can outlive logout, disable, or rebinding this browser to another user.
156
+ const [endpoint] = await sql<{ id: string }[]>`
157
+ SELECT id FROM notifications.endpoints
158
+ WHERE id = ${payload.endpointId}::uuid AND channel = 'browser'
159
+ AND verified_at IS NOT NULL AND disabled_at IS NULL
160
+ `;
161
+ if (!endpoint) {
162
+ throw Object.assign(new Error("Browser notification endpoint is no longer registered"), {
163
+ code: "endpoint_gone",
164
+ retryable: false,
165
+ });
166
+ }
155
167
  await ensureWebPushConfigured();
156
168
  try {
157
169
  await sendPinnedWebPush(
@@ -6,7 +6,6 @@ import { logger } from "../logging";
6
6
  import { escapeLikePattern } from "../postgres";
7
7
  import { sendEmail } from "./email";
8
8
  import "./browser";
9
- import { notificationLive } from "./live";
10
9
  import { notificationObservability } from "./observability";
11
10
  import { sendTypedNotification, type TypedNotificationSendResult } from "./platform";
12
11
  import { userNotifications } from "./user";
@@ -610,7 +609,6 @@ export const notifications = {
610
609
  sendAllPendingSystem,
611
610
  getStatusSummary,
612
611
  getSearchSummary,
613
- live: notificationLive,
614
612
  observability: notificationObservability,
615
613
  user: userNotifications,
616
614
  };