@aicent/iqa 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/iqa-uri.mjs CHANGED
@@ -1,169 +1,169 @@
1
- // iqa:// URI codec -- validate, canonicalise, derive (RFC-009 sec. 10/sec. 11).
2
- //
3
- // Authority boundaries:
4
- // Grammar ......... RFC-009 sec. 10.2 ABNF (sole authority)
5
- // Closed sets ..... sec. 10.1 (organ and action are CLOSED sets)
6
- // Case discipline . sec. 10.3 (lowercase US-ASCII only) + sec. 12 #8 (fail closed)
7
- // Deref safety .... sec. 11.3 (action safety classes)
8
- // Route derivation SPEC/IQA-URI-ATTEST-v1.2.6.md sec. 3 (IQA_ROUTE)
9
- //
10
- // Zero dependencies. Node 18+. No build step -- the published src/ is what runs.
11
-
12
- import { createHash } from 'node:crypto';
13
-
14
- export const SCHEME = 'iqa';
15
-
16
- /** RFC-009 sec. 10.1 -- organ is a CLOSED set (RFC-009-A/-B/-C). */
17
- export const ORGANS = Object.freeze(['forge', 'tss', 'gateway']);
18
-
19
- /**
20
- * RFC-009 sec. 10.1 -- action is a CLOSED set. This is the deliberate difference
21
- * from `rttp`, whose verb set is open: here anything outside the four verbs
22
- * is rejected.
23
- */
24
- export const ACTIONS = Object.freeze(['verify', 'audit', 'attest', 'revoke']);
25
-
26
- /** RFC-009-C sec. 3 -- standings an Organ may render (closed set, lowercase). */
27
- export const STANDINGS = Object.freeze(['ghost', 'probation', 'radiant', 'genesis']);
28
-
29
- /** RFC-009 sec. 10.2 -- hash-subject lengths: routing short form / 128-bit / 256-bit. */
30
- export const HASH_LENGTHS = Object.freeze([8, 32, 64]);
31
-
32
- const SAFE = new Set([null, 'verify']); // sec. 11.3: omitted = standing read; verify = read-only
33
- const LOWHEX = new Set('0123456789abcdef');
34
- const NAME_CHARS = new Set('abcdefghijklmnopqrstuvwxyz0123456789-');
35
- const ALLOWED = new Set([...LOWHEX, ...NAME_CHARS, '.', '/']);
36
- const FORBIDDEN = new Set(['@', ':', '?', '#', '[', ']']);
37
-
38
- export class IqaUriError extends Error {
39
- constructor(message) {
40
- super(message);
41
- this.name = 'IqaUriError';
42
- }
43
- }
44
-
45
- const fail = (uri, reason) => new IqaUriError(`${JSON.stringify(uri)}: ${reason}`);
46
-
47
- /** sec. 10.2 action test -- true only for the closed four-verb set. */
48
- export function isValidAction(value) {
49
- return ACTIONS.includes(value);
50
- }
51
-
52
- /**
53
- * IQA_ROUTE = SHA-256(ASCII(canonical_authority))[0:4] (SPEC sec. 3, decision D1).
54
- *
55
- * Pure computation, DNS-free (RFC-009 sec. 12 #9). Mirrors the RTTP ROUTE_SHARD
56
- * rule so both schemes of the same addressing family derive the same way.
57
- * NOTE: this is the *address* fingerprint -- NOT the subject hash, which is a
58
- * routing hint for the *AID*. Different numbers about different things.
59
- */
60
- export function deriveRoute(authority) {
61
- if (!authority) throw new IqaUriError('deriveRoute: authority is empty');
62
- return createHash('sha256').update(authority, 'ascii').digest().subarray(0, 4);
63
- }
64
-
65
- /** The same four bytes, as 8 lowercase hex characters. */
66
- export function deriveRouteHex(authority) {
67
- return deriveRoute(authority).toString('hex');
68
- }
69
-
70
- /**
71
- * Validate, canonicalise and derive. Throws IqaUriError on any violation.
72
- *
73
- * Returned fields are snake_case ON PURPOSE so this output and the Python
74
- * package's output compare directly against the published vectors -- field
75
- * for field, byte for byte.
76
- */
77
- export function parse(uri) {
78
- if (typeof uri !== 'string') throw fail(uri, 'input must be a string');
79
-
80
- const prefix = SCHEME + '://';
81
- if (!uri.startsWith(prefix)) {
82
- throw fail(uri, `scheme must be exactly ${JSON.stringify(prefix)} - no other `
83
- + 'scheme, no case variant, no fallback (RFC-009 sec. 10.3, sec. 12 #8)');
84
- }
85
- const rest = uri.slice(prefix.length);
86
-
87
- // Charset gate: lowercase US-ASCII only (sec. 10.3). One scan rejects uppercase
88
- // anywhere, userinfo '@', port ':', query '?', fragment '#', whitespace and
89
- // every other foreign byte.
90
- for (const ch of rest) {
91
- if (!ALLOWED.has(ch)) {
92
- if (FORBIDDEN.has(ch)) {
93
- throw fail(uri, `${JSON.stringify(ch)} is not defined by the scheme (RFC-009 sec. 10.3)`);
94
- }
95
- if (/\s/.test(ch)) {
96
- throw fail(uri, 'whitespace is not allowed (RFC-009 sec. 10.3 lowercase US-ASCII only)');
97
- }
98
- throw fail(uri, `character ${JSON.stringify(ch)} is outside the allowed set `
99
- + "(lowercase US-ASCII, digits, '-', '.', '/')");
100
- }
101
- }
102
-
103
- // Path: at most one '/', and the action behind it must be non-empty.
104
- let authority = rest;
105
- let action = null;
106
- const slash = rest.indexOf('/');
107
- if (slash !== -1) {
108
- authority = rest.slice(0, slash);
109
- action = rest.slice(slash + 1);
110
- if (action === '') {
111
- throw fail(uri, "trailing '/' with empty action - action is a closed-set "
112
- + 'verb, not omissible by empty string (sec. 10.2)');
113
- }
114
- if (action.includes('/')) {
115
- throw fail(uri, "second path segment - path is a single '/<action>' (sec. 10.2)");
116
- }
117
- }
118
-
119
- // Authority: exactly three segments.
120
- const segments = authority.split('.');
121
- if (segments.length !== 3) {
122
- throw fail(uri, `authority has ${segments.length} segment(s), must be `
123
- + '<subject>.<organ>.<root> (RFC-009 sec. 10.2)');
124
- }
125
- const [subject, organ, root] = segments;
126
- if (subject === '' || root === '') {
127
- throw fail(uri, 'authority segments must be non-empty (RFC-009 sec. 10.2)');
128
- }
129
-
130
- // Subject: hash form (8/32/64 lowercase hex) or name form. Ordered choice
131
- // per the ABNF: all-lowerhex input is tried as hash-subject first; a
132
- // lowercase-hex string of another length is therefore a NAME subject,
133
- // exactly as the grammar says. Documented, not "fixed".
134
- let subjectIsHash = false;
135
- let subjectHex = null;
136
- if ([...subject].every((c) => LOWHEX.has(c)) && HASH_LENGTHS.includes(subject.length)) {
137
- subjectIsHash = true;
138
- subjectHex = subject;
139
- }
140
-
141
- // Organ -- CLOSED set, case-sensitive (sec. 10.1). 'Forge', 'forgery', '' all fail.
142
- if (!ORGANS.includes(organ)) {
143
- throw fail(uri, `organ ${JSON.stringify(organ)} is outside the closed set `
144
- + `[${ORGANS.join(', ')}] (RFC-009 sec. 10.1/sec. 10.3)`);
145
- }
146
-
147
- // Action -- CLOSED set (sec. 10.1). The deliberate difference from rttp.
148
- if (action !== null && !ACTIONS.includes(action)) {
149
- throw fail(uri, `action ${JSON.stringify(action)} is outside the closed set `
150
- + `[${ACTIONS.join(', ')}] (RFC-009 sec. 10.1)`);
151
- }
152
-
153
- const canonicalUri = SCHEME + '://' + authority + (action ? '/' + action : '');
154
-
155
- return {
156
- scheme: SCHEME,
157
- subject,
158
- subject_is_hash: subjectIsHash,
159
- subject_hex: subjectHex,
160
- organ,
161
- root,
162
- authority,
163
- action,
164
- action_omitted: action === null,
165
- action_safe: SAFE.has(action), // sec. 11.3
166
- canonical_uri: canonicalUri,
167
- route_hex: deriveRouteHex(authority), // SPEC sec. 3
168
- };
169
- }
1
+ // iqa:// URI codec -- validate, canonicalise, derive (AICENT-009 sec. 10/sec. 11).
2
+ //
3
+ // Authority boundaries:
4
+ // Grammar ......... AICENT-009 sec. 10.2 ABNF (sole authority)
5
+ // Closed sets ..... sec. 10.1 (organ and action are CLOSED sets)
6
+ // Case discipline . sec. 10.3 (lowercase US-ASCII only) + sec. 12 #8 (fail closed)
7
+ // Deref safety .... sec. 11.3 (action safety classes)
8
+ // Route derivation SPEC/IQA-URI-ATTEST-v1.2.6.md sec. 3 (IQA_ROUTE)
9
+ //
10
+ // Zero dependencies. Node 18+. No build step -- the published src/ is what runs.
11
+
12
+ import { createHash } from 'node:crypto';
13
+
14
+ export const SCHEME = 'iqa';
15
+
16
+ /** AICENT-009 sec. 10.1 -- organ is a CLOSED set (AICENT-009-A/-B/-C). */
17
+ export const ORGANS = Object.freeze(['forge', 'tss', 'gateway']);
18
+
19
+ /**
20
+ * AICENT-009 sec. 10.1 -- action is a CLOSED set. This is the deliberate difference
21
+ * from `rttp`, whose verb set is open: here anything outside the four verbs
22
+ * is rejected.
23
+ */
24
+ export const ACTIONS = Object.freeze(['verify', 'audit', 'attest', 'revoke']);
25
+
26
+ /** AICENT-009-C sec. 3 -- standings an Organ may render (closed set, lowercase). */
27
+ export const STANDINGS = Object.freeze(['ghost', 'probation', 'radiant', 'genesis']);
28
+
29
+ /** AICENT-009 sec. 10.2 -- hash-subject lengths: routing short form / 128-bit / 256-bit. */
30
+ export const HASH_LENGTHS = Object.freeze([8, 32, 64]);
31
+
32
+ const SAFE = new Set([null, 'verify']); // sec. 11.3: omitted = standing read; verify = read-only
33
+ const LOWHEX = new Set('0123456789abcdef');
34
+ const NAME_CHARS = new Set('abcdefghijklmnopqrstuvwxyz0123456789-');
35
+ const ALLOWED = new Set([...LOWHEX, ...NAME_CHARS, '.', '/']);
36
+ const FORBIDDEN = new Set(['@', ':', '?', '#', '[', ']']);
37
+
38
+ export class IqaUriError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = 'IqaUriError';
42
+ }
43
+ }
44
+
45
+ const fail = (uri, reason) => new IqaUriError(`${JSON.stringify(uri)}: ${reason}`);
46
+
47
+ /** sec. 10.2 action test -- true only for the closed four-verb set. */
48
+ export function isValidAction(value) {
49
+ return ACTIONS.includes(value);
50
+ }
51
+
52
+ /**
53
+ * IQA_ROUTE = SHA-256(ASCII(canonical_authority))[0:4] (SPEC sec. 3, decision D1).
54
+ *
55
+ * Pure computation, DNS-free (AICENT-009 sec. 12 #9). Mirrors the RTTP ROUTE_SHARD
56
+ * rule so both schemes of the same addressing family derive the same way.
57
+ * NOTE: this is the *address* fingerprint -- NOT the subject hash, which is a
58
+ * routing hint for the *AID*. Different numbers about different things.
59
+ */
60
+ export function deriveRoute(authority) {
61
+ if (!authority) throw new IqaUriError('deriveRoute: authority is empty');
62
+ return createHash('sha256').update(authority, 'ascii').digest().subarray(0, 4);
63
+ }
64
+
65
+ /** The same four bytes, as 8 lowercase hex characters. */
66
+ export function deriveRouteHex(authority) {
67
+ return deriveRoute(authority).toString('hex');
68
+ }
69
+
70
+ /**
71
+ * Validate, canonicalise and derive. Throws IqaUriError on any violation.
72
+ *
73
+ * Returned fields are snake_case ON PURPOSE so this output and the Python
74
+ * package's output compare directly against the published vectors -- field
75
+ * for field, byte for byte.
76
+ */
77
+ export function parse(uri) {
78
+ if (typeof uri !== 'string') throw fail(uri, 'input must be a string');
79
+
80
+ const prefix = SCHEME + '://';
81
+ if (!uri.startsWith(prefix)) {
82
+ throw fail(uri, `scheme must be exactly ${JSON.stringify(prefix)} - no other `
83
+ + 'scheme, no case variant, no fallback (AICENT-009 sec. 10.3, sec. 12 #8)');
84
+ }
85
+ const rest = uri.slice(prefix.length);
86
+
87
+ // Charset gate: lowercase US-ASCII only (sec. 10.3). One scan rejects uppercase
88
+ // anywhere, userinfo '@', port ':', query '?', fragment '#', whitespace and
89
+ // every other foreign byte.
90
+ for (const ch of rest) {
91
+ if (!ALLOWED.has(ch)) {
92
+ if (FORBIDDEN.has(ch)) {
93
+ throw fail(uri, `${JSON.stringify(ch)} is not defined by the scheme (AICENT-009 sec. 10.3)`);
94
+ }
95
+ if (/\s/.test(ch)) {
96
+ throw fail(uri, 'whitespace is not allowed (AICENT-009 sec. 10.3 lowercase US-ASCII only)');
97
+ }
98
+ throw fail(uri, `character ${JSON.stringify(ch)} is outside the allowed set `
99
+ + "(lowercase US-ASCII, digits, '-', '.', '/')");
100
+ }
101
+ }
102
+
103
+ // Path: at most one '/', and the action behind it must be non-empty.
104
+ let authority = rest;
105
+ let action = null;
106
+ const slash = rest.indexOf('/');
107
+ if (slash !== -1) {
108
+ authority = rest.slice(0, slash);
109
+ action = rest.slice(slash + 1);
110
+ if (action === '') {
111
+ throw fail(uri, "trailing '/' with empty action - action is a closed-set "
112
+ + 'verb, not omissible by empty string (sec. 10.2)');
113
+ }
114
+ if (action.includes('/')) {
115
+ throw fail(uri, "second path segment - path is a single '/<action>' (sec. 10.2)");
116
+ }
117
+ }
118
+
119
+ // Authority: exactly three segments.
120
+ const segments = authority.split('.');
121
+ if (segments.length !== 3) {
122
+ throw fail(uri, `authority has ${segments.length} segment(s), must be `
123
+ + '<subject>.<organ>.<root> (AICENT-009 sec. 10.2)');
124
+ }
125
+ const [subject, organ, root] = segments;
126
+ if (subject === '' || root === '') {
127
+ throw fail(uri, 'authority segments must be non-empty (AICENT-009 sec. 10.2)');
128
+ }
129
+
130
+ // Subject: hash form (8/32/64 lowercase hex) or name form. Ordered choice
131
+ // per the ABNF: all-lowerhex input is tried as hash-subject first; a
132
+ // lowercase-hex string of another length is therefore a NAME subject,
133
+ // exactly as the grammar says. Documented, not "fixed".
134
+ let subjectIsHash = false;
135
+ let subjectHex = null;
136
+ if ([...subject].every((c) => LOWHEX.has(c)) && HASH_LENGTHS.includes(subject.length)) {
137
+ subjectIsHash = true;
138
+ subjectHex = subject;
139
+ }
140
+
141
+ // Organ -- CLOSED set, case-sensitive (sec. 10.1). 'Forge', 'forgery', '' all fail.
142
+ if (!ORGANS.includes(organ)) {
143
+ throw fail(uri, `organ ${JSON.stringify(organ)} is outside the closed set `
144
+ + `[${ORGANS.join(', ')}] (AICENT-009 sec. 10.1/sec. 10.3)`);
145
+ }
146
+
147
+ // Action -- CLOSED set (sec. 10.1). The deliberate difference from rttp.
148
+ if (action !== null && !ACTIONS.includes(action)) {
149
+ throw fail(uri, `action ${JSON.stringify(action)} is outside the closed set `
150
+ + `[${ACTIONS.join(', ')}] (AICENT-009 sec. 10.1)`);
151
+ }
152
+
153
+ const canonicalUri = SCHEME + '://' + authority + (action ? '/' + action : '');
154
+
155
+ return {
156
+ scheme: SCHEME,
157
+ subject,
158
+ subject_is_hash: subjectIsHash,
159
+ subject_hex: subjectHex,
160
+ organ,
161
+ root,
162
+ authority,
163
+ action,
164
+ action_omitted: action === null,
165
+ action_safe: SAFE.has(action), // sec. 11.3
166
+ canonical_uri: canonicalUri,
167
+ route_hex: deriveRouteHex(authority), // SPEC sec. 3
168
+ };
169
+ }