@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/README.md +276 -275
- package/package.json +55 -55
- package/src/conformance.d.mts +108 -108
- package/src/conformance.mjs +358 -358
- package/src/rttp-uri.mjs +325 -325
package/src/rttp-uri.mjs
CHANGED
|
@@ -1,325 +1,325 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @aicent/rttp -
|
|
3
|
-
* ROUTE_SHARD derivation.
|
|
4
|
-
*
|
|
5
|
-
* This module is the reference implementation of the mapping
|
|
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
|
|
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/
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|