@k2b/cloud 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/package.json +7 -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/PermissionEditor.tsx +9 -3
  21. package/src/access/PrincipalPicker.tsx +14 -4
  22. package/src/access/ResourceApiKeys.tsx +3 -4
  23. package/src/access/app-access-messages.ts +6 -0
  24. package/src/account/ProfilePictureDialog.tsx +699 -0
  25. package/src/account/profile-picture-messages.ts +269 -0
  26. package/src/account/ui.ts +7 -0
  27. package/src/ai/admin.ts +3 -0
  28. package/src/ai/app-frame.ts +12 -0
  29. package/src/ai/application-tasks.ts +101 -0
  30. package/src/ai/assistant-models.ts +126 -64
  31. package/src/ai/capabilities.ts +19 -4
  32. package/src/ai/chart-block.ts +63 -30
  33. package/src/ai/chat/chart-block.tsx +12 -3
  34. package/src/ai/chat/composer-adapter.ts +8 -7
  35. package/src/ai/chat/message-actions.tsx +1 -1
  36. package/src/ai/chat/message-utils.ts +2 -2
  37. package/src/ai/chat/presentation.tsx +22 -1
  38. package/src/ai/chat-quotas.ts +5 -4
  39. package/src/ai/chat-tasks.ts +2 -2
  40. package/src/ai/client/controller.ts +11 -10
  41. package/src/ai/client/projection.ts +3 -3
  42. package/src/ai/compaction.ts +3 -3
  43. package/src/ai/credentials.ts +13 -114
  44. package/src/ai/default-tool-contracts.ts +4 -3
  45. package/src/ai/dictation-runtime.ts +4 -4
  46. package/src/ai/enrich.ts +2 -2
  47. package/src/ai/executor.ts +31 -22
  48. package/src/ai/http.ts +17 -29
  49. package/src/ai/index.ts +11 -5
  50. package/src/ai/inference-calls.ts +5 -4
  51. package/src/ai/maintenance.ts +1 -1
  52. package/src/ai/memory-learning-runs.ts +5 -5
  53. package/src/ai/memory-learning.ts +3 -3
  54. package/src/ai/message-queue.ts +1 -2
  55. package/src/ai/migrate.ts +75 -42
  56. package/src/ai/model-access-migrate.ts +3 -17
  57. package/src/ai/model-access.ts +44 -85
  58. package/src/ai/model-card.tsx +134 -0
  59. package/src/ai/model-catalog.ts +359 -0
  60. package/src/ai/model-chooser-messages.ts +88 -0
  61. package/src/ai/model-chooser.tsx +577 -0
  62. package/src/ai/model-delete.ts +234 -0
  63. package/src/ai/model-fixture.ts +39 -0
  64. package/src/ai/model-icon.tsx +36 -0
  65. package/src/ai/model-overview.ts +53 -0
  66. package/src/ai/model-usage.ts +45 -0
  67. package/src/ai/openai-api-messages.ts +0 -4
  68. package/src/ai/openai-api.ts +26 -9
  69. package/src/ai/personal-agent.ts +1 -1
  70. package/src/ai/prefs.ts +12 -10
  71. package/src/ai/projects-routes.ts +2 -1
  72. package/src/ai/projects.ts +13 -12
  73. package/src/ai/protocol.ts +2 -2
  74. package/src/ai/provider-connections.ts +161 -0
  75. package/src/ai/provider-errors.ts +55 -6
  76. package/src/ai/provider-fetch.ts +4 -0
  77. package/src/ai/provider.ts +1 -1
  78. package/src/ai/quota-limits.ts +36 -0
  79. package/src/ai/quota-provider.ts +4 -2
  80. package/src/ai/quota-report.ts +7 -4
  81. package/src/ai/quotas.ts +19 -8
  82. package/src/ai/recent-resources.ts +6 -0
  83. package/src/ai/request-headers.ts +14 -87
  84. package/src/ai/resource-refs.ts +6 -2
  85. package/src/ai/routes.ts +17 -18
  86. package/src/ai/runtime.ts +50 -22
  87. package/src/ai/settings.ts +93 -580
  88. package/src/ai/store.ts +45 -23
  89. package/src/ai/stream.ts +1 -1
  90. package/src/ai/structured-runs.ts +2 -2
  91. package/src/ai/structured.ts +21 -44
  92. package/src/ai/task-contracts.ts +25 -5
  93. package/src/ai/task-execution.ts +15 -6
  94. package/src/ai/task-model.ts +9 -0
  95. package/src/ai/transcription.ts +27 -22
  96. package/src/ai/types.ts +50 -62
  97. package/src/ai/ui.tsx +13 -0
  98. package/src/ai/usage.ts +8 -8
  99. package/src/ai/validate.ts +8 -7
  100. package/src/ai/view-block.ts +2 -2
  101. package/src/api/accounts-entities.ts +16 -0
  102. package/src/api/admin-ai-quotas.ts +34 -56
  103. package/src/api/admin-ai-usage.ts +1 -1
  104. package/src/api/admin-ai.ts +173 -0
  105. package/src/api/admin-core-settings.ts +68 -244
  106. package/src/api/app-approval.ts +3 -1
  107. package/src/api/app-event-delivery.ts +51 -0
  108. package/src/api/app-events.ts +123 -0
  109. package/src/api/app-resource-access.ts +48 -0
  110. package/src/api/auth/account-mismatch.ts +24 -0
  111. package/src/api/auth.ts +26 -3
  112. package/src/api/capabilities.ts +28 -4
  113. package/src/api/index.ts +7 -1
  114. package/src/api/mcp.ts +175 -2
  115. package/src/api/me.ts +6 -4
  116. package/src/api/pwa-phone.ts +75 -26
  117. package/src/api/pwa.ts +34 -8
  118. package/src/api/resource-links.ts +83 -6
  119. package/src/api/resource-resolve.ts +50 -0
  120. package/src/api/search/schemas.ts +3 -1
  121. package/src/api/search.ts +9 -7
  122. package/src/app-pwa/contracts.ts +4 -0
  123. package/src/app-pwa/core-query-error.ts +20 -0
  124. package/src/app-pwa/index.ts +1 -0
  125. package/src/app-pwa/service.ts +91 -0
  126. package/src/app-pwa/transport.ts +22 -0
  127. package/src/approvals/messages.ts +2 -2
  128. package/src/browser/cloud-id.ts +86 -0
  129. package/src/browser/resource-clipboard.ts +4 -0
  130. package/src/browser/resource-links.ts +52 -0
  131. package/src/capabilities/server-invoke.ts +2 -11
  132. package/src/capabilities/testing.ts +5 -2
  133. package/src/cli/access.ts +6 -5
  134. package/src/cli/admin/ai-models.ts +199 -0
  135. package/src/cli/admin/ai-quotas.ts +6 -102
  136. package/src/cli/admin/ai-usage.ts +4 -4
  137. package/src/cli/admin/app-credentials.ts +5 -3
  138. package/src/cli/admin/index.ts +5 -2
  139. package/src/cli/apps.ts +89 -26
  140. package/src/cli/capabilities.ts +22 -1
  141. package/src/cli/events.ts +320 -0
  142. package/src/config/env.ts +40 -0
  143. package/src/contracts/app-access.ts +26 -2
  144. package/src/contracts/app-details.ts +89 -0
  145. package/src/contracts/app-events.ts +51 -0
  146. package/src/contracts/app.ts +21 -5
  147. package/src/contracts/capabilities.ts +143 -3
  148. package/src/contracts/index.ts +2 -0
  149. package/src/contracts/notification-types.ts +36 -9
  150. package/src/contracts/pwa.ts +17 -1
  151. package/src/contracts/registry.ts +6 -0
  152. package/src/contracts/resource-clipboard.ts +35 -0
  153. package/src/contracts/resource-pages.ts +140 -0
  154. package/src/contracts/resource-resolution.ts +13 -0
  155. package/src/contracts/shared.ts +4 -0
  156. package/src/contracts/user-notifications.ts +2 -0
  157. package/src/events/app-event-delivery.ts +100 -0
  158. package/src/events/app-events.testing.ts +65 -0
  159. package/src/events/app-events.ts +134 -0
  160. package/src/events/core-user-created.ts +24 -0
  161. package/src/events/index.ts +9 -0
  162. package/src/events/testing.ts +2 -0
  163. package/src/index.ts +1 -1
  164. package/src/server/api/respond.ts +8 -3
  165. package/src/server/index.ts +1 -0
  166. package/src/server/middleware/app-access.ts +1 -1
  167. package/src/server/middleware/auth.ts +21 -7
  168. package/src/server/middleware/index.ts +1 -0
  169. package/src/server/middleware/invocation.ts +2 -0
  170. package/src/server/public-storage.ts +106 -0
  171. package/src/server/services/access.ts +8 -0
  172. package/src/services/accounts/app.ts +6 -0
  173. package/src/services/accounts/entities.ts +14 -3
  174. package/src/services/accounts/groups.ts +3 -1
  175. package/src/services/accounts/identities.ts +62 -10
  176. package/src/services/accounts/users.ts +7 -6
  177. package/src/services/app-access.ts +25 -7
  178. package/src/services/app-details.ts +224 -0
  179. package/src/services/app-discovery.ts +2 -1
  180. package/src/services/app-event-shapes.ts +139 -0
  181. package/src/services/app-events/accept.ts +116 -0
  182. package/src/services/app-events/admin.ts +250 -0
  183. package/src/services/app-events/declarations.ts +187 -0
  184. package/src/services/app-events/deliver.ts +218 -0
  185. package/src/services/app-events.ts +92 -0
  186. package/src/services/config-conflict.ts +11 -0
  187. package/src/services/core-user-events.ts +1 -0
  188. package/src/services/freeipa-config.ts +9 -0
  189. package/src/services/geo/ledger.ts +28 -10
  190. package/src/services/geo/service.ts +43 -24
  191. package/src/services/identity/core-origin.ts +12 -0
  192. package/src/services/identity/invocation-actor.ts +20 -2
  193. package/src/services/identity/invocation-authority.ts +4 -0
  194. package/src/services/identity/invocation-token.ts +238 -56
  195. package/src/services/identity/issue-invocation.ts +6 -1
  196. package/src/services/image-metadata.ts +65 -1
  197. package/src/services/index.ts +13 -1
  198. package/src/services/ipa/sync.ts +6 -2
  199. package/src/services/ipa/users.ts +4 -1
  200. package/src/services/mandates/policy.ts +19 -12
  201. package/src/services/notifications/browser.ts +4 -0
  202. package/src/services/notifications/catalog.ts +5 -2
  203. package/src/services/notifications/platform.ts +7 -2
  204. package/src/services/notifications/user.ts +18 -7
  205. package/src/services/oauth-tokens.ts +28 -2
  206. package/src/services/object-store.ts +217 -0
  207. package/src/services/providers/local/users.ts +5 -0
  208. package/src/services/pwa-devices.ts +147 -37
  209. package/src/services/session/index.ts +14 -0
  210. package/src/services/settings/core-settings.ts +71 -43
  211. package/src/services/settings/index.ts +8 -0
  212. package/src/services/settings/versions.ts +26 -0
  213. package/src/shared/ai-models.ts +274 -0
  214. package/src/shared/ai-quotas.ts +2 -2
  215. package/src/shared/ai-usage.ts +2 -2
  216. package/src/shared/index.ts +2 -0
  217. package/src/shared-text/documents.ts +25 -8
  218. package/src/shared-text/log.ts +1 -1
  219. package/src/shared-text/text-diff.ts +75 -0
  220. package/src/ssr/AdminArea.tsx +131 -0
  221. package/src/ssr/AdminLayout.tsx +9 -3
  222. package/src/ssr/AdminSidebar.tsx +7 -7
  223. package/src/ssr/AppAccessDenied.tsx +2 -0
  224. package/src/ssr/AppBadge.tsx +14 -0
  225. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  226. package/src/ssr/CloudShell.island.tsx +25 -4
  227. package/src/ssr/Layout.tsx +8 -6
  228. package/src/ssr/PwaLayout.tsx +16 -1
  229. package/src/ssr/PwaPartCount.island.tsx +23 -0
  230. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  231. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  232. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  233. package/src/ssr/admin-active-link.ts +16 -0
  234. package/src/ssr/admin-navigation.ts +25 -20
  235. package/src/ssr/app-badges.ts +31 -7
  236. package/src/ssr/index.ts +8 -0
  237. package/src/ssr/page-trail-runtime.ts +3 -1
  238. package/src/ssr/platform-messages.ts +9 -1
  239. package/src/ssr/pwa-messages.ts +2 -2
  240. package/src/ssr/rail-navigation.ts +5 -1
  241. package/src/ssr/settings-draft.ts +153 -0
  242. package/src/ssr/shell-data.tsx +2 -0
  243. package/src/styles/global.css +1 -0
  244. package/src/styles/profile-picture.css +270 -0
  245. package/src/styles/utilities-navigation.css +6 -0
  246. package/src/workflows/ai/policy.ts +49 -0
  247. package/src/workflows/ai/runtime.ts +131 -21
  248. package/src/workflows/ai/store.ts +283 -35
  249. package/src/workflows/ai/types.ts +8 -2
  250. package/src/workflows/ai-actions.ts +148 -120
  251. package/src/workflows/authoring.ts +31 -0
  252. package/src/workflows/contracts.ts +1 -0
  253. package/src/workflows/definition.ts +2 -0
  254. package/src/workflows/editor.ts +15 -0
  255. package/src/workflows/index.ts +2 -1
  256. package/src/workflows/language/compiler.ts +34 -3
  257. package/src/workflows/runtime/executor.ts +132 -33
  258. package/src/workflows/runtime/index.ts +2 -0
  259. package/src/workflows/runtime/ports.ts +5 -1
  260. package/src/workflows/store/actions.ts +1 -1
  261. package/src/workflows/store/definitions.ts +5 -0
  262. package/src/workflows/store/runs.ts +5 -3
  263. package/src/workflows/store/worker.ts +7 -2
  264. package/src/ai/model-pricing.ts +0 -43
  265. package/src/ai/model-request-settings.ts +0 -98
  266. package/src/ai/settings-messages.ts +0 -129
  267. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { isDeepStrictEqual } from "node:util";
2
3
  import { createRemoteJWKSet, decodeProtectedHeader, errors, type JWTVerifyGetKey, jwtVerify, SignJWT } from "jose";
3
4
  import { z } from "zod";
@@ -46,7 +47,7 @@ export const normalizeInvocationRequestId = (value: unknown): string | undefined
46
47
  return parsed.success ? parsed.data : undefined;
47
48
  };
48
49
 
49
- const InvocationPayloadSchema = z
50
+ const InvocationPayloadBaseSchema = z
50
51
  .object({
51
52
  iss: z.string().url(),
52
53
  aud: z.string().regex(/^app:[a-z][a-z0-9-]{0,79}$/),
@@ -61,6 +62,8 @@ const InvocationPayloadSchema = z
61
62
  /** Only for an app session of the mobile app, which never acts as an administrator. */
62
63
  session_kind: z.literal("app").optional(),
63
64
  credential_id: BoundedIdentifierSchema.optional(),
65
+ oauth_client_id: z.string().min(1).max(200).optional(),
66
+ credential_expires_at: z.iso.datetime().optional(),
64
67
  scopes: z
65
68
  .array(ScopeSchema)
66
69
  .max(100)
@@ -79,52 +82,66 @@ const InvocationPayloadSchema = z
79
82
  nbf: z.number().int().nonnegative(),
80
83
  exp: z.number().int().positive(),
81
84
  })
82
- .strict()
83
- .superRefine((claims, context) => {
84
- if (
85
- (claims.parent_invocation_id
86
- ? claims.exp <= claims.iat ||
87
- claims.exp - claims.iat > CLOUD_INVOCATION_TOKEN_TTL_SECONDS ||
88
- !claims.op.startsWith("capability.query:")
89
- : claims.exp - claims.iat !== CLOUD_INVOCATION_TOKEN_TTL_SECONDS) ||
90
- claims.nbf !== claims.iat
91
- ) {
92
- context.addIssue({ code: "custom", message: "Invocation lifetime is invalid" });
93
- }
85
+ .strict();
94
86
 
95
- if (claims.principal_type === "user") {
96
- if (claims.access_subject_type !== "user" || claims.access_subject_id !== claims.sub || claims.delegated_user_id !== undefined) {
97
- context.addIssue({ code: "custom", message: "User invocation authority is inconsistent" });
98
- }
99
- if (claims.credential_kind === "api_key") {
100
- context.addIssue({ code: "custom", message: "An API key must act through a service account" });
101
- }
102
- } else if (claims.delegated_user_id) {
103
- if (claims.access_subject_type !== "user" || claims.access_subject_id !== claims.delegated_user_id) {
104
- context.addIssue({ code: "custom", message: "Delegated invocation authority is inconsistent" });
105
- }
106
- } else if (claims.access_subject_type !== "service_account" || claims.access_subject_id !== claims.sub) {
107
- context.addIssue({ code: "custom", message: "Service-account invocation authority is inconsistent" });
108
- }
109
- if (claims.credential_kind === "session" && claims.principal_type !== "user") {
110
- context.addIssue({ code: "custom", message: "A session invocation must use a user principal" });
111
- }
112
- if (claims.session_kind && claims.credential_kind !== "session") {
113
- context.addIssue({ code: "custom", message: "Only a session invocation has a session kind" });
87
+ const validateInvocationClaims = (claims: z.infer<typeof InvocationPayloadBaseSchema>, context: z.RefinementCtx, subjectProbe = false) => {
88
+ if (
89
+ (claims.parent_invocation_id
90
+ ? claims.exp <= claims.iat ||
91
+ claims.exp - claims.iat > CLOUD_INVOCATION_TOKEN_TTL_SECONDS ||
92
+ !claims.op.startsWith("capability.query:")
93
+ : claims.exp - claims.iat !== CLOUD_INVOCATION_TOKEN_TTL_SECONDS) ||
94
+ claims.nbf !== claims.iat
95
+ ) {
96
+ context.addIssue({ code: "custom", message: "Invocation lifetime is invalid" });
97
+ }
98
+
99
+ if (claims.principal_type === "user") {
100
+ if (claims.access_subject_type !== "user" || claims.access_subject_id !== claims.sub || claims.delegated_user_id !== undefined) {
101
+ context.addIssue({ code: "custom", message: "User invocation authority is inconsistent" });
114
102
  }
115
- if (claims.credential_kind === "session" && claims.scopes.length > 0) {
116
- context.addIssue({ code: "custom", message: "A session invocation cannot carry credential scopes" });
103
+ if (claims.credential_kind === "api_key") {
104
+ context.addIssue({ code: "custom", message: "An API key must act through a service account" });
117
105
  }
118
- if (claims.credential_kind === "api_key" && !claims.credential_id) {
119
- context.addIssue({ code: "custom", message: "An API-key invocation requires credential provenance" });
106
+ } else if (claims.delegated_user_id) {
107
+ if (claims.access_subject_type !== "user" || claims.access_subject_id !== claims.delegated_user_id) {
108
+ context.addIssue({ code: "custom", message: "Delegated invocation authority is inconsistent" });
120
109
  }
110
+ } else if (claims.access_subject_type !== "service_account" || claims.access_subject_id !== claims.sub) {
111
+ context.addIssue({ code: "custom", message: "Service-account invocation authority is inconsistent" });
112
+ }
113
+ if (claims.credential_kind === "session" && claims.principal_type !== "user") {
114
+ context.addIssue({ code: "custom", message: "A session invocation must use a user principal" });
115
+ }
116
+ if (claims.session_kind && claims.credential_kind !== "session") {
117
+ context.addIssue({ code: "custom", message: "Only a session invocation has a session kind" });
118
+ }
119
+ if (claims.credential_kind === "session" && claims.scopes.length > 0) {
120
+ context.addIssue({ code: "custom", message: "A session invocation cannot carry credential scopes" });
121
+ }
122
+ if (claims.credential_kind === "api_key" && !claims.credential_id) {
123
+ context.addIssue({ code: "custom", message: "An API-key invocation requires credential provenance" });
124
+ }
121
125
 
122
- const mandateFields = [claims.mandate_id, claims.mandate_revision, claims.workload_type, claims.workload_id];
123
- const mandateFieldCount = mandateFields.filter((value) => value !== undefined).length;
124
- if (claims.credential_kind === "mandate" ? mandateFieldCount !== mandateFields.length : mandateFieldCount !== 0) {
125
- context.addIssue({ code: "custom", message: "Mandate provenance is incomplete or unexpected" });
126
- }
127
- });
126
+ const mandateFields = [claims.mandate_id, claims.mandate_revision, claims.workload_type, claims.workload_id];
127
+ const mandateFieldCount = mandateFields.filter((value) => value !== undefined).length;
128
+ if (
129
+ claims.credential_kind === "mandate"
130
+ ? mandateFieldCount !== mandateFields.length && !(subjectProbe && mandateFieldCount === 0)
131
+ : mandateFieldCount !== 0
132
+ ) {
133
+ context.addIssue({ code: "custom", message: "Mandate provenance is incomplete or unexpected" });
134
+ }
135
+ };
136
+
137
+ const InvocationPayloadSchema = InvocationPayloadBaseSchema.superRefine((claims, context) => validateInvocationClaims(claims, context));
138
+ const AppAccessProbeSchema = InvocationPayloadBaseSchema.extend({
139
+ token_use: z.literal("app_access_probe"),
140
+ act: z.object({ sub: z.literal("app:core") }).strict(),
141
+ op: z.literal("app-access.check"),
142
+ schema_hash: z.null(),
143
+ parent_invocation_id: z.never().optional(),
144
+ }).superRefine((claims, context) => validateInvocationClaims({ ...claims, token_use: "invocation" }, context, true));
128
145
 
129
146
  export type CloudInvocationClaims = z.infer<typeof InvocationPayloadSchema>;
130
147
  export type InvocationAuthority = Pick<
@@ -137,6 +154,8 @@ export type InvocationAuthority = Pick<
137
154
  | "credential_kind"
138
155
  | "session_kind"
139
156
  | "credential_id"
157
+ | "oauth_client_id"
158
+ | "credential_expires_at"
140
159
  | "scopes"
141
160
  | "mandate_id"
142
161
  | "mandate_revision"
@@ -245,6 +264,26 @@ export const signInvocationToken = async (params: {
245
264
  }
246
265
  };
247
266
 
267
+ async function verifyWithKeyRefresh(
268
+ verify: (key: JWTVerifyGetKey) => ReturnType<typeof jwtVerify>,
269
+ key: JWTVerifyGetKey,
270
+ remote: { url: URL; state: RemoteSetState } | null,
271
+ ) {
272
+ try {
273
+ return await verify(key);
274
+ } catch (error) {
275
+ if (!(error instanceof errors.JWKSNoMatchingKey) || !remote) throw error;
276
+ const now = Date.now();
277
+ if (now - remote.state.lastForcedRefreshAt < 30_000) throw error;
278
+ remote.state.lastForcedRefreshAt = now;
279
+ identityMetrics.increment("unknown_kid_refresh");
280
+ const refreshed = createRemoteSet(remote.url);
281
+ const verified = await verify(refreshed);
282
+ remote.state.key = refreshed;
283
+ return verified;
284
+ }
285
+ }
286
+
248
287
  /** With deferSchemaBinding, the caller must compare schema_hash after resolving live authority. */
249
288
  export const verifyInvocationToken = async (
250
289
  token: string,
@@ -262,7 +301,7 @@ export const verifyInvocationToken = async (
262
301
  const jwksUrl = options.jwksUrl ?? runtime?.invocationJwksUrl;
263
302
  const state = options.key || !jwksUrl ? null : remoteSet(jwksUrl);
264
303
  if (state && jwksUrl) remote = { url: jwksUrl, state };
265
- let key = options.key ?? state?.key;
304
+ const key = options.key ?? state?.key;
266
305
  if (!key) throw new Error("Cloud invocation verifier has no public key source");
267
306
  assertProtectedHeader(decodeProtectedHeader(token));
268
307
  const verify = (candidate: JWTVerifyGetKey) =>
@@ -274,19 +313,7 @@ export const verifyInvocationToken = async (
274
313
  clockTolerance: INVOCATION_CLOCK_TOLERANCE_SECONDS,
275
314
  currentDate: options.now,
276
315
  });
277
- let verified: Awaited<ReturnType<typeof jwtVerify>>;
278
- try {
279
- verified = await verify(key);
280
- } catch (error) {
281
- if (!(error instanceof errors.JWKSNoMatchingKey) || !remote) throw error;
282
- const now = Date.now();
283
- if (now - remote.state.lastForcedRefreshAt < 30_000) throw error;
284
- remote.state.lastForcedRefreshAt = now;
285
- identityMetrics.increment("unknown_kid_refresh");
286
- key = createRemoteSet(remote.url);
287
- verified = await verify(key);
288
- remote.state.key = key;
289
- }
316
+ const verified = await verifyWithKeyRefresh(verify, key, remote);
290
317
  const parsed = InvocationPayloadSchema.safeParse(verified.payload);
291
318
  if (!parsed.success || parsed.data.op !== operation || (!options.deferSchemaBinding && parsed.data.schema_hash !== schemaHash)) {
292
319
  throw new Error("Cloud invocation JWT claims do not match the target operation");
@@ -303,3 +330,158 @@ export const verifyInvocationToken = async (
303
330
  export const clearInvocationVerifierCachesForTest = (): void => {
304
331
  remoteSets.clear();
305
332
  };
333
+
334
+ const AppEventInvocationClaimsSchema = z
335
+ .object({
336
+ iss: z.string().url(),
337
+ aud: z.string(),
338
+ token_use: z.literal("app_event_delivery"),
339
+ act: z.literal("app:core"),
340
+ op: z.literal("app-event.deliver"),
341
+ subscription_id: z.string().min(1).max(400),
342
+ event_id: z.uuid(),
343
+ payload_hash: z.string().regex(/^[a-f0-9]{64}$/),
344
+ iat: z.number().int(),
345
+ nbf: z.number().int(),
346
+ exp: z.number().int(),
347
+ jti: z.uuid(),
348
+ })
349
+ .strict();
350
+
351
+ export const appEventPayloadHash = (payload: unknown): string => createHash("sha256").update(JSON.stringify(payload)).digest("hex");
352
+
353
+ /** Core-to-app invocation, bound to one subscription, event and exact request body. */
354
+ export const signAppEventInvocation = async (input: {
355
+ appId: string;
356
+ subscriptionId: string;
357
+ eventId: string;
358
+ payload: unknown;
359
+ issuer?: string;
360
+ signer?: PreparedIdentitySigner;
361
+ now?: Date;
362
+ jti?: string;
363
+ }) => {
364
+ const issuer = input.issuer ?? (await getIdentityRuntimeConfig()).issuer;
365
+ const signer = input.signer ?? (await prepareIdentitySigner("invocation"));
366
+ const now = Math.floor((input.now?.getTime() ?? Date.now()) / 1_000);
367
+ if (signer.signUntil.getTime() < (now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS) * 1000)
368
+ throw new Error("Invocation signing key expires before the delivery token");
369
+ const claims = AppEventInvocationClaimsSchema.parse({
370
+ iss: issuer,
371
+ aud: `app:${AppIdSchema.parse(input.appId)}`,
372
+ token_use: "app_event_delivery",
373
+ act: "app:core",
374
+ op: "app-event.deliver",
375
+ subscription_id: input.subscriptionId,
376
+ event_id: input.eventId,
377
+ payload_hash: appEventPayloadHash(input.payload),
378
+ iat: now,
379
+ nbf: now,
380
+ exp: now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS,
381
+ jti: input.jti ?? crypto.randomUUID(),
382
+ });
383
+ return new SignJWT(claims)
384
+ .setProtectedHeader({ alg: CLOUD_IDENTITY_ALGORITHM, typ: CLOUD_INVOCATION_TOKEN_TYPE, kid: signer.kid })
385
+ .sign(signer.key);
386
+ };
387
+
388
+ export const verifyAppEventInvocation = async (
389
+ token: string,
390
+ input: { appId: string; subscriptionId: string; eventId: string; payload: unknown },
391
+ options: { issuer?: string; key?: JWTVerifyGetKey; jwksUrl?: URL; now?: Date } = {},
392
+ ): Promise<boolean> => {
393
+ if (new TextEncoder().encode(token).byteLength > IDENTITY_MAX_COMPACT_TOKEN_BYTES) return false;
394
+ try {
395
+ assertProtectedHeader(decodeProtectedHeader(token));
396
+ const runtime = options.issuer && (options.key || options.jwksUrl) ? null : await getIdentityRuntimeConfig();
397
+ const issuer = options.issuer ?? runtime!.issuer;
398
+ const url = options.jwksUrl ?? runtime?.invocationJwksUrl;
399
+ const state = options.key || !url ? null : remoteSet(url);
400
+ const key = options.key ?? state?.key;
401
+ if (!key) return false;
402
+ const verify = (key: JWTVerifyGetKey) =>
403
+ jwtVerify(token, key, {
404
+ algorithms: [CLOUD_IDENTITY_ALGORITHM],
405
+ audience: `app:${AppIdSchema.parse(input.appId)}`,
406
+ issuer,
407
+ typ: CLOUD_INVOCATION_TOKEN_TYPE,
408
+ clockTolerance: INVOCATION_CLOCK_TOLERANCE_SECONDS,
409
+ currentDate: options.now,
410
+ });
411
+ const result = await verifyWithKeyRefresh(verify, key, state && url ? { url, state } : null);
412
+ const claims = AppEventInvocationClaimsSchema.parse(result.payload);
413
+ return (
414
+ claims.exp - claims.iat === CLOUD_INVOCATION_TOKEN_TTL_SECONDS &&
415
+ claims.nbf === claims.iat &&
416
+ claims.subscription_id === input.subscriptionId &&
417
+ claims.event_id === input.eventId &&
418
+ claims.payload_hash === appEventPayloadHash(input.payload)
419
+ );
420
+ } catch {
421
+ return false;
422
+ }
423
+ };
424
+
425
+ /** Core-only, target-bound existence proof. It cannot authorize an application operation. */
426
+ export const signAppAccessProbe = async (input: {
427
+ appId: string;
428
+ authority: InvocationAuthority;
429
+ issuer?: string;
430
+ signer?: PreparedIdentitySigner;
431
+ now?: Date;
432
+ }) => {
433
+ const issuer = input.issuer ?? (await getIdentityRuntimeConfig()).issuer;
434
+ const signer = input.signer ?? (await prepareIdentitySigner("invocation"));
435
+ const now = Math.floor((input.now?.getTime() ?? Date.now()) / 1_000);
436
+ if (signer.signUntil.getTime() < (now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS) * 1000)
437
+ throw new Error("Invocation signing key expires before the app-access probe");
438
+ const claims = AppAccessProbeSchema.parse({
439
+ ...input.authority,
440
+ iss: issuer,
441
+ aud: `app:${AppIdSchema.parse(input.appId)}`,
442
+ token_use: "app_access_probe",
443
+ act: { sub: "app:core" },
444
+ op: "app-access.check",
445
+ schema_hash: null,
446
+ ver: CLOUD_INVOCATION_PROTOCOL_VERSION,
447
+ iat: now,
448
+ nbf: now,
449
+ exp: now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS,
450
+ jti: crypto.randomUUID(),
451
+ });
452
+ const token = await new SignJWT(claims)
453
+ .setProtectedHeader({ alg: CLOUD_IDENTITY_ALGORITHM, typ: CLOUD_INVOCATION_TOKEN_TYPE, kid: signer.kid })
454
+ .sign(signer.key);
455
+ if (new TextEncoder().encode(token).byteLength > IDENTITY_MAX_COMPACT_TOKEN_BYTES) throw new Error("App-access probe exceeds 4 KiB");
456
+ return token;
457
+ };
458
+
459
+ export const verifyAppAccessProbe = async (
460
+ token: string,
461
+ appId: string,
462
+ options: { issuer?: string; key?: JWTVerifyGetKey; jwksUrl?: URL; now?: Date } = {},
463
+ ): Promise<z.infer<typeof AppAccessProbeSchema> | null> => {
464
+ if (new TextEncoder().encode(token).byteLength > IDENTITY_MAX_COMPACT_TOKEN_BYTES) return null;
465
+ try {
466
+ assertProtectedHeader(decodeProtectedHeader(token));
467
+ const runtime = options.issuer && (options.key || options.jwksUrl) ? null : await getIdentityRuntimeConfig();
468
+ const issuer = options.issuer ?? runtime!.issuer;
469
+ const url = options.jwksUrl ?? runtime?.invocationJwksUrl;
470
+ const state = options.key || !url ? null : remoteSet(url);
471
+ const key = options.key ?? state?.key;
472
+ if (!key) return null;
473
+ const verify = (key: JWTVerifyGetKey) =>
474
+ jwtVerify(token, key, {
475
+ algorithms: [CLOUD_IDENTITY_ALGORITHM],
476
+ audience: `app:${AppIdSchema.parse(appId)}`,
477
+ issuer,
478
+ typ: CLOUD_INVOCATION_TOKEN_TYPE,
479
+ clockTolerance: INVOCATION_CLOCK_TOLERANCE_SECONDS,
480
+ currentDate: options.now,
481
+ });
482
+ const result = await verifyWithKeyRefresh(verify, key, state && url ? { url, state } : null);
483
+ return AppAccessProbeSchema.parse(result.payload);
484
+ } catch {
485
+ return null;
486
+ }
487
+ };
@@ -33,6 +33,7 @@ export const resolveIssuanceAuthority = async (
33
33
 
34
34
  type RegisteredAppAccessOptions = {
35
35
  db?: SQL;
36
+ signal?: AbortSignal;
36
37
  getApp?: (id: string) => Promise<Pick<AppMeta, "id" | "access"> | null>;
37
38
  };
38
39
 
@@ -41,8 +42,10 @@ export const requireRegisteredAppAccess = async (
41
42
  appId: string,
42
43
  options: RegisteredAppAccessOptions = {},
43
44
  ): Promise<void> => {
45
+ options.signal?.throwIfAborted();
44
46
  const app = await (options.getApp ?? getApp)(appId).catch(() => null);
45
- if (!app || !authority || !(await canUseApp(authority, app, { db: options.db }))) {
47
+ if (!app || !authority || !(await canUseApp(authority, app, { db: options.db, signal: options.signal }))) {
48
+ options.signal?.throwIfAborted();
46
49
  if (app) recordAppAccessDenied(app.id);
47
50
  throw new AppAccessDeniedError(app ?? { id: appId });
48
51
  }
@@ -57,6 +60,7 @@ export const issueInvocationToken = async (
57
60
  sign?: typeof signInvocationToken;
58
61
  } = {},
59
62
  ): ReturnType<typeof signInvocationToken> => {
63
+ dependencies.signal?.throwIfAborted();
60
64
  const authority = await (dependencies.resolve ?? resolveIssuanceAuthority)(
61
65
  params.authority,
62
66
  params.callingAppId,
@@ -66,5 +70,6 @@ export const issueInvocationToken = async (
66
70
  if (params.parent && (!authority || !matchesInvocationSubject(params.parent, authority)))
67
71
  throw new AppAccessDeniedError({ id: params.targetAppId });
68
72
  await requireRegisteredAppAccess(authority, params.targetAppId, dependencies);
73
+ dependencies.signal?.throwIfAborted();
69
74
  return (dependencies.sign ?? signInvocationToken)(params);
70
75
  };
@@ -366,12 +366,76 @@ const stripWebp = (bytes: Uint8Array): Uint8Array => {
366
366
  return out.subarray(0, written);
367
367
  };
368
368
 
369
+ const stripGif = (bytes: Uint8Array): Uint8Array => {
370
+ const bad = () => new ImageMetadataError("GIF");
371
+ if (bytes.length < 13) throw bad();
372
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
373
+ if (!view.getUint16(6, true) || !view.getUint16(8, true)) throw bad();
374
+ let offset = 13 + (bytes[10]! & 0x80 ? 3 * (1 << ((bytes[10]! & 7) + 1)) : 0);
375
+ if (offset > bytes.length) throw bad();
376
+ const out = new Uint8Array(bytes.length);
377
+ let written = copy(out, 0, bytes, 0, offset);
378
+ let image = false;
379
+ const blocksEnd = (start: number) => {
380
+ let next = start;
381
+ while (next < bytes.length) {
382
+ const size = bytes[next++]!;
383
+ if (!size) return next;
384
+ if (next + size > bytes.length) throw bad();
385
+ next += size;
386
+ }
387
+ throw bad();
388
+ };
389
+ while (offset < bytes.length) {
390
+ const start = offset;
391
+ const type = bytes[offset++]!;
392
+ if (type === 0x3b) {
393
+ if (!image) throw bad();
394
+ out[written++] = type;
395
+ return out.subarray(0, written);
396
+ }
397
+ if (type === 0x2c) {
398
+ if (offset + 9 > bytes.length || !view.getUint16(offset + 4, true) || !view.getUint16(offset + 6, true)) throw bad();
399
+ const packed = bytes[offset + 8]!;
400
+ offset += 9 + (packed & 0x80 ? 3 * (1 << ((packed & 7) + 1)) : 0);
401
+ if (offset >= bytes.length || bytes[offset]! < 2 || bytes[offset]! > 8) throw bad();
402
+ offset = blocksEnd(offset + 1);
403
+ written = copy(out, written, bytes, start, offset);
404
+ image = true;
405
+ } else if (type === 0x21) {
406
+ if (offset >= bytes.length) throw bad();
407
+ const label = bytes[offset++]!;
408
+ const payload = offset;
409
+ offset = blocksEnd(offset);
410
+ if (label === 0xf9) {
411
+ if (bytes[payload] !== 4 || offset !== payload + 6) throw bad();
412
+ written = copy(out, written, bytes, start, offset);
413
+ } else if (label === 0x01) {
414
+ // Plain text extensions contribute visible content, unlike comments and private application blocks.
415
+ if (bytes[payload] !== 12) throw bad();
416
+ written = copy(out, written, bytes, start, offset);
417
+ } else if (
418
+ label === 0xff &&
419
+ bytes[payload] === 11 &&
420
+ offset === start + 19 &&
421
+ (startsWith(bytes, "NETSCAPE2.0", payload + 1) || startsWith(bytes, "ANIMEXTS1.0", payload + 1)) &&
422
+ bytes[payload + 12] === 3 &&
423
+ bytes[payload + 13] === 1
424
+ ) {
425
+ written = copy(out, written, bytes, start, offset);
426
+ }
427
+ } else throw bad();
428
+ }
429
+ throw bad();
430
+ };
431
+
369
432
  /**
370
- * Losslessly removes non-rendering metadata from JPEG, PNG and WebP, selected
433
+ * Losslessly removes non-rendering metadata from JPEG, PNG, WebP and GIF, selected
371
434
  * by magic bytes. Call only for uploads the application classifies as images.
372
435
  * Other formats are returned unchanged. No decoding; linear time and space.
373
436
  */
374
437
  export const stripImageMetadata = (bytes: Uint8Array): Uint8Array => {
438
+ if (startsWith(bytes, "GIF87a") || startsWith(bytes, "GIF89a")) return stripGif(bytes);
375
439
  if (bytes[0] === 0xff && bytes[1] === 0xd8) return stripJpeg(bytes);
376
440
  if (startsWith(bytes, "\x89PNG")) return stripPng(bytes);
377
441
  if (startsWith(bytes, "RIFF") && startsWith(bytes, "WEBP", 8)) return stripWebp(bytes);
@@ -104,7 +104,7 @@ export type {
104
104
  ServiceAccountCredentialOwner,
105
105
  ServiceAccountCredentialStatus,
106
106
  } from "./service-account-credentials";
107
- export { oauthTokens } from "./oauth-tokens";
107
+ export { isServiceAccountOAuthAuthorityCurrent, oauthTokens } from "./oauth-tokens";
108
108
  export type { AuthenticatedOAuthToken } from "./oauth-tokens";
109
109
  export { webauthn } from "./webauthn";
110
110
  export type { WebAuthnRp } from "./webauthn";
@@ -251,6 +251,7 @@ export {
251
251
  export type { NetworkLookup, PublicNetworkAddress } from "./network-security";
252
252
 
253
253
  export { geoip } from "./geoip";
254
+ export { appDetails } from "./app-details";
254
255
  export {
255
256
  knownBrowsers,
256
257
  KNOWN_BROWSER_LIMITS,
@@ -279,3 +280,14 @@ export {
279
280
  requireAppAccessManager,
280
281
  type AppAccessSnapshot,
281
282
  } from "./app-access-management";
283
+
284
+ export { storage } from "./object-store";
285
+ export type {
286
+ ObjectStoreFile,
287
+ ObjectVisibility,
288
+ ObjectWriteData,
289
+ ObjectWriteOptions,
290
+ ObjectPresignOptions,
291
+ ObjectListOptions,
292
+ ObjectListResult,
293
+ } from "./object-store";
@@ -1,4 +1,5 @@
1
1
  import { sql } from "bun";
2
+ import { coreUserEventPublisher } from "../../events/core-user-created";
2
3
  import { freeipa } from "../../server/services";
3
4
  import { writeDeletedAccountAudit } from "../account-lifecycle/audit";
4
5
  import { calculateIpaProfileFromGroupNames, parseIpaAccountTransitionPolicy, parseIpaMatchMode } from "../account-model";
@@ -461,8 +462,11 @@ export const syncFromIpa = async (runtime: IpaSyncRuntime = {}): Promise<void> =
461
462
  RETURNING id, (xmax = 0) AS inserted`;
462
463
  await upsertUserIpaData(tx, { userId: upserted[0]!.id as string, ...u });
463
464
  upsertedUsersByUid += 1;
464
- if (Boolean(upserted[0]?.inserted)) insertedUsersByUid += 1;
465
- else updatedUsersByUid += 1;
465
+ if (Boolean(upserted[0]?.inserted)) {
466
+ insertedUsersByUid += 1;
467
+ const userId = upserted[0]!.id as string;
468
+ await coreUserEventPublisher().publish(tx, "user.created", { subject: userId, data: { userId, provider: "ipa" } });
469
+ } else updatedUsersByUid += 1;
466
470
  }
467
471
 
468
472
  for (let staleIndex = 0; staleIndex < staleLocalUsers.length; staleIndex += 1) {
@@ -1,6 +1,7 @@
1
1
  import { password } from "@k2b/stdlib";
2
2
  import { sql } from "bun";
3
3
  import type { MutationResult, UserProfile } from "../../contracts/shared";
4
+ import { coreUserEventPublisher } from "../../events/core-user-created";
4
5
  import { freeipa } from "../../server/services";
5
6
  import { writeDeletedAccountAudit } from "../account-lifecycle/audit";
6
7
  import { resolveProviderProfile } from "../accounts/base-user";
@@ -366,7 +367,7 @@ export const addIpa = async (params: {
366
367
  display_name = EXCLUDED.display_name,
367
368
  mail = EXCLUDED.mail,
368
369
  account_expires = EXCLUDED.account_expires
369
- RETURNING id
370
+ RETURNING id, (xmax = 0) AS inserted
370
371
  `;
371
372
  id = insertRows[0]!.id as string;
372
373
  await upsertUserIpaData(
@@ -381,6 +382,8 @@ export const addIpa = async (params: {
381
382
  },
382
383
  tx,
383
384
  );
385
+ if (insertRows[0]?.inserted)
386
+ await coreUserEventPublisher().publish(tx, "user.created", { subject: id, data: { userId: id, provider: "ipa" } });
384
387
  }
385
388
  } catch (dbError) {
386
389
  log.error("CRITICAL: FreeIPA user created but local DB update failed. Manual reconciliation needed.", {
@@ -194,6 +194,11 @@ export const isMandatePolicyNarrowing = (current: MandatePolicyV1, next: Mandate
194
194
  }),
195
195
  ));
196
196
 
197
+ /**
198
+ * True when a background mandate permits one operation. Unattended Actions must fit the live
199
+ * capability policy whenever the mandate has grants or preapproves Actions; a `require_approval`
200
+ * mandate without grants runs an Action only after its separate approval completed through Cloud.
201
+ */
197
202
  export const mandatePolicyAllows = (
198
203
  policy: MandatePolicyV1,
199
204
  input: {
@@ -224,14 +229,14 @@ export const mandatePolicyAllows = (
224
229
  )
225
230
  );
226
231
  }
232
+ const [operation, capabilityId] = input.operation.split(":");
233
+ const kind =
234
+ operation === "capability.query"
235
+ ? "query"
236
+ : operation === "capability.action.run" || operation === "capability.action.review"
237
+ ? "action"
238
+ : null;
227
239
  if (policy.grants !== undefined) {
228
- const [operation, capabilityId] = input.operation.split(":");
229
- const kind =
230
- operation === "capability.query"
231
- ? "query"
232
- : operation === "capability.action.run" || operation === "capability.action.review"
233
- ? "action"
234
- : null;
235
240
  if (
236
241
  !kind ||
237
242
  !capabilityId ||
@@ -240,11 +245,13 @@ export const mandatePolicyAllows = (
240
245
  )
241
246
  )
242
247
  return false;
243
- if (kind === "action") {
244
- if (input.capabilityPolicy) {
245
- if (!("silent" in input.capabilityPolicy) || input.capabilityPolicy.routines === false) return false;
246
- } else if (input.capabilityApproval !== "none" && input.capabilityApproval !== "rememberable") return false;
247
- }
248
+ }
249
+ // Routine grants and unattended preapproval must both fit the live capability policy.
250
+ // A no-grants require_approval mandate keeps its separately approved execution path.
251
+ if (kind === "action" && (policy.grants !== undefined || policy.actions === "preapproved")) {
252
+ if (input.capabilityPolicy) {
253
+ if (!("silent" in input.capabilityPolicy) || input.capabilityPolicy.routines === false) return false;
254
+ } else if (input.capabilityApproval !== "none" && input.capabilityApproval !== "rememberable") return false;
248
255
  }
249
256
  if (!input.operation.startsWith("capability.action.run:")) return true;
250
257
  if (policy.actions === "deny") return false;
@@ -29,6 +29,7 @@ type BrowserDeliveryPayload = {
29
29
  group?: string;
30
30
  badge?: number;
31
31
  createdAt?: number;
32
+ alert?: false;
32
33
  };
33
34
 
34
35
  // RFC 8030 section 7.2 guarantees 4,096 bytes; aes128gcm adds a header, tag, and padding delimiter.
@@ -44,6 +45,7 @@ const browserPushMessage = (payload: BrowserDeliveryPayload): string =>
44
45
  ...(payload.group !== undefined ? { group: payload.group } : {}),
45
46
  ...(payload.badge !== undefined ? { badge: payload.badge } : {}),
46
47
  ...(payload.createdAt !== undefined ? { createdAt: payload.createdAt } : {}),
48
+ ...(payload.alert === false ? { alert: false } : {}),
47
49
  });
48
50
 
49
51
  const BrowserDeliveryPayloadSchema = z.object({
@@ -59,6 +61,7 @@ const BrowserDeliveryPayloadSchema = z.object({
59
61
  .optional(),
60
62
  badge: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
61
63
  createdAt: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
64
+ alert: z.literal(false).optional(),
62
65
  });
63
66
 
64
67
  const BrowserDestinationContextSchema = z.object({
@@ -192,6 +195,7 @@ const browserDriver = {
192
195
  ...(group !== undefined ? { group } : {}),
193
196
  ...(presentation.badge !== undefined ? { badge: presentation.badge } : {}),
194
197
  ...(presentation.group !== undefined || presentation.badge !== undefined ? { createdAt: Date.now() } : {}),
198
+ ...(presentation.alert === false ? { alert: false } : {}),
195
199
  });
196
200
  if (payload.preview !== undefined && Buffer.byteLength(browserPushMessage(payload), "utf8") > WEB_PUSH_PLAINTEXT_BUDGET) {
197
201
  const { preview: _preview, ...titleOnlyPayload } = payload;
@@ -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()