@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
@@ -17,15 +17,16 @@ const isSchemaUnavailable = (error: unknown): boolean => {
17
17
  const upsertDefinition = async (definition: AnyBoundNotificationDefinition, db: typeof sql = sql): Promise<void> => {
18
18
  const recommended = toPgTextArray([...(definition.delivery?.recommended ?? [])]);
19
19
  const required = toPgTextArray([...(definition.delivery?.required ?? [])]);
20
+ const excluded = toPgTextArray([...(definition.delivery?.excluded ?? [])]);
20
21
  await db`
21
22
  INSERT INTO notifications.definitions (
22
23
  id, app_id, kind, label, description, presentation, recipient_kind,
23
- recommended_channels, required_channels, active, last_seen_at, updated_at
24
+ recommended_channels, required_channels, excluded_channels, excluded_reason, active, last_seen_at, updated_at
24
25
  ) VALUES (
25
26
  ${definition.id}, ${definition.appId}, ${definition.key}, ${definition.label},
26
27
  ${definition.description}, (${definition.presentation ? JSON.stringify(definition.presentation) : null}::text)::jsonb,
27
28
  ${definition.recipient}, ${recommended}::text[],
28
- ${required}::text[], true, now(), now()
29
+ ${required}::text[], ${excluded}::text[], ${definition.excludedReason ?? null}, true, now(), now()
29
30
  )
30
31
  ON CONFLICT (id) DO UPDATE SET
31
32
  app_id = EXCLUDED.app_id,
@@ -36,6 +37,8 @@ const upsertDefinition = async (definition: AnyBoundNotificationDefinition, db:
36
37
  recipient_kind = EXCLUDED.recipient_kind,
37
38
  recommended_channels = EXCLUDED.recommended_channels,
38
39
  required_channels = EXCLUDED.required_channels,
40
+ excluded_channels = EXCLUDED.excluded_channels,
41
+ excluded_reason = EXCLUDED.excluded_reason,
39
42
  active = true,
40
43
  last_seen_at = now(),
41
44
  updated_at = now()
@@ -95,6 +95,9 @@ const validatePresentation = (presentation: NotificationPresentation, appId: str
95
95
  if (presentation.badge !== undefined && (!Number.isSafeInteger(presentation.badge) || presentation.badge < 0)) {
96
96
  throw new Error("Notification badge must be a non-negative safe integer");
97
97
  }
98
+ if (presentation.alert !== undefined && typeof presentation.alert !== "boolean") {
99
+ throw new Error("Notification alert must be a boolean");
100
+ }
98
101
  return {
99
102
  title,
100
103
  ...(body ? { body } : {}),
@@ -102,6 +105,7 @@ const validatePresentation = (presentation: NotificationPresentation, appId: str
102
105
  ...(targetHref ? { targetHref } : {}),
103
106
  ...(presentation.group !== undefined ? { group: presentation.group } : {}),
104
107
  ...(presentation.badge !== undefined ? { badge: presentation.badge } : {}),
108
+ ...(presentation.alert === false ? { alert: false } : {}),
105
109
  };
106
110
  };
107
111
 
@@ -274,10 +278,11 @@ export const sendTypedNotification = async <
274
278
  const candidateEventId = crypto.randomUUID();
275
279
  await ensureNotificationDefinition(definition);
276
280
 
277
- const requiredChannels = unique([...(definition.delivery?.required ?? [])]);
281
+ const excluded = new Set<string>(definition.delivery?.excluded ?? []);
282
+ const requiredChannels = unique([...(definition.delivery?.required ?? [])]).filter((channel) => !excluded.has(channel));
278
283
  const requiredChannelSet = new Set<string>(requiredChannels);
279
284
  const preference = await preferredChannels(definition.id, resolved.recipient, definition.delivery?.recommended ?? []);
280
- const preferred = preference.channels.filter((channel) => !requiredChannelSet.has(channel));
285
+ const preferred = preference.channels.filter((channel) => !requiredChannelSet.has(channel) && !excluded.has(channel));
281
286
 
282
287
  const identity = await sql.begin(async (tx) => {
283
288
  const eventRows = await tx<{ id: string }[]>`
@@ -25,6 +25,8 @@ type PreferenceRow = {
25
25
  presentation: NotificationDefinitionPresentationCatalog | null;
26
26
  recommended_channels: string[];
27
27
  required_channels: string[];
28
+ excluded_channels: string[];
29
+ excluded_reason: string | null;
28
30
  channels: string[] | null;
29
31
  customized: boolean;
30
32
  };
@@ -57,12 +59,14 @@ const definitionPresentation = (
57
59
  ): { label: string; description: string } => resolveNotificationDefinitionPresentation(row, locale);
58
60
 
59
61
  const mapPreference = (row: PreferenceRow, locale?: string): UserNotificationPreference => {
60
- const requiredChannels = unique(row.required_channels);
62
+ const excludedChannels = unique(row.excluded_channels);
63
+ const excluded = new Set(excludedChannels);
64
+ const requiredChannels = unique(row.required_channels).filter((channel) => !excluded.has(channel));
61
65
  const required = new Set(requiredChannels);
62
66
  const selectedChannels = unique(row.customized ? (row.channels ?? []) : row.recommended_channels).filter(
63
- (channel) => !required.has(channel),
67
+ (channel) => !required.has(channel) && !excluded.has(channel),
64
68
  );
65
- const presentation = definitionPresentation(row, locale);
69
+ const presentation = resolveNotificationDefinitionPresentation({ ...row, excludedReason: row.excluded_reason ?? undefined }, locale);
66
70
  return {
67
71
  id: row.id,
68
72
  appId: row.app_id,
@@ -71,6 +75,8 @@ const mapPreference = (row: PreferenceRow, locale?: string): UserNotificationPre
71
75
  description: presentation.description,
72
76
  recommendedChannels: unique(row.recommended_channels),
73
77
  requiredChannels,
78
+ excludedChannels,
79
+ excludedReason: presentation.excludedReason ?? null,
74
80
  selectedChannels,
75
81
  effectiveChannels: unique([...requiredChannels, ...selectedChannels]),
76
82
  customized: row.customized,
@@ -80,7 +86,7 @@ const mapPreference = (row: PreferenceRow, locale?: string): UserNotificationPre
80
86
  const listPreferences = async (userId: string, locale?: string): Promise<UserNotificationPreferencesResponse> => {
81
87
  const rows = await sql<PreferenceRow[]>`
82
88
  SELECT d.id, d.app_id, d.kind, d.label, d.description, d.presentation,
83
- d.recommended_channels, d.required_channels, p.channels,
89
+ d.recommended_channels, d.required_channels, d.excluded_channels, d.excluded_reason, p.channels,
84
90
  (p.user_id IS NOT NULL) AS customized
85
91
  FROM notifications.definitions d
86
92
  LEFT JOIN notifications.preferences p
@@ -110,8 +116,8 @@ const setPreference = async (config: {
110
116
  channels: string[];
111
117
  locale?: string;
112
118
  }): Promise<MutationResult<UserNotificationPreference>> => {
113
- const rows = await sql<{ required_channels: string[] }[]>`
114
- SELECT required_channels
119
+ const rows = await sql<{ required_channels: string[]; excluded_channels: string[] }[]>`
120
+ SELECT required_channels, excluded_channels
115
121
  FROM notifications.definitions
116
122
  WHERE id = ${config.definitionId} AND active = true AND recipient_kind = 'user'
117
123
  LIMIT 1
@@ -119,8 +125,13 @@ const setPreference = async (config: {
119
125
  const definition = rows[0];
120
126
  if (!definition) return { ok: false, error: "Notification preference not found", status: 404 };
121
127
 
128
+ const requested = unique(config.channels.map((channel) => channel.trim()).filter(Boolean));
129
+ const excluded = requested.filter((channel) => definition.excluded_channels.includes(channel));
130
+ if (excluded.length > 0) {
131
+ return { ok: false, error: `Excluded notification channel: ${excluded.join(", ")}`, status: 400 };
132
+ }
122
133
  const required = new Set(definition.required_channels);
123
- const channels = unique(config.channels.map((channel) => channel.trim()).filter(Boolean)).filter((channel) => !required.has(channel));
134
+ const channels = requested.filter((channel) => !required.has(channel));
124
135
  const available = new Set(listNotificationChannels());
125
136
  const unavailable = channels.filter((channel) => !available.has(channel));
126
137
  if (unavailable.length > 0) {
@@ -4,7 +4,7 @@ import type { User } from "../contracts/shared";
4
4
  import { isAccountCategoryAllowed } from "./account-category-policy";
5
5
  import { isAccountExpired } from "./account-model";
6
6
  import { IDENTITY_JWKS_MAX_AGE_SECONDS } from "./identity/constants";
7
- import { getIdentityRuntimeConfig } from "./identity/runtime-config";
7
+ import { getIdentityRuntimeConfig, readIdentityGroupsAdmin } from "./identity/runtime-config";
8
8
  import type { ServiceAccount } from "./service-accounts";
9
9
  import { buildProjectedUser, userProjectionJoin, userProjectionSql } from "./session/user";
10
10
 
@@ -59,6 +59,7 @@ export type AuthenticatedOAuthToken =
59
59
  kind: "service_account";
60
60
  payload: JWTPayload;
61
61
  serviceAccount: ServiceAccount;
62
+ oauthClientId: string;
62
63
  delegatedUser: User | null;
63
64
  scopes: string[];
64
65
  };
@@ -138,7 +139,7 @@ export const resolveOAuthTokenActor = async (
138
139
  if (serviceAccount.kind === "user_delegated" && !delegatedUser) return null;
139
140
  if (delegatedUser && isAccountExpired(delegatedUser.accountExpires)) return null;
140
141
  if (delegatedUser && !(await isAccountCategoryAllowed(delegatedUser, query))) return null;
141
- return { kind: "service_account", payload, serviceAccount, delegatedUser, scopes };
142
+ return { kind: "service_account", payload, serviceAccount, oauthClientId: clientId, delegatedUser, scopes };
142
143
  }
143
144
 
144
145
  const rawUserId = getStringClaim(payload, "id");
@@ -161,6 +162,31 @@ export const resolveOAuthTokenActor = async (
161
162
  return { kind: "user", payload, user, scopes };
162
163
  };
163
164
 
165
+ /**
166
+ * Revalidate a retained, previously authenticated service-account OAuth reference.
167
+ * Identifiers are not credentials: callers must protect their provenance and keep
168
+ * the captured scopes and expiry as caps, then authorize the domain operation.
169
+ */
170
+ export const isServiceAccountOAuthAuthorityCurrent = async (
171
+ authority: {
172
+ serviceAccountId: string;
173
+ clientId: string;
174
+ expiresAt: string | null | undefined;
175
+ },
176
+ options: { query?: typeof sql; groupsAdmin?: readonly string[] } = {},
177
+ ): Promise<boolean> => {
178
+ const expiry = authority.expiresAt ? Date.parse(authority.expiresAt) : NaN;
179
+ if (!Number.isFinite(expiry) || expiry <= Date.now()) return false;
180
+ const groupsAdmin =
181
+ options.groupsAdmin ?? (options.query ? await readIdentityGroupsAdmin(options.query) : (await getIdentityRuntimeConfig()).groupsAdmin);
182
+ const current = await resolveOAuthTokenActor(
183
+ { client_id: authority.clientId, service_account_id: authority.serviceAccountId },
184
+ [...groupsAdmin],
185
+ options.query ?? sql,
186
+ );
187
+ return current?.kind === "service_account";
188
+ };
189
+
164
190
  export const verifyAccessToken = async (
165
191
  token: string,
166
192
  expectedAudience: string | string[] = "cloud",
@@ -0,0 +1,217 @@
1
+ import type { S3ListObjectsResponse, S3Stats } from "bun";
2
+ import { objectStoreArea } from "../_internal/object-store-area";
3
+ import { objectStoreBackend } from "../_internal/object-store-config";
4
+ import { validateObjectKey as validateKey } from "../_internal/object-store-key";
5
+ import { publicCloudOrigin } from "../shared/app-url";
6
+ import { get } from "./settings";
7
+
8
+ export type ObjectVisibility = "private" | "public";
9
+ export type ObjectWriteData = string | ArrayBuffer | ArrayBufferView | Blob | Request | Response;
10
+ export type ObjectWriteOptions = { type?: string };
11
+ export type ObjectPresignOptions = { method?: "GET" | "HEAD" | "DELETE"; expiresIn?: number };
12
+ export type ObjectListOptions = {
13
+ prefix?: string;
14
+ startAfter?: string;
15
+ continuationToken?: string;
16
+ maxKeys?: number;
17
+ };
18
+ export type ObjectListResult = Pick<S3ListObjectsResponse, "contents" | "isTruncated" | "nextContinuationToken">;
19
+
20
+ export interface ObjectStoreFile {
21
+ readonly key: string;
22
+ readonly size: Promise<number>;
23
+ stat(): Promise<S3Stats>;
24
+ write(data: ObjectWriteData, options?: ObjectWriteOptions): Promise<number>;
25
+ text(): Promise<string>;
26
+ bytes(): Promise<Uint8Array>;
27
+ stream(): ReadableStream<Uint8Array>;
28
+ exists(): Promise<boolean>;
29
+ delete(): Promise<void>;
30
+ copy(destinationKey: string): Promise<number>;
31
+ presign(options?: ObjectPresignOptions): Promise<string>;
32
+ readonly url: Promise<string>;
33
+ }
34
+
35
+ const assertSize = (size: number, limit: number): void => {
36
+ if (!Number.isSafeInteger(size) || size < 0 || size > limit) throw new RangeError(`Object exceeds the ${limit}-byte limit.`);
37
+ };
38
+
39
+ /** Pull one chunk at a time, propagate cancellation, and reject before buffering past the budget. */
40
+ const boundedStream = (source: ReadableStream<Uint8Array>, limit: number): ReadableStream<Uint8Array> => {
41
+ const reader = source.getReader();
42
+ let size = 0;
43
+ return new ReadableStream<Uint8Array>(
44
+ {
45
+ async pull(controller) {
46
+ try {
47
+ const next = await reader.read();
48
+ if (next.done) {
49
+ reader.releaseLock();
50
+ controller.close();
51
+ return;
52
+ }
53
+ size += next.value.byteLength;
54
+ assertSize(size, limit);
55
+ controller.enqueue(next.value);
56
+ } catch (error) {
57
+ await reader.cancel(error).catch(() => {});
58
+ reader.releaseLock();
59
+ controller.error(error);
60
+ }
61
+ },
62
+ async cancel(reason) {
63
+ try {
64
+ await reader.cancel(reason);
65
+ } finally {
66
+ reader.releaseLock();
67
+ }
68
+ },
69
+ },
70
+ { highWaterMark: 0 },
71
+ );
72
+ };
73
+
74
+ const uploadData = async (data: ObjectWriteData, limit: number): Promise<string | ArrayBuffer | Uint8Array<ArrayBufferLike>> => {
75
+ if (typeof data === "string") {
76
+ assertSize(Buffer.byteLength(data), limit);
77
+ return data;
78
+ }
79
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
80
+ assertSize(data.byteLength, limit);
81
+ return ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : data;
82
+ }
83
+ if (data instanceof Blob || data instanceof Request || data instanceof Response) {
84
+ if (data instanceof Blob) assertSize(await data.size, limit);
85
+ const stream = data instanceof Blob ? data.stream() : data.body;
86
+ if (!stream) return new Uint8Array();
87
+ return new Response(boundedStream(stream, limit)).bytes();
88
+ }
89
+ throw new TypeError("Unsupported object data.");
90
+ };
91
+
92
+ const file = (key: string, visibility: ObjectVisibility = "private"): ObjectStoreFile => {
93
+ const prefix = objectStoreArea(visibility);
94
+ validateKey(key, prefix);
95
+ const path = prefix + key;
96
+ const stream = () => {
97
+ let reader: Promise<ReadableStreamDefaultReader<Uint8Array>> | undefined;
98
+ const source = () =>
99
+ (reader ??= objectStoreBackend().then(({ client, limit }) => boundedStream(client.file(path).stream(), limit).getReader()));
100
+ return new ReadableStream<Uint8Array>(
101
+ {
102
+ async pull(controller) {
103
+ try {
104
+ const current = await source();
105
+ const next = await current.read();
106
+ if (next.done) {
107
+ current.releaseLock();
108
+ controller.close();
109
+ } else controller.enqueue(next.value);
110
+ } catch (error) {
111
+ void reader?.then(
112
+ (current) => current.releaseLock(),
113
+ () => {},
114
+ );
115
+ controller.error(error);
116
+ }
117
+ },
118
+ async cancel(reason) {
119
+ if (!reader) return;
120
+ const current = await reader;
121
+ try {
122
+ await current.cancel(reason);
123
+ } finally {
124
+ current.releaseLock();
125
+ }
126
+ },
127
+ },
128
+ { highWaterMark: 0 },
129
+ );
130
+ };
131
+ return Object.freeze({
132
+ key,
133
+ get size() {
134
+ return objectStoreBackend().then(({ client }) => client.size(path));
135
+ },
136
+ async stat() {
137
+ return (await objectStoreBackend()).client.stat(path);
138
+ },
139
+ async write(data: ObjectWriteData, options: ObjectWriteOptions = {}) {
140
+ const { client, limit } = await objectStoreBackend();
141
+ const content = await uploadData(data, limit);
142
+ const inheritedType =
143
+ data instanceof Blob
144
+ ? data.type
145
+ : data instanceof Request || data instanceof Response
146
+ ? data.headers.get("content-type")
147
+ : undefined;
148
+ return client.file(path).write(content, { type: options.type ?? (inheritedType || undefined) });
149
+ },
150
+ text: () => new Response(stream()).text(),
151
+ bytes: () => new Response(stream()).bytes(),
152
+ stream,
153
+ async exists() {
154
+ return (await objectStoreBackend()).client.file(path).exists();
155
+ },
156
+ async delete() {
157
+ return (await objectStoreBackend()).client.file(path).delete();
158
+ },
159
+ async copy(destinationKey: string) {
160
+ validateKey(destinationKey, prefix);
161
+ const { client, limit } = await objectStoreBackend();
162
+ const metadata = await client.stat(path);
163
+ assertSize(metadata.size, limit);
164
+ const content = await new Response(boundedStream(client.file(path).stream(), limit)).bytes();
165
+ return client.file(prefix + destinationKey).write(content, { type: metadata.type });
166
+ },
167
+ presign(options: ObjectPresignOptions = {}) {
168
+ if (visibility !== "private") throw new Error("Use url for public objects.");
169
+ const expiresIn = options.expiresIn ?? 300;
170
+ const method = options.method ?? "GET";
171
+ if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > 900) throw new RangeError("Presign expiry must be 1–900 seconds.");
172
+ if (!["GET", "HEAD", "DELETE"].includes(method))
173
+ throw new TypeError("Presign supports GET, HEAD, and DELETE; upload through write().");
174
+ return objectStoreBackend().then(({ client }) => client.file(path).presign({ method, expiresIn }));
175
+ },
176
+ get url() {
177
+ if (visibility !== "public") throw new Error("Private objects have no public URL.");
178
+ return get<string>("app.url").then(
179
+ (base) => `${publicCloudOrigin(base)}/public/storage/${path.slice("public/".length).split("/").map(encodeURIComponent).join("/")}`,
180
+ );
181
+ },
182
+ });
183
+ };
184
+
185
+ /** App-bound S3 operations. Identity comes from app.start(), never from an API parameter. */
186
+ const list = async (options: ObjectListOptions = {}, visibility: ObjectVisibility = "private"): Promise<ObjectListResult> => {
187
+ const prefix = objectStoreArea(visibility);
188
+ const relative = validateKey(options.prefix ?? "", prefix, true);
189
+ const startAfter = options.startAfter === undefined ? undefined : prefix + validateKey(options.startAfter, prefix);
190
+ const maxKeys = options.maxKeys ?? 1000;
191
+ if (!Number.isInteger(maxKeys) || maxKeys < 1 || maxKeys > 1000) throw new RangeError("List maxKeys must be 1–1,000.");
192
+ if (
193
+ options.continuationToken !== undefined &&
194
+ (typeof options.continuationToken !== "string" || options.continuationToken.length > 8192)
195
+ ) {
196
+ throw new TypeError("Invalid S3 continuation token.");
197
+ }
198
+ const { client } = await objectStoreBackend();
199
+ const result = await client.list({ prefix: prefix + relative, startAfter, maxKeys, continuationToken: options.continuationToken });
200
+ const contents = result.contents?.flatMap((object) => {
201
+ if (!object.key.startsWith(prefix + relative)) throw new Error("S3 returned an object outside the requested area.");
202
+ try {
203
+ return [{ ...object, key: validateKey(object.key.slice(prefix.length), prefix) }];
204
+ } catch {
205
+ // Console folder markers and externally written nonconforming names are
206
+ // inaccessible through this API, but must not break paging for valid keys.
207
+ return [];
208
+ }
209
+ });
210
+ return { contents, isTruncated: result.isTruncated, nextContinuationToken: result.nextContinuationToken };
211
+ };
212
+
213
+ export const storage = Object.freeze({
214
+ file: (key: string) => file(key),
215
+ list: (options?: ObjectListOptions) => list(options),
216
+ public: Object.freeze({ file: (key: string) => file(key, "public"), list: (options?: ObjectListOptions) => list(options, "public") }),
217
+ });
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import type { MutationResult, UserProfile } from "../../../contracts/shared";
3
+ import { coreUserEventPublisher } from "../../../events/core-user-created";
3
4
  import { isAccountCategoryAllowed } from "../../account-category-policy";
4
5
  import { writeDeletedAccountAudit } from "../../account-lifecycle/audit";
5
6
  import { emailAlreadyUsed, findAccountsByEmail, lockAccountEmail, normalizeAccountEmail } from "../../accounts/email-write";
@@ -96,6 +97,10 @@ export const create = async (params: {
96
97
  )
97
98
  RETURNING id
98
99
  `;
100
+ await coreUserEventPublisher().publish(tx, "user.created", {
101
+ subject: rows[0]!.id,
102
+ data: { userId: rows[0]!.id, provider: "local" },
103
+ });
99
104
  return { ok: true, data: { id: rows[0]!.id } };
100
105
  }, params.actor);
101
106
  } catch (error) {