@aicent/rttp 1.2.8 → 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.
package/src/rttp-uri.mjs CHANGED
@@ -1,325 +1,325 @@
1
- /**
2
- * @aicent/rttp - RFC-002 sec. 10 `rttp` URI validation, canonicalisation and
3
- * ROUTE_SHARD derivation.
4
- *
5
- * This module is the reference implementation of the mapping RFC-002 sec. 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 (sec. 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 sec. 10.2 ABNF. This module
17
- * adds no syntax.
18
- * * This module does exactly two things: (1) validate and **canonicalise**
19
- * per sec. 10.2/sec. 10.3; (2) derive ROUTE_SHARD.
20
- *
21
- * Design constraints (each is a requirement in the specification):
22
- * * sec. 10.1 `intent` is either 8 lowercase hex digits (a 32-bit routing hash)
23
- * or a readable organ token.
24
- * * sec. 10.2 `action` is an **open set**: 1*( %x61-7A / DIGIT / "-" ).
25
- * * sec. 10.3 The canonical form is **lowercase US-ASCII**; there is no userinfo,
26
- * port, query or fragment.
27
- * * sec. 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 (sec. 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 sec. 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 prefix a user agent requires on a scheme it does not already know, before
53
- * it will let a page register a protocol handler. It is a property of the
54
- * **client**, never of the target: both spellings name the same address, and the
55
- * canonical output of this module is always `rttp://`.
56
- *
57
- * Accepted for **tolerance only** -- the specification defines no such form. A
58
- * conforming third-party implementation is not required to reproduce this
59
- * tolerance; refusing the string a browser is about to deliver would, however,
60
- * break the handler path.
61
- */
62
- export const BROWSER_HANDLER_SCHEME = 'web+rttp';
63
-
64
- /** sec. 4.1: ROUTE_SHARD is a u128. */
65
- export const ROUTE_SHARD_BYTES = 16;
66
-
67
- /** sec. 10.2: `lowhex = %x30-39 / %x61-66`; 8 of them is a 32-bit routing hash. */
68
- export const HASH_INTENT_LEN = 8;
69
-
70
- /**
71
- * The capacity of the frame's ACTION field (`0x69`..`0x78`, one byte of
72
- * ACTION_LEN at `0x68`). **Not** a syntax rule: sec. 10.2 places no length limit on
73
- * `action`, so `parse()` accepts longer values. A frame builder must reject
74
- * them, because the field cannot hold more.
75
- */
76
- export const ACTION_MAX_LEN = 16;
77
-
78
- /** The RTTP specification release this module was written against. */
79
- export const SPEC_RELEASE = 'v1.2.6';
80
-
81
- // sec. 10.2: name-intent / pillar / root / action = 1*( %x61-7A / DIGIT / "-" )
82
- const TOKEN = /^[a-z0-9-]+$/;
83
- const LOWHEX = /^[0-9a-f]+$/;
84
-
85
- // sec. 10.3: this scheme defines no userinfo / port / query / fragment
86
- const FORBIDDEN_CHARS = ['@', '?', '#', '[', ']'];
87
-
88
- // ---------------------------------------------------------------------------
89
- // Error
90
- // ---------------------------------------------------------------------------
91
-
92
- /**
93
- * The URI does not conform. **Always fail closed** (sec. 10.5: no fallback, and no
94
- * `rttps`).
95
- */
96
- export class RttpUriError extends Error {
97
- constructor(message) {
98
- super(message);
99
- this.name = 'RttpUriError';
100
- }
101
- }
102
-
103
- // ---------------------------------------------------------------------------
104
- // Validation helpers
105
- // ---------------------------------------------------------------------------
106
-
107
- /**
108
- * sec. 10.2 token check, with the case rule of sec. 10.3 kept separate so that an
109
- * uppercase letter produces a message that says what is actually wrong.
110
- *
111
- * @param {string} value
112
- * @param {string} what field name, for the error message
113
- */
114
- function checkToken(value, what) {
115
- if (typeof value !== 'string' || value === '') {
116
- throw new RttpUriError(`${what} is empty`);
117
- }
118
- for (const ch of value) {
119
- if (TOKEN.test(ch)) continue;
120
- if (ch !== ch.toLowerCase()) {
121
- // sec. 10.3: the canonical form is lowercase. Uppercase is not a normalisable
122
- // spelling difference - it is illegal input.
123
- throw new RttpUriError(
124
- `${what} contains uppercase '${ch}' - lowercase US-ASCII only (RFC-002 sec. 10.3)`);
125
- }
126
- throw new RttpUriError(
127
- `${what} contains illegal character ${JSON.stringify(ch)} (allowed: a-z, 0-9, '-')`);
128
- }
129
- if (value.startsWith('-') || value.endsWith('-')) {
130
- throw new RttpUriError(`${what} must not begin or end with '-'`);
131
- }
132
- }
133
-
134
- /**
135
- * @param {string} value
136
- * @returns {boolean} true for a hash-intent (8 lowhex), false for a name-intent
137
- */
138
- function checkIntent(value) {
139
- if (value.length === HASH_INTENT_LEN && LOWHEX.test(value)) return true;
140
- checkToken(value, 'intent');
141
- return false;
142
- }
143
-
144
- /**
145
- * sec. 10.2 `action` syntax test: 1*( %x61-7A / DIGIT / "-" ).
146
- *
147
- * `action` is an **open set** - this only judges the *form*, never whether the
148
- * verb is one this specification knows. Frame builders reuse it so the rule is
149
- * not written twice.
150
- *
151
- * @param {unknown} value
152
- * @returns {boolean}
153
- */
154
- export function isValidAction(value) {
155
- if (typeof value !== 'string' || value === '') return false;
156
- if (value.startsWith('-') || value.endsWith('-')) return false;
157
- return TOKEN.test(value);
158
- }
159
-
160
- // ---------------------------------------------------------------------------
161
- // ROUTE_SHARD derivation
162
- // ---------------------------------------------------------------------------
163
-
164
- /**
165
- * authority -> ROUTE_SHARD (16 bytes).
166
- *
167
- * ROUTE_SHARD = SHA-256( ASCII(canonical_authority) )[0:16]
168
- *
169
- * Properties:
170
- * * **Deterministic** - one authority always yields one shard.
171
- * * **Pure computation** - no DNS, no registry, no network (sec. 10.5).
172
- * * **One-way** - the authority cannot be recovered from the shard.
173
- * * **Independent of `action`** - it routes *where*, never *what*, which is
174
- * exactly why `action` must be a separate frame field (see spec sec. 5.3).
175
- *
176
- * @param {string} canonicalAuthority
177
- * @returns {Uint8Array} 16 bytes
178
- */
179
- export function deriveRouteShard(canonicalAuthority) {
180
- if (typeof canonicalAuthority !== 'string' || canonicalAuthority === '') {
181
- throw new RttpUriError('canonical_authority is empty');
182
- }
183
- if (canonicalAuthority !== canonicalAuthority.toLowerCase()) {
184
- throw new RttpUriError('canonical_authority must be lowercase (RFC-002 sec. 10.3)');
185
- }
186
- const digest = createHash('sha256')
187
- .update(Buffer.from(canonicalAuthority, 'ascii'))
188
- .digest();
189
- return new Uint8Array(digest.subarray(0, ROUTE_SHARD_BYTES));
190
- }
191
-
192
- /**
193
- * @param {string} canonicalAuthority
194
- * @returns {string} 32 lowercase hex characters
195
- */
196
- export function deriveRouteShardHex(canonicalAuthority) {
197
- return Buffer.from(deriveRouteShard(canonicalAuthority)).toString('hex');
198
- }
199
-
200
- // ---------------------------------------------------------------------------
201
- // Parse / canonicalise
202
- // ---------------------------------------------------------------------------
203
-
204
- /**
205
- * Parse one `rttp` URI and return its canonical fields.
206
- *
207
- * The validation order deliberately stays "reject before interpreting": any
208
- * suspicious form throws immediately, and nothing is ever guessed at.
209
- * (sec. 10.5: user agents that do not implement this scheme fail closed.)
210
- *
211
- * Field names are `snake_case` on purpose: the published conformance vectors use
212
- * those names, so the output of the Python and JavaScript implementations
213
- * compares directly.
214
- *
215
- * @param {string} uri
216
- * @returns {{
217
- * scheme: string,
218
- * intent: string,
219
- * intent_is_hash: boolean,
220
- * intent_hash32: number | null,
221
- * pillar: string,
222
- * root: string,
223
- * action: string,
224
- * action_omitted: boolean,
225
- * authority: string,
226
- * canonical_uri: string,
227
- * route_shard: Uint8Array,
228
- * route_shard_hex: string
229
- * }}
230
- */
231
- export function parse(uri) {
232
- if (typeof uri !== 'string') {
233
- throw new RttpUriError('uri must be a string');
234
- }
235
- if (uri === '') {
236
- throw new RttpUriError('uri is empty');
237
- }
238
- if (uri !== uri.trim()) {
239
- // Leading/trailing whitespace may be paste contamination, or an attempt to
240
- // slip past a prefix check. Either way: reject.
241
- throw new RttpUriError('uri must not contain leading/trailing whitespace');
242
- }
243
-
244
- // --- scheme (sec. 10.6 prefix whitelist) ---
245
- let scheme;
246
- let rest;
247
- if (uri.startsWith(`${BROWSER_HANDLER_SCHEME}://`)) {
248
- scheme = BROWSER_HANDLER_SCHEME;
249
- rest = uri.slice(BROWSER_HANDLER_SCHEME.length + 3);
250
- } else if (uri.startsWith(`${SCHEME}://`)) {
251
- scheme = SCHEME;
252
- rest = uri.slice(SCHEME.length + 3);
253
- } else {
254
- throw new RttpUriError(
255
- `not an rttp URI: must begin with '${SCHEME}://' or '${BROWSER_HANDLER_SCHEME}://'`);
256
- }
257
-
258
- // --- sec. 10.3 excluded characters ---
259
- for (const ch of FORBIDDEN_CHARS) {
260
- if (uri.includes(ch)) {
261
- throw new RttpUriError(
262
- `illegal character ${JSON.stringify(ch)}: this scheme defines no `
263
- + 'userinfo / query / fragment');
264
- }
265
- }
266
-
267
- // --- authority and path ---
268
- const slash = rest.indexOf('/');
269
- const authority = slash === -1 ? rest : rest.slice(0, slash);
270
- const action = slash === -1 ? '' : rest.slice(slash + 1);
271
-
272
- if (slash !== -1 && action.includes('/')) {
273
- throw new RttpUriError("path must be a single segment '/<action>'");
274
- }
275
- if (slash !== -1 && action === '') {
276
- // sec. 10.2: path = "/" action, action = 1*(...). A trailing slash with an empty
277
- // action is a different form from "no path", and the two must not be
278
- // conflated.
279
- throw new RttpUriError("trailing '/' with empty action: path must be '/<action>'");
280
- }
281
-
282
- const parts = authority.split('.');
283
- if (parts.length !== 3) {
284
- throw new RttpUriError(
285
- "authority must be exactly '<intent>.<pillar>.<root>' "
286
- + `(got ${parts.length} segment(s))`);
287
- }
288
- const [intent, pillar, root] = parts;
289
-
290
- const intentIsHash = checkIntent(intent);
291
- checkToken(pillar, 'pillar');
292
- checkToken(root, 'root');
293
- if (action) checkToken(action, 'action');
294
-
295
- const canonicalAuthority = `${intent}.${pillar}.${root}`;
296
- const canonicalUri = `${SCHEME}://${canonicalAuthority}${action ? `/${action}` : ''}`;
297
- const shard = deriveRouteShard(canonicalAuthority);
298
-
299
- return {
300
- scheme,
301
- intent,
302
- intent_is_hash: intentIsHash,
303
- intent_hash32: intentIsHash ? parseInt(intent, 16) : null,
304
- pillar,
305
- root,
306
- action,
307
- action_omitted: action === '',
308
- authority: canonicalAuthority,
309
- canonical_uri: canonicalUri,
310
- route_shard: shard,
311
- route_shard_hex: Buffer.from(shard).toString('hex'),
312
- };
313
- }
314
-
315
- /**
316
- * Convenience entry point: parse and derive in one step. Identical to `parse`;
317
- * it exists so that callers reading the specification's wording have a
318
- * function to name.
319
- *
320
- * @param {string} uri
321
- * @returns {ReturnType<typeof parse>}
322
- */
323
- export function parseAndDerive(uri) {
324
- return parse(uri);
325
- }
1
+ /**
2
+ * @aicent/rttp - AICENT-002 sec. 10 `rttp` URI validation, canonicalisation and
3
+ * ROUTE_SHARD derivation.
4
+ *
5
+ * This module is the reference implementation of the mapping AICENT-002 sec. 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 (sec. 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 AICENT-002 sec. 10.2 ABNF. This module
17
+ * adds no syntax.
18
+ * * This module does exactly two things: (1) validate and **canonicalise**
19
+ * per sec. 10.2/sec. 10.3; (2) derive ROUTE_SHARD.
20
+ *
21
+ * Design constraints (each is a requirement in the specification):
22
+ * * sec. 10.1 `intent` is either 8 lowercase hex digits (a 32-bit routing hash)
23
+ * or a readable organ token.
24
+ * * sec. 10.2 `action` is an **open set**: 1*( %x61-7A / DIGIT / "-" ).
25
+ * * sec. 10.3 The canonical form is **lowercase US-ASCII**; there is no userinfo,
26
+ * port, query or fragment.
27
+ * * sec. 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 (sec. 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 sec. 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/AICENT-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 prefix a user agent requires on a scheme it does not already know, before
53
+ * it will let a page register a protocol handler. It is a property of the
54
+ * **client**, never of the target: both spellings name the same address, and the
55
+ * canonical output of this module is always `rttp://`.
56
+ *
57
+ * Accepted for **tolerance only** -- the specification defines no such form. A
58
+ * conforming third-party implementation is not required to reproduce this
59
+ * tolerance; refusing the string a browser is about to deliver would, however,
60
+ * break the handler path.
61
+ */
62
+ export const BROWSER_HANDLER_SCHEME = 'web+rttp';
63
+
64
+ /** sec. 4.1: ROUTE_SHARD is a u128. */
65
+ export const ROUTE_SHARD_BYTES = 16;
66
+
67
+ /** sec. 10.2: `lowhex = %x30-39 / %x61-66`; 8 of them is a 32-bit routing hash. */
68
+ export const HASH_INTENT_LEN = 8;
69
+
70
+ /**
71
+ * The capacity of the frame's ACTION field (`0x69`..`0x78`, one byte of
72
+ * ACTION_LEN at `0x68`). **Not** a syntax rule: sec. 10.2 places no length limit on
73
+ * `action`, so `parse()` accepts longer values. A frame builder must reject
74
+ * them, because the field cannot hold more.
75
+ */
76
+ export const ACTION_MAX_LEN = 16;
77
+
78
+ /** The RTTP specification release this module was written against. */
79
+ export const SPEC_RELEASE = 'v1.2.6';
80
+
81
+ // sec. 10.2: name-intent / pillar / root / action = 1*( %x61-7A / DIGIT / "-" )
82
+ const TOKEN = /^[a-z0-9-]+$/;
83
+ const LOWHEX = /^[0-9a-f]+$/;
84
+
85
+ // sec. 10.3: this scheme defines no userinfo / port / query / fragment
86
+ const FORBIDDEN_CHARS = ['@', '?', '#', '[', ']'];
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Error
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * The URI does not conform. **Always fail closed** (sec. 10.5: no fallback, and no
94
+ * `rttps`).
95
+ */
96
+ export class RttpUriError extends Error {
97
+ constructor(message) {
98
+ super(message);
99
+ this.name = 'RttpUriError';
100
+ }
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Validation helpers
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * sec. 10.2 token check, with the case rule of sec. 10.3 kept separate so that an
109
+ * uppercase letter produces a message that says what is actually wrong.
110
+ *
111
+ * @param {string} value
112
+ * @param {string} what field name, for the error message
113
+ */
114
+ function checkToken(value, what) {
115
+ if (typeof value !== 'string' || value === '') {
116
+ throw new RttpUriError(`${what} is empty`);
117
+ }
118
+ for (const ch of value) {
119
+ if (TOKEN.test(ch)) continue;
120
+ if (ch !== ch.toLowerCase()) {
121
+ // sec. 10.3: the canonical form is lowercase. Uppercase is not a normalisable
122
+ // spelling difference - it is illegal input.
123
+ throw new RttpUriError(
124
+ `${what} contains uppercase '${ch}' - lowercase US-ASCII only (AICENT-002 sec. 10.3)`);
125
+ }
126
+ throw new RttpUriError(
127
+ `${what} contains illegal character ${JSON.stringify(ch)} (allowed: a-z, 0-9, '-')`);
128
+ }
129
+ if (value.startsWith('-') || value.endsWith('-')) {
130
+ throw new RttpUriError(`${what} must not begin or end with '-'`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * @param {string} value
136
+ * @returns {boolean} true for a hash-intent (8 lowhex), false for a name-intent
137
+ */
138
+ function checkIntent(value) {
139
+ if (value.length === HASH_INTENT_LEN && LOWHEX.test(value)) return true;
140
+ checkToken(value, 'intent');
141
+ return false;
142
+ }
143
+
144
+ /**
145
+ * sec. 10.2 `action` syntax test: 1*( %x61-7A / DIGIT / "-" ).
146
+ *
147
+ * `action` is an **open set** - this only judges the *form*, never whether the
148
+ * verb is one this specification knows. Frame builders reuse it so the rule is
149
+ * not written twice.
150
+ *
151
+ * @param {unknown} value
152
+ * @returns {boolean}
153
+ */
154
+ export function isValidAction(value) {
155
+ if (typeof value !== 'string' || value === '') return false;
156
+ if (value.startsWith('-') || value.endsWith('-')) return false;
157
+ return TOKEN.test(value);
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // ROUTE_SHARD derivation
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * authority -> ROUTE_SHARD (16 bytes).
166
+ *
167
+ * ROUTE_SHARD = SHA-256( ASCII(canonical_authority) )[0:16]
168
+ *
169
+ * Properties:
170
+ * * **Deterministic** - one authority always yields one shard.
171
+ * * **Pure computation** - no DNS, no registry, no network (sec. 10.5).
172
+ * * **One-way** - the authority cannot be recovered from the shard.
173
+ * * **Independent of `action`** - it routes *where*, never *what*, which is
174
+ * exactly why `action` must be a separate frame field (see spec sec. 5.3).
175
+ *
176
+ * @param {string} canonicalAuthority
177
+ * @returns {Uint8Array} 16 bytes
178
+ */
179
+ export function deriveRouteShard(canonicalAuthority) {
180
+ if (typeof canonicalAuthority !== 'string' || canonicalAuthority === '') {
181
+ throw new RttpUriError('canonical_authority is empty');
182
+ }
183
+ if (canonicalAuthority !== canonicalAuthority.toLowerCase()) {
184
+ throw new RttpUriError('canonical_authority must be lowercase (AICENT-002 sec. 10.3)');
185
+ }
186
+ const digest = createHash('sha256')
187
+ .update(Buffer.from(canonicalAuthority, 'ascii'))
188
+ .digest();
189
+ return new Uint8Array(digest.subarray(0, ROUTE_SHARD_BYTES));
190
+ }
191
+
192
+ /**
193
+ * @param {string} canonicalAuthority
194
+ * @returns {string} 32 lowercase hex characters
195
+ */
196
+ export function deriveRouteShardHex(canonicalAuthority) {
197
+ return Buffer.from(deriveRouteShard(canonicalAuthority)).toString('hex');
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Parse / canonicalise
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Parse one `rttp` URI and return its canonical fields.
206
+ *
207
+ * The validation order deliberately stays "reject before interpreting": any
208
+ * suspicious form throws immediately, and nothing is ever guessed at.
209
+ * (sec. 10.5: user agents that do not implement this scheme fail closed.)
210
+ *
211
+ * Field names are `snake_case` on purpose: the published conformance vectors use
212
+ * those names, so the output of the Python and JavaScript implementations
213
+ * compares directly.
214
+ *
215
+ * @param {string} uri
216
+ * @returns {{
217
+ * scheme: string,
218
+ * intent: string,
219
+ * intent_is_hash: boolean,
220
+ * intent_hash32: number | null,
221
+ * pillar: string,
222
+ * root: string,
223
+ * action: string,
224
+ * action_omitted: boolean,
225
+ * authority: string,
226
+ * canonical_uri: string,
227
+ * route_shard: Uint8Array,
228
+ * route_shard_hex: string
229
+ * }}
230
+ */
231
+ export function parse(uri) {
232
+ if (typeof uri !== 'string') {
233
+ throw new RttpUriError('uri must be a string');
234
+ }
235
+ if (uri === '') {
236
+ throw new RttpUriError('uri is empty');
237
+ }
238
+ if (uri !== uri.trim()) {
239
+ // Leading/trailing whitespace may be paste contamination, or an attempt to
240
+ // slip past a prefix check. Either way: reject.
241
+ throw new RttpUriError('uri must not contain leading/trailing whitespace');
242
+ }
243
+
244
+ // --- scheme (sec. 10.6 prefix whitelist) ---
245
+ let scheme;
246
+ let rest;
247
+ if (uri.startsWith(`${BROWSER_HANDLER_SCHEME}://`)) {
248
+ scheme = BROWSER_HANDLER_SCHEME;
249
+ rest = uri.slice(BROWSER_HANDLER_SCHEME.length + 3);
250
+ } else if (uri.startsWith(`${SCHEME}://`)) {
251
+ scheme = SCHEME;
252
+ rest = uri.slice(SCHEME.length + 3);
253
+ } else {
254
+ throw new RttpUriError(
255
+ `not an rttp URI: must begin with '${SCHEME}://' or '${BROWSER_HANDLER_SCHEME}://'`);
256
+ }
257
+
258
+ // --- sec. 10.3 excluded characters ---
259
+ for (const ch of FORBIDDEN_CHARS) {
260
+ if (uri.includes(ch)) {
261
+ throw new RttpUriError(
262
+ `illegal character ${JSON.stringify(ch)}: this scheme defines no `
263
+ + 'userinfo / query / fragment');
264
+ }
265
+ }
266
+
267
+ // --- authority and path ---
268
+ const slash = rest.indexOf('/');
269
+ const authority = slash === -1 ? rest : rest.slice(0, slash);
270
+ const action = slash === -1 ? '' : rest.slice(slash + 1);
271
+
272
+ if (slash !== -1 && action.includes('/')) {
273
+ throw new RttpUriError("path must be a single segment '/<action>'");
274
+ }
275
+ if (slash !== -1 && action === '') {
276
+ // sec. 10.2: path = "/" action, action = 1*(...). A trailing slash with an empty
277
+ // action is a different form from "no path", and the two must not be
278
+ // conflated.
279
+ throw new RttpUriError("trailing '/' with empty action: path must be '/<action>'");
280
+ }
281
+
282
+ const parts = authority.split('.');
283
+ if (parts.length !== 3) {
284
+ throw new RttpUriError(
285
+ "authority must be exactly '<intent>.<pillar>.<root>' "
286
+ + `(got ${parts.length} segment(s))`);
287
+ }
288
+ const [intent, pillar, root] = parts;
289
+
290
+ const intentIsHash = checkIntent(intent);
291
+ checkToken(pillar, 'pillar');
292
+ checkToken(root, 'root');
293
+ if (action) checkToken(action, 'action');
294
+
295
+ const canonicalAuthority = `${intent}.${pillar}.${root}`;
296
+ const canonicalUri = `${SCHEME}://${canonicalAuthority}${action ? `/${action}` : ''}`;
297
+ const shard = deriveRouteShard(canonicalAuthority);
298
+
299
+ return {
300
+ scheme,
301
+ intent,
302
+ intent_is_hash: intentIsHash,
303
+ intent_hash32: intentIsHash ? parseInt(intent, 16) : null,
304
+ pillar,
305
+ root,
306
+ action,
307
+ action_omitted: action === '',
308
+ authority: canonicalAuthority,
309
+ canonical_uri: canonicalUri,
310
+ route_shard: shard,
311
+ route_shard_hex: Buffer.from(shard).toString('hex'),
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Convenience entry point: parse and derive in one step. Identical to `parse`;
317
+ * it exists so that callers reading the specification's wording have a
318
+ * function to name.
319
+ *
320
+ * @param {string} uri
321
+ * @returns {ReturnType<typeof parse>}
322
+ */
323
+ export function parseAndDerive(uri) {
324
+ return parse(uri);
325
+ }