@aicent/rttp 1.2.7 → 1.3.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.
@@ -1,358 +1,358 @@
1
- /**
2
- * @aicent/rttp/conformance - the RTTP conformance vectors, and their replay.
3
- *
4
- * Why this package exists
5
- * -----------------------
6
- * A specification is worth exactly what an independent implementation can
7
- * reproduce from it. Two implementations that share no code, given the same
8
- * vectors, must produce bit-identical output; one implementation agreeing with
9
- * itself proves nothing.
10
- *
11
- * This module is therefore an **independent second implementation** of the same
12
- * vectors as `SPEC/tools/conformance.py` on the Python side. It deliberately
13
- * imports nothing from `src/rttp-uri.mjs` and must not: a conformance suite that
14
- * reuses the code it is checking only proves that the code equals itself.
15
- *
16
- * Authority boundary: URI syntax = RFC-002 sec. 10.2; frame layout = sec. 4.1 as
17
- * extended by `SPEC/RTTP-FRAME-EXT-v1.2.6.md`.
18
- *
19
- * Zero dependencies: `node:crypto` and `node:fs`, nothing else.
20
- */
21
-
22
- import { createHash } from 'node:crypto';
23
- import { readFileSync } from 'node:fs';
24
- import { fileURLToPath } from 'node:url';
25
-
26
- /**
27
- * The vector set shipped inside this package. Loading it with `readFileSync`
28
- * keeps this package importable from plain ESM without JSON import attributes,
29
- * and lets a caller replay a *newer* vector set without reinstalling.
30
- */
31
- export const DEFAULT_VECTORS_PATH = fileURLToPath(new URL('./vectors.json', import.meta.url));
32
-
33
- // ---------------------------------------------------------------------------
34
- // Frame constants (sec. 4.1)
35
- // ---------------------------------------------------------------------------
36
-
37
- const MAGIC = 0x52545450;
38
- const VERSION_ID = 130n;
39
- const SIZE = 128;
40
-
41
- /** Offset of ACTION_LEN and of the ACTION field itself. */
42
- const OFF_ACTION_LEN = 0x68;
43
- const OFF_ACTION = 0x69;
44
-
45
- /** Offsets fixed by RTTP-FRAME-EXT-v1.2.6 sec. 2 (extension block at 0x66..0x7F). */
46
- const OFF_SPEC_REV = 0x66;
47
- const OFF_FLAGS = 0x67;
48
- const OFF_RESERVED = 0x79;
49
- const KNOWN_SPEC_REVS = new Set([0, 1]);
50
- const FLAG_URI_ANCHORED = 0x01;
51
-
52
- /** The ACTION field is 16 bytes; ACTION_LEN is one byte. See RFC-002 sec. 10.2. */
53
- export const ACTION_MAX_LEN = 16;
54
-
55
- const TOKEN = /^[a-z0-9-]+$/;
56
- const HASH_INTENT = /^[0-9a-f]{8}$/;
57
-
58
- // ---------------------------------------------------------------------------
59
- // Independent implementation: URI parsing + canonicalisation
60
- // ---------------------------------------------------------------------------
61
-
62
- /**
63
- * Parse one `rttp` URI - an implementation written from RFC-002 sec. 10.2 alone,
64
- * sharing no code with `src/rttp-uri.mjs`.
65
- *
66
- * @param {string} uri
67
- * @returns {{ authority: string, action: string, shardHex: string }}
68
- */
69
- export function parseUri(uri) {
70
- if (typeof uri !== 'string' || uri === '') throw new Error('empty uri');
71
- if (uri !== uri.trim()) throw new Error('leading/trailing whitespace');
72
-
73
- let rest;
74
- // The browser-registration form is accepted as a **tolerance only**: the
75
- // specification defines no such form, but a browser hands it to the handler.
76
- if (uri.startsWith('web+rttp://')) rest = uri.slice(11);
77
- else if (uri.startsWith('rttp://')) rest = uri.slice(7);
78
- else throw new Error('scheme must be rttp:// or the browser-registration form');
79
-
80
- for (const ch of ['@', '?', '#', '[', ']']) {
81
- if (uri.includes(ch)) throw new Error(`illegal char ${ch}`);
82
- }
83
-
84
- const slash = rest.indexOf('/');
85
- let authority;
86
- let action = '';
87
- if (slash === -1) {
88
- authority = rest;
89
- } else {
90
- authority = rest.slice(0, slash);
91
- action = rest.slice(slash + 1);
92
- if (action.includes('/')) throw new Error('path must be one segment');
93
- if (action === '') throw new Error('trailing slash with empty action');
94
- }
95
-
96
- const parts = authority.split('.');
97
- if (parts.length !== 3) throw new Error('authority must be intent.pillar.root');
98
- const [intent, pillar, root] = parts;
99
-
100
- const isHash = HASH_INTENT.test(intent);
101
- if (!isHash && !TOKEN.test(intent)) throw new Error('bad intent');
102
- if (!TOKEN.test(pillar)) throw new Error('bad pillar');
103
- if (!TOKEN.test(root)) throw new Error('bad root');
104
- if (action !== '' && !TOKEN.test(action)) throw new Error('bad action');
105
-
106
- for (const [value, name] of [[intent, 'intent'], [pillar, 'pillar'], [root, 'root']]) {
107
- if (value.startsWith('-') || value.endsWith('-')) {
108
- throw new Error(`${name} must not begin/end with '-'`);
109
- }
110
- }
111
- if (action !== '' && (action.startsWith('-') || action.endsWith('-'))) {
112
- throw new Error("action must not begin/end with '-'");
113
- }
114
-
115
- const canonicalAuthority = `${intent}.${pillar}.${root}`;
116
- const shard = createHash('sha256')
117
- .update(canonicalAuthority, 'ascii').digest().subarray(0, 16);
118
-
119
- return { authority: canonicalAuthority, action, shardHex: shard.toString('hex') };
120
- }
121
-
122
- // ---------------------------------------------------------------------------
123
- // Independent implementation: the 128-byte frame
124
- // ---------------------------------------------------------------------------
125
-
126
- /**
127
- * Build one `PulseHeader128` from a URI and the vector's frame fields.
128
- *
129
- * Refuses an `action` that cannot fit the field rather than truncating it: a
130
- * silently shortened verb would be a routing decision made by the codec, and the
131
- * codec does not get to make that decision.
132
- *
133
- * @param {{
134
- * sequenceId: number|string|bigint, ttl: number, priority: number,
135
- * shardHex: string, aidHex: string, timestampNs: number|string|bigint,
136
- * action: string, uriAnchored: boolean
137
- * }} fields
138
- * @returns {Buffer} 128 bytes
139
- */
140
- export function buildFrame({ sequenceId, ttl, priority, shardHex, aidHex, timestampNs, action, uriAnchored }) {
141
- const actionBytes = Buffer.from(action, 'ascii');
142
- if (actionBytes.length > ACTION_MAX_LEN) {
143
- throw new Error(`action is ${actionBytes.length} bytes; the field holds ${ACTION_MAX_LEN}`);
144
- }
145
- if (shardHex.length !== 32) throw new Error('ROUTE_SHARD must be 32 hex characters');
146
- if (aidHex.length !== 64) throw new Error('AID_ORIGIN must be 64 hex characters');
147
-
148
- const buf = Buffer.alloc(SIZE);
149
- buf.writeUInt32BE(MAGIC, 0x00);
150
- buf.writeBigUInt64BE(0n, 0x04); // VERSION_ID u128 high half
151
- buf.writeBigUInt64BE(VERSION_ID, 0x0C); // VERSION_ID = 130
152
- const seq = BigInt(sequenceId);
153
- buf.writeBigUInt64BE(seq >> 64n, 0x14); // SEQUENCE_ID u128
154
- buf.writeBigUInt64BE(seq & 0xFFFFFFFFFFFFFFFFn, 0x1C);
155
- const ts = BigInt(timestampNs);
156
- buf.writeBigUInt64BE(ts >> 64n, 0x24); // TIMESTAMP u128
157
- buf.writeBigUInt64BE(ts & 0xFFFFFFFFFFFFFFFFn, 0x2C);
158
- buf.writeUInt8(ttl & 0xFF, 0x34); // TTL_PULSE
159
- buf.writeUInt8(priority & 0xFF, 0x35); // PRIORITY
160
- Buffer.from(shardHex, 'hex').copy(buf, 0x36); // ROUTE_SHARD u128
161
- Buffer.from(aidHex, 'hex').copy(buf, 0x46); // AID_ORIGIN 256-bit
162
- buf[0x66] = 1; // SPEC_REV
163
- buf[0x67] = uriAnchored ? 1 : 0; // FLAGS
164
- buf[OFF_ACTION_LEN] = actionBytes.length; // ACTION_LEN
165
- actionBytes.copy(buf, OFF_ACTION); // ACTION (zero-padded)
166
- return buf; // 0x79..0x80 RESERVED = 0
167
- }
168
-
169
- /**
170
- * Verify a `PulseHeader128`, mirroring the Python `pulse_header.verify`.
171
- *
172
- * Implements the extension-block rules of RTTP-FRAME-EXT-v1.2.6 sec. 2 / sec. 4
173
- * (R2-R7) and the ACTION grammar of RFC-002 sec. 10.2. Every failure throws: a
174
- * check that cannot be performed is a failure, never a pass.
175
- *
176
- * @param {Buffer} raw 128 bytes
177
- * @param {{ expectedAidOrigin?: string }} [options]
178
- * @returns {{ specRev: number, flags: number, uriAnchored: boolean,
179
- * action: string, actionOmitted: boolean }}
180
- */
181
- export function verifyFrame(raw, options = {}) {
182
- if (!Buffer.isBuffer(raw) || raw.length !== SIZE) {
183
- throw new Error(`frame must be ${SIZE} bytes`);
184
- }
185
- if (raw.readUInt32BE(0x00) !== MAGIC) throw new Error('bad RTTP_MAGIC');
186
- if (raw.readBigUInt64BE(0x0C) !== VERSION_ID) throw new Error('unsupported VERSION_ID');
187
-
188
- const specRev = raw[OFF_SPEC_REV];
189
- if (!KNOWN_SPEC_REVS.has(specRev)) {
190
- throw new Error(`unknown SPEC_REV: ${specRev} (fail closed)`);
191
- }
192
-
193
- const flags = raw[OFF_FLAGS];
194
- const actionLen = raw[OFF_ACTION_LEN];
195
-
196
- if (specRev === 0) {
197
- // R6: a pre-v1.2.6 frame must have an all-zero extension block.
198
- for (let i = OFF_SPEC_REV; i < SIZE; i += 1) {
199
- if (raw[i] !== 0) throw new Error('SPEC_REV=0 but extension block is not all-zero');
200
- }
201
- } else {
202
- // R7: unknown FLAGS bits and any RESERVED byte are rejections.
203
- if ((flags & ~FLAG_URI_ANCHORED) !== 0) {
204
- throw new Error(`unknown FLAGS bits set: 0x${(flags & ~FLAG_URI_ANCHORED).toString(16).padStart(2, '0')}`);
205
- }
206
- for (let i = OFF_RESERVED; i < SIZE; i += 1) {
207
- if (raw[i] !== 0) throw new Error('RESERVED bytes must be zero in SPEC_REV=1');
208
- }
209
- // R4: ACTION_LEN is one byte, the field holds 16.
210
- if (actionLen > ACTION_MAX_LEN) {
211
- throw new Error(`ACTION_LEN ${actionLen} > ${ACTION_MAX_LEN}`);
212
- }
213
- for (let i = OFF_ACTION + actionLen; i < OFF_ACTION + ACTION_MAX_LEN; i += 1) {
214
- if (raw[i] !== 0) throw new Error('ACTION padding must be zero');
215
- }
216
- }
217
-
218
- let action = '';
219
- if (specRev === 1 && actionLen > 0) {
220
- action = raw.subarray(OFF_ACTION, OFF_ACTION + actionLen).toString('ascii');
221
- if (!TOKEN.test(action)) {
222
- throw new Error(`ACTION violates RFC-002 sec. 10.2: ${JSON.stringify(action)}`);
223
- }
224
- }
225
-
226
- if (options.expectedAidOrigin !== undefined) {
227
- const got = raw.subarray(0x46, 0x66).toString('hex');
228
- if (got !== options.expectedAidOrigin) {
229
- throw new Error('AID_ORIGIN does not match the expected origin');
230
- }
231
- }
232
-
233
- return {
234
- specRev,
235
- flags,
236
- uriAnchored: (flags & FLAG_URI_ANCHORED) !== 0,
237
- action,
238
- actionOmitted: action === '',
239
- };
240
- }
241
-
242
- // ---------------------------------------------------------------------------
243
- // Vector loading and replay
244
- // ---------------------------------------------------------------------------
245
-
246
- /**
247
- * @param {string} [path] defaults to the vector set shipped with this package
248
- * @returns {object} the parsed vector set
249
- */
250
- export function loadVectors(path = DEFAULT_VECTORS_PATH) {
251
- return JSON.parse(readFileSync(path, 'utf8'));
252
- }
253
-
254
- /**
255
- * Replay a vector set against the independent implementations above.
256
- *
257
- * @param {object} vectors
258
- * @returns {{ checked: number, failures: string[], passed: boolean }}
259
- */
260
- export function runConformance(vectors) {
261
- const failures = [];
262
- let checked = 0;
263
-
264
- for (const c of vectors.positive_uris) {
265
- checked += 1;
266
- try {
267
- const got = parseUri(c.uri);
268
- if (got.authority !== c.authority) failures.push(`${c.uri} :: authority ${got.authority} != ${c.authority}`);
269
- if (got.action !== c.action) failures.push(`${c.uri} :: action ${got.action} != ${c.action}`);
270
- if (got.shardHex !== c.route_shard_hex) failures.push(`${c.uri} :: shard ${got.shardHex} != ${c.route_shard_hex}`);
271
- } catch (err) {
272
- failures.push(`${c.uri} :: expected ACCEPT but threw: ${err.message}`);
273
- }
274
- }
275
-
276
- for (const c of vectors.negative_uris) {
277
- checked += 1;
278
- try {
279
- parseUri(c.uri);
280
- failures.push(`${c.uri} :: expected REJECT but parsed`);
281
- } catch { /* expected */ }
282
- }
283
-
284
- const aidHex = vectors.frame_vectors[0].aid_origin_hex;
285
-
286
- for (const c of vectors.frame_vectors) {
287
- checked += 1;
288
- const { shardHex, action } = parseUri(c.uri);
289
- const frame = buildFrame({
290
- sequenceId: c.sequence_id,
291
- ttl: c.ttl,
292
- priority: c.priority,
293
- shardHex,
294
- aidHex,
295
- timestampNs: c.timestamp_ns,
296
- action,
297
- uriAnchored: true,
298
- });
299
- if (frame.toString('hex') !== c.header_hex) {
300
- failures.push(`${c.uri} :: frame bytes mismatch`);
301
- }
302
- try {
303
- verifyFrame(frame, { expectedAidOrigin: aidHex });
304
- } catch (err) {
305
- failures.push(`${c.uri} :: published frame rejected by verifyFrame: ${err.message}`);
306
- }
307
- }
308
-
309
- for (const c of (vectors.negative_frames || [])) {
310
- checked += 1;
311
- try {
312
- verifyFrame(Buffer.from(c.header_hex, 'hex'), { expectedAidOrigin: aidHex });
313
- failures.push(`frame ${c.rule} :: ${c.label} :: expected REJECT but accepted`);
314
- } catch { /* expected */ }
315
- }
316
-
317
- for (const c of (vectors.positive_frames || [])) {
318
- checked += 1;
319
- try {
320
- const got = verifyFrame(Buffer.from(c.header_hex, 'hex'), { expectedAidOrigin: aidHex });
321
- if (got.action !== c.action) {
322
- failures.push(`frame ${c.rule} :: action ${got.action} != ${c.action}`);
323
- }
324
- } catch (err) {
325
- failures.push(`frame ${c.rule} :: ${c.label} :: expected ACCEPT but rejected: ${err.message}`);
326
- }
327
- }
328
-
329
- {
330
- checked += 1;
331
- const cv = vectors.compat_vector_spec_rev_0;
332
- const { shardHex, action } = parseUri(cv.uri);
333
- const frame = buildFrame({
334
- sequenceId: vectors.frame_vectors[0].sequence_id,
335
- ttl: vectors.frame_vectors[0].ttl,
336
- priority: vectors.frame_vectors[0].priority,
337
- shardHex,
338
- aidHex,
339
- timestampNs: vectors.frame_vectors[0].timestamp_ns,
340
- action,
341
- uriAnchored: true,
342
- });
343
- frame.fill(0, 0x66, SIZE); // SPEC_REV=0: the extension block is all zero
344
- if (frame.toString('hex') !== cv.header_hex) {
345
- failures.push('compat vector :: frame bytes mismatch');
346
- }
347
- try {
348
- const got = verifyFrame(Buffer.from(cv.header_hex, 'hex'), { expectedAidOrigin: aidHex });
349
- if (got.specRev !== 0 || !got.actionOmitted) {
350
- failures.push('compat vector :: SPEC_REV=0 semantics mismatch');
351
- }
352
- } catch (err) {
353
- failures.push(`compat vector :: verifyFrame rejected an old frame: ${err.message}`);
354
- }
355
- }
356
-
357
- return { checked, failures, passed: failures.length === 0 };
358
- }
1
+ /**
2
+ * @aicent/rttp/conformance - the RTTP conformance vectors, and their replay.
3
+ *
4
+ * Why this package exists
5
+ * -----------------------
6
+ * A specification is worth exactly what an independent implementation can
7
+ * reproduce from it. Two implementations that share no code, given the same
8
+ * vectors, must produce bit-identical output; one implementation agreeing with
9
+ * itself proves nothing.
10
+ *
11
+ * This module is therefore an **independent second implementation** of the same
12
+ * vectors as `SPEC/tools/conformance.py` on the Python side. It deliberately
13
+ * imports nothing from `src/rttp-uri.mjs` and must not: a conformance suite that
14
+ * reuses the code it is checking only proves that the code equals itself.
15
+ *
16
+ * Authority boundary: URI syntax = AICENT-002 sec. 10.2; frame layout = sec. 4.1 as
17
+ * extended by `SPEC/RTTP-FRAME-EXT-v1.2.6.md`.
18
+ *
19
+ * Zero dependencies: `node:crypto` and `node:fs`, nothing else.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { readFileSync } from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ /**
27
+ * The vector set shipped inside this package. Loading it with `readFileSync`
28
+ * keeps this package importable from plain ESM without JSON import attributes,
29
+ * and lets a caller replay a *newer* vector set without reinstalling.
30
+ */
31
+ export const DEFAULT_VECTORS_PATH = fileURLToPath(new URL('./vectors.json', import.meta.url));
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Frame constants (sec. 4.1)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const MAGIC = 0x52545450;
38
+ const VERSION_ID = 130n;
39
+ const SIZE = 128;
40
+
41
+ /** Offset of ACTION_LEN and of the ACTION field itself. */
42
+ const OFF_ACTION_LEN = 0x68;
43
+ const OFF_ACTION = 0x69;
44
+
45
+ /** Offsets fixed by RTTP-FRAME-EXT-v1.2.6 sec. 2 (extension block at 0x66..0x7F). */
46
+ const OFF_SPEC_REV = 0x66;
47
+ const OFF_FLAGS = 0x67;
48
+ const OFF_RESERVED = 0x79;
49
+ const KNOWN_SPEC_REVS = new Set([0, 1]);
50
+ const FLAG_URI_ANCHORED = 0x01;
51
+
52
+ /** The ACTION field is 16 bytes; ACTION_LEN is one byte. See AICENT-002 sec. 10.2. */
53
+ export const ACTION_MAX_LEN = 16;
54
+
55
+ const TOKEN = /^[a-z0-9-]+$/;
56
+ const HASH_INTENT = /^[0-9a-f]{8}$/;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Independent implementation: URI parsing + canonicalisation
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Parse one `rttp` URI - an implementation written from AICENT-002 sec. 10.2 alone,
64
+ * sharing no code with `src/rttp-uri.mjs`.
65
+ *
66
+ * @param {string} uri
67
+ * @returns {{ authority: string, action: string, shardHex: string }}
68
+ */
69
+ export function parseUri(uri) {
70
+ if (typeof uri !== 'string' || uri === '') throw new Error('empty uri');
71
+ if (uri !== uri.trim()) throw new Error('leading/trailing whitespace');
72
+
73
+ let rest;
74
+ // The browser-registration form is accepted as a **tolerance only**: the
75
+ // specification defines no such form, but a browser hands it to the handler.
76
+ if (uri.startsWith('web+rttp://')) rest = uri.slice(11);
77
+ else if (uri.startsWith('rttp://')) rest = uri.slice(7);
78
+ else throw new Error('scheme must be rttp:// or the browser-registration form');
79
+
80
+ for (const ch of ['@', '?', '#', '[', ']']) {
81
+ if (uri.includes(ch)) throw new Error(`illegal char ${ch}`);
82
+ }
83
+
84
+ const slash = rest.indexOf('/');
85
+ let authority;
86
+ let action = '';
87
+ if (slash === -1) {
88
+ authority = rest;
89
+ } else {
90
+ authority = rest.slice(0, slash);
91
+ action = rest.slice(slash + 1);
92
+ if (action.includes('/')) throw new Error('path must be one segment');
93
+ if (action === '') throw new Error('trailing slash with empty action');
94
+ }
95
+
96
+ const parts = authority.split('.');
97
+ if (parts.length !== 3) throw new Error('authority must be intent.pillar.root');
98
+ const [intent, pillar, root] = parts;
99
+
100
+ const isHash = HASH_INTENT.test(intent);
101
+ if (!isHash && !TOKEN.test(intent)) throw new Error('bad intent');
102
+ if (!TOKEN.test(pillar)) throw new Error('bad pillar');
103
+ if (!TOKEN.test(root)) throw new Error('bad root');
104
+ if (action !== '' && !TOKEN.test(action)) throw new Error('bad action');
105
+
106
+ for (const [value, name] of [[intent, 'intent'], [pillar, 'pillar'], [root, 'root']]) {
107
+ if (value.startsWith('-') || value.endsWith('-')) {
108
+ throw new Error(`${name} must not begin/end with '-'`);
109
+ }
110
+ }
111
+ if (action !== '' && (action.startsWith('-') || action.endsWith('-'))) {
112
+ throw new Error("action must not begin/end with '-'");
113
+ }
114
+
115
+ const canonicalAuthority = `${intent}.${pillar}.${root}`;
116
+ const shard = createHash('sha256')
117
+ .update(canonicalAuthority, 'ascii').digest().subarray(0, 16);
118
+
119
+ return { authority: canonicalAuthority, action, shardHex: shard.toString('hex') };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Independent implementation: the 128-byte frame
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Build one `PulseHeader128` from a URI and the vector's frame fields.
128
+ *
129
+ * Refuses an `action` that cannot fit the field rather than truncating it: a
130
+ * silently shortened verb would be a routing decision made by the codec, and the
131
+ * codec does not get to make that decision.
132
+ *
133
+ * @param {{
134
+ * sequenceId: number|string|bigint, ttl: number, priority: number,
135
+ * shardHex: string, aidHex: string, timestampNs: number|string|bigint,
136
+ * action: string, uriAnchored: boolean
137
+ * }} fields
138
+ * @returns {Buffer} 128 bytes
139
+ */
140
+ export function buildFrame({ sequenceId, ttl, priority, shardHex, aidHex, timestampNs, action, uriAnchored }) {
141
+ const actionBytes = Buffer.from(action, 'ascii');
142
+ if (actionBytes.length > ACTION_MAX_LEN) {
143
+ throw new Error(`action is ${actionBytes.length} bytes; the field holds ${ACTION_MAX_LEN}`);
144
+ }
145
+ if (shardHex.length !== 32) throw new Error('ROUTE_SHARD must be 32 hex characters');
146
+ if (aidHex.length !== 64) throw new Error('AID_ORIGIN must be 64 hex characters');
147
+
148
+ const buf = Buffer.alloc(SIZE);
149
+ buf.writeUInt32BE(MAGIC, 0x00);
150
+ buf.writeBigUInt64BE(0n, 0x04); // VERSION_ID u128 high half
151
+ buf.writeBigUInt64BE(VERSION_ID, 0x0C); // VERSION_ID = 130
152
+ const seq = BigInt(sequenceId);
153
+ buf.writeBigUInt64BE(seq >> 64n, 0x14); // SEQUENCE_ID u128
154
+ buf.writeBigUInt64BE(seq & 0xFFFFFFFFFFFFFFFFn, 0x1C);
155
+ const ts = BigInt(timestampNs);
156
+ buf.writeBigUInt64BE(ts >> 64n, 0x24); // TIMESTAMP u128
157
+ buf.writeBigUInt64BE(ts & 0xFFFFFFFFFFFFFFFFn, 0x2C);
158
+ buf.writeUInt8(ttl & 0xFF, 0x34); // TTL_PULSE
159
+ buf.writeUInt8(priority & 0xFF, 0x35); // PRIORITY
160
+ Buffer.from(shardHex, 'hex').copy(buf, 0x36); // ROUTE_SHARD u128
161
+ Buffer.from(aidHex, 'hex').copy(buf, 0x46); // AID_ORIGIN 256-bit
162
+ buf[0x66] = 1; // SPEC_REV
163
+ buf[0x67] = uriAnchored ? 1 : 0; // FLAGS
164
+ buf[OFF_ACTION_LEN] = actionBytes.length; // ACTION_LEN
165
+ actionBytes.copy(buf, OFF_ACTION); // ACTION (zero-padded)
166
+ return buf; // 0x79..0x80 RESERVED = 0
167
+ }
168
+
169
+ /**
170
+ * Verify a `PulseHeader128`, mirroring the Python `pulse_header.verify`.
171
+ *
172
+ * Implements the extension-block rules of RTTP-FRAME-EXT-v1.2.6 sec. 2 / sec. 4
173
+ * (R2-R7) and the ACTION grammar of AICENT-002 sec. 10.2. Every failure throws: a
174
+ * check that cannot be performed is a failure, never a pass.
175
+ *
176
+ * @param {Buffer} raw 128 bytes
177
+ * @param {{ expectedAidOrigin?: string }} [options]
178
+ * @returns {{ specRev: number, flags: number, uriAnchored: boolean,
179
+ * action: string, actionOmitted: boolean }}
180
+ */
181
+ export function verifyFrame(raw, options = {}) {
182
+ if (!Buffer.isBuffer(raw) || raw.length !== SIZE) {
183
+ throw new Error(`frame must be ${SIZE} bytes`);
184
+ }
185
+ if (raw.readUInt32BE(0x00) !== MAGIC) throw new Error('bad RTTP_MAGIC');
186
+ if (raw.readBigUInt64BE(0x0C) !== VERSION_ID) throw new Error('unsupported VERSION_ID');
187
+
188
+ const specRev = raw[OFF_SPEC_REV];
189
+ if (!KNOWN_SPEC_REVS.has(specRev)) {
190
+ throw new Error(`unknown SPEC_REV: ${specRev} (fail closed)`);
191
+ }
192
+
193
+ const flags = raw[OFF_FLAGS];
194
+ const actionLen = raw[OFF_ACTION_LEN];
195
+
196
+ if (specRev === 0) {
197
+ // R6: a pre-v1.2.6 frame must have an all-zero extension block.
198
+ for (let i = OFF_SPEC_REV; i < SIZE; i += 1) {
199
+ if (raw[i] !== 0) throw new Error('SPEC_REV=0 but extension block is not all-zero');
200
+ }
201
+ } else {
202
+ // R7: unknown FLAGS bits and any RESERVED byte are rejections.
203
+ if ((flags & ~FLAG_URI_ANCHORED) !== 0) {
204
+ throw new Error(`unknown FLAGS bits set: 0x${(flags & ~FLAG_URI_ANCHORED).toString(16).padStart(2, '0')}`);
205
+ }
206
+ for (let i = OFF_RESERVED; i < SIZE; i += 1) {
207
+ if (raw[i] !== 0) throw new Error('RESERVED bytes must be zero in SPEC_REV=1');
208
+ }
209
+ // R4: ACTION_LEN is one byte, the field holds 16.
210
+ if (actionLen > ACTION_MAX_LEN) {
211
+ throw new Error(`ACTION_LEN ${actionLen} > ${ACTION_MAX_LEN}`);
212
+ }
213
+ for (let i = OFF_ACTION + actionLen; i < OFF_ACTION + ACTION_MAX_LEN; i += 1) {
214
+ if (raw[i] !== 0) throw new Error('ACTION padding must be zero');
215
+ }
216
+ }
217
+
218
+ let action = '';
219
+ if (specRev === 1 && actionLen > 0) {
220
+ action = raw.subarray(OFF_ACTION, OFF_ACTION + actionLen).toString('ascii');
221
+ if (!TOKEN.test(action)) {
222
+ throw new Error(`ACTION violates AICENT-002 sec. 10.2: ${JSON.stringify(action)}`);
223
+ }
224
+ }
225
+
226
+ if (options.expectedAidOrigin !== undefined) {
227
+ const got = raw.subarray(0x46, 0x66).toString('hex');
228
+ if (got !== options.expectedAidOrigin) {
229
+ throw new Error('AID_ORIGIN does not match the expected origin');
230
+ }
231
+ }
232
+
233
+ return {
234
+ specRev,
235
+ flags,
236
+ uriAnchored: (flags & FLAG_URI_ANCHORED) !== 0,
237
+ action,
238
+ actionOmitted: action === '',
239
+ };
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Vector loading and replay
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * @param {string} [path] defaults to the vector set shipped with this package
248
+ * @returns {object} the parsed vector set
249
+ */
250
+ export function loadVectors(path = DEFAULT_VECTORS_PATH) {
251
+ return JSON.parse(readFileSync(path, 'utf8'));
252
+ }
253
+
254
+ /**
255
+ * Replay a vector set against the independent implementations above.
256
+ *
257
+ * @param {object} vectors
258
+ * @returns {{ checked: number, failures: string[], passed: boolean }}
259
+ */
260
+ export function runConformance(vectors) {
261
+ const failures = [];
262
+ let checked = 0;
263
+
264
+ for (const c of vectors.positive_uris) {
265
+ checked += 1;
266
+ try {
267
+ const got = parseUri(c.uri);
268
+ if (got.authority !== c.authority) failures.push(`${c.uri} :: authority ${got.authority} != ${c.authority}`);
269
+ if (got.action !== c.action) failures.push(`${c.uri} :: action ${got.action} != ${c.action}`);
270
+ if (got.shardHex !== c.route_shard_hex) failures.push(`${c.uri} :: shard ${got.shardHex} != ${c.route_shard_hex}`);
271
+ } catch (err) {
272
+ failures.push(`${c.uri} :: expected ACCEPT but threw: ${err.message}`);
273
+ }
274
+ }
275
+
276
+ for (const c of vectors.negative_uris) {
277
+ checked += 1;
278
+ try {
279
+ parseUri(c.uri);
280
+ failures.push(`${c.uri} :: expected REJECT but parsed`);
281
+ } catch { /* expected */ }
282
+ }
283
+
284
+ const aidHex = vectors.frame_vectors[0].aid_origin_hex;
285
+
286
+ for (const c of vectors.frame_vectors) {
287
+ checked += 1;
288
+ const { shardHex, action } = parseUri(c.uri);
289
+ const frame = buildFrame({
290
+ sequenceId: c.sequence_id,
291
+ ttl: c.ttl,
292
+ priority: c.priority,
293
+ shardHex,
294
+ aidHex,
295
+ timestampNs: c.timestamp_ns,
296
+ action,
297
+ uriAnchored: true,
298
+ });
299
+ if (frame.toString('hex') !== c.header_hex) {
300
+ failures.push(`${c.uri} :: frame bytes mismatch`);
301
+ }
302
+ try {
303
+ verifyFrame(frame, { expectedAidOrigin: aidHex });
304
+ } catch (err) {
305
+ failures.push(`${c.uri} :: published frame rejected by verifyFrame: ${err.message}`);
306
+ }
307
+ }
308
+
309
+ for (const c of (vectors.negative_frames || [])) {
310
+ checked += 1;
311
+ try {
312
+ verifyFrame(Buffer.from(c.header_hex, 'hex'), { expectedAidOrigin: aidHex });
313
+ failures.push(`frame ${c.rule} :: ${c.label} :: expected REJECT but accepted`);
314
+ } catch { /* expected */ }
315
+ }
316
+
317
+ for (const c of (vectors.positive_frames || [])) {
318
+ checked += 1;
319
+ try {
320
+ const got = verifyFrame(Buffer.from(c.header_hex, 'hex'), { expectedAidOrigin: aidHex });
321
+ if (got.action !== c.action) {
322
+ failures.push(`frame ${c.rule} :: action ${got.action} != ${c.action}`);
323
+ }
324
+ } catch (err) {
325
+ failures.push(`frame ${c.rule} :: ${c.label} :: expected ACCEPT but rejected: ${err.message}`);
326
+ }
327
+ }
328
+
329
+ {
330
+ checked += 1;
331
+ const cv = vectors.compat_vector_spec_rev_0;
332
+ const { shardHex, action } = parseUri(cv.uri);
333
+ const frame = buildFrame({
334
+ sequenceId: vectors.frame_vectors[0].sequence_id,
335
+ ttl: vectors.frame_vectors[0].ttl,
336
+ priority: vectors.frame_vectors[0].priority,
337
+ shardHex,
338
+ aidHex,
339
+ timestampNs: vectors.frame_vectors[0].timestamp_ns,
340
+ action,
341
+ uriAnchored: true,
342
+ });
343
+ frame.fill(0, 0x66, SIZE); // SPEC_REV=0: the extension block is all zero
344
+ if (frame.toString('hex') !== cv.header_hex) {
345
+ failures.push('compat vector :: frame bytes mismatch');
346
+ }
347
+ try {
348
+ const got = verifyFrame(Buffer.from(cv.header_hex, 'hex'), { expectedAidOrigin: aidHex });
349
+ if (got.specRev !== 0 || !got.actionOmitted) {
350
+ failures.push('compat vector :: SPEC_REV=0 semantics mismatch');
351
+ }
352
+ } catch (err) {
353
+ failures.push(`compat vector :: verifyFrame rejected an old frame: ${err.message}`);
354
+ }
355
+ }
356
+
357
+ return { checked, failures, passed: failures.length === 0 };
358
+ }