@k2b/cloud 0.27.0 → 0.29.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 (155) hide show
  1. package/package.json +7 -5
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. package/src/styles/utilities-navigation.css +41 -0
@@ -119,6 +119,8 @@ export type AppMeta = {
119
119
  section: "primary" | "more" | "hidden";
120
120
  requiresAuth?: boolean;
121
121
  requiresRoles?: Role[];
122
+ /** Same-origin path of a `GET` route this app serves that returns an `AppNavBadge`; see `defineApp()`. */
123
+ badge?: string;
122
124
  };
123
125
  /**
124
126
  * Legal/info pages this app owns. Aggregated app-wide and rendered in
@@ -146,6 +148,14 @@ export type AppMeta = {
146
148
  cli?: AppCliModules;
147
149
  };
148
150
 
151
+ /**
152
+ * Response of an app's `nav.badge` route: how many items wait for the signed-in
153
+ * person. A positive safe integer shows a count on the app's icon in the rail
154
+ * and the app grid (above 99 as "99+"); `0`, a non-200 response, or anything
155
+ * else shows none.
156
+ */
157
+ export type AppNavBadge = { count: number };
158
+
149
159
  export type WidgetEndpoint = {
150
160
  /** Unique-within-the-app id, e.g. "open-requests". */
151
161
  id: string;
@@ -8,7 +8,9 @@ export const CommandPathSchema = z
8
8
  .refine((value) => {
9
9
  if (!value.startsWith("/") || /[\\\u0000-\u0020]/u.test(value)) return false;
10
10
  try {
11
- return new URL(value, "https://cloud.invalid").origin === "https://cloud.invalid";
11
+ const url = new URL(value, "https://cloud.invalid");
12
+ // Dot segments can resolve to `//host`, which a browser reads as another host.
13
+ return url.origin === "https://cloud.invalid" && !url.pathname.startsWith("//");
12
14
  } catch {
13
15
  return false;
14
16
  }
@@ -0,0 +1,6 @@
1
+ /** App-chosen keys stay separate from the namespaced tags sent to browsers. */
2
+ export const isNotificationGroup = (value: unknown): value is string =>
3
+ typeof value === "string" && value.length >= 1 && value.length <= 128 && /^[A-Za-z0-9._:-]+$/.test(value);
4
+
5
+ export const notificationGroupTag = (appId: string, group: string): string | null =>
6
+ typeof appId === "string" && /^[a-z][a-z0-9-]*$/.test(appId) && isNotificationGroup(group) ? `${appId}:${group}` : null;
@@ -31,6 +31,12 @@ export const validateNotificationTargetHref = (value: string): `/${string}` => {
31
31
  export type NotificationPresentation = {
32
32
  title: string;
33
33
  body?: string;
34
+ /** Short text shown in the browser/OS notification, including the lock screen. Cloud passes it only to the browser channel; email, deployment channels, and notification history never receive it. */
35
+ preview?: string;
36
+ /** Stable app-chosen browser group key, namespaced by Cloud per application. */
37
+ group?: string;
38
+ /** Non-negative safe integer for the app badge; zero clears it. Browser-only. */
39
+ badge?: number;
34
40
  /** Same-origin absolute path. Validated again before persistence and navigation. */
35
41
  targetHref?: `/${string}`;
36
42
  };
@@ -1,7 +1,12 @@
1
+ import type { PageParams, Paginated, ServiceError } from "@k2b/stdlib";
1
2
  import { z } from "zod";
3
+ import { type RequestActor, ServiceAccountSchema, UserSchema } from "./shared";
2
4
 
3
5
  export const PlatformPermissionSchema = z.enum(["mail:send"]);
4
6
  export type PlatformPermission = z.infer<typeof PlatformPermissionSchema>;
7
+ export const MailRetentionSchema = z.object({ contentDays: z.int().min(1).max(36500), recordDays: z.int().min(1).max(36500) }).strict();
8
+ export type MailRetention = z.infer<typeof MailRetentionSchema>;
9
+
5
10
  export const MailProfileKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
6
11
  const headerText = z
7
12
  .string()
@@ -9,6 +14,26 @@ const headerText = z
9
14
  .min(1)
10
15
  .max(120)
11
16
  .refine((value) => !/[\r\n\0]/.test(value), "Header must not contain control characters");
17
+ export const MailImapInputSchema = z
18
+ .object({
19
+ host: z
20
+ .string()
21
+ .trim()
22
+ .min(1)
23
+ .max(253)
24
+ .refine((value) => !/[\s/@\0]/.test(value), "Use an IMAP hostname"),
25
+ port: z.int().min(1).max(65535),
26
+ secure: z.boolean(),
27
+ user: z.string().trim().min(1).max(320),
28
+ password: z.string().max(16384).nullable().optional(),
29
+ folder: z
30
+ .string()
31
+ .trim()
32
+ .min(1)
33
+ .max(200)
34
+ .refine((value) => !/[\x00-\x1f\x7f]/.test(value), "Folder must not contain control characters"),
35
+ })
36
+ .strict();
12
37
  export const MailProfileInputSchema = z
13
38
  .object({
14
39
  name: headerText,
@@ -24,6 +49,7 @@ export const MailProfileInputSchema = z
24
49
  smtpSecure: z.boolean(),
25
50
  smtpUser: z.string().max(320).nullable(),
26
51
  smtpPassword: z.string().max(16384).nullable().optional(),
52
+ imap: MailImapInputSchema.nullable().optional(),
27
53
  pacePerMinute: z.int().min(1).max(6000),
28
54
  dailyRecipientLimit: z.int().min(1).max(2147483647).nullable(),
29
55
  maxAttachmentBytes: z.int().min(1).max(26214400),
@@ -46,6 +72,19 @@ export const MailProfileSchema = z.object({
46
72
  });
47
73
  export type MailProfile = z.infer<typeof MailProfileSchema>;
48
74
  export const MailErrorCodeSchema = z.enum([
75
+ "bad_input",
76
+ "profile_not_allowed",
77
+ "profile_required",
78
+ "quota_exceeded",
79
+ "backlog_full",
80
+ "attachments_too_large",
81
+ "attachment_storage_full",
82
+ "profile_removed",
83
+ "attachment_lost",
84
+ "cancelled_by_admin",
85
+ "message_unknown",
86
+ "batch_unknown",
87
+ "message_not_queued",
49
88
  "mail_unavailable",
50
89
  "mail_not_declared",
51
90
  "profile_unknown",
@@ -56,9 +95,13 @@ export const MailErrorCodeSchema = z.enum([
56
95
  "smtp_failed",
57
96
  ]);
58
97
  export type MailErrorCode = z.infer<typeof MailErrorCodeSchema>;
59
- export const AdminMailProfileSchema = MailProfileInputSchema.omit({ smtpPassword: true, revision: true }).extend({
98
+ export const MailBounceErrorCodeSchema = z.enum(["open_failed", "search_failed", "apply_failed", "save_failed", "interrupted"]);
99
+ export type MailBounceErrorCode = z.infer<typeof MailBounceErrorCodeSchema>;
100
+ export const AdminMailProfileSchema = MailProfileInputSchema.omit({ smtpPassword: true, revision: true, imap: true }).extend({
60
101
  key: MailProfileKeySchema,
61
102
  hasPassword: z.boolean(),
103
+ imap: MailImapInputSchema.omit({ password: true }).extend({ hasPassword: z.boolean() }).nullable(),
104
+ bounces: z.object({ checkedAt: z.string().nullable(), error: MailBounceErrorCodeSchema.nullable() }).nullable(),
62
105
  isDefault: z.boolean(),
63
106
  revision: z.int(),
64
107
  createdAt: z.string(),
@@ -75,3 +118,148 @@ export type AdminMailApp = {
75
118
  mode: "default" | "selected";
76
119
  profiles: string[];
77
120
  };
121
+
122
+ export const MailStatusSchema = z.enum(["queued", "sending", "sent", "failed", "bounced", "cancelled"]);
123
+ export type MailStatus = z.infer<typeof MailStatusSchema>;
124
+ export type MailAttachment = {
125
+ filename: string;
126
+ contentType: string;
127
+ content: Uint8Array | Blob | ReadableStream<Uint8Array>;
128
+ };
129
+ const body = z
130
+ .string()
131
+ .refine((value) => !value.includes("\0"), "Body must not contain NUL")
132
+ .max(512 * 1024)
133
+ .refine((value) => new TextEncoder().encode(value).byteLength <= 512 * 1024, "Body exceeds 512 KiB");
134
+ const safeHeader = z.string().refine((value) => !/[\r\n\0]/.test(value), "Header must not contain control characters");
135
+ const allowedHeaders = new Set([
136
+ "list-id",
137
+ "list-unsubscribe",
138
+ "list-unsubscribe-post",
139
+ "in-reply-to",
140
+ "references",
141
+ "auto-submitted",
142
+ "precedence",
143
+ ]);
144
+ export const MailHeadersSchema = z.record(z.string(), safeHeader).refine((headers) => {
145
+ let bytes = 0;
146
+ const seen = new Set<string>();
147
+ for (const [name, value] of Object.entries(headers)) {
148
+ const lower = name.toLowerCase();
149
+ if (!/^[\x21-\x39\x3B-\x7E]+$/.test(name) || seen.has(lower)) return false;
150
+ seen.add(lower);
151
+ if (!(lower.startsWith("x-") && !lower.startsWith("x-cloud-")) && !allowedHeaders.has(lower)) return false;
152
+ bytes += new TextEncoder().encode(`${name}: ${value}\r\n`).byteLength;
153
+ }
154
+ return bytes <= 8192;
155
+ }, "Invalid mail headers or headers exceed 8 KiB");
156
+ const referenceText = z
157
+ .string()
158
+ .min(1)
159
+ .max(200)
160
+ .refine((value) => !value.includes("\0"), "Reference must not contain NUL");
161
+ const ref = z.object({ scope: referenceText, id: referenceText }).strict();
162
+ const mailAddress = z.email({ pattern: z.regexes.html5Email }).max(320);
163
+ export const MailMessageSchema = z
164
+ .object({
165
+ to: z.array(mailAddress).min(1).max(50),
166
+ subject: safeHeader.max(998),
167
+ text: body,
168
+ html: body.optional(),
169
+ fromName: safeHeader.max(998).optional(),
170
+ replyTo: mailAddress.optional(),
171
+ attachments: z
172
+ .array(
173
+ z
174
+ .object({
175
+ filename: safeHeader.min(1).max(998),
176
+ contentType: safeHeader.min(1).max(998),
177
+ content: z.custom<MailAttachment["content"]>(
178
+ (value) => value instanceof Uint8Array || value instanceof Blob || value instanceof ReadableStream,
179
+ ),
180
+ })
181
+ .strict(),
182
+ )
183
+ .max(20)
184
+ .optional(),
185
+ headers: MailHeadersSchema.optional(),
186
+ profile: MailProfileKeySchema.optional(),
187
+ ref: ref.optional(),
188
+ actor: z
189
+ .custom<RequestActor>((value) => {
190
+ if (!value || typeof value !== "object" || !("kind" in value)) return false;
191
+ if (value.kind === "user" && "user" in value) return UserSchema.safeParse(value.user).success;
192
+ return (
193
+ value.kind === "service_account" && "serviceAccount" in value && ServiceAccountSchema.safeParse(value.serviceAccount).success
194
+ );
195
+ })
196
+ .optional(),
197
+ key: z
198
+ .string()
199
+ .min(1)
200
+ .max(200)
201
+ .refine((value) => !value.includes("\0"), "Key must not contain NUL")
202
+ .optional(),
203
+ })
204
+ .strict();
205
+ export type MailMessage = z.infer<typeof MailMessageSchema>;
206
+ export const MailBatchSchema = z
207
+ .array(MailMessageSchema)
208
+ .min(1)
209
+ .max(1000)
210
+ .refine((messages) => {
211
+ const keys = messages.flatMap((message) => (message.key === undefined ? [] : [message.key]));
212
+ return new Set(keys).size === keys.length;
213
+ }, "Duplicate outgoing mail keys in a batch");
214
+ export const MailFilterSchema = z
215
+ .object({
216
+ ref: ref.partial({ id: true }).optional(),
217
+ ids: z.array(z.uuid()).max(100).optional(),
218
+ batchId: z.uuid().optional(),
219
+ status: z.array(MailStatusSchema).max(6).optional(),
220
+ since: z.iso.datetime({ offset: true }).optional(),
221
+ })
222
+ .strict();
223
+ export type MailFilter = z.infer<typeof MailFilterSchema>;
224
+ export const MailRecordSchema = z.object({
225
+ id: z.uuid(),
226
+ batchId: z.uuid().optional(),
227
+ profile: z.string(),
228
+ ref: ref.optional(),
229
+ to: z.array(z.string()),
230
+ subject: z.string(),
231
+ text: z.string().optional(),
232
+ attachments: z.array(z.object({ filename: z.string(), contentType: z.string(), size: z.int().nonnegative(), sha256: z.string() })),
233
+ status: MailStatusSchema,
234
+ error: z.string().optional(),
235
+ failures: z.array(z.object({ recipient: z.string(), reason: z.string(), at: z.string() })),
236
+ attempts: z.int(),
237
+ actor: z.object({ id: z.string(), name: z.string() }).optional(),
238
+ createdAt: z.string(),
239
+ sentAt: z.string().optional(),
240
+ contentPurgedAt: z.string().optional(),
241
+ });
242
+ export type MailRecord = z.infer<typeof MailRecordSchema>;
243
+ export const AdminMailRecordSchema = MailRecordSchema.omit({ text: true }).extend({
244
+ appId: z.string(),
245
+ errorCode: z.string().optional(),
246
+ response: z.string().optional(),
247
+ });
248
+ export type AdminMailRecord = z.infer<typeof AdminMailRecordSchema>;
249
+ /** Extends the shared offset-page shape with stable keyset navigation. */
250
+ export type MailPageParams = PageParams & { cursor?: string };
251
+ export type MailPage<T = MailRecord> = Paginated<T> & { nextCursor?: string };
252
+ export const MailPageParamsSchema = z
253
+ .object({ page: z.int().min(1).optional(), perPage: z.int().min(1).max(100).optional(), cursor: z.string().max(1024).optional() })
254
+ .strict();
255
+ export const AdminMailFilterSchema = MailFilterSchema.extend({
256
+ app: z.string().min(1).optional(),
257
+ profile: MailProfileKeySchema.optional(),
258
+ recipient: z.string().max(320).optional(),
259
+ });
260
+ export type AdminMailFilter = z.infer<typeof AdminMailFilterSchema>;
261
+ export type MailServiceError = ServiceError<MailErrorCode> & {
262
+ limit?: number;
263
+ used?: number;
264
+ requested?: number;
265
+ };
@@ -16,6 +16,7 @@ export type AppRegistryNav = {
16
16
  section: "primary" | "more" | "hidden";
17
17
  requiresAuth?: boolean;
18
18
  requiresRoles?: Role[];
19
+ badge?: string;
19
20
  adminHref?: string;
20
21
  };
21
22
 
@@ -58,6 +58,80 @@ export const UserNotificationHistoryResponseSchema = z.object({
58
58
  });
59
59
  export type UserNotificationHistoryResponse = z.infer<typeof UserNotificationHistoryResponseSchema>;
60
60
 
61
+ // Contracts reach the browser bundle, so this uses Intl directly instead of the stdlib date helpers.
62
+ const isTimeZone = (value: string): boolean => {
63
+ try {
64
+ new Intl.DateTimeFormat("en", { timeZone: value }).format(0);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ };
70
+
71
+ /** Wall-clock time `HH:MM` in the quiet hours' time zone. */
72
+ const QuietTimeOfDaySchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use a time from 00:00 to 23:59");
73
+
74
+ /**
75
+ * One recurring quiet period. Days are ISO weekdays (1 = Monday … 7 = Sunday). A period that ends at or before
76
+ * its start runs overnight into the next day; equal start and end make 24 hours quiet, so 00:00 to 00:00 is the
77
+ * whole day.
78
+ */
79
+ export const NotificationQuietPeriodSchema = z.object({
80
+ days: z
81
+ .array(z.number().int().min(1).max(7))
82
+ .min(1)
83
+ .max(7)
84
+ .refine((days) => new Set(days).size === days.length, "Each weekday can appear only once"),
85
+ start: QuietTimeOfDaySchema,
86
+ end: QuietTimeOfDaySchema,
87
+ });
88
+ export type NotificationQuietPeriod = z.infer<typeof NotificationQuietPeriodSchema>;
89
+
90
+ /** One period per weekday is the most a weekly schedule needs, so seven periods bound the schedule. */
91
+ export const NOTIFICATION_QUIET_PERIOD_LIMIT = 7;
92
+
93
+ export const NotificationQuietHoursSchema = z.object({
94
+ timeZone: z.string().trim().min(1).max(64).refine(isTimeZone, "Unknown time zone"),
95
+ periods: z.array(NotificationQuietPeriodSchema).max(NOTIFICATION_QUIET_PERIOD_LIMIT),
96
+ });
97
+ export type NotificationQuietHours = z.infer<typeof NotificationQuietHoursSchema>;
98
+
99
+ export const NotificationQuietReasonSchema = z.enum(["doNotDisturb", "quietHours"]);
100
+ export type NotificationQuietReason = z.infer<typeof NotificationQuietReasonSchema>;
101
+
102
+ /** Whether Cloud holds back browser notifications right now and, when known, until when. */
103
+ export const NotificationQuietStateSchema = z.object({
104
+ active: z.boolean(),
105
+ reason: NotificationQuietReasonSchema.nullable(),
106
+ /** End of the current quiet time; null when inactive or when no end lies within the next week. */
107
+ until: z.string().datetime().nullable(),
108
+ /** Start of the next quiet hours while inactive; null when active or when none starts within the next week. */
109
+ nextStart: z.string().datetime().nullable(),
110
+ });
111
+ export type NotificationQuietState = z.infer<typeof NotificationQuietStateSchema>;
112
+
113
+ export const NotificationQuietSettingsSchema = z.object({
114
+ doNotDisturbUntil: z.string().datetime().nullable(),
115
+ quietHours: NotificationQuietHoursSchema,
116
+ state: NotificationQuietStateSchema,
117
+ });
118
+ export type NotificationQuietSettings = z.infer<typeof NotificationQuietSettingsSchema>;
119
+
120
+ /**
121
+ * Do not disturb is a pause, so it ends within a year (366 days covers the same date next year). Holding back
122
+ * browser notifications for longer is turning them off, which the notification preferences and the device do.
123
+ */
124
+ export const NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS = 366;
125
+
126
+ export const UpdateNotificationQuietSettingsSchema = z
127
+ .object({
128
+ /** A future instant at most `NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS` ahead pauses browser notifications until then; null resumes them. */
129
+ doNotDisturbUntil: z.iso.datetime({ offset: true }).nullable().optional(),
130
+ quietHours: NotificationQuietHoursSchema.optional(),
131
+ })
132
+ .refine((value) => value.doNotDisturbUntil !== undefined || value.quietHours !== undefined, "Nothing to update");
133
+ export type UpdateNotificationQuietSettings = z.infer<typeof UpdateNotificationQuietSettingsSchema>;
134
+
61
135
  export const isSafeBrowserPushEndpoint = (value: string): boolean => {
62
136
  try {
63
137
  if (/[\\\u0000-\u001f\u007f]/.test(value)) return false;
@@ -1,6 +1,8 @@
1
1
  import { createMiddleware } from "hono/factory";
2
2
  import { logger } from "../../services/logging";
3
+ import { redactSensitivePath } from "../../services/logging/redaction";
3
4
  import type { AuthContext } from "./auth";
5
+ import { matchedRouteTemplate } from "./route-template";
4
6
 
5
7
  const log = logger("http");
6
8
 
@@ -15,11 +17,18 @@ const SKIP_PREFIXES = ["/public/", "/_ssr/", "/favicon", "/branding/"];
15
17
  * - Everything else (2xx, 3xx, 400, 404) → not logged (too noisy)
16
18
  */
17
19
  export const requestLogger = createMiddleware<AuthContext>(async (c, next) => {
18
- const path = c.req.path;
19
- if (SKIP_PREFIXES.some((p) => path.startsWith(p))) return next();
20
+ const requestPath = c.req.path;
21
+ if (SKIP_PREFIXES.some((p) => requestPath.startsWith(p))) return next();
20
22
 
21
23
  const start = Date.now();
22
24
  await next();
25
+ let path: string | null = null;
26
+ try {
27
+ path = matchedRouteTemplate(c);
28
+ } catch {
29
+ // Best-effort: an unrouted context must never break request logging.
30
+ }
31
+ path ??= redactSensitivePath(requestPath);
23
32
  const status = c.res.status;
24
33
  const duration = Date.now() - start;
25
34
 
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { sql } from "bun";
3
3
  import { MAX_AVATAR_BYTES, MAX_AVATAR_DATA_URL_LENGTH } from "../../contracts/profile";
4
4
  import type { MutationResult } from "../../contracts/shared";
5
+ import { stripImageMetadata } from "../image-metadata";
5
6
 
6
7
  export { MAX_AVATAR_BYTES, MAX_AVATAR_DATA_URL_LENGTH } from "../../contracts/profile";
7
8
 
@@ -95,10 +96,12 @@ export const setAvatar = async (params: { id: string; dataUrl: string }): Promis
95
96
  const parsed = parseAvatarDataUrl(params.dataUrl);
96
97
  if (!parsed.ok) return parsed;
97
98
 
99
+ const bytes = Buffer.from(stripImageMetadata(parsed.data.bytes));
100
+ const dataUrl = `data:${parsed.data.contentType};base64,${bytes.toString("base64")}`;
98
101
  const rows = await sql<{ avatar_hash: string }[]>`
99
102
  UPDATE auth.users
100
- SET avatar_data_url = ${parsed.data.dataUrl},
101
- avatar_hash = ${parsed.data.hash}
103
+ SET avatar_data_url = ${dataUrl},
104
+ avatar_hash = ${hashAvatar(dataUrl)}
102
105
  WHERE id = ${params.id}::uuid
103
106
  RETURNING avatar_hash
104
107
  `;
@@ -1,5 +1,6 @@
1
1
  import { type SQL, sql } from "bun";
2
2
  import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
3
+ import { ensureSchema } from "../postgres";
3
4
  import type { HelpCorpus, HelpMetadata } from "./types";
4
5
 
5
6
  // One expired app lease of grace after an app can no longer be discovered.
@@ -10,7 +11,7 @@ const searchConfigs = ["english", "german", "simple"] as const;
10
11
  export const migrateHelp = async (db: SQL = sql): Promise<void> => {
11
12
  await db.begin(async (tx) => {
12
13
  await tx`SELECT pg_advisory_xact_lock(hashtext('cloud:help:migrate'))`;
13
- await tx`CREATE SCHEMA IF NOT EXISTS help`.simple();
14
+ await ensureSchema(tx, "help");
14
15
  await tx`CREATE TABLE IF NOT EXISTS help.corpora (
15
16
  app_id text NOT NULL, manifest_hash text NOT NULL, base_locale text NOT NULL,
16
17
  last_seen_at timestamptz NOT NULL DEFAULT now(),