@canonmsg/backend-contracts 8.1.0 → 8.2.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.
package/dist/cjs/index.js CHANGED
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./diffRedaction.js"), exports);
18
18
  __exportStar(require("./environment.js"), exports);
19
19
  __exportStar(require("./media.js"), exports);
20
+ __exportStar(require("./media-url.js"), exports);
20
21
  __exportStar(require("./message.js"), exports);
21
22
  __exportStar(require("./messageText.js"), exports);
22
23
  __exportStar(require("./runtimeCardFields.js"), exports);
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MEDIA_ORIGINS = void 0;
4
+ // Generated by scripts/sync-runtime-endpoints.mjs from canonical endpoints and storage policies.
5
+ exports.MEDIA_ORIGINS = {
6
+ "canonmail-dev.firebasestorage.app": "https://dev.api.canonmail.com",
7
+ "canonmail-prod.firebasestorage.app": "https://api.canonmail.com"
8
+ };
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildFirebaseMediaUrl = buildFirebaseMediaUrl;
4
+ exports.buildMediaDownloadUrl = buildMediaDownloadUrl;
5
+ exports.parseMediaStoragePath = parseMediaStoragePath;
6
+ exports.canonMediaDownloadUrl = canonMediaDownloadUrl;
7
+ const media_js_1 = require("./media.js");
8
+ const media_origins_generated_js_1 = require("./media-origins.generated.js");
9
+ function buildFirebaseMediaUrl(bucket, storagePath, token) {
10
+ return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucket)}/o/${encodeURIComponent(storagePath)}?alt=media&token=${encodeURIComponent(token)}`;
11
+ }
12
+ function validMediaPath(path) {
13
+ const parts = path.split('/');
14
+ return parts.length === 4 && parts[0] === 'media'
15
+ && parts.every((part) => Boolean(part) && part !== '.' && part !== '..' && !/[\u0000-\u001f\u007f\\]/.test(part));
16
+ }
17
+ function buildMediaDownloadUrl(bucket, storagePath, token) {
18
+ const origin = media_origins_generated_js_1.MEDIA_ORIGINS[bucket];
19
+ if (!origin || !validMediaPath(storagePath))
20
+ return buildFirebaseMediaUrl(bucket, storagePath, token);
21
+ return `${origin}/files/${storagePath.split('/').slice(1).map(encodeURIComponent).join('/')}?token=${encodeURIComponent(token)}`;
22
+ }
23
+ /** Accept historical Firebase URLs and Canon links, only for the expected bucket. */
24
+ function parseMediaStoragePath(value, bucket) {
25
+ if (typeof value !== 'string')
26
+ return null;
27
+ try {
28
+ const url = new URL(value);
29
+ if (url.protocol !== 'https:' || url.username || url.password)
30
+ return null;
31
+ let path;
32
+ const prefix = `/v0/b/${bucket}/o/`;
33
+ if (url.origin === 'https://firebasestorage.googleapis.com' && url.pathname.startsWith(prefix)) {
34
+ path = decodeURIComponent(url.pathname.slice(prefix.length));
35
+ }
36
+ else if (url.origin === media_origins_generated_js_1.MEDIA_ORIGINS[bucket] && url.pathname.startsWith('/files/')) {
37
+ path = `media/${decodeURIComponent(url.pathname.slice('/files/'.length))}`;
38
+ }
39
+ else
40
+ return null;
41
+ return validMediaPath(path) ? path : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** Presentation upgrade for existing messages without rewriting their stored URLs. */
48
+ function canonMediaDownloadUrl(value, downloadFileName) {
49
+ for (const bucket of Object.keys(media_origins_generated_js_1.MEDIA_ORIGINS)) {
50
+ const path = parseMediaStoragePath(value, bucket);
51
+ if (path) {
52
+ const token = new URL(value).searchParams.get('token');
53
+ if (token) {
54
+ const url = new URL(buildMediaDownloadUrl(bucket, path, token));
55
+ if (downloadFileName !== undefined)
56
+ url.searchParams.set('download', (0, media_js_1.sanitizeDownloadFileName)(downloadFileName));
57
+ return url.href;
58
+ }
59
+ }
60
+ }
61
+ return value;
62
+ }
package/dist/cjs/media.js CHANGED
@@ -1,11 +1,56 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MEDIA_CACHE_CONTROL = void 0;
4
+ exports.sanitizeDownloadFileName = sanitizeDownloadFileName;
5
+ exports.buildMediaContentDisposition = buildMediaContentDisposition;
6
+ exports.processedVideoFileName = processedVideoFileName;
3
7
  exports.inferMediaAttachmentKind = inferMediaAttachmentKind;
4
8
  exports.getStoredFileExtension = getStoredFileExtension;
5
9
  exports.normalizeStoredAttachments = normalizeStoredAttachments;
6
10
  exports.getMessageAttachments = getMessageAttachments;
7
11
  exports.getPrimaryAttachment = getPrimaryAttachment;
8
12
  exports.describeAttachment = describeAttachment;
13
+ /** Allow private conditional caching, but recheck access before every reuse. */
14
+ exports.MEDIA_CACHE_CONTROL = 'private, max-age=0, must-revalidate';
15
+ /** Preserve Unicode and the extension while removing unsafe filesystem characters. */
16
+ function sanitizeDownloadFileName(value, fallback = 'attachment') {
17
+ const baseName = typeof value === 'string'
18
+ ? value.trim().split(/[\\/]/).filter(Boolean).pop() ?? ''
19
+ : '';
20
+ let name = Array.from(baseName, (char) => /^[\ud800-\udfff]$/.test(char) ? '-' : char).join('').normalize('NFC')
21
+ .replace(/[\u0000-\u001f\u007f<>:"/\\|?*\u202a-\u202e\u2066-\u2069]+/g, '-')
22
+ .replace(/\s+/g, ' ')
23
+ .replace(/^[. -]+|[. -]+$/g, '');
24
+ if (!name)
25
+ return sanitizeDownloadFileName(fallback, 'attachment');
26
+ if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name))
27
+ name = `_${name}`;
28
+ const extension = name.match(/\.[a-z0-9]{1,16}$/i)?.[0] ?? '';
29
+ const stem = extension ? name.slice(0, -extension.length) : name;
30
+ // Stay below filesystem byte limits as well as the display-length limit.
31
+ let safeStem = '';
32
+ let bytes = extension.length;
33
+ for (const char of Array.from(stem).slice(0, 120 - extension.length)) {
34
+ bytes += encodeURIComponent(char).replace(/%[0-9A-F]{2}|./g, '_').length;
35
+ if (bytes > 240)
36
+ break;
37
+ safeStem += char;
38
+ }
39
+ return `${safeStem.replace(/[. -]+$/g, '')}${extension}`;
40
+ }
41
+ /** RFC 6266: readable ASCII fallback plus the exact UTF-8 filename. */
42
+ function buildMediaContentDisposition(input) {
43
+ const fileName = sanitizeDownloadFileName(input.fileName, input.fallbackFileName);
44
+ const asciiName = fileName.replace(/[^\x20-\x7e]|%/g, '_');
45
+ const encodedName = encodeURIComponent(fileName).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
46
+ return `${input.disposition ?? 'attachment'}; filename="${asciiName}"`
47
+ + (fileName === asciiName ? '' : `; filename*=UTF-8''${encodedName}`);
48
+ }
49
+ /** The processor changes the container to MP4; keep its name consistent everywhere. */
50
+ function processedVideoFileName(value) {
51
+ const stem = typeof value === 'string' ? value.trim().replace(/\.[a-z0-9]{1,16}$/i, '') : '';
52
+ return sanitizeDownloadFileName(`${stem || 'video'}.mp4`, 'video.mp4');
53
+ }
9
54
  function inferMediaAttachmentKind(mimeType) {
10
55
  if (mimeType.startsWith('image/'))
11
56
  return 'image';
@@ -118,6 +118,13 @@ function shouldPromoteConversationMessage(input) {
118
118
  if (isHiddenRuntimeCardMetadata(input.metadata)) {
119
119
  return false;
120
120
  }
121
+ // Ordinary agent speech can update previews and notify people without
122
+ // opting into automatic agent-to-agent turns. Keep malformed explicit
123
+ // semantics on the existing conservative fallback.
124
+ if (input.metadata == null
125
+ || (isRecord(input.metadata) && input.metadata.turnSemantics === undefined)) {
126
+ return true;
127
+ }
121
128
  return resolveTurnMessageSemantics(input) !== 'progress';
122
129
  }
123
130
  function shouldTriggerAgentTurn(input) {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './diffRedaction.js';
2
2
  export * from './environment.js';
3
3
  export * from './media.js';
4
+ export * from './media-url.js';
4
5
  export * from './message.js';
5
6
  export * from './messageText.js';
6
7
  export * from './runtimeCardFields.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './diffRedaction.js';
2
2
  export * from './environment.js';
3
3
  export * from './media.js';
4
+ export * from './media-url.js';
4
5
  export * from './message.js';
5
6
  export * from './messageText.js';
6
7
  export * from './runtimeCardFields.js';
@@ -0,0 +1 @@
1
+ export declare const MEDIA_ORIGINS: Readonly<Record<string, string>>;
@@ -0,0 +1,5 @@
1
+ // Generated by scripts/sync-runtime-endpoints.mjs from canonical endpoints and storage policies.
2
+ export const MEDIA_ORIGINS = {
3
+ "canonmail-dev.firebasestorage.app": "https://dev.api.canonmail.com",
4
+ "canonmail-prod.firebasestorage.app": "https://api.canonmail.com"
5
+ };
@@ -0,0 +1,6 @@
1
+ export declare function buildFirebaseMediaUrl(bucket: string, storagePath: string, token: string): string;
2
+ export declare function buildMediaDownloadUrl(bucket: string, storagePath: string, token: string): string;
3
+ /** Accept historical Firebase URLs and Canon links, only for the expected bucket. */
4
+ export declare function parseMediaStoragePath(value: unknown, bucket: string): string | null;
5
+ /** Presentation upgrade for existing messages without rewriting their stored URLs. */
6
+ export declare function canonMediaDownloadUrl(value: string, downloadFileName?: string): string;
@@ -0,0 +1,56 @@
1
+ import { sanitizeDownloadFileName } from './media.js';
2
+ import { MEDIA_ORIGINS } from './media-origins.generated.js';
3
+ export function buildFirebaseMediaUrl(bucket, storagePath, token) {
4
+ return `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(bucket)}/o/${encodeURIComponent(storagePath)}?alt=media&token=${encodeURIComponent(token)}`;
5
+ }
6
+ function validMediaPath(path) {
7
+ const parts = path.split('/');
8
+ return parts.length === 4 && parts[0] === 'media'
9
+ && parts.every((part) => Boolean(part) && part !== '.' && part !== '..' && !/[\u0000-\u001f\u007f\\]/.test(part));
10
+ }
11
+ export function buildMediaDownloadUrl(bucket, storagePath, token) {
12
+ const origin = MEDIA_ORIGINS[bucket];
13
+ if (!origin || !validMediaPath(storagePath))
14
+ return buildFirebaseMediaUrl(bucket, storagePath, token);
15
+ return `${origin}/files/${storagePath.split('/').slice(1).map(encodeURIComponent).join('/')}?token=${encodeURIComponent(token)}`;
16
+ }
17
+ /** Accept historical Firebase URLs and Canon links, only for the expected bucket. */
18
+ export function parseMediaStoragePath(value, bucket) {
19
+ if (typeof value !== 'string')
20
+ return null;
21
+ try {
22
+ const url = new URL(value);
23
+ if (url.protocol !== 'https:' || url.username || url.password)
24
+ return null;
25
+ let path;
26
+ const prefix = `/v0/b/${bucket}/o/`;
27
+ if (url.origin === 'https://firebasestorage.googleapis.com' && url.pathname.startsWith(prefix)) {
28
+ path = decodeURIComponent(url.pathname.slice(prefix.length));
29
+ }
30
+ else if (url.origin === MEDIA_ORIGINS[bucket] && url.pathname.startsWith('/files/')) {
31
+ path = `media/${decodeURIComponent(url.pathname.slice('/files/'.length))}`;
32
+ }
33
+ else
34
+ return null;
35
+ return validMediaPath(path) ? path : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Presentation upgrade for existing messages without rewriting their stored URLs. */
42
+ export function canonMediaDownloadUrl(value, downloadFileName) {
43
+ for (const bucket of Object.keys(MEDIA_ORIGINS)) {
44
+ const path = parseMediaStoragePath(value, bucket);
45
+ if (path) {
46
+ const token = new URL(value).searchParams.get('token');
47
+ if (token) {
48
+ const url = new URL(buildMediaDownloadUrl(bucket, path, token));
49
+ if (downloadFileName !== undefined)
50
+ url.searchParams.set('download', sanitizeDownloadFileName(downloadFileName));
51
+ return url.href;
52
+ }
53
+ }
54
+ }
55
+ return value;
56
+ }
package/dist/media.d.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  export type MediaAttachmentKind = 'image' | 'audio' | 'video' | 'file';
2
+ /** Allow private conditional caching, but recheck access before every reuse. */
3
+ export declare const MEDIA_CACHE_CONTROL = "private, max-age=0, must-revalidate";
4
+ /** Preserve Unicode and the extension while removing unsafe filesystem characters. */
5
+ export declare function sanitizeDownloadFileName(value: unknown, fallback?: string): string;
6
+ /** RFC 6266: readable ASCII fallback plus the exact UTF-8 filename. */
7
+ export declare function buildMediaContentDisposition(input: {
8
+ fileName?: unknown;
9
+ fallbackFileName: string;
10
+ disposition?: 'inline' | 'attachment';
11
+ }): string;
12
+ /** The processor changes the container to MP4; keep its name consistent everywhere. */
13
+ export declare function processedVideoFileName(value: unknown): string;
2
14
  /** Lifecycle for newly uploaded videos that are normalized server-side. */
3
15
  export type VideoProcessingStatus = 'processing' | 'ready' | 'failed';
4
16
  /** Stable, display-safe reasons for a terminal video processing failure. */
@@ -7,9 +19,9 @@ export interface MediaAttachment {
7
19
  kind: MediaAttachmentKind;
8
20
  url: string;
9
21
  /**
10
- * Server-issued identity for a finalized resumable upload. The send path
22
+ * Server-issued identity for a finalized upload. The send path
11
23
  * uses this to retain temporary canonical media atomically with the message.
12
- * Legacy/base64 uploads intentionally omit it.
24
+ * Older attachments and forwarded copies may omit it.
13
25
  */
14
26
  uploadId?: string;
15
27
  mimeType?: string;
package/dist/media.js CHANGED
@@ -1,3 +1,44 @@
1
+ /** Allow private conditional caching, but recheck access before every reuse. */
2
+ export const MEDIA_CACHE_CONTROL = 'private, max-age=0, must-revalidate';
3
+ /** Preserve Unicode and the extension while removing unsafe filesystem characters. */
4
+ export function sanitizeDownloadFileName(value, fallback = 'attachment') {
5
+ const baseName = typeof value === 'string'
6
+ ? value.trim().split(/[\\/]/).filter(Boolean).pop() ?? ''
7
+ : '';
8
+ let name = Array.from(baseName, (char) => /^[\ud800-\udfff]$/.test(char) ? '-' : char).join('').normalize('NFC')
9
+ .replace(/[\u0000-\u001f\u007f<>:"/\\|?*\u202a-\u202e\u2066-\u2069]+/g, '-')
10
+ .replace(/\s+/g, ' ')
11
+ .replace(/^[. -]+|[. -]+$/g, '');
12
+ if (!name)
13
+ return sanitizeDownloadFileName(fallback, 'attachment');
14
+ if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name))
15
+ name = `_${name}`;
16
+ const extension = name.match(/\.[a-z0-9]{1,16}$/i)?.[0] ?? '';
17
+ const stem = extension ? name.slice(0, -extension.length) : name;
18
+ // Stay below filesystem byte limits as well as the display-length limit.
19
+ let safeStem = '';
20
+ let bytes = extension.length;
21
+ for (const char of Array.from(stem).slice(0, 120 - extension.length)) {
22
+ bytes += encodeURIComponent(char).replace(/%[0-9A-F]{2}|./g, '_').length;
23
+ if (bytes > 240)
24
+ break;
25
+ safeStem += char;
26
+ }
27
+ return `${safeStem.replace(/[. -]+$/g, '')}${extension}`;
28
+ }
29
+ /** RFC 6266: readable ASCII fallback plus the exact UTF-8 filename. */
30
+ export function buildMediaContentDisposition(input) {
31
+ const fileName = sanitizeDownloadFileName(input.fileName, input.fallbackFileName);
32
+ const asciiName = fileName.replace(/[^\x20-\x7e]|%/g, '_');
33
+ const encodedName = encodeURIComponent(fileName).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
34
+ return `${input.disposition ?? 'attachment'}; filename="${asciiName}"`
35
+ + (fileName === asciiName ? '' : `; filename*=UTF-8''${encodedName}`);
36
+ }
37
+ /** The processor changes the container to MP4; keep its name consistent everywhere. */
38
+ export function processedVideoFileName(value) {
39
+ const stem = typeof value === 'string' ? value.trim().replace(/\.[a-z0-9]{1,16}$/i, '') : '';
40
+ return sanitizeDownloadFileName(`${stem || 'video'}.mp4`, 'video.mp4');
41
+ }
1
42
  export function inferMediaAttachmentKind(mimeType) {
2
43
  if (mimeType.startsWith('image/'))
3
44
  return 'image';
@@ -107,6 +107,13 @@ export function shouldPromoteConversationMessage(input) {
107
107
  if (isHiddenRuntimeCardMetadata(input.metadata)) {
108
108
  return false;
109
109
  }
110
+ // Ordinary agent speech can update previews and notify people without
111
+ // opting into automatic agent-to-agent turns. Keep malformed explicit
112
+ // semantics on the existing conservative fallback.
113
+ if (input.metadata == null
114
+ || (isRecord(input.metadata) && input.metadata.turnSemantics === undefined)) {
115
+ return true;
116
+ }
110
117
  return resolveTurnMessageSemantics(input) !== 'progress';
111
118
  }
112
119
  export function shouldTriggerAgentTurn(input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/backend-contracts",
3
- "version": "8.1.0",
3
+ "version": "8.2.0",
4
4
  "description": "Canon backend contract helpers shared by Functions and stream-service",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",