@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.
@@ -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;
@@ -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
+ }