@canonmsg/backend-contracts 8.1.1 → 8.3.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);
@@ -34,3 +35,4 @@ __exportStar(require("./firestoreValues.js"), exports);
34
35
  __exportStar(require("./moderation.js"), exports);
35
36
  __exportStar(require("./selfContext.js"), exports);
36
37
  __exportStar(require("./replyAuthority.js"), exports);
38
+ __exportStar(require("./runtimeDescriptor.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';
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_PUBLIC_RUNTIME_COMMANDS = void 0;
4
+ exports.normalizeRuntimeCommandAlias = normalizeRuntimeCommandAlias;
5
+ exports.normalizeRuntimeCommandAliases = normalizeRuntimeCommandAliases;
6
+ exports.isRuntimePrimitiveId = isRuntimePrimitiveId;
7
+ exports.normalizePublicRuntimeCommands = normalizePublicRuntimeCommands;
8
+ exports.normalizePublicRuntimeDescriptor = normalizePublicRuntimeDescriptor;
9
+ exports.normalizePublicRuntimeFacts = normalizePublicRuntimeFacts;
10
+ function normalizeRuntimeCommandAlias(value) {
11
+ const normalized = value.trim().replace(/^\/+/, '').replace(/\s+/g, '-');
12
+ return /^[A-Za-z0-9:_-]+$/.test(normalized) ? normalized : null;
13
+ }
14
+ function normalizeRuntimeCommandAliases(command) {
15
+ const rawAliases = command.aliases?.length ? command.aliases : [command.id];
16
+ const aliases = rawAliases
17
+ .map(normalizeRuntimeCommandAlias)
18
+ .filter((value) => Boolean(value));
19
+ return Array.from(new Set(aliases));
20
+ }
21
+ exports.MAX_PUBLIC_RUNTIME_COMMANDS = 256;
22
+ function record(value) {
23
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
24
+ ? value : {};
25
+ }
26
+ function text(value, max) {
27
+ return typeof value === 'string' && value.length > 0 && value.length <= max ? value : undefined;
28
+ }
29
+ function member(value, choices) {
30
+ return choices.includes(value) ? value : undefined;
31
+ }
32
+ function members(value, choices) {
33
+ if (!Array.isArray(value))
34
+ return undefined;
35
+ return [...new Set(value.filter((entry) => choices.includes(entry)))];
36
+ }
37
+ function defined(value) {
38
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
39
+ }
40
+ const PRIMITIVES = [
41
+ 'runtime.status', 'runtime.reasoning.set', 'runtime.verbosity.set', 'runtime.usage',
42
+ 'context.compact', 'session.new', 'session.reset',
43
+ ];
44
+ function isRuntimePrimitiveId(value) {
45
+ return typeof value === 'string' && PRIMITIVES.includes(value);
46
+ }
47
+ function normalizeDispatch(value) {
48
+ const dispatch = record(value);
49
+ switch (dispatch.kind) {
50
+ case 'signal': {
51
+ const signal = member(dispatch.signal, ['interrupt', 'stop_and_drop', 'new_session']);
52
+ return signal ? { kind: 'signal', signal } : undefined;
53
+ }
54
+ case 'primitive': {
55
+ const primitive = member(dispatch.primitive, PRIMITIVES);
56
+ return primitive ? { kind: 'primitive', primitive } : undefined;
57
+ }
58
+ case 'control': {
59
+ const controlId = text(dispatch.controlId, 128);
60
+ return controlId ? defined({ kind: 'control', controlId, value: text(dispatch.value, 1024) }) : undefined;
61
+ }
62
+ case 'text_passthrough': {
63
+ const template = text(dispatch.template, 2048);
64
+ return template ? { kind: 'text_passthrough', template } : undefined;
65
+ }
66
+ case 'compose': {
67
+ const content = text(dispatch.text, 2048);
68
+ return content ? { kind: 'compose', text: content } : undefined;
69
+ }
70
+ case 'open_details':
71
+ return defined({ kind: 'open_details', target: text(dispatch.target, 128) });
72
+ default:
73
+ return undefined;
74
+ }
75
+ }
76
+ function normalizeArgument(value) {
77
+ const arg = record(value);
78
+ const id = text(arg.id, 128);
79
+ const label = text(arg.label, 160);
80
+ const kind = member(arg.kind, ['string', 'enum', 'boolean']);
81
+ if (!id || !label || !kind)
82
+ return undefined;
83
+ if (['required', 'captureRemaining'].some((key) => arg[key] !== undefined && typeof arg[key] !== 'boolean'))
84
+ return undefined;
85
+ if (arg.choices !== undefined && !Array.isArray(arg.choices))
86
+ return undefined;
87
+ const choices = Array.isArray(arg.choices) ? arg.choices.slice(0, 128).flatMap((value) => {
88
+ const choice = record(value);
89
+ const entry = text(choice.value, 1024);
90
+ const label = text(choice.label, 160);
91
+ return entry && label ? [defined({ value: entry, label, description: text(choice.description, 1024) })] : [];
92
+ }) : undefined;
93
+ // Do not advertise an argument whose required enum has no usable choices.
94
+ if (kind === 'enum' && !choices?.length)
95
+ return undefined;
96
+ return defined({ id, label, kind, choices,
97
+ required: typeof arg.required === 'boolean' ? arg.required : undefined,
98
+ captureRemaining: typeof arg.captureRemaining === 'boolean' ? arg.captureRemaining : undefined,
99
+ });
100
+ }
101
+ /** Public command metadata shared by REST and direct RTDB publishers. Never copies local configuration. */
102
+ function normalizePublicRuntimeCommands(value) {
103
+ if (!Array.isArray(value))
104
+ return [];
105
+ const seenIds = new Set();
106
+ const seenAliases = new Set();
107
+ const result = [];
108
+ for (const item of value.slice(0, exports.MAX_PUBLIC_RUNTIME_COMMANDS)) {
109
+ const command = record(item);
110
+ const id = text(command.id, 128);
111
+ const label = text(command.label, 160);
112
+ const dispatch = normalizeDispatch(command.dispatch);
113
+ if (!id || !label || !dispatch || seenIds.has(id)
114
+ || command.visibility === 'hidden' || command.sensitive === true)
115
+ continue;
116
+ if (['ownerOnly', 'sensitive'].some((key) => command[key] !== undefined && typeof command[key] !== 'boolean'))
117
+ continue;
118
+ if (['aliases', 'args', 'placements', 'availability'].some((key) => command[key] !== undefined && !Array.isArray(command[key])))
119
+ continue;
120
+ const rawAliases = Array.isArray(command.aliases) && command.aliases.length ? command.aliases : [id];
121
+ const aliases = [...new Set(rawAliases.slice(0, 8).flatMap((value) => {
122
+ const alias = typeof value === 'string' && value.length <= 128 ? normalizeRuntimeCommandAlias(value) : null;
123
+ return alias && /^[A-Za-z0-9:_-]+$/.test(alias) && !seenAliases.has(alias.toLowerCase()) ? [alias] : [];
124
+ }))];
125
+ if (!aliases.length)
126
+ continue;
127
+ const args = Array.isArray(command.args) ? command.args.slice(0, 12).map(normalizeArgument) : undefined;
128
+ // A malformed argument must not silently turn a parameterized action into a different command.
129
+ if (args?.some((arg) => !arg) || (Array.isArray(command.args) && command.args.length > 12))
130
+ continue;
131
+ seenIds.add(id);
132
+ aliases.forEach((alias) => seenAliases.add(alias.toLowerCase()));
133
+ result.push(defined({ id, label, aliases, dispatch,
134
+ args: args,
135
+ description: text(command.description, 1024),
136
+ category: member(command.category, ['plan', 'turn', 'session', 'runtime', 'details', 'skill', 'custom']),
137
+ placements: members(command.placements, ['composer_slash', 'command_palette', 'session_strip']),
138
+ availability: members(command.availability, ['idle', 'busy', 'busy_with_queue', 'waiting_input', 'always']),
139
+ ownerOnly: typeof command.ownerOnly === 'boolean' ? command.ownerOnly : undefined,
140
+ disabledReason: text(command.disabledReason, 1024),
141
+ trailingTextBehavior: member(command.trailingTextBehavior, ['ignore', 'send_as_prompt']),
142
+ primitive: member(command.primitive, PRIMITIVES),
143
+ tier: member(command.tier, ['primary', 'detail', 'diagnostic']),
144
+ }));
145
+ }
146
+ return result;
147
+ }
148
+ function normalizePublicRuntimeDescriptor(value) {
149
+ const descriptor = record(value);
150
+ return defined({
151
+ supportsInterrupt: typeof descriptor.supportsInterrupt === 'boolean' ? descriptor.supportsInterrupt : undefined,
152
+ supportsInputInterrupt: typeof descriptor.supportsInputInterrupt === 'boolean' ? descriptor.supportsInputInterrupt : undefined,
153
+ streamingTextMode: member(descriptor.streamingTextMode, ['none', 'status', 'snapshot', 'block', 'delta']),
154
+ commands: Array.isArray(descriptor.commands) ? normalizePublicRuntimeCommands(descriptor.commands) : undefined,
155
+ });
156
+ }
157
+ const PUBLIC_RUNTIME_FACT_GROUPS = {
158
+ harness: 'runtime', runtime: 'runtime', provider: 'model', model: 'model',
159
+ reasoning: 'model', connection: 'connection', gateway: 'connection', route: 'route',
160
+ };
161
+ /** Read-only session facts, excluding local paths, inventories, and hidden/sensitive values. */
162
+ function normalizePublicRuntimeFacts(value) {
163
+ if (!Array.isArray(value))
164
+ return [];
165
+ const seen = new Set();
166
+ return value.slice(0, 16).flatMap((entry) => {
167
+ const fact = record(entry);
168
+ const id = text(fact.id, 64);
169
+ const group = id && Object.hasOwn(PUBLIC_RUNTIME_FACT_GROUPS, id)
170
+ ? PUBLIC_RUNTIME_FACT_GROUPS[id] : undefined;
171
+ const label = text(fact.label, 80);
172
+ const content = text(fact.value, 512);
173
+ if (!id || !group || !label || !content || seen.has(id)
174
+ || fact.sensitive === true || fact.visibility === 'hidden')
175
+ return [];
176
+ seen.add(id);
177
+ return [defined({ id, group, label, value: content,
178
+ tier: member(fact.tier, ['primary', 'detail', 'diagnostic']),
179
+ tone: member(fact.tone, ['neutral', 'good', 'warning', 'danger']),
180
+ copyable: typeof fact.copyable === 'boolean' ? fact.copyable : undefined,
181
+ updatedAt: typeof fact.updatedAt === 'number' && Number.isFinite(fact.updatedAt) && fact.updatedAt >= 0
182
+ ? fact.updatedAt : undefined, })];
183
+ });
184
+ }
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';
@@ -18,3 +19,4 @@ export * from './firestoreValues.js';
18
19
  export * from './moderation.js';
19
20
  export * from './selfContext.js';
20
21
  export * from './replyAuthority.js';
22
+ export * from './runtimeDescriptor.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';
@@ -18,3 +19,4 @@ export * from './firestoreValues.js';
18
19
  export * from './moderation.js';
19
20
  export * from './selfContext.js';
20
21
  export * from './replyAuthority.js';
22
+ export * from './runtimeDescriptor.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';
@@ -0,0 +1,88 @@
1
+ export type CanonControlValue = string;
2
+ export type CanonRuntimeStreamingMode = 'none' | 'status' | 'snapshot' | 'block' | 'delta';
3
+ export type CanonRuntimeDetailTier = 'primary' | 'detail' | 'diagnostic';
4
+ export type CanonRuntimeVisibility = 'conversation' | 'hidden';
5
+ export type CanonRuntimeActionAvailability = 'idle' | 'busy' | 'busy_with_queue' | 'waiting_input' | 'always';
6
+ export type CanonRuntimeActionPlacement = 'composer_slash' | 'command_palette' | 'session_strip';
7
+ export type CanonRuntimeActionCategory = 'plan' | 'turn' | 'session' | 'runtime' | 'details' | 'skill' | 'custom';
8
+ export type CanonRuntimePrimitiveId = 'runtime.status' | 'runtime.reasoning.set' | 'runtime.verbosity.set' | 'runtime.usage' | 'context.compact' | 'session.new' | 'session.reset';
9
+ export type CanonRuntimeCommandArgumentKind = 'string' | 'enum' | 'boolean';
10
+ export interface CanonRuntimeCommandArgumentChoice {
11
+ value: string;
12
+ label: string;
13
+ description?: string;
14
+ }
15
+ export interface CanonRuntimeCommandArgumentDescriptor {
16
+ id: string;
17
+ label: string;
18
+ kind: CanonRuntimeCommandArgumentKind;
19
+ required?: boolean;
20
+ captureRemaining?: boolean;
21
+ choices?: ReadonlyArray<CanonRuntimeCommandArgumentChoice>;
22
+ }
23
+ export type CanonRuntimeActionDispatch = {
24
+ kind: 'control';
25
+ controlId: string;
26
+ value?: CanonControlValue;
27
+ } | {
28
+ kind: 'signal';
29
+ signal: 'interrupt' | 'stop_and_drop' | 'new_session';
30
+ } | {
31
+ kind: 'primitive';
32
+ primitive: CanonRuntimePrimitiveId;
33
+ } | {
34
+ kind: 'text_passthrough';
35
+ template: string;
36
+ } | {
37
+ kind: 'compose';
38
+ text: string;
39
+ } | {
40
+ kind: 'open_details';
41
+ target?: string;
42
+ };
43
+ export interface CanonRuntimeActionDescriptor {
44
+ id: string;
45
+ label: string;
46
+ description?: string;
47
+ visibility?: CanonRuntimeVisibility;
48
+ tier?: CanonRuntimeDetailTier;
49
+ sensitive?: boolean;
50
+ primitive?: CanonRuntimePrimitiveId;
51
+ aliases?: ReadonlyArray<string>;
52
+ category?: CanonRuntimeActionCategory;
53
+ placements?: ReadonlyArray<CanonRuntimeActionPlacement>;
54
+ availability?: ReadonlyArray<CanonRuntimeActionAvailability>;
55
+ ownerOnly?: boolean;
56
+ disabledReason?: string | null;
57
+ trailingTextBehavior?: 'ignore' | 'send_as_prompt';
58
+ args?: ReadonlyArray<CanonRuntimeCommandArgumentDescriptor>;
59
+ dispatch: CanonRuntimeActionDispatch;
60
+ }
61
+ export interface CanonRuntimeCommandDescriptor extends CanonRuntimeActionDescriptor {
62
+ primitive?: CanonRuntimePrimitiveId;
63
+ args?: ReadonlyArray<CanonRuntimeCommandArgumentDescriptor>;
64
+ }
65
+ export declare function normalizeRuntimeCommandAlias(value: string): string | null;
66
+ export declare function normalizeRuntimeCommandAliases(command: Pick<CanonRuntimeCommandDescriptor, 'id' | 'aliases'>): string[];
67
+ export declare const MAX_PUBLIC_RUNTIME_COMMANDS = 256;
68
+ export declare function isRuntimePrimitiveId(value: unknown): value is CanonRuntimePrimitiveId;
69
+ /** Public command metadata shared by REST and direct RTDB publishers. Never copies local configuration. */
70
+ export declare function normalizePublicRuntimeCommands(value: unknown): CanonRuntimeCommandDescriptor[];
71
+ export interface PublicRuntimeDescriptor {
72
+ supportsInterrupt?: boolean;
73
+ supportsInputInterrupt?: boolean;
74
+ streamingTextMode?: CanonRuntimeStreamingMode;
75
+ commands?: CanonRuntimeCommandDescriptor[];
76
+ }
77
+ export declare function normalizePublicRuntimeDescriptor(value: unknown): PublicRuntimeDescriptor;
78
+ /** Read-only session facts, excluding local paths, inventories, and hidden/sensitive values. */
79
+ export declare function normalizePublicRuntimeFacts(value: unknown): {
80
+ id: string;
81
+ group: "runtime" | "model" | "connection" | "route";
82
+ label: string;
83
+ value: string;
84
+ tier: "primary" | "detail" | "diagnostic" | undefined;
85
+ tone: "neutral" | "good" | "warning" | "danger" | undefined;
86
+ copyable: boolean | undefined;
87
+ updatedAt: number | undefined;
88
+ }[];
@@ -0,0 +1,175 @@
1
+ export function normalizeRuntimeCommandAlias(value) {
2
+ const normalized = value.trim().replace(/^\/+/, '').replace(/\s+/g, '-');
3
+ return /^[A-Za-z0-9:_-]+$/.test(normalized) ? normalized : null;
4
+ }
5
+ export function normalizeRuntimeCommandAliases(command) {
6
+ const rawAliases = command.aliases?.length ? command.aliases : [command.id];
7
+ const aliases = rawAliases
8
+ .map(normalizeRuntimeCommandAlias)
9
+ .filter((value) => Boolean(value));
10
+ return Array.from(new Set(aliases));
11
+ }
12
+ export const MAX_PUBLIC_RUNTIME_COMMANDS = 256;
13
+ function record(value) {
14
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
15
+ ? value : {};
16
+ }
17
+ function text(value, max) {
18
+ return typeof value === 'string' && value.length > 0 && value.length <= max ? value : undefined;
19
+ }
20
+ function member(value, choices) {
21
+ return choices.includes(value) ? value : undefined;
22
+ }
23
+ function members(value, choices) {
24
+ if (!Array.isArray(value))
25
+ return undefined;
26
+ return [...new Set(value.filter((entry) => choices.includes(entry)))];
27
+ }
28
+ function defined(value) {
29
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
30
+ }
31
+ const PRIMITIVES = [
32
+ 'runtime.status', 'runtime.reasoning.set', 'runtime.verbosity.set', 'runtime.usage',
33
+ 'context.compact', 'session.new', 'session.reset',
34
+ ];
35
+ export function isRuntimePrimitiveId(value) {
36
+ return typeof value === 'string' && PRIMITIVES.includes(value);
37
+ }
38
+ function normalizeDispatch(value) {
39
+ const dispatch = record(value);
40
+ switch (dispatch.kind) {
41
+ case 'signal': {
42
+ const signal = member(dispatch.signal, ['interrupt', 'stop_and_drop', 'new_session']);
43
+ return signal ? { kind: 'signal', signal } : undefined;
44
+ }
45
+ case 'primitive': {
46
+ const primitive = member(dispatch.primitive, PRIMITIVES);
47
+ return primitive ? { kind: 'primitive', primitive } : undefined;
48
+ }
49
+ case 'control': {
50
+ const controlId = text(dispatch.controlId, 128);
51
+ return controlId ? defined({ kind: 'control', controlId, value: text(dispatch.value, 1024) }) : undefined;
52
+ }
53
+ case 'text_passthrough': {
54
+ const template = text(dispatch.template, 2048);
55
+ return template ? { kind: 'text_passthrough', template } : undefined;
56
+ }
57
+ case 'compose': {
58
+ const content = text(dispatch.text, 2048);
59
+ return content ? { kind: 'compose', text: content } : undefined;
60
+ }
61
+ case 'open_details':
62
+ return defined({ kind: 'open_details', target: text(dispatch.target, 128) });
63
+ default:
64
+ return undefined;
65
+ }
66
+ }
67
+ function normalizeArgument(value) {
68
+ const arg = record(value);
69
+ const id = text(arg.id, 128);
70
+ const label = text(arg.label, 160);
71
+ const kind = member(arg.kind, ['string', 'enum', 'boolean']);
72
+ if (!id || !label || !kind)
73
+ return undefined;
74
+ if (['required', 'captureRemaining'].some((key) => arg[key] !== undefined && typeof arg[key] !== 'boolean'))
75
+ return undefined;
76
+ if (arg.choices !== undefined && !Array.isArray(arg.choices))
77
+ return undefined;
78
+ const choices = Array.isArray(arg.choices) ? arg.choices.slice(0, 128).flatMap((value) => {
79
+ const choice = record(value);
80
+ const entry = text(choice.value, 1024);
81
+ const label = text(choice.label, 160);
82
+ return entry && label ? [defined({ value: entry, label, description: text(choice.description, 1024) })] : [];
83
+ }) : undefined;
84
+ // Do not advertise an argument whose required enum has no usable choices.
85
+ if (kind === 'enum' && !choices?.length)
86
+ return undefined;
87
+ return defined({ id, label, kind, choices,
88
+ required: typeof arg.required === 'boolean' ? arg.required : undefined,
89
+ captureRemaining: typeof arg.captureRemaining === 'boolean' ? arg.captureRemaining : undefined,
90
+ });
91
+ }
92
+ /** Public command metadata shared by REST and direct RTDB publishers. Never copies local configuration. */
93
+ export function normalizePublicRuntimeCommands(value) {
94
+ if (!Array.isArray(value))
95
+ return [];
96
+ const seenIds = new Set();
97
+ const seenAliases = new Set();
98
+ const result = [];
99
+ for (const item of value.slice(0, MAX_PUBLIC_RUNTIME_COMMANDS)) {
100
+ const command = record(item);
101
+ const id = text(command.id, 128);
102
+ const label = text(command.label, 160);
103
+ const dispatch = normalizeDispatch(command.dispatch);
104
+ if (!id || !label || !dispatch || seenIds.has(id)
105
+ || command.visibility === 'hidden' || command.sensitive === true)
106
+ continue;
107
+ if (['ownerOnly', 'sensitive'].some((key) => command[key] !== undefined && typeof command[key] !== 'boolean'))
108
+ continue;
109
+ if (['aliases', 'args', 'placements', 'availability'].some((key) => command[key] !== undefined && !Array.isArray(command[key])))
110
+ continue;
111
+ const rawAliases = Array.isArray(command.aliases) && command.aliases.length ? command.aliases : [id];
112
+ const aliases = [...new Set(rawAliases.slice(0, 8).flatMap((value) => {
113
+ const alias = typeof value === 'string' && value.length <= 128 ? normalizeRuntimeCommandAlias(value) : null;
114
+ return alias && /^[A-Za-z0-9:_-]+$/.test(alias) && !seenAliases.has(alias.toLowerCase()) ? [alias] : [];
115
+ }))];
116
+ if (!aliases.length)
117
+ continue;
118
+ const args = Array.isArray(command.args) ? command.args.slice(0, 12).map(normalizeArgument) : undefined;
119
+ // A malformed argument must not silently turn a parameterized action into a different command.
120
+ if (args?.some((arg) => !arg) || (Array.isArray(command.args) && command.args.length > 12))
121
+ continue;
122
+ seenIds.add(id);
123
+ aliases.forEach((alias) => seenAliases.add(alias.toLowerCase()));
124
+ result.push(defined({ id, label, aliases, dispatch,
125
+ args: args,
126
+ description: text(command.description, 1024),
127
+ category: member(command.category, ['plan', 'turn', 'session', 'runtime', 'details', 'skill', 'custom']),
128
+ placements: members(command.placements, ['composer_slash', 'command_palette', 'session_strip']),
129
+ availability: members(command.availability, ['idle', 'busy', 'busy_with_queue', 'waiting_input', 'always']),
130
+ ownerOnly: typeof command.ownerOnly === 'boolean' ? command.ownerOnly : undefined,
131
+ disabledReason: text(command.disabledReason, 1024),
132
+ trailingTextBehavior: member(command.trailingTextBehavior, ['ignore', 'send_as_prompt']),
133
+ primitive: member(command.primitive, PRIMITIVES),
134
+ tier: member(command.tier, ['primary', 'detail', 'diagnostic']),
135
+ }));
136
+ }
137
+ return result;
138
+ }
139
+ export function normalizePublicRuntimeDescriptor(value) {
140
+ const descriptor = record(value);
141
+ return defined({
142
+ supportsInterrupt: typeof descriptor.supportsInterrupt === 'boolean' ? descriptor.supportsInterrupt : undefined,
143
+ supportsInputInterrupt: typeof descriptor.supportsInputInterrupt === 'boolean' ? descriptor.supportsInputInterrupt : undefined,
144
+ streamingTextMode: member(descriptor.streamingTextMode, ['none', 'status', 'snapshot', 'block', 'delta']),
145
+ commands: Array.isArray(descriptor.commands) ? normalizePublicRuntimeCommands(descriptor.commands) : undefined,
146
+ });
147
+ }
148
+ const PUBLIC_RUNTIME_FACT_GROUPS = {
149
+ harness: 'runtime', runtime: 'runtime', provider: 'model', model: 'model',
150
+ reasoning: 'model', connection: 'connection', gateway: 'connection', route: 'route',
151
+ };
152
+ /** Read-only session facts, excluding local paths, inventories, and hidden/sensitive values. */
153
+ export function normalizePublicRuntimeFacts(value) {
154
+ if (!Array.isArray(value))
155
+ return [];
156
+ const seen = new Set();
157
+ return value.slice(0, 16).flatMap((entry) => {
158
+ const fact = record(entry);
159
+ const id = text(fact.id, 64);
160
+ const group = id && Object.hasOwn(PUBLIC_RUNTIME_FACT_GROUPS, id)
161
+ ? PUBLIC_RUNTIME_FACT_GROUPS[id] : undefined;
162
+ const label = text(fact.label, 80);
163
+ const content = text(fact.value, 512);
164
+ if (!id || !group || !label || !content || seen.has(id)
165
+ || fact.sensitive === true || fact.visibility === 'hidden')
166
+ return [];
167
+ seen.add(id);
168
+ return [defined({ id, group, label, value: content,
169
+ tier: member(fact.tier, ['primary', 'detail', 'diagnostic']),
170
+ tone: member(fact.tone, ['neutral', 'good', 'warning', 'danger']),
171
+ copyable: typeof fact.copyable === 'boolean' ? fact.copyable : undefined,
172
+ updatedAt: typeof fact.updatedAt === 'number' && Number.isFinite(fact.updatedAt) && fact.updatedAt >= 0
173
+ ? fact.updatedAt : undefined, })];
174
+ });
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/backend-contracts",
3
- "version": "8.1.1",
3
+ "version": "8.3.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",