@k2b/cloud 0.28.0 → 0.29.1

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 (112) hide show
  1. package/package.json +9 -6
  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/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +259 -197
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. package/src/styles/utilities-navigation.css +41 -0
@@ -0,0 +1,438 @@
1
+ import { HTTPException } from "hono/http-exception";
2
+
3
+ /** A recognized image container cannot be safely stripped. Hono maps this to 422. */
4
+ export class ImageMetadataError extends HTTPException {
5
+ readonly code = "MALFORMED_IMAGE";
6
+
7
+ constructor(format: string) {
8
+ const message = `Malformed ${format} image.`;
9
+ super(422, { message, res: Response.json({ message, code: "MALFORMED_IMAGE" }, { status: 422 }) });
10
+ this.name = "ImageMetadataError";
11
+ }
12
+ }
13
+
14
+ const startsWith = (bytes: Uint8Array, signature: string, offset = 0, end = bytes.length): boolean => {
15
+ if (offset + signature.length > end) return false;
16
+ for (let i = 0; i < signature.length; i++) if (bytes[offset + i] !== signature.charCodeAt(i)) return false;
17
+ return true;
18
+ };
19
+
20
+ // Copy ranges without allocating a view for each segment or chunk.
21
+ const copy = (out: Uint8Array, written: number, bytes: Uint8Array, start: number, end: number): number => {
22
+ for (let i = start; i < end; i++) out[written++] = bytes[i]!;
23
+ return written;
24
+ };
25
+
26
+ const exifOrientation = (bytes: Uint8Array, view: DataView, start: number, end: number): number => {
27
+ const bad = () => new ImageMetadataError("JPEG EXIF");
28
+ if (!startsWith(bytes, "Exif\0\0", start, end) || end - start < 14) throw bad();
29
+ const tiff = start + 6;
30
+ const little = startsWith(bytes, "II", tiff, end);
31
+ if (!little && !startsWith(bytes, "MM", tiff, end)) throw bad();
32
+ if (view.getUint16(tiff + 2, little) !== 42) throw bad();
33
+ const ifdOffset = view.getUint32(tiff + 4, little);
34
+ if (ifdOffset < 8 || ifdOffset > end - tiff - 2) throw bad();
35
+ const ifd = tiff + ifdOffset;
36
+ const count = view.getUint16(ifd, little);
37
+ if (ifd + 2 + count * 12 > end) throw bad();
38
+ let orientation = 1;
39
+ let found = false;
40
+ for (let i = 0; i < count; i++) {
41
+ const entry = ifd + 2 + i * 12;
42
+ if (view.getUint16(entry, little) === 0x0112) {
43
+ if (found || view.getUint16(entry + 2, little) !== 3 || view.getUint32(entry + 4, little) !== 1) throw bad();
44
+ const value = view.getUint16(entry + 8, little);
45
+ orientation = value >= 1 && value <= 8 ? value : 1;
46
+ found = true;
47
+ }
48
+ }
49
+ return orientation;
50
+ };
51
+
52
+ const orientationSegment = (orientation: number): Uint8Array =>
53
+ new Uint8Array([
54
+ 0xff,
55
+ 0xe1,
56
+ 0,
57
+ 34,
58
+ 69,
59
+ 120,
60
+ 105,
61
+ 102,
62
+ 0,
63
+ 0,
64
+ 77,
65
+ 77,
66
+ 0,
67
+ 42,
68
+ 0,
69
+ 0,
70
+ 0,
71
+ 8,
72
+ 0,
73
+ 1,
74
+ 1,
75
+ 18,
76
+ 0,
77
+ 3,
78
+ 0,
79
+ 0,
80
+ 0,
81
+ 1,
82
+ 0,
83
+ orientation,
84
+ 0,
85
+ 0,
86
+ 0,
87
+ 0,
88
+ 0,
89
+ 0,
90
+ ]);
91
+
92
+ const stripJpeg = (bytes: Uint8Array): Uint8Array => {
93
+ const bad = () => new ImageMetadataError("JPEG");
94
+ const out = new Uint8Array(bytes.length + 36);
95
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
96
+ let written = copy(out, 0, bytes, 0, 2);
97
+ let offset = 2;
98
+ let orientation = 1;
99
+ let sawExif = false;
100
+ let frame = false;
101
+ let scan = false;
102
+ let entropy = false;
103
+ let orientationPosition = 2;
104
+ while (offset < bytes.length) {
105
+ if (entropy) {
106
+ const start = offset;
107
+ while (offset < bytes.length) {
108
+ if (bytes[offset] !== 0xff) {
109
+ offset++;
110
+ continue;
111
+ }
112
+ const markerStart = offset++;
113
+ while (bytes[offset] === 0xff) offset++;
114
+ if (offset >= bytes.length) throw bad();
115
+ const marker = bytes[offset]!;
116
+ if (marker === 0 || (marker >= 0xd0 && marker <= 0xd7)) {
117
+ offset++;
118
+ continue;
119
+ }
120
+ offset = markerStart;
121
+ break;
122
+ }
123
+ written = copy(out, written, bytes, start, offset);
124
+ if (offset >= bytes.length) throw bad();
125
+ entropy = false;
126
+ }
127
+ // libjpeg tolerates extraneous bytes before a marker outside scan data.
128
+ while (offset < bytes.length && bytes[offset] !== 0xff) offset++;
129
+ const start = offset++;
130
+ while (bytes[offset] === 0xff) offset++;
131
+ if (offset >= bytes.length) throw bad();
132
+ const marker = bytes[offset++]!;
133
+ if (marker === 0xd9) {
134
+ if (!frame || !scan) throw bad();
135
+ written = copy(out, written, bytes, start, offset);
136
+ if (orientation !== 1) {
137
+ out.copyWithin(orientationPosition + 36, orientationPosition, written);
138
+ out.set(orientationSegment(orientation), orientationPosition);
139
+ written += 36;
140
+ }
141
+ return out.subarray(0, written);
142
+ }
143
+ if (offset > bytes.length - 2) throw bad();
144
+ const length = view.getUint16(offset);
145
+ if (length < 2 || length > bytes.length - offset) throw bad();
146
+ const end = offset + length;
147
+ const payload = offset + 2;
148
+ const size = end - payload;
149
+ const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
150
+ if (isFrame) {
151
+ if (frame || size < 6 || size !== 6 + 3 * bytes[payload + 5]! || bytes[payload + 5] === 0) throw bad();
152
+ frame = true;
153
+ }
154
+ if (marker === 0xda) {
155
+ if (!frame || size < 4 || size !== 4 + 2 * bytes[payload]! || bytes[payload] === 0) throw bad();
156
+ scan = true;
157
+ entropy = true;
158
+ }
159
+ if (marker === 0xdc) {
160
+ if (!scan || size !== 2) throw bad();
161
+ entropy = true;
162
+ }
163
+ if (marker === 0xdd && size !== 2) throw bad();
164
+ if (marker === 0xe1 && startsWith(bytes, "Exif\0\0", payload, end)) {
165
+ const value = exifOrientation(bytes, view, payload, end);
166
+ if (sawExif && value !== orientation) throw bad();
167
+ orientation = value;
168
+ sawExif = true;
169
+ }
170
+ if (marker === 0xe0 && startsWith(bytes, "JFIF\0", payload, end)) {
171
+ if (size < 14) throw bad();
172
+ // Keep the JFIF header, discarding the thumbnail and any padding.
173
+ out[written] = 0xff;
174
+ out[written + 1] = 0xe0;
175
+ out[written + 2] = 0;
176
+ out[written + 3] = 16;
177
+ written = copy(out, written + 4, bytes, payload, payload + 14);
178
+ out[written - 2] = 0;
179
+ out[written - 1] = 0;
180
+ if (!scan) orientationPosition = written;
181
+ } else if (
182
+ (marker === 0xe2 && startsWith(bytes, "ICC_PROFILE\0", payload, end)) ||
183
+ (marker === 0xee && startsWith(bytes, "Adobe", payload, end) && size === 12) ||
184
+ isFrame ||
185
+ marker === 0xc4 ||
186
+ marker === 0xcc ||
187
+ marker === 0xdb ||
188
+ marker === 0xdd ||
189
+ marker === 0xdc ||
190
+ marker === 0xda
191
+ ) {
192
+ if (marker === 0xe2 && size < 14) throw bad();
193
+ written = copy(out, written, bytes, start, end);
194
+ } else if (!(marker >= 0xe0 && marker <= 0xef) && marker !== 0xfe) throw bad();
195
+ offset = end;
196
+ }
197
+ throw bad();
198
+ };
199
+
200
+ const PNG_SIGNATURE = "\x89PNG\r\n\x1a\n";
201
+ const fourCC = (type: string): number =>
202
+ type.charCodeAt(0) * 0x1000000 + (type.charCodeAt(1) << 16) + (type.charCodeAt(2) << 8) + type.charCodeAt(3);
203
+ const IHDR = fourCC("IHDR"),
204
+ PLTE = fourCC("PLTE"),
205
+ IDAT = fourCC("IDAT"),
206
+ IEND = fourCC("IEND");
207
+ const PNG_KEEP = new Set(
208
+ [
209
+ "IHDR",
210
+ "PLTE",
211
+ "IDAT",
212
+ "IEND",
213
+ "cHRM",
214
+ "gAMA",
215
+ "iCCP",
216
+ "sBIT",
217
+ "sRGB",
218
+ "cICP",
219
+ "mDCV",
220
+ "cLLI",
221
+ "tRNS",
222
+ "bKGD",
223
+ "pHYs",
224
+ "acTL",
225
+ "fcTL",
226
+ "fdAT",
227
+ ].map(fourCC),
228
+ );
229
+
230
+ const stripPng = (bytes: Uint8Array): Uint8Array => {
231
+ const bad = () => new ImageMetadataError("PNG");
232
+ if (!startsWith(bytes, PNG_SIGNATURE)) throw bad();
233
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
234
+ const out = new Uint8Array(bytes.length);
235
+ let written = copy(out, 0, bytes, 0, 8);
236
+ let offset = 8;
237
+ let header = false;
238
+ let data = false;
239
+ let dataEnded = false;
240
+ while (offset < bytes.length) {
241
+ if (offset > bytes.length - 12) throw bad();
242
+ const size = view.getUint32(offset);
243
+ if (size > bytes.length - offset - 12) throw bad();
244
+ const type = view.getUint32(offset + 4);
245
+ for (let i = offset + 4; i < offset + 8; i++) {
246
+ const char = bytes[i]!;
247
+ if (!(char >= 65 && char <= 90) && !(char >= 97 && char <= 122)) throw bad();
248
+ }
249
+ const end = offset + size + 12;
250
+ if (!header && type !== IHDR) throw bad();
251
+ if (type === IHDR) {
252
+ if (header || size !== 13 || view.getUint32(offset + 8) === 0 || view.getUint32(offset + 12) === 0) throw bad();
253
+ header = true;
254
+ }
255
+ if (type === PLTE && (data || size === 0 || size > 768 || size % 3 !== 0)) throw bad();
256
+ if (type === IDAT) {
257
+ if (dataEnded) throw bad();
258
+ data = true;
259
+ } else if (data) dataEnded = true;
260
+ if (type === IEND) {
261
+ if (size !== 0 || !data) throw bad();
262
+ written = copy(out, written, bytes, offset, end);
263
+ return out.subarray(0, written);
264
+ }
265
+ if (PNG_KEEP.has(type)) written = copy(out, written, bytes, offset, end);
266
+ offset = end;
267
+ }
268
+ throw bad();
269
+ };
270
+
271
+ const VP8X = fourCC("VP8X"),
272
+ VP8 = fourCC("VP8 "),
273
+ VP8L = fourCC("VP8L"),
274
+ ANIM = fourCC("ANIM"),
275
+ ANMF = fourCC("ANMF"),
276
+ ALPH = fourCC("ALPH"),
277
+ ICCP = fourCC("ICCP");
278
+
279
+ const stripWebp = (bytes: Uint8Array): Uint8Array => {
280
+ const bad = () => new ImageMetadataError("WebP");
281
+ if (bytes.length < 12 || !startsWith(bytes, "WEBP", 8)) throw bad();
282
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
283
+ const end = view.getUint32(4, true) + 8;
284
+ if (end < 12 || end > bytes.length || end % 2 !== 0) throw bad();
285
+ const out = new Uint8Array(bytes.length);
286
+ const outputView = new DataView(out.buffer);
287
+ let written = copy(out, 0, bytes, 0, 12);
288
+ let offset = 12;
289
+ let image = false,
290
+ extended = false,
291
+ animated = false,
292
+ animationHeader = false,
293
+ alpha = false;
294
+ const checkCodec = (type: number, start: number, size: number) => {
295
+ if (type === VP8 && (size < 10 || !startsWith(bytes, "\x9d\x01\x2a", start + 3, start + size))) throw bad();
296
+ if (type === VP8L && (size < 5 || bytes[start] !== 0x2f)) throw bad();
297
+ };
298
+ const keepChunk = (start: number, size: number) => {
299
+ written = copy(out, written, bytes, start, start + 8 + size);
300
+ if (size % 2) out[written++] = 0; // Canonical padding matches the previous writer.
301
+ };
302
+ while (offset < end) {
303
+ if (offset > end - 8) throw bad();
304
+ const size = view.getUint32(offset + 4, true);
305
+ const padded = size + (size % 2);
306
+ if (padded > end - offset - 8) throw bad();
307
+ const type = view.getUint32(offset);
308
+ const payload = offset + 8;
309
+ if (type === VP8X) {
310
+ if (extended || offset !== 12 || size !== 10) throw bad();
311
+ extended = true;
312
+ animated = (bytes[payload]! & 2) !== 0;
313
+ const flags = written + 8;
314
+ keepChunk(offset, size);
315
+ out[flags] = out[flags]! & ~0x0c;
316
+ } else if (type === ANIM) {
317
+ if (!animated || animationHeader || image || size !== 6) throw bad();
318
+ animationHeader = true;
319
+ keepChunk(offset, size);
320
+ } else if (type === ANMF) {
321
+ if (!animationHeader || size < 24) throw bad();
322
+ const frameStart = written;
323
+ written = copy(out, written, bytes, offset, payload + 16);
324
+ let cursor = payload + 16;
325
+ const frameEnd = payload + size;
326
+ let frameImage = false,
327
+ frameAlpha = false;
328
+ while (cursor < frameEnd) {
329
+ if (cursor > frameEnd - 8) throw bad();
330
+ const length = view.getUint32(cursor + 4, true);
331
+ const next = cursor + 8 + length + (length % 2);
332
+ if (next > frameEnd) throw bad();
333
+ const kind = view.getUint32(cursor);
334
+ if (kind === VP8 || kind === VP8L) {
335
+ if (frameImage || (frameAlpha && kind === VP8L)) throw bad();
336
+ checkCodec(kind, cursor + 8, length);
337
+ frameImage = true;
338
+ keepChunk(cursor, length);
339
+ } else if (kind === ALPH) {
340
+ if (frameImage || frameAlpha || length === 0) throw bad();
341
+ frameAlpha = true;
342
+ keepChunk(cursor, length);
343
+ }
344
+ cursor = next;
345
+ }
346
+ if (!frameImage) throw bad();
347
+ image = true;
348
+ outputView.setUint32(frameStart + 4, written - frameStart - 8, true);
349
+ } else if (type === VP8 || type === VP8L) {
350
+ if (image || animated || (alpha && type === VP8L)) throw bad();
351
+ checkCodec(type, payload, size);
352
+ image = true;
353
+ keepChunk(offset, size);
354
+ } else if (type === ALPH) {
355
+ if (!extended || image || alpha || size === 0) throw bad();
356
+ alpha = true;
357
+ keepChunk(offset, size);
358
+ } else if (type === ICCP) {
359
+ if (!extended || image || size === 0) throw bad();
360
+ keepChunk(offset, size);
361
+ }
362
+ offset += 8 + padded;
363
+ }
364
+ if (!image || (animated && !animationHeader)) throw bad();
365
+ outputView.setUint32(4, written - 8, true);
366
+ return out.subarray(0, written);
367
+ };
368
+
369
+ /**
370
+ * Losslessly removes non-rendering metadata from JPEG, PNG and WebP, selected
371
+ * by magic bytes. Call only for uploads the application classifies as images.
372
+ * Other formats are returned unchanged. No decoding; linear time and space.
373
+ */
374
+ export const stripImageMetadata = (bytes: Uint8Array): Uint8Array => {
375
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) return stripJpeg(bytes);
376
+ if (startsWith(bytes, "\x89PNG")) return stripPng(bytes);
377
+ if (startsWith(bytes, "RIFF") && startsWith(bytes, "WEBP", 8)) return stripWebp(bytes);
378
+ return bytes;
379
+ };
380
+
381
+ // WHATWG data URL processing: URL parsing, percent decoding and forgiving base64.
382
+ const trimAscii = (value: string): string => value.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, "");
383
+ const hexValue = (code: number): number => {
384
+ if (code >= 48 && code <= 57) return code - 48;
385
+ const lower = code | 32;
386
+ return lower >= 97 && lower <= 102 ? lower - 87 : -1;
387
+ };
388
+ const parseDataUrl = (value: string): { bytes: Uint8Array; essence: string } | null => {
389
+ // Parse like a browser image source: surrounding spaces and embedded tabs or newlines do not hide the scheme.
390
+ let url: URL;
391
+ try {
392
+ url = new URL(value);
393
+ } catch {
394
+ return null;
395
+ }
396
+ if (url.protocol !== "data:") return null;
397
+ url.hash = "";
398
+ const input = url.href.slice(5);
399
+ const comma = input.indexOf(",");
400
+ if (comma < 0) return null;
401
+ let declaration = trimAscii(input.slice(0, comma));
402
+ const semicolon = declaration.lastIndexOf(";");
403
+ const base64 = semicolon >= 0 && trimAscii(declaration.slice(semicolon + 1)).toLowerCase() === "base64";
404
+ if (base64) declaration = trimAscii(declaration.slice(0, semicolon));
405
+ const essence = trimAscii(declaration.split(";", 1)[0]!).toLowerCase();
406
+ const encoded = new TextEncoder().encode(input.slice(comma + 1));
407
+ let written = 0;
408
+ for (let i = 0; i < encoded.length; i++) {
409
+ if (encoded[i] === 37 && i + 2 < encoded.length) {
410
+ const high = hexValue(encoded[i + 1]!);
411
+ const low = hexValue(encoded[i + 2]!);
412
+ if (high >= 0 && low >= 0) {
413
+ encoded[written++] = high * 16 + low;
414
+ i += 2;
415
+ continue;
416
+ }
417
+ }
418
+ encoded[written++] = encoded[i]!;
419
+ }
420
+ const decoded = encoded.subarray(0, written);
421
+ if (!base64) return { bytes: decoded, essence };
422
+ // Latin-1 keeps non-ASCII bytes visible to the alphabet validation.
423
+ let body = Buffer.from(decoded.buffer, decoded.byteOffset, decoded.byteLength)
424
+ .toString("latin1")
425
+ .replace(/[\t\n\f\r ]/g, "");
426
+ if (body.length % 4 === 0) body = body.replace(/={1,2}$/, "");
427
+ if (body.length % 4 === 1 || /[^A-Za-z0-9+/]/.test(body)) throw new ImageMetadataError("base64");
428
+ return { bytes: Buffer.from(body, "base64"), essence };
429
+ };
430
+
431
+ /** Strip inline image data URLs; external URLs and other representations stay unchanged. */
432
+ export const stripImageDataUrlMetadata = (value: string): string => {
433
+ const parsed = parseDataUrl(value);
434
+ if (!parsed) return value;
435
+ const stripped = stripImageMetadata(parsed.bytes);
436
+ if (stripped === parsed.bytes) return value;
437
+ return `data:${parsed.essence};base64,${Buffer.from(stripped).toString("base64")}`;
438
+ };
@@ -13,6 +13,7 @@ export { authFlows } from "./auth-flows";
13
13
  export { toPgIntArray, toPgTextArray, toPgUuidArray, escapeLikePattern, isUniqueViolation } from "./postgres";
14
14
 
15
15
  export { logger, logging, TRACE_STUCK_AFTER_MS, trace } from "./logging";
16
+ export { redactSensitivePath } from "./logging/redaction";
16
17
  export {
17
18
  createRuntimeLifecycle,
18
19
  createRuntimeTaskTracker,
@@ -18,3 +18,53 @@ export const redactMetadata = (input: unknown): unknown => {
18
18
  }
19
19
  return out;
20
20
  };
21
+
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+ const CREDENTIAL = /^[A-Za-z0-9_-]{16,}$/;
24
+ const WORD_SLUG = /^[a-z]+(?:[-_][a-z]+)*$/;
25
+
26
+ const decodeSegment = (segment: string): string => {
27
+ try {
28
+ return decodeURIComponent(segment);
29
+ } catch {
30
+ return segment;
31
+ }
32
+ };
33
+
34
+ /**
35
+ * Redact a pathname for logs and fallback templates; drop queries and fragments.
36
+ * Compare safely decoded, case-sensitive prefixes, ignoring empty segments.
37
+ * Collapse the suffix below /share/<app>/ (keeping the raw app segment),
38
+ * /api/mail/public-attachments/, and /app/mail/a/ into one :token marker.
39
+ * Elsewhere, use a shape heuristic: decoded UUID segments become :id; a segment
40
+ * becomes :token if its decoded value or any dot-separated part has at least
41
+ * 16 characters, only A-Z, a-z, 0-9, _ or -, and is not a lowercase word slug
42
+ * (words separated by single hyphens or underscores). Preserve other raw segments.
43
+ * This keeps readable params and filenames while covering tokens with file suffixes.
44
+ */
45
+ export const redactSensitivePath = (pathname: string): string => {
46
+ const path = pathname.split(/[?#]/, 1)[0] ?? "";
47
+ const segments = path.split("/").filter(Boolean);
48
+ const decoded = segments.map(decodeSegment);
49
+ if (decoded[0] === "share" && segments.length > 2) {
50
+ return `/share/${segments[1]}/:token`;
51
+ }
52
+ if (
53
+ segments.length > 3 &&
54
+ decoded[1] === "mail" &&
55
+ ((decoded[0] === "api" && decoded[2] === "public-attachments") || (decoded[0] === "app" && decoded[2] === "a"))
56
+ ) {
57
+ return `/${decoded.slice(0, 3).join("/")}/:token`;
58
+ }
59
+ return path
60
+ .split("/")
61
+ .map((segment) => {
62
+ const value = decodeSegment(segment);
63
+ if (UUID.test(value)) return ":id";
64
+ // Dot-separated parts cover signed tokens and credential filenames such as .ics.
65
+ // Lowercase word slugs preserve readable routes like getting-started-with-grids.
66
+ if (value.split(".").some((part) => CREDENTIAL.test(part) && !WORD_SLUG.test(part))) return ":token";
67
+ return segment;
68
+ })
69
+ .join("/");
70
+ };
@@ -616,6 +616,8 @@ export const revokeMandate = (input: Parameters<typeof revokeMandateInDb>[0], op
616
616
  mutate: (db) => revokeMandateInDb(input, db),
617
617
  });
618
618
 
619
+ export const MANDATE_POLICY_DENIED_MESSAGE = "Mandate policy does not allow this operation";
620
+
619
621
  export const validateMandateIssueAuthority = async (
620
622
  input: {
621
623
  mandateId: string;
@@ -687,7 +689,7 @@ export const validateMandateIssueAuthority = async (
687
689
  capabilityApproval: input.capabilityApproval,
688
690
  })
689
691
  ) {
690
- return denyMandateIssue(err.forbidden("Mandate policy does not allow this operation"));
692
+ return denyMandateIssue(err.forbidden(MANDATE_POLICY_DENIED_MESSAGE));
691
693
  }
692
694
  const mandate = mapMandate(row);
693
695
  return ok({
@@ -716,7 +718,7 @@ const metricForIssueDenial = (error: MandateIssueError): Exclude<MandateMetricNa
716
718
  case "Mandate revision changed":
717
719
  return "issue_denied_revision";
718
720
  case "Mandate policy is invalid":
719
- case "Mandate policy does not allow this operation":
721
+ case MANDATE_POLICY_DENIED_MESSAGE:
720
722
  return "issue_denied_policy";
721
723
  default:
722
724
  return "issue_denied_invalid";
@@ -246,3 +246,23 @@ export const mandatePolicyAllows = (
246
246
  if (policy.actions === "require_approval") return input.actionApproval === "approved";
247
247
  return true;
248
248
  };
249
+
250
+ /** Discovery ignores fixed inputs: at least one unattended call must fit the policy. */
251
+ export const mandatePolicyCanPermitCapability = (
252
+ policy: MandatePolicyV1,
253
+ input: { appId: string; capabilityId: string; kind: "query" | "action"; approval?: "none" | "rememberable" | "always" },
254
+ ): boolean => {
255
+ const call = {
256
+ appId: input.appId,
257
+ operation: `capability.${input.kind === "query" ? "query" : "action.run"}:${input.capabilityId}`,
258
+ actionApproval: "none" as const,
259
+ capabilityApproval: input.approval,
260
+ };
261
+ if (policy.grants === undefined) return mandatePolicyAllows(policy, call);
262
+ return policy.grants.some(
263
+ (grant) =>
264
+ "appId" in grant &&
265
+ capabilityGrantAllows(grant, { ...input, input: grant.fixedInput }) &&
266
+ mandatePolicyAllows(policy, { ...call, input: grant.fixedInput }),
267
+ );
268
+ };
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
2
2
  import { sql } from "bun";
3
3
  import webpush from "web-push";
4
4
  import { z } from "zod";
5
+ import { notificationGroupTag } from "../../contracts/notification-group";
5
6
  import { isSafeNotificationTargetHref } from "../../contracts/notification-types";
6
7
  import { type BrowserPushSubscription, BrowserPushSubscriptionSchema } from "../../contracts/user-notifications";
7
8
  import { decryptSecret, encryptSecret } from "../secrets";
8
9
  import { coreSettings } from "../settings/api";
9
10
  import { type NotificationDestination, registerNotificationChannel } from "./channels";
11
+ import { NOTIFICATION_PREVIEW_LIMIT } from "./preview";
10
12
  import { sendPinnedWebPush } from "./web-push-transport";
11
13
 
12
14
  type EndpointRow = {
@@ -21,15 +23,41 @@ type BrowserDeliveryPayload = {
21
23
  subscription: BrowserPushSubscription;
22
24
  eventId: string;
23
25
  title: string;
26
+ preview?: string;
24
27
  targetHref?: string;
28
+ group?: string;
29
+ badge?: number;
30
+ createdAt?: number;
25
31
  };
26
32
 
33
+ // RFC 8030 section 7.2 guarantees 4,096 bytes; aes128gcm adds a header, tag, and padding delimiter.
34
+ const WEB_PUSH_PLAINTEXT_BUDGET = 4_096 - 86 - 16 - 1;
35
+
36
+ const browserPushMessage = (payload: BrowserDeliveryPayload): string =>
37
+ JSON.stringify({
38
+ type: "cloud-notification",
39
+ eventId: payload.eventId,
40
+ title: payload.title,
41
+ ...(payload.preview !== undefined ? { preview: payload.preview } : {}),
42
+ targetHref: payload.targetHref,
43
+ ...(payload.group !== undefined ? { group: payload.group } : {}),
44
+ ...(payload.badge !== undefined ? { badge: payload.badge } : {}),
45
+ ...(payload.createdAt !== undefined ? { createdAt: payload.createdAt } : {}),
46
+ });
47
+
27
48
  const BrowserDeliveryPayloadSchema = z.object({
28
49
  endpointId: z.uuid(),
29
50
  subscription: BrowserPushSubscriptionSchema,
30
51
  eventId: z.uuid(),
31
52
  title: z.string().min(1).max(200),
53
+ preview: z.string().min(1).max(NOTIFICATION_PREVIEW_LIMIT).optional(),
32
54
  targetHref: z.string().max(4_000).refine(isSafeNotificationTargetHref).optional(),
55
+ group: z
56
+ .string()
57
+ .regex(/^[A-Za-z0-9._:-]+$/)
58
+ .optional(),
59
+ badge: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
60
+ createdAt: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
33
61
  });
34
62
 
35
63
  const BrowserDestinationContextSchema = z.object({
@@ -142,13 +170,27 @@ const browserDriver = {
142
170
  },
143
171
  createPayload: ({ presentation, destination, event }) => {
144
172
  const context = BrowserDestinationContextSchema.parse(destination.context);
145
- return BrowserDeliveryPayloadSchema.parse({
173
+ const group =
174
+ presentation.group === undefined
175
+ ? undefined
176
+ : notificationGroupTag(event.definitionId.slice(0, event.definitionId.lastIndexOf(".")), presentation.group);
177
+ if (group === null) throw new Error("Invalid notification group tag");
178
+ const payload = BrowserDeliveryPayloadSchema.parse({
146
179
  endpointId: context.endpointId,
147
180
  subscription: context.subscription,
148
181
  eventId: event.id,
149
182
  title: presentation.title,
183
+ ...(presentation.preview !== undefined ? { preview: presentation.preview } : {}),
150
184
  targetHref: presentation.targetHref,
185
+ ...(group !== undefined ? { group } : {}),
186
+ ...(presentation.badge !== undefined ? { badge: presentation.badge } : {}),
187
+ ...(presentation.group !== undefined || presentation.badge !== undefined ? { createdAt: Date.now() } : {}),
151
188
  });
189
+ if (payload.preview !== undefined && Buffer.byteLength(browserPushMessage(payload), "utf8") > WEB_PUSH_PLAINTEXT_BUDGET) {
190
+ const { preview: _preview, ...titleOnlyPayload } = payload;
191
+ return titleOnlyPayload;
192
+ }
193
+ return payload;
152
194
  },
153
195
  deliver: async (value: unknown) => {
154
196
  const payload: BrowserDeliveryPayload = BrowserDeliveryPayloadSchema.parse(value);
@@ -166,20 +208,11 @@ const browserDriver = {
166
208
  }
167
209
  await ensureWebPushConfigured();
168
210
  try {
169
- await sendPinnedWebPush(
170
- payload.subscription,
171
- JSON.stringify({
172
- type: "cloud-notification",
173
- eventId: payload.eventId,
174
- title: payload.title,
175
- targetHref: payload.targetHref,
176
- }),
177
- {
178
- TTL: 24 * 60 * 60,
179
- urgency: "normal",
180
- topic: createHash("sha256").update(payload.eventId).digest("base64url").slice(0, 32),
181
- },
182
- );
211
+ await sendPinnedWebPush(payload.subscription, browserPushMessage(payload), {
212
+ TTL: 24 * 60 * 60,
213
+ urgency: "normal",
214
+ topic: createHash("sha256").update(payload.eventId).digest("base64url").slice(0, 32),
215
+ });
183
216
  } catch (error) {
184
217
  const statusCode = error && typeof error === "object" && "statusCode" in error ? Number(error.statusCode) : null;
185
218
  if (statusCode === 404 || statusCode === 410) {