@lazyingart/agent-web 0.1.40
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/LICENSE +22 -0
- package/README.md +438 -0
- package/docs/architecture.md +503 -0
- package/package.json +43 -0
- package/src/aginti-adapter.js +602 -0
- package/src/chat-context.js +1020 -0
- package/src/chat-migrations.js +947 -0
- package/src/chat-store.js +3308 -0
- package/src/cli.js +134 -0
- package/src/cloud-server.js +2043 -0
- package/src/contracts.js +103 -0
- package/src/deterministic-context-summarizer.js +254 -0
- package/src/direct-chat-capability-limits.js +66 -0
- package/src/direct-chat-contract.js +3 -0
- package/src/errors.js +50 -0
- package/src/http-contract.js +592 -0
- package/src/index.js +88 -0
- package/src/localllm-connector.js +667 -0
- package/src/migrations.js +231 -0
- package/src/operator-health.js +184 -0
- package/src/password-verifier.js +131 -0
- package/src/service-config.js +547 -0
- package/src/service.js +408 -0
- package/src/sqlite-health.js +83 -0
- package/src/storage-path.js +130 -0
- package/src/store.js +914 -0
- package/src/validation.js +181 -0
- package/src/vision-attachment.js +404 -0
- package/src/web/aginti-client.js +552 -0
- package/src/web/aginti-protocol.js +1146 -0
- package/src/web/asset-map.js +462 -0
- package/src/web/browser-app.js +6491 -0
- package/src/web/cloud-session-client.js +427 -0
- package/src/web/direct-chat-client.js +1482 -0
- package/src/web/index.js +10 -0
- package/src/web/presentation-state.js +107 -0
- package/src/web/pwa-assets.js +854 -0
- package/src/web/pwa-update-handoff-store.js +179 -0
- package/src/web/safe-rendering.js +836 -0
- package/src/web/vision-image-client.js +546 -0
- package/src/web/vision-image-sanitizer.js +168 -0
- package/src/web/web-release.js +28 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
const arrayIsArray = Array.isArray;
|
|
6
|
+
const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors;
|
|
7
|
+
const getPrototypeOf = Object.getPrototypeOf;
|
|
8
|
+
const hasOwn = Object.hasOwn;
|
|
9
|
+
const ownKeys = Reflect.ownKeys;
|
|
10
|
+
|
|
11
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
|
12
|
+
const EVENT_HASH_PATTERN = /^[a-f0-9]{64}$/u;
|
|
13
|
+
|
|
14
|
+
function plainDataKeys(value, name) {
|
|
15
|
+
const descriptors = getOwnPropertyDescriptors(value);
|
|
16
|
+
const keys = ownKeys(descriptors);
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
if (typeof key !== 'string') throw new ValidationError(`${name} must not contain symbol keys.`);
|
|
19
|
+
const descriptor = descriptors[key];
|
|
20
|
+
if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
|
|
21
|
+
throw new ValidationError(`${name} must contain only enumerable data properties.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { descriptors, keys };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assertPlainObject(value, name) {
|
|
28
|
+
if (value === null || typeof value !== 'object' || arrayIsArray(value)) {
|
|
29
|
+
throw new ValidationError(`${name} must be an object.`);
|
|
30
|
+
}
|
|
31
|
+
const prototype = getPrototypeOf(value);
|
|
32
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
33
|
+
throw new ValidationError(`${name} must be a plain object.`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function assertExactKeys(value, { required = [], optional = [] }, name) {
|
|
39
|
+
assertPlainObject(value, name);
|
|
40
|
+
const allowed = new Set([...required, ...optional]);
|
|
41
|
+
const { keys } = plainDataKeys(value, name);
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
if (!allowed.has(key)) {
|
|
44
|
+
throw new ValidationError(`${name} contains unsupported field ${JSON.stringify(key)}.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const key of required) {
|
|
48
|
+
if (!hasOwn(value, key)) {
|
|
49
|
+
throw new ValidationError(`${name}.${key} is required.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function assertIdentifier(value, name) {
|
|
56
|
+
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
|
57
|
+
throw new ValidationError(`${name} must be a 1-128 character portable identifier.`);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function assertBoundedString(value, name, { min = 1, max, allowControl = false } = {}) {
|
|
63
|
+
if (typeof value !== 'string' || value.length < min || value.length > max) {
|
|
64
|
+
throw new ValidationError(`${name} must be a string between ${min} and ${max} characters.`);
|
|
65
|
+
}
|
|
66
|
+
if (!allowControl && /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
67
|
+
throw new ValidationError(`${name} must not contain control characters.`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function assertBoolean(value, name) {
|
|
73
|
+
if (typeof value !== 'boolean') {
|
|
74
|
+
throw new ValidationError(`${name} must be a boolean.`);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function assertInteger(value, name, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
80
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
81
|
+
throw new ValidationError(`${name} must be an integer between ${min} and ${max}.`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function assertEventHash(value, sequence, name = 'lastEventHash') {
|
|
87
|
+
if (sequence === 0 && value === null) return null;
|
|
88
|
+
if (typeof value !== 'string' || !EVENT_HASH_PATTERN.test(value)) {
|
|
89
|
+
throw new ValidationError(`${name} must be a lowercase 64-character hexadecimal digest when sequence is non-zero.`);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function assertCanonicalIsoTimestamp(value, name) {
|
|
95
|
+
if (typeof value !== 'string') {
|
|
96
|
+
throw new ValidationError(`${name} must be an ISO-8601 timestamp.`);
|
|
97
|
+
}
|
|
98
|
+
const date = new Date(value);
|
|
99
|
+
if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) {
|
|
100
|
+
throw new ValidationError(`${name} must be a canonical UTC ISO-8601 timestamp.`);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function nowIso(clock) {
|
|
106
|
+
const value = clock();
|
|
107
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
108
|
+
if (!Number.isFinite(date.getTime())) {
|
|
109
|
+
throw new ValidationError('The configured clock returned an invalid time.');
|
|
110
|
+
}
|
|
111
|
+
return date.toISOString();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function assertIdempotencyKey(value) {
|
|
115
|
+
return assertBoundedString(value, 'idempotencyKey', { min: 16, max: 256 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function digestSecret(value, name, { min = 32, max = 1024 } = {}) {
|
|
119
|
+
assertBoundedString(value, name, { min, max });
|
|
120
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function sha256(value) {
|
|
124
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function canonicalize(value, seen) {
|
|
128
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
129
|
+
if (typeof value === 'number') {
|
|
130
|
+
if (!Number.isFinite(value)) throw new ValidationError('Idempotent request data must contain finite numbers.');
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value !== 'object' || typeof value === 'bigint') {
|
|
134
|
+
throw new ValidationError('Idempotent request data must be JSON-compatible.');
|
|
135
|
+
}
|
|
136
|
+
if (seen.has(value)) throw new ValidationError('Idempotent request data must not be cyclic.');
|
|
137
|
+
seen.add(value);
|
|
138
|
+
let result;
|
|
139
|
+
if (arrayIsArray(value)) {
|
|
140
|
+
if (!Number.isSafeInteger(value.length) || value.length > 10_000) {
|
|
141
|
+
throw new ValidationError('Idempotent request arrays must contain at most 10,000 items.');
|
|
142
|
+
}
|
|
143
|
+
const descriptors = getOwnPropertyDescriptors(value);
|
|
144
|
+
const keys = ownKeys(descriptors);
|
|
145
|
+
const expectedKeys = new Set(['length']);
|
|
146
|
+
result = new Array(value.length);
|
|
147
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
148
|
+
const key = String(index);
|
|
149
|
+
expectedKeys.add(key);
|
|
150
|
+
if (!hasOwn(descriptors, key)) {
|
|
151
|
+
throw new ValidationError('Idempotent request arrays must not be sparse.');
|
|
152
|
+
}
|
|
153
|
+
const descriptor = descriptors[key];
|
|
154
|
+
if (!descriptor.enumerable || !hasOwn(descriptor, 'value')) {
|
|
155
|
+
throw new ValidationError('Idempotent request arrays must contain only enumerable data items.');
|
|
156
|
+
}
|
|
157
|
+
result[index] = canonicalize(descriptor.value, seen);
|
|
158
|
+
}
|
|
159
|
+
for (const key of keys) {
|
|
160
|
+
if (typeof key !== 'string' || !expectedKeys.has(key)) {
|
|
161
|
+
throw new ValidationError('Idempotent request arrays must not contain extra properties.');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
assertPlainObject(value, 'idempotent request data');
|
|
166
|
+
result = {};
|
|
167
|
+
const { descriptors, keys } = plainDataKeys(value, 'idempotent request data');
|
|
168
|
+
for (const key of keys.sort()) {
|
|
169
|
+
if (descriptors[key].value === undefined) {
|
|
170
|
+
throw new ValidationError('Idempotent request data must not contain undefined values.');
|
|
171
|
+
}
|
|
172
|
+
result[key] = canonicalize(descriptors[key].value, seen);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
seen.delete(value);
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function canonicalJson(value) {
|
|
180
|
+
return JSON.stringify(canonicalize(value, new Set()));
|
|
181
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { inflateSync } from 'node:zlib';
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from './errors.js';
|
|
5
|
+
import { assertExactKeys, assertIdentifier } from './validation.js';
|
|
6
|
+
import { sanitizeVisionImageBytes } from './web/vision-image-sanitizer.js';
|
|
7
|
+
|
|
8
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
9
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
10
|
+
const PNG_METADATA_CHUNKS = new Set(['caBX', 'eXIf', 'iCCP', 'iTXt', 'tEXt', 'zTXt']);
|
|
11
|
+
const PNG_RENDERING_CHUNKS = new Map([
|
|
12
|
+
['cHRM', 32],
|
|
13
|
+
['gAMA', 4],
|
|
14
|
+
['pHYs', 9],
|
|
15
|
+
['sRGB', 1]
|
|
16
|
+
]);
|
|
17
|
+
const JPEG_METADATA_MARKERS = new Set([
|
|
18
|
+
0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8,
|
|
19
|
+
0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef, 0xfe
|
|
20
|
+
]);
|
|
21
|
+
const JPEG_SOF_MARKERS = new Set([
|
|
22
|
+
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
|
|
23
|
+
0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf
|
|
24
|
+
]);
|
|
25
|
+
const CRC32_TABLE = (() => {
|
|
26
|
+
const table = new Uint32Array(256);
|
|
27
|
+
for (let index = 0; index < table.length; index += 1) {
|
|
28
|
+
let value = index;
|
|
29
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
30
|
+
value = (value >>> 1) ^ (0xedb88320 & -(value & 1));
|
|
31
|
+
}
|
|
32
|
+
table[index] = value >>> 0;
|
|
33
|
+
}
|
|
34
|
+
return table;
|
|
35
|
+
})();
|
|
36
|
+
|
|
37
|
+
export const VISION_ATTACHMENT_LIMITS = Object.freeze({
|
|
38
|
+
bytes: 4 * 1024 * 1024,
|
|
39
|
+
encodedBytes: Math.ceil((4 * 1024 * 1024) / 3) * 4,
|
|
40
|
+
attachmentsPerMessage: 4,
|
|
41
|
+
bytesPerMessage: 16 * 1024 * 1024,
|
|
42
|
+
maximumEdge: 4_096,
|
|
43
|
+
pixels: 16 * 1024 * 1024,
|
|
44
|
+
attachmentsPerThread: 32,
|
|
45
|
+
bytesPerThread: 64 * 1024 * 1024,
|
|
46
|
+
bytesPerAccount: 256 * 1024 * 1024
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const VISION_MODEL_ALIAS = 'localllm-vision';
|
|
50
|
+
|
|
51
|
+
function invalid(message) {
|
|
52
|
+
throw new ValidationError(message);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function boundedBase64(value) {
|
|
56
|
+
let padding = 0;
|
|
57
|
+
if (value.endsWith('=')) padding = value.endsWith('==') ? 2 : 1;
|
|
58
|
+
const contentLength = value.length - padding;
|
|
59
|
+
if ((padding === 0 && contentLength % 4 !== 0)
|
|
60
|
+
|| (padding === 1 && contentLength % 4 !== 3)
|
|
61
|
+
|| (padding === 2 && contentLength % 4 !== 2)) return false;
|
|
62
|
+
for (let index = 0; index < contentLength; index += 1) {
|
|
63
|
+
const code = value.charCodeAt(index);
|
|
64
|
+
if (!((code >= 65 && code <= 90) || (code >= 97 && code <= 122)
|
|
65
|
+
|| (code >= 48 && code <= 57) || code === 43 || code === 47)) return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function crc32(bytes) {
|
|
71
|
+
let crc = 0xffffffff;
|
|
72
|
+
for (const byte of bytes) {
|
|
73
|
+
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 0xff];
|
|
74
|
+
}
|
|
75
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inspectPng(bytes) {
|
|
79
|
+
if (bytes.byteLength < 45 || !bytes.subarray(0, PNG_SIGNATURE.byteLength).equals(PNG_SIGNATURE)) {
|
|
80
|
+
invalid('attachment data is not a valid PNG image.');
|
|
81
|
+
}
|
|
82
|
+
let offset = PNG_SIGNATURE.byteLength;
|
|
83
|
+
let width = null;
|
|
84
|
+
let height = null;
|
|
85
|
+
let sawIdat = false;
|
|
86
|
+
let idatEnded = false;
|
|
87
|
+
let sawIend = false;
|
|
88
|
+
let sawPalette = false;
|
|
89
|
+
let bitDepth = null;
|
|
90
|
+
let colorType = null;
|
|
91
|
+
const idatChunks = [];
|
|
92
|
+
const renderingChunks = new Set();
|
|
93
|
+
let chunks = 0;
|
|
94
|
+
while (offset < bytes.byteLength) {
|
|
95
|
+
if (bytes.byteLength - offset < 12) invalid('attachment PNG framing is truncated.');
|
|
96
|
+
const length = bytes.readUInt32BE(offset);
|
|
97
|
+
const end = offset + 12 + length;
|
|
98
|
+
if (!Number.isSafeInteger(end) || end > bytes.byteLength) invalid('attachment PNG chunk is truncated.');
|
|
99
|
+
const typeBytes = bytes.subarray(offset + 4, offset + 8);
|
|
100
|
+
const type = typeBytes.toString('ascii');
|
|
101
|
+
if (!/^[A-Za-z]{4}$/u.test(type)) invalid('attachment PNG chunk type is invalid.');
|
|
102
|
+
const data = bytes.subarray(offset + 8, offset + 8 + length);
|
|
103
|
+
const expectedCrc = bytes.readUInt32BE(offset + 8 + length);
|
|
104
|
+
if (crc32(Buffer.concat([typeBytes, data])) !== expectedCrc) {
|
|
105
|
+
invalid('attachment PNG checksum is invalid.');
|
|
106
|
+
}
|
|
107
|
+
chunks += 1;
|
|
108
|
+
if (chunks > 16_384) invalid('attachment PNG contains too many chunks.');
|
|
109
|
+
if (chunks === 1) {
|
|
110
|
+
if (type !== 'IHDR' || length !== 13) invalid('attachment PNG header is invalid.');
|
|
111
|
+
width = data.readUInt32BE(0);
|
|
112
|
+
height = data.readUInt32BE(4);
|
|
113
|
+
bitDepth = data[8];
|
|
114
|
+
colorType = data[9];
|
|
115
|
+
const validDepths = new Map([
|
|
116
|
+
[0, new Set([1, 2, 4, 8])],
|
|
117
|
+
[2, new Set([8])],
|
|
118
|
+
[3, new Set([1, 2, 4, 8])],
|
|
119
|
+
[4, new Set([8])],
|
|
120
|
+
[6, new Set([8])]
|
|
121
|
+
]);
|
|
122
|
+
if (!validDepths.get(colorType)?.has(bitDepth)
|
|
123
|
+
|| data[10] !== 0 || data[11] !== 0 || data[12] !== 0) {
|
|
124
|
+
invalid('attachment PNG must be a canonical non-interlaced image.');
|
|
125
|
+
}
|
|
126
|
+
} else if (type === 'IHDR') {
|
|
127
|
+
invalid('attachment PNG contains a repeated header.');
|
|
128
|
+
}
|
|
129
|
+
if (PNG_METADATA_CHUNKS.has(type)) invalid('attachment PNG still contains metadata.');
|
|
130
|
+
if ((type.charCodeAt(0) & 0x20) !== 0) {
|
|
131
|
+
const expectedLength = PNG_RENDERING_CHUNKS.get(type);
|
|
132
|
+
if (expectedLength === undefined || renderingChunks.has(type)
|
|
133
|
+
|| length !== expectedLength || sawIdat) {
|
|
134
|
+
invalid('attachment PNG contains unsupported ancillary data.');
|
|
135
|
+
}
|
|
136
|
+
if ((type === 'sRGB' && data[0] > 3)
|
|
137
|
+
|| (type === 'gAMA' && data.readUInt32BE(0) === 0)
|
|
138
|
+
|| (type === 'pHYs' && (data.readUInt32BE(0) === 0
|
|
139
|
+
|| data.readUInt32BE(4) === 0 || data[8] > 1))) {
|
|
140
|
+
invalid('attachment PNG rendering data is invalid.');
|
|
141
|
+
}
|
|
142
|
+
renderingChunks.add(type);
|
|
143
|
+
}
|
|
144
|
+
if (type === 'PLTE') {
|
|
145
|
+
if (sawPalette || sawIdat || length < 3 || length > 768 || length % 3 !== 0
|
|
146
|
+
|| colorType === 0 || colorType === 4
|
|
147
|
+
|| (colorType === 3 && length / 3 > 2 ** bitDepth)) {
|
|
148
|
+
invalid('attachment PNG palette is invalid.');
|
|
149
|
+
}
|
|
150
|
+
sawPalette = true;
|
|
151
|
+
}
|
|
152
|
+
if (type === 'IDAT') {
|
|
153
|
+
if (idatEnded || (colorType === 3 && !sawPalette)) {
|
|
154
|
+
invalid('attachment PNG image data order is invalid.');
|
|
155
|
+
}
|
|
156
|
+
sawIdat = true;
|
|
157
|
+
idatChunks.push(data);
|
|
158
|
+
} else if (sawIdat && type !== 'IEND') {
|
|
159
|
+
idatEnded = true;
|
|
160
|
+
}
|
|
161
|
+
if ((type.charCodeAt(0) & 0x20) === 0
|
|
162
|
+
&& !['IHDR', 'PLTE', 'IDAT', 'IEND'].includes(type)) {
|
|
163
|
+
invalid('attachment PNG contains an unsupported critical chunk.');
|
|
164
|
+
}
|
|
165
|
+
if (type === 'IEND') {
|
|
166
|
+
if (length !== 0 || !sawIdat || end !== bytes.byteLength) {
|
|
167
|
+
invalid('attachment PNG terminator is invalid.');
|
|
168
|
+
}
|
|
169
|
+
sawIend = true;
|
|
170
|
+
}
|
|
171
|
+
offset = end;
|
|
172
|
+
}
|
|
173
|
+
if (!sawIend || width === null || height === null) invalid('attachment PNG is incomplete.');
|
|
174
|
+
validateDimensions(width, height);
|
|
175
|
+
const samplesPerPixel = new Map([[0, 1], [2, 3], [3, 1], [4, 2], [6, 4]]).get(colorType);
|
|
176
|
+
const rowBytes = Math.ceil((width * samplesPerPixel * bitDepth) / 8);
|
|
177
|
+
const expectedInflatedBytes = (rowBytes + 1) * height;
|
|
178
|
+
const compressed = Buffer.concat(idatChunks);
|
|
179
|
+
let pixels;
|
|
180
|
+
let compressedBytesRead;
|
|
181
|
+
try {
|
|
182
|
+
const inflated = inflateSync(compressed, { maxOutputLength: expectedInflatedBytes, info: true });
|
|
183
|
+
pixels = inflated.buffer;
|
|
184
|
+
compressedBytesRead = inflated.engine?.bytesWritten;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
invalid(`attachment PNG image data is invalid: ${error?.code ?? 'decode failed'}.`);
|
|
187
|
+
}
|
|
188
|
+
if (compressedBytesRead !== compressed.byteLength) invalid('attachment PNG image data contains trailing bytes.');
|
|
189
|
+
if (pixels.byteLength !== expectedInflatedBytes) invalid('attachment PNG decoded size is invalid.');
|
|
190
|
+
for (let row = 0; row < height; row += 1) {
|
|
191
|
+
if (pixels[row * (rowBytes + 1)] > 4) invalid('attachment PNG scanline filter is invalid.');
|
|
192
|
+
}
|
|
193
|
+
return { width, height };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function inspectJpeg(bytes) {
|
|
197
|
+
if (bytes.byteLength < 16 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
|
198
|
+
invalid('attachment data is not a valid JPEG image.');
|
|
199
|
+
}
|
|
200
|
+
let offset = 2;
|
|
201
|
+
let width = null;
|
|
202
|
+
let height = null;
|
|
203
|
+
let sawScan = false;
|
|
204
|
+
let sawEnd = false;
|
|
205
|
+
let sawJfif = false;
|
|
206
|
+
let markers = 0;
|
|
207
|
+
while (offset < bytes.byteLength) {
|
|
208
|
+
if (bytes[offset] !== 0xff) invalid('attachment JPEG marker framing is invalid.');
|
|
209
|
+
while (offset < bytes.byteLength && bytes[offset] === 0xff) offset += 1;
|
|
210
|
+
if (offset >= bytes.byteLength) invalid('attachment JPEG marker is truncated.');
|
|
211
|
+
const marker = bytes[offset];
|
|
212
|
+
offset += 1;
|
|
213
|
+
markers += 1;
|
|
214
|
+
if (markers > 65_536) invalid('attachment JPEG contains too many markers.');
|
|
215
|
+
if (marker === 0xd9) {
|
|
216
|
+
if (!sawScan || offset !== bytes.byteLength) invalid('attachment JPEG terminator is invalid.');
|
|
217
|
+
sawEnd = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
if (marker === 0xd8 || marker === 0x00 || marker === 0x01
|
|
221
|
+
|| (marker >= 0xd0 && marker <= 0xd7)) {
|
|
222
|
+
invalid('attachment JPEG contains an invalid standalone marker.');
|
|
223
|
+
}
|
|
224
|
+
if (bytes.byteLength - offset < 2) invalid('attachment JPEG segment is truncated.');
|
|
225
|
+
const length = bytes.readUInt16BE(offset);
|
|
226
|
+
if (length < 2 || offset + length > bytes.byteLength) invalid('attachment JPEG segment length is invalid.');
|
|
227
|
+
const dataStart = offset + 2;
|
|
228
|
+
const dataEnd = offset + length;
|
|
229
|
+
if (JPEG_METADATA_MARKERS.has(marker)) invalid('attachment JPEG still contains metadata.');
|
|
230
|
+
if (marker === 0xe0) {
|
|
231
|
+
const data = bytes.subarray(dataStart, dataEnd);
|
|
232
|
+
if (sawJfif || length !== 16 || data.subarray(0, 5).toString('ascii') !== 'JFIF\0'
|
|
233
|
+
|| data[5] !== 1 || data[6] > 2 || data[7] > 2
|
|
234
|
+
|| data[12] !== 0 || data[13] !== 0) {
|
|
235
|
+
invalid('attachment JPEG application metadata is not canonical JFIF.');
|
|
236
|
+
}
|
|
237
|
+
sawJfif = true;
|
|
238
|
+
}
|
|
239
|
+
if (JPEG_SOF_MARKERS.has(marker)) {
|
|
240
|
+
const components = bytes[dataStart + 5];
|
|
241
|
+
if (width !== null || length < 11 || bytes[dataStart] !== 8
|
|
242
|
+
|| !Number.isSafeInteger(components) || components < 1 || components > 4
|
|
243
|
+
|| length !== 8 + (3 * components)) {
|
|
244
|
+
invalid('attachment JPEG frame header is invalid.');
|
|
245
|
+
}
|
|
246
|
+
height = bytes.readUInt16BE(dataStart + 1);
|
|
247
|
+
width = bytes.readUInt16BE(dataStart + 3);
|
|
248
|
+
}
|
|
249
|
+
offset = dataEnd;
|
|
250
|
+
if (marker === 0xda) {
|
|
251
|
+
const scanComponents = bytes[dataStart];
|
|
252
|
+
if (width === null || !Number.isSafeInteger(scanComponents)
|
|
253
|
+
|| scanComponents < 1 || scanComponents > 4
|
|
254
|
+
|| length !== 6 + (2 * scanComponents)) {
|
|
255
|
+
invalid('attachment JPEG scan header is invalid.');
|
|
256
|
+
}
|
|
257
|
+
sawScan = true;
|
|
258
|
+
while (offset < bytes.byteLength) {
|
|
259
|
+
if (bytes[offset] !== 0xff) {
|
|
260
|
+
offset += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
let markerOffset = offset + 1;
|
|
264
|
+
while (markerOffset < bytes.byteLength && bytes[markerOffset] === 0xff) markerOffset += 1;
|
|
265
|
+
if (markerOffset >= bytes.byteLength) invalid('attachment JPEG scan is truncated.');
|
|
266
|
+
const scanMarker = bytes[markerOffset];
|
|
267
|
+
if (scanMarker === 0x00 || (scanMarker >= 0xd0 && scanMarker <= 0xd7)) {
|
|
268
|
+
offset = markerOffset + 1;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!sawEnd || width === null || height === null) invalid('attachment JPEG is incomplete.');
|
|
276
|
+
return { width, height };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function validateDimensions(width, height) {
|
|
280
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1
|
|
281
|
+
|| width > VISION_ATTACHMENT_LIMITS.maximumEdge
|
|
282
|
+
|| height > VISION_ATTACHMENT_LIMITS.maximumEdge
|
|
283
|
+
|| width * height > VISION_ATTACHMENT_LIMITS.pixels) {
|
|
284
|
+
invalid('attachment image dimensions exceed the safe vision limit.');
|
|
285
|
+
}
|
|
286
|
+
return { width, height };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function inspectBytes(mediaType, content) {
|
|
290
|
+
if (!Buffer.isBuffer(content) || content.byteLength < 1
|
|
291
|
+
|| content.byteLength > VISION_ATTACHMENT_LIMITS.bytes) {
|
|
292
|
+
invalid('attachment image bytes exceed the safe vision limit.');
|
|
293
|
+
}
|
|
294
|
+
const dimensions = mediaType === 'image/png' ? inspectPng(content) : inspectJpeg(content);
|
|
295
|
+
return validateDimensions(dimensions.width, dimensions.height);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function descriptorFrom({ attachmentId, mediaType, byteLength, width, height, contentSha256 }) {
|
|
299
|
+
return Object.freeze({
|
|
300
|
+
attachmentId,
|
|
301
|
+
mediaType,
|
|
302
|
+
byteLength,
|
|
303
|
+
width,
|
|
304
|
+
height,
|
|
305
|
+
sha256: contentSha256
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function validateVisionAttachmentRequest(value) {
|
|
310
|
+
assertExactKeys(
|
|
311
|
+
value,
|
|
312
|
+
{ required: ['attachmentId', 'mediaType', 'data'] },
|
|
313
|
+
'vision attachment'
|
|
314
|
+
);
|
|
315
|
+
const attachmentId = assertIdentifier(value.attachmentId, 'attachmentId');
|
|
316
|
+
if (!['image/jpeg', 'image/png'].includes(value.mediaType)) {
|
|
317
|
+
invalid('attachment mediaType must be image/jpeg or image/png.');
|
|
318
|
+
}
|
|
319
|
+
if (typeof value.data !== 'string' || value.data.length < 4
|
|
320
|
+
|| value.data.length > VISION_ATTACHMENT_LIMITS.encodedBytes
|
|
321
|
+
|| value.data.length % 4 !== 0 || !boundedBase64(value.data)) {
|
|
322
|
+
invalid('attachment data must be canonical bounded base64.');
|
|
323
|
+
}
|
|
324
|
+
const submitted = Buffer.from(value.data, 'base64');
|
|
325
|
+
if (submitted.toString('base64') !== value.data) invalid('attachment data must be canonical bounded base64.');
|
|
326
|
+
let content;
|
|
327
|
+
try {
|
|
328
|
+
content = Buffer.from(sanitizeVisionImageBytes(submitted, value.mediaType));
|
|
329
|
+
} catch (error) {
|
|
330
|
+
invalid(`attachment image framing or metadata is invalid: ${error?.message ?? 'sanitization failed'}.`);
|
|
331
|
+
}
|
|
332
|
+
const { width, height } = inspectBytes(value.mediaType, content);
|
|
333
|
+
const contentSha256 = createHash('sha256').update(content).digest('hex');
|
|
334
|
+
return Object.freeze({
|
|
335
|
+
attachmentId,
|
|
336
|
+
mediaType: value.mediaType,
|
|
337
|
+
byteLength: content.byteLength,
|
|
338
|
+
width,
|
|
339
|
+
height,
|
|
340
|
+
contentSha256,
|
|
341
|
+
content
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function validateVisionAttachmentsRequest(value) {
|
|
346
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype
|
|
347
|
+
|| value.length < 1 || value.length > VISION_ATTACHMENT_LIMITS.attachmentsPerMessage) {
|
|
348
|
+
invalid(`vision attachments must contain between 1 and ${VISION_ATTACHMENT_LIMITS.attachmentsPerMessage} images.`);
|
|
349
|
+
}
|
|
350
|
+
const attachments = [];
|
|
351
|
+
const identifiers = new Set();
|
|
352
|
+
let totalBytes = 0;
|
|
353
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
354
|
+
if (!Object.hasOwn(value, index)) invalid('vision attachments must be a dense array.');
|
|
355
|
+
const attachment = validateVisionAttachmentRequest(value[index]);
|
|
356
|
+
if (identifiers.has(attachment.attachmentId)) invalid('vision attachment identifiers must be unique.');
|
|
357
|
+
identifiers.add(attachment.attachmentId);
|
|
358
|
+
totalBytes += attachment.byteLength;
|
|
359
|
+
if (totalBytes > VISION_ATTACHMENT_LIMITS.bytesPerMessage) {
|
|
360
|
+
invalid('vision attachments exceed the per-message byte limit.');
|
|
361
|
+
}
|
|
362
|
+
attachments.push(attachment);
|
|
363
|
+
}
|
|
364
|
+
const keys = Reflect.ownKeys(value);
|
|
365
|
+
if (keys.some((key) => key !== 'length'
|
|
366
|
+
&& (typeof key !== 'string' || !/^(0|[1-9]\d*)$/u.test(key)
|
|
367
|
+
|| Number(key) >= value.length))) {
|
|
368
|
+
invalid('vision attachments contain an unsupported property.');
|
|
369
|
+
}
|
|
370
|
+
return Object.freeze(attachments);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function validateStoredVisionAttachment(value) {
|
|
374
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
375
|
+
throw new ValidationError('stored vision attachment must be an object.');
|
|
376
|
+
}
|
|
377
|
+
const attachmentId = assertIdentifier(value.attachmentId, 'attachment.attachmentId');
|
|
378
|
+
if (!['image/jpeg', 'image/png'].includes(value.mediaType)) {
|
|
379
|
+
invalid('stored attachment media type is invalid.');
|
|
380
|
+
}
|
|
381
|
+
const content = value.content instanceof Uint8Array ? Buffer.from(value.content) : null;
|
|
382
|
+
const { width, height } = inspectBytes(value.mediaType, content);
|
|
383
|
+
const contentSha256 = createHash('sha256').update(content).digest('hex');
|
|
384
|
+
if (value.byteLength !== content.byteLength || value.width !== width || value.height !== height
|
|
385
|
+
|| typeof value.contentSha256 !== 'string' || !SHA256_PATTERN.test(value.contentSha256)
|
|
386
|
+
|| value.contentSha256 !== contentSha256) {
|
|
387
|
+
invalid('stored attachment descriptor does not match its private bytes.');
|
|
388
|
+
}
|
|
389
|
+
return Object.freeze({
|
|
390
|
+
...value,
|
|
391
|
+
attachmentId,
|
|
392
|
+
mediaType: value.mediaType,
|
|
393
|
+
byteLength: content.byteLength,
|
|
394
|
+
width,
|
|
395
|
+
height,
|
|
396
|
+
contentSha256,
|
|
397
|
+
content,
|
|
398
|
+
descriptor: descriptorFrom({ attachmentId, mediaType: value.mediaType, byteLength: content.byteLength, width, height, contentSha256 })
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function visionAttachmentDescriptor(value) {
|
|
403
|
+
return descriptorFrom(value);
|
|
404
|
+
}
|