@aicent/rttp 0.1.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/LICENSE +201 -0
- package/README.md +238 -0
- package/bin/rttp.mjs +85 -0
- package/package.json +54 -0
- package/src/conformance.d.mts +108 -0
- package/src/conformance.mjs +243 -0
- package/src/rttp-uri.d.mts +70 -0
- package/src/rttp-uri.mjs +322 -0
- package/src/vectors.json +198 -0
|
@@ -0,0 +1,243 @@
|
|
|
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 §10.2; frame layout = §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 (§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
|
+
/** The ACTION field is 16 bytes; ACTION_LEN is one byte. See RFC-002 §10.2. */
|
|
46
|
+
export const ACTION_MAX_LEN = 16;
|
|
47
|
+
|
|
48
|
+
const TOKEN = /^[a-z0-9-]+$/;
|
|
49
|
+
const HASH_INTENT = /^[0-9a-f]{8}$/;
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Independent implementation: URI parsing + canonicalisation
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse one `rttp` URI - an implementation written from RFC-002 §10.2 alone,
|
|
57
|
+
* sharing no code with `src/rttp-uri.mjs`.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} uri
|
|
60
|
+
* @returns {{ authority: string, action: string, shardHex: string }}
|
|
61
|
+
*/
|
|
62
|
+
export function parseUri(uri) {
|
|
63
|
+
if (typeof uri !== 'string' || uri === '') throw new Error('empty uri');
|
|
64
|
+
if (uri !== uri.trim()) throw new Error('leading/trailing whitespace');
|
|
65
|
+
|
|
66
|
+
let rest;
|
|
67
|
+
if (uri.startsWith('web+rttp://')) rest = uri.slice(11);
|
|
68
|
+
else if (uri.startsWith('rttp://')) rest = uri.slice(7);
|
|
69
|
+
else throw new Error('scheme must be rttp:// or web+rttp://');
|
|
70
|
+
|
|
71
|
+
for (const ch of ['@', '?', '#', '[', ']']) {
|
|
72
|
+
if (uri.includes(ch)) throw new Error(`illegal char ${ch}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const slash = rest.indexOf('/');
|
|
76
|
+
let authority;
|
|
77
|
+
let action = '';
|
|
78
|
+
if (slash === -1) {
|
|
79
|
+
authority = rest;
|
|
80
|
+
} else {
|
|
81
|
+
authority = rest.slice(0, slash);
|
|
82
|
+
action = rest.slice(slash + 1);
|
|
83
|
+
if (action.includes('/')) throw new Error('path must be one segment');
|
|
84
|
+
if (action === '') throw new Error('trailing slash with empty action');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parts = authority.split('.');
|
|
88
|
+
if (parts.length !== 3) throw new Error('authority must be intent.pillar.root');
|
|
89
|
+
const [intent, pillar, root] = parts;
|
|
90
|
+
|
|
91
|
+
const isHash = HASH_INTENT.test(intent);
|
|
92
|
+
if (!isHash && !TOKEN.test(intent)) throw new Error('bad intent');
|
|
93
|
+
if (!TOKEN.test(pillar)) throw new Error('bad pillar');
|
|
94
|
+
if (!TOKEN.test(root)) throw new Error('bad root');
|
|
95
|
+
if (action !== '' && !TOKEN.test(action)) throw new Error('bad action');
|
|
96
|
+
|
|
97
|
+
for (const [value, name] of [[intent, 'intent'], [pillar, 'pillar'], [root, 'root']]) {
|
|
98
|
+
if (value.startsWith('-') || value.endsWith('-')) {
|
|
99
|
+
throw new Error(`${name} must not begin/end with '-'`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (action !== '' && (action.startsWith('-') || action.endsWith('-'))) {
|
|
103
|
+
throw new Error("action must not begin/end with '-'");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const canonicalAuthority = `${intent}.${pillar}.${root}`;
|
|
107
|
+
const shard = createHash('sha256')
|
|
108
|
+
.update(canonicalAuthority, 'ascii').digest().subarray(0, 16);
|
|
109
|
+
|
|
110
|
+
return { authority: canonicalAuthority, action, shardHex: shard.toString('hex') };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Independent implementation: the 128-byte frame
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build one `PulseHeader128` from a URI and the vector's frame fields.
|
|
119
|
+
*
|
|
120
|
+
* Refuses an `action` that cannot fit the field rather than truncating it: a
|
|
121
|
+
* silently shortened verb would be a routing decision made by the codec, and the
|
|
122
|
+
* codec does not get to make that decision.
|
|
123
|
+
*
|
|
124
|
+
* @param {{
|
|
125
|
+
* sequenceId: number|string|bigint, ttl: number, priority: number,
|
|
126
|
+
* shardHex: string, aidHex: string, timestampNs: number|string|bigint,
|
|
127
|
+
* action: string, uriAnchored: boolean
|
|
128
|
+
* }} fields
|
|
129
|
+
* @returns {Buffer} 128 bytes
|
|
130
|
+
*/
|
|
131
|
+
export function buildFrame({ sequenceId, ttl, priority, shardHex, aidHex, timestampNs, action, uriAnchored }) {
|
|
132
|
+
const actionBytes = Buffer.from(action, 'ascii');
|
|
133
|
+
if (actionBytes.length > ACTION_MAX_LEN) {
|
|
134
|
+
throw new Error(`action is ${actionBytes.length} bytes; the field holds ${ACTION_MAX_LEN}`);
|
|
135
|
+
}
|
|
136
|
+
if (shardHex.length !== 32) throw new Error('ROUTE_SHARD must be 32 hex characters');
|
|
137
|
+
if (aidHex.length !== 64) throw new Error('AID_ORIGIN must be 64 hex characters');
|
|
138
|
+
|
|
139
|
+
const buf = Buffer.alloc(SIZE);
|
|
140
|
+
buf.writeUInt32BE(MAGIC, 0x00);
|
|
141
|
+
buf.writeBigUInt64BE(0n, 0x04); // VERSION_ID u128 high half
|
|
142
|
+
buf.writeBigUInt64BE(VERSION_ID, 0x0C); // VERSION_ID = 130
|
|
143
|
+
const seq = BigInt(sequenceId);
|
|
144
|
+
buf.writeBigUInt64BE(seq >> 64n, 0x14); // SEQUENCE_ID u128
|
|
145
|
+
buf.writeBigUInt64BE(seq & 0xFFFFFFFFFFFFFFFFn, 0x1C);
|
|
146
|
+
const ts = BigInt(timestampNs);
|
|
147
|
+
buf.writeBigUInt64BE(ts >> 64n, 0x24); // TIMESTAMP u128
|
|
148
|
+
buf.writeBigUInt64BE(ts & 0xFFFFFFFFFFFFFFFFn, 0x2C);
|
|
149
|
+
buf.writeUInt8(ttl & 0xFF, 0x34); // TTL_PULSE
|
|
150
|
+
buf.writeUInt8(priority & 0xFF, 0x35); // PRIORITY
|
|
151
|
+
Buffer.from(shardHex, 'hex').copy(buf, 0x36); // ROUTE_SHARD u128
|
|
152
|
+
Buffer.from(aidHex, 'hex').copy(buf, 0x46); // AID_ORIGIN 256-bit
|
|
153
|
+
buf[0x66] = 1; // SPEC_REV
|
|
154
|
+
buf[0x67] = uriAnchored ? 1 : 0; // FLAGS
|
|
155
|
+
buf[OFF_ACTION_LEN] = actionBytes.length; // ACTION_LEN
|
|
156
|
+
actionBytes.copy(buf, OFF_ACTION); // ACTION (zero-padded)
|
|
157
|
+
return buf; // 0x79..0x80 RESERVED = 0
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Vector loading and replay
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {string} [path] defaults to the vector set shipped with this package
|
|
166
|
+
* @returns {object} the parsed vector set
|
|
167
|
+
*/
|
|
168
|
+
export function loadVectors(path = DEFAULT_VECTORS_PATH) {
|
|
169
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Replay a vector set against the independent implementations above.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} vectors
|
|
176
|
+
* @returns {{ checked: number, failures: string[], passed: boolean }}
|
|
177
|
+
*/
|
|
178
|
+
export function runConformance(vectors) {
|
|
179
|
+
const failures = [];
|
|
180
|
+
let checked = 0;
|
|
181
|
+
|
|
182
|
+
for (const c of vectors.positive_uris) {
|
|
183
|
+
checked += 1;
|
|
184
|
+
try {
|
|
185
|
+
const got = parseUri(c.uri);
|
|
186
|
+
if (got.authority !== c.authority) failures.push(`${c.uri} :: authority ${got.authority} != ${c.authority}`);
|
|
187
|
+
if (got.action !== c.action) failures.push(`${c.uri} :: action ${got.action} != ${c.action}`);
|
|
188
|
+
if (got.shardHex !== c.route_shard_hex) failures.push(`${c.uri} :: shard ${got.shardHex} != ${c.route_shard_hex}`);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
failures.push(`${c.uri} :: expected ACCEPT but threw: ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const c of vectors.negative_uris) {
|
|
195
|
+
checked += 1;
|
|
196
|
+
try {
|
|
197
|
+
parseUri(c.uri);
|
|
198
|
+
failures.push(`${c.uri} :: expected REJECT but parsed`);
|
|
199
|
+
} catch { /* expected */ }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const aidHex = vectors.frame_vectors[0].aid_origin_hex;
|
|
203
|
+
|
|
204
|
+
for (const c of vectors.frame_vectors) {
|
|
205
|
+
checked += 1;
|
|
206
|
+
const { shardHex, action } = parseUri(c.uri);
|
|
207
|
+
const frame = buildFrame({
|
|
208
|
+
sequenceId: c.sequence_id,
|
|
209
|
+
ttl: c.ttl,
|
|
210
|
+
priority: c.priority,
|
|
211
|
+
shardHex,
|
|
212
|
+
aidHex,
|
|
213
|
+
timestampNs: c.timestamp_ns,
|
|
214
|
+
action,
|
|
215
|
+
uriAnchored: true,
|
|
216
|
+
});
|
|
217
|
+
if (frame.toString('hex') !== c.header_hex) {
|
|
218
|
+
failures.push(`${c.uri} :: frame bytes mismatch`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
checked += 1;
|
|
224
|
+
const cv = vectors.compat_vector_spec_rev_0;
|
|
225
|
+
const { shardHex, action } = parseUri(cv.uri);
|
|
226
|
+
const frame = buildFrame({
|
|
227
|
+
sequenceId: vectors.frame_vectors[0].sequence_id,
|
|
228
|
+
ttl: vectors.frame_vectors[0].ttl,
|
|
229
|
+
priority: vectors.frame_vectors[0].priority,
|
|
230
|
+
shardHex,
|
|
231
|
+
aidHex,
|
|
232
|
+
timestampNs: vectors.frame_vectors[0].timestamp_ns,
|
|
233
|
+
action,
|
|
234
|
+
uriAnchored: true,
|
|
235
|
+
});
|
|
236
|
+
frame.fill(0, 0x66, SIZE); // SPEC_REV=0: the extension block is all zero
|
|
237
|
+
if (frame.toString('hex') !== cv.header_hex) {
|
|
238
|
+
failures.push('compat vector :: frame bytes mismatch');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { checked, failures, passed: failures.length === 0 };
|
|
243
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for @aicent/rttp (main entry).
|
|
3
|
+
*
|
|
4
|
+
* Hand-written so the package stays build-free: the published `src/` is exactly
|
|
5
|
+
* what runs, and exactly what a reviewer reads.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The canonical field set of a parsed `rttp` URI. */
|
|
9
|
+
export interface RttpUri {
|
|
10
|
+
/** `rttp` for the canonical form, `web+rttp` when the input used the browser-handler prefix. */
|
|
11
|
+
scheme: 'rttp' | 'web+rttp';
|
|
12
|
+
/** 8 lowercase hex digits (32-bit routing hash) or a readable organ token. */
|
|
13
|
+
intent: string;
|
|
14
|
+
/** True when `intent` is a hash-intent. */
|
|
15
|
+
intent_is_hash: boolean;
|
|
16
|
+
/** `intent` read as a 32-bit routing hash; `null` when it is a name. */
|
|
17
|
+
intent_hash32: number | null;
|
|
18
|
+
pillar: string;
|
|
19
|
+
root: string;
|
|
20
|
+
/** Empty string when omitted (§10.4 default operation). */
|
|
21
|
+
action: string;
|
|
22
|
+
action_omitted: boolean;
|
|
23
|
+
/** `<intent>.<pillar>.<root>`, canonical. */
|
|
24
|
+
authority: string;
|
|
25
|
+
/** Always starts `rttp://`, whatever prefix the input used. */
|
|
26
|
+
canonical_uri: string;
|
|
27
|
+
/** 16 bytes, SHA-256(authority)[0:16]. */
|
|
28
|
+
route_shard: Uint8Array;
|
|
29
|
+
/** The same 16 bytes as 32 lowercase hex characters. */
|
|
30
|
+
route_shard_hex: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The URI does not conform. Always fail closed (§10.5). */
|
|
34
|
+
export declare class RttpUriError extends Error {
|
|
35
|
+
constructor(message: string);
|
|
36
|
+
name: 'RttpUriError';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The canonical scheme name. */
|
|
40
|
+
export declare const SCHEME: 'rttp';
|
|
41
|
+
|
|
42
|
+
/** The browser protocol-handler form - a property of the client, never of the target. */
|
|
43
|
+
export declare const BROWSER_HANDLER_SCHEME: 'web+rttp';
|
|
44
|
+
|
|
45
|
+
/** §4.1: ROUTE_SHARD is a u128. */
|
|
46
|
+
export declare const ROUTE_SHARD_BYTES: 16;
|
|
47
|
+
|
|
48
|
+
/** Length of a hash-intent, in lowercase hex characters. */
|
|
49
|
+
export declare const HASH_INTENT_LEN: 8;
|
|
50
|
+
|
|
51
|
+
/** Capacity of the frame's ACTION field. Not a syntax rule - see the README. */
|
|
52
|
+
export declare const ACTION_MAX_LEN: 16;
|
|
53
|
+
|
|
54
|
+
/** The RTTP specification release this module was written against. */
|
|
55
|
+
export declare const SPEC_RELEASE: string;
|
|
56
|
+
|
|
57
|
+
/** Parse one `rttp` URI and return its canonical fields. Throws `RttpUriError`. */
|
|
58
|
+
export declare function parse(uri: string): RttpUri;
|
|
59
|
+
|
|
60
|
+
/** Identical to `parse`. */
|
|
61
|
+
export declare function parseAndDerive(uri: string): RttpUri;
|
|
62
|
+
|
|
63
|
+
/** §10.2 `action` syntax test. Judges form only - the verb set is open. */
|
|
64
|
+
export declare function isValidAction(value: unknown): boolean;
|
|
65
|
+
|
|
66
|
+
/** `authority` -> ROUTE_SHARD (16 bytes). Throws `RttpUriError`. */
|
|
67
|
+
export declare function deriveRouteShard(canonicalAuthority: string): Uint8Array;
|
|
68
|
+
|
|
69
|
+
/** `authority` -> ROUTE_SHARD as 32 lowercase hex characters. */
|
|
70
|
+
export declare function deriveRouteShardHex(canonicalAuthority: string): string;
|
package/src/rttp-uri.mjs
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aicent/rttp - RFC-002 §10 `rttp` URI validation, canonicalisation and
|
|
3
|
+
* ROUTE_SHARD derivation.
|
|
4
|
+
*
|
|
5
|
+
* This module is the reference implementation of the mapping RFC-002 §10.4
|
|
6
|
+
* promises:
|
|
7
|
+
* "Dereferencing an `rttp` URI emits one pulse ... carrying `action` as the
|
|
8
|
+
* intent verb."
|
|
9
|
+
* The specification previously had syntax (§10.2 ABNF) but no "URI -> frame
|
|
10
|
+
* field" mapping; this module and `SPEC/RTTP-FRAME-EXT-v1.2.6.md` supply that
|
|
11
|
+
* segment. It is an independent implementation of the same conformance vectors
|
|
12
|
+
* as the Python package `rttp` on PyPI - two implementations that share no code
|
|
13
|
+
* and agree byte for byte is what makes the vectors worth anything.
|
|
14
|
+
*
|
|
15
|
+
* Authority (important):
|
|
16
|
+
* * URI **syntax** - the sole authority is the RFC-002 §10.2 ABNF. This module
|
|
17
|
+
* adds no syntax.
|
|
18
|
+
* * This module does exactly two things: (1) validate and **canonicalise**
|
|
19
|
+
* per §10.2/§10.3; (2) derive ROUTE_SHARD.
|
|
20
|
+
*
|
|
21
|
+
* Design constraints (each is a requirement in the specification):
|
|
22
|
+
* * §10.1 `intent` is either 8 lowercase hex digits (a 32-bit routing hash)
|
|
23
|
+
* or a readable organ token.
|
|
24
|
+
* * §10.2 `action` is an **open set**: 1*( %x61-7A / DIGIT / "-" ).
|
|
25
|
+
* * §10.3 The canonical form is **lowercase US-ASCII**; there is no userinfo,
|
|
26
|
+
* port, query or fragment.
|
|
27
|
+
* * §10.5 **No DNS**: ROUTE_SHARD is pure computation - no registry, no
|
|
28
|
+
* resolver, no network.
|
|
29
|
+
*
|
|
30
|
+
* Case is **not** normalised. A case variant is not a spelling difference, it is
|
|
31
|
+
* a different string, and it is rejected (§10.3). Nothing here is ever accepted
|
|
32
|
+
* because a check could not be performed: this module fails closed.
|
|
33
|
+
*
|
|
34
|
+
* ROUTE_SHARD derivation (see spec §5): the first 16 bytes of
|
|
35
|
+
* SHA-256(ASCII(canonical_authority)).
|
|
36
|
+
*
|
|
37
|
+
* Zero dependencies - `node:crypto` and nothing else.
|
|
38
|
+
*
|
|
39
|
+
* @see https://rttp.com/RFC-002/
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { createHash } from 'node:crypto';
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Constants
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** The canonical scheme name. */
|
|
49
|
+
export const SCHEME = 'rttp';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The browser protocol-handler form. A user agent accepts
|
|
53
|
+
* `navigator.registerProtocolHandler()` only for schemes on its safelisted set,
|
|
54
|
+
* or for a scheme beginning `web+`, so a web page must use this prefix to
|
|
55
|
+
* register an `rttp` handler (RFC-002 §10.6). It is a property of the **client**,
|
|
56
|
+
* never of the target: both forms are the same address, and the canonical output
|
|
57
|
+
* of this module is always `rttp://`.
|
|
58
|
+
*/
|
|
59
|
+
export const BROWSER_HANDLER_SCHEME = 'web+rttp';
|
|
60
|
+
|
|
61
|
+
/** §4.1: ROUTE_SHARD is a u128. */
|
|
62
|
+
export const ROUTE_SHARD_BYTES = 16;
|
|
63
|
+
|
|
64
|
+
/** §10.2: `lowhex = %x30-39 / %x61-66`; 8 of them is a 32-bit routing hash. */
|
|
65
|
+
export const HASH_INTENT_LEN = 8;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The capacity of the frame's ACTION field (`0x69`..`0x78`, one byte of
|
|
69
|
+
* ACTION_LEN at `0x68`). **Not** a syntax rule: §10.2 places no length limit on
|
|
70
|
+
* `action`, so `parse()` accepts longer values. A frame builder must reject
|
|
71
|
+
* them, because the field cannot hold more.
|
|
72
|
+
*/
|
|
73
|
+
export const ACTION_MAX_LEN = 16;
|
|
74
|
+
|
|
75
|
+
/** The RTTP specification release this module was written against. */
|
|
76
|
+
export const SPEC_RELEASE = 'v1.2.6';
|
|
77
|
+
|
|
78
|
+
// §10.2: name-intent / pillar / root / action = 1*( %x61-7A / DIGIT / "-" )
|
|
79
|
+
const TOKEN = /^[a-z0-9-]+$/;
|
|
80
|
+
const LOWHEX = /^[0-9a-f]+$/;
|
|
81
|
+
|
|
82
|
+
// §10.3: this scheme defines no userinfo / port / query / fragment
|
|
83
|
+
const FORBIDDEN_CHARS = ['@', '?', '#', '[', ']'];
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Error
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The URI does not conform. **Always fail closed** (§10.5: no fallback, and no
|
|
91
|
+
* `rttps`).
|
|
92
|
+
*/
|
|
93
|
+
export class RttpUriError extends Error {
|
|
94
|
+
constructor(message) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = 'RttpUriError';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Validation helpers
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* §10.2 token check, with the case rule of §10.3 kept separate so that an
|
|
106
|
+
* uppercase letter produces a message that says what is actually wrong.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} value
|
|
109
|
+
* @param {string} what field name, for the error message
|
|
110
|
+
*/
|
|
111
|
+
function checkToken(value, what) {
|
|
112
|
+
if (typeof value !== 'string' || value === '') {
|
|
113
|
+
throw new RttpUriError(`${what} is empty`);
|
|
114
|
+
}
|
|
115
|
+
for (const ch of value) {
|
|
116
|
+
if (TOKEN.test(ch)) continue;
|
|
117
|
+
if (ch !== ch.toLowerCase()) {
|
|
118
|
+
// §10.3: the canonical form is lowercase. Uppercase is not a normalisable
|
|
119
|
+
// spelling difference - it is illegal input.
|
|
120
|
+
throw new RttpUriError(
|
|
121
|
+
`${what} contains uppercase '${ch}' - lowercase US-ASCII only (RFC-002 §10.3)`);
|
|
122
|
+
}
|
|
123
|
+
throw new RttpUriError(
|
|
124
|
+
`${what} contains illegal character ${JSON.stringify(ch)} (allowed: a-z, 0-9, '-')`);
|
|
125
|
+
}
|
|
126
|
+
if (value.startsWith('-') || value.endsWith('-')) {
|
|
127
|
+
throw new RttpUriError(`${what} must not begin or end with '-'`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {string} value
|
|
133
|
+
* @returns {boolean} true for a hash-intent (8 lowhex), false for a name-intent
|
|
134
|
+
*/
|
|
135
|
+
function checkIntent(value) {
|
|
136
|
+
if (value.length === HASH_INTENT_LEN && LOWHEX.test(value)) return true;
|
|
137
|
+
checkToken(value, 'intent');
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* §10.2 `action` syntax test: 1*( %x61-7A / DIGIT / "-" ).
|
|
143
|
+
*
|
|
144
|
+
* `action` is an **open set** - this only judges the *form*, never whether the
|
|
145
|
+
* verb is one this specification knows. Frame builders reuse it so the rule is
|
|
146
|
+
* not written twice.
|
|
147
|
+
*
|
|
148
|
+
* @param {unknown} value
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
export function isValidAction(value) {
|
|
152
|
+
if (typeof value !== 'string' || value === '') return false;
|
|
153
|
+
if (value.startsWith('-') || value.endsWith('-')) return false;
|
|
154
|
+
return TOKEN.test(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// ROUTE_SHARD derivation
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* authority -> ROUTE_SHARD (16 bytes).
|
|
163
|
+
*
|
|
164
|
+
* ROUTE_SHARD = SHA-256( ASCII(canonical_authority) )[0:16]
|
|
165
|
+
*
|
|
166
|
+
* Properties:
|
|
167
|
+
* * **Deterministic** - one authority always yields one shard.
|
|
168
|
+
* * **Pure computation** - no DNS, no registry, no network (§10.5).
|
|
169
|
+
* * **One-way** - the authority cannot be recovered from the shard.
|
|
170
|
+
* * **Independent of `action`** - it routes *where*, never *what*, which is
|
|
171
|
+
* exactly why `action` must be a separate frame field (see spec §5.3).
|
|
172
|
+
*
|
|
173
|
+
* @param {string} canonicalAuthority
|
|
174
|
+
* @returns {Uint8Array} 16 bytes
|
|
175
|
+
*/
|
|
176
|
+
export function deriveRouteShard(canonicalAuthority) {
|
|
177
|
+
if (typeof canonicalAuthority !== 'string' || canonicalAuthority === '') {
|
|
178
|
+
throw new RttpUriError('canonical_authority is empty');
|
|
179
|
+
}
|
|
180
|
+
if (canonicalAuthority !== canonicalAuthority.toLowerCase()) {
|
|
181
|
+
throw new RttpUriError('canonical_authority must be lowercase (RFC-002 §10.3)');
|
|
182
|
+
}
|
|
183
|
+
const digest = createHash('sha256')
|
|
184
|
+
.update(Buffer.from(canonicalAuthority, 'ascii'))
|
|
185
|
+
.digest();
|
|
186
|
+
return new Uint8Array(digest.subarray(0, ROUTE_SHARD_BYTES));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {string} canonicalAuthority
|
|
191
|
+
* @returns {string} 32 lowercase hex characters
|
|
192
|
+
*/
|
|
193
|
+
export function deriveRouteShardHex(canonicalAuthority) {
|
|
194
|
+
return Buffer.from(deriveRouteShard(canonicalAuthority)).toString('hex');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Parse / canonicalise
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse one `rttp` URI and return its canonical fields.
|
|
203
|
+
*
|
|
204
|
+
* The validation order deliberately stays "reject before interpreting": any
|
|
205
|
+
* suspicious form throws immediately, and nothing is ever guessed at.
|
|
206
|
+
* (§10.5: user agents that do not implement this scheme fail closed.)
|
|
207
|
+
*
|
|
208
|
+
* Field names are `snake_case` on purpose: the published conformance vectors use
|
|
209
|
+
* those names, so the output of the Python and JavaScript implementations
|
|
210
|
+
* compares directly.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} uri
|
|
213
|
+
* @returns {{
|
|
214
|
+
* scheme: string,
|
|
215
|
+
* intent: string,
|
|
216
|
+
* intent_is_hash: boolean,
|
|
217
|
+
* intent_hash32: number | null,
|
|
218
|
+
* pillar: string,
|
|
219
|
+
* root: string,
|
|
220
|
+
* action: string,
|
|
221
|
+
* action_omitted: boolean,
|
|
222
|
+
* authority: string,
|
|
223
|
+
* canonical_uri: string,
|
|
224
|
+
* route_shard: Uint8Array,
|
|
225
|
+
* route_shard_hex: string
|
|
226
|
+
* }}
|
|
227
|
+
*/
|
|
228
|
+
export function parse(uri) {
|
|
229
|
+
if (typeof uri !== 'string') {
|
|
230
|
+
throw new RttpUriError('uri must be a string');
|
|
231
|
+
}
|
|
232
|
+
if (uri === '') {
|
|
233
|
+
throw new RttpUriError('uri is empty');
|
|
234
|
+
}
|
|
235
|
+
if (uri !== uri.trim()) {
|
|
236
|
+
// Leading/trailing whitespace may be paste contamination, or an attempt to
|
|
237
|
+
// slip past a prefix check. Either way: reject.
|
|
238
|
+
throw new RttpUriError('uri must not contain leading/trailing whitespace');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- scheme (§10.6 prefix whitelist) ---
|
|
242
|
+
let scheme;
|
|
243
|
+
let rest;
|
|
244
|
+
if (uri.startsWith(`${BROWSER_HANDLER_SCHEME}://`)) {
|
|
245
|
+
scheme = BROWSER_HANDLER_SCHEME;
|
|
246
|
+
rest = uri.slice(BROWSER_HANDLER_SCHEME.length + 3);
|
|
247
|
+
} else if (uri.startsWith(`${SCHEME}://`)) {
|
|
248
|
+
scheme = SCHEME;
|
|
249
|
+
rest = uri.slice(SCHEME.length + 3);
|
|
250
|
+
} else {
|
|
251
|
+
throw new RttpUriError(
|
|
252
|
+
`not an rttp URI: must begin with '${SCHEME}://' or '${BROWSER_HANDLER_SCHEME}://'`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --- §10.3 excluded characters ---
|
|
256
|
+
for (const ch of FORBIDDEN_CHARS) {
|
|
257
|
+
if (uri.includes(ch)) {
|
|
258
|
+
throw new RttpUriError(
|
|
259
|
+
`illegal character ${JSON.stringify(ch)}: this scheme defines no `
|
|
260
|
+
+ 'userinfo / query / fragment');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- authority and path ---
|
|
265
|
+
const slash = rest.indexOf('/');
|
|
266
|
+
const authority = slash === -1 ? rest : rest.slice(0, slash);
|
|
267
|
+
const action = slash === -1 ? '' : rest.slice(slash + 1);
|
|
268
|
+
|
|
269
|
+
if (slash !== -1 && action.includes('/')) {
|
|
270
|
+
throw new RttpUriError("path must be a single segment '/<action>'");
|
|
271
|
+
}
|
|
272
|
+
if (slash !== -1 && action === '') {
|
|
273
|
+
// §10.2: path = "/" action, action = 1*(...). A trailing slash with an empty
|
|
274
|
+
// action is a different form from "no path", and the two must not be
|
|
275
|
+
// conflated.
|
|
276
|
+
throw new RttpUriError("trailing '/' with empty action: path must be '/<action>'");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const parts = authority.split('.');
|
|
280
|
+
if (parts.length !== 3) {
|
|
281
|
+
throw new RttpUriError(
|
|
282
|
+
"authority must be exactly '<intent>.<pillar>.<root>' "
|
|
283
|
+
+ `(got ${parts.length} segment(s))`);
|
|
284
|
+
}
|
|
285
|
+
const [intent, pillar, root] = parts;
|
|
286
|
+
|
|
287
|
+
const intentIsHash = checkIntent(intent);
|
|
288
|
+
checkToken(pillar, 'pillar');
|
|
289
|
+
checkToken(root, 'root');
|
|
290
|
+
if (action) checkToken(action, 'action');
|
|
291
|
+
|
|
292
|
+
const canonicalAuthority = `${intent}.${pillar}.${root}`;
|
|
293
|
+
const canonicalUri = `${SCHEME}://${canonicalAuthority}${action ? `/${action}` : ''}`;
|
|
294
|
+
const shard = deriveRouteShard(canonicalAuthority);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
scheme,
|
|
298
|
+
intent,
|
|
299
|
+
intent_is_hash: intentIsHash,
|
|
300
|
+
intent_hash32: intentIsHash ? parseInt(intent, 16) : null,
|
|
301
|
+
pillar,
|
|
302
|
+
root,
|
|
303
|
+
action,
|
|
304
|
+
action_omitted: action === '',
|
|
305
|
+
authority: canonicalAuthority,
|
|
306
|
+
canonical_uri: canonicalUri,
|
|
307
|
+
route_shard: shard,
|
|
308
|
+
route_shard_hex: Buffer.from(shard).toString('hex'),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Convenience entry point: parse and derive in one step. Identical to `parse`;
|
|
314
|
+
* it exists so that callers reading the specification's wording have a
|
|
315
|
+
* function to name.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} uri
|
|
318
|
+
* @returns {ReturnType<typeof parse>}
|
|
319
|
+
*/
|
|
320
|
+
export function parseAndDerive(uri) {
|
|
321
|
+
return parse(uri);
|
|
322
|
+
}
|