@continuonai/rcan-ts 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,8 @@ Official TypeScript SDK for the **RCAN v1.2** Robot Communication and Accountabi
9
9
  npm install @continuonai/rcan-ts
10
10
  ```
11
11
 
12
+ > **v0.2.0** — RCAN v1.2 spec compliance including §17 Distributed Registry Node Protocol
13
+
12
14
  ### CDN / Browser (no build step)
13
15
 
14
16
  ```html
@@ -139,6 +141,89 @@ result.warnings.forEach((w) => console.warn("⚠️", w));
139
141
  | `validateURI` | Validate a Robot URI string |
140
142
  | `validateMessage` | Validate a RCAN message object |
141
143
  | `validateConfig` | L1/L2/L3 conformance check for a robot RCAN config |
144
+ | `NodeClient` | Resolve RRNs from federated registry nodes (§17) |
145
+ | `fetchCanonicalSchema` | Fetch the canonical JSON schema from rcan.dev |
146
+ | `validateConfigAgainstSchema` | Validate a config object against the live JSON schema |
147
+ | `validateNodeAgainstSchema` | Validate a node manifest against the node schema |
148
+
149
+ ---
150
+
151
+ ## Distributed Registry Nodes (§17)
152
+
153
+ RCAN v1.2 §17 introduces a federated registry network. `NodeClient` resolves RRNs from any node — root or delegated authoritative.
154
+
155
+ ```typescript
156
+ import { NodeClient } from '@continuonai/rcan-ts';
157
+
158
+ const client = new NodeClient();
159
+
160
+ // Resolve an RRN across the federation
161
+ const result = await client.resolve('RRN-BD-000000000001');
162
+ console.log(`Resolved by: ${result.resolved_by}`);
163
+ console.log(`Robot: ${result.record.name}`);
164
+
165
+ // Discover the authoritative node for a namespace
166
+ const node = await client.discover('RRN-BD-000000000001');
167
+ console.log(`Authoritative: ${node.operator} at ${node.api_base}`);
168
+
169
+ // List all known registry nodes
170
+ const nodes = await client.listNodes();
171
+ nodes.forEach(n => console.log(`${n.operator}: ${n.namespace_prefix}`));
172
+
173
+ // Verify a node manifest
174
+ const manifest = await client.getNodeManifest('https://registry.example.com');
175
+ const isValid = client.verifyNode(manifest);
176
+
177
+ // Error handling
178
+ import { RCANNodeNotFoundError, RCANNodeTrustError } from '@continuonai/rcan-ts';
179
+ try {
180
+ const result = await client.resolve('RRN-UNKNOWN-000000000001');
181
+ } catch (e) {
182
+ if (e instanceof RCANNodeNotFoundError) {
183
+ console.log(`Not found: ${e.rrn}`);
184
+ } else if (e instanceof RCANNodeTrustError) {
185
+ console.log(`Trust failure: ${e.reason}`);
186
+ }
187
+ }
188
+ ```
189
+
190
+ ### RRN Format
191
+
192
+ ```
193
+ Root namespace: RRN-000000000001 (12-digit recommended, 8-digit still valid)
194
+ Delegated: RRN-BD-000000000001 (prefix 2-8 alphanumeric chars)
195
+ Legacy (valid): RRN-00000001 (8-digit, backward compatible)
196
+ ```
197
+
198
+ ## Schema Validation
199
+
200
+ Validate configs against the canonical JSON schema published at rcan.dev:
201
+
202
+ ```typescript
203
+ import { validateConfigAgainstSchema, validateNodeAgainstSchema } from '@continuonai/rcan-ts';
204
+
205
+ // Validate a RCAN config against the canonical schema from rcan.dev
206
+ const result = await validateConfigAgainstSchema(myConfig);
207
+ if (!result.valid) {
208
+ console.error('Config invalid:', result.errors);
209
+ } else if (result.skipped) {
210
+ console.warn('Schema validation skipped (rcan.dev unreachable)');
211
+ }
212
+
213
+ // Validate a node manifest
214
+ const nodeResult = await validateNodeAgainstSchema(manifest);
215
+ ```
216
+
217
+ ### CDN / Browser Usage
218
+
219
+ ```html
220
+ <script src="https://unpkg.com/@continuonai/rcan-ts/dist/rcan.iife.js"></script>
221
+ <script>
222
+ const { validateConfig, NodeClient } = window.RCAN;
223
+ const client = new NodeClient();
224
+ client.resolve('RRN-000000000001').then(r => console.log(r));
225
+ </script>
226
+ ```
142
227
 
143
228
  ---
144
229
 
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ import { NodeClient, validateConfig, VERSION, SPEC_VERSION } from '../dist/index.js';
3
+ import { readFileSync } from 'fs';
4
+ import { parseArgs } from 'util';
5
+
6
+ const { values, positionals } = parseArgs({
7
+ args: process.argv.slice(2),
8
+ options: {
9
+ version: { type: 'boolean', short: 'v' },
10
+ file: { type: 'string', short: 'f' },
11
+ },
12
+ allowPositionals: true,
13
+ });
14
+
15
+ if (values.version) {
16
+ console.log(`rcan-validate (ts) ${VERSION} (RCAN spec ${SPEC_VERSION})`);
17
+ process.exit(0);
18
+ }
19
+
20
+ const [subcommand, target] = positionals;
21
+
22
+ if (subcommand === 'node') {
23
+ if (!target) {
24
+ console.error('Usage: rcan-validate node <url>');
25
+ process.exit(1);
26
+ }
27
+
28
+ const client = new NodeClient();
29
+ console.log(`Validating node manifest: ${target}`);
30
+
31
+ try {
32
+ const manifest = await client.getNodeManifest(target);
33
+ const valid = client.verifyNode(manifest);
34
+
35
+ const checks = [
36
+ ['node_type', manifest.node_type, ['root','authoritative','resolver','cache'].includes(manifest.node_type)],
37
+ ['operator', manifest.operator, !!manifest.operator],
38
+ ['namespace_prefix', manifest.namespace_prefix, !!manifest.namespace_prefix],
39
+ ['public_key', manifest.public_key?.substring(0, 20) + '...', manifest.public_key?.startsWith('ed25519:')],
40
+ ['api_base', manifest.api_base, manifest.api_base?.startsWith('https://')],
41
+ ];
42
+
43
+ for (const [name, value, pass] of checks) {
44
+ console.log(` ${pass ? '✓' : '✗'} ${name}: ${value}`);
45
+ }
46
+
47
+ const passed = checks.filter(([,,p]) => p).length;
48
+ console.log(`\n${valid ? 'PASS' : 'FAIL'} (${passed}/${checks.length} checks)`);
49
+ process.exit(valid ? 0 : 1);
50
+ } catch (e) {
51
+ console.error(`❌ Error: ${e.message}`);
52
+ process.exit(1);
53
+ }
54
+ }
55
+
56
+ if (subcommand === 'config') {
57
+ const filePath = target || values.file;
58
+ if (!filePath) {
59
+ console.error('Usage: rcan-validate config <file>');
60
+ process.exit(1);
61
+ }
62
+
63
+ try {
64
+ const raw = readFileSync(filePath, 'utf-8');
65
+ const config = JSON.parse(raw);
66
+ const result = validateConfig(config);
67
+ if (result.ok) {
68
+ console.log('✓ Config valid');
69
+ if (result.warnings?.length) {
70
+ result.warnings.forEach(w => console.warn(` ⚠️ ${w}`));
71
+ }
72
+ process.exit(0);
73
+ } else {
74
+ console.error('✗ Config invalid:');
75
+ result.issues?.forEach(i => console.error(` ❌ ${i}`));
76
+ process.exit(1);
77
+ }
78
+ } catch (e) {
79
+ console.error(`❌ Error: ${e.message}`);
80
+ process.exit(1);
81
+ }
82
+ }
83
+
84
+ console.error(`Unknown subcommand: ${subcommand}`);
85
+ console.error('Usage: rcan-validate node <url> | rcan-validate config <file> | rcan-validate --version');
86
+ process.exit(1);
@@ -256,6 +256,31 @@ interface RCANMessageEnvelope {
256
256
  signature?: string;
257
257
  [key: string]: unknown;
258
258
  }
259
+ interface RCANRegistryNode {
260
+ rcan_node_version: string;
261
+ node_type: "root" | "authoritative" | "resolver" | "cache";
262
+ operator: string;
263
+ namespace_prefix: string;
264
+ public_key: string;
265
+ api_base: string;
266
+ registry_ui?: string;
267
+ spec_version?: string;
268
+ capabilities?: string[];
269
+ sync_endpoint?: string;
270
+ last_sync?: string;
271
+ ttl_seconds?: number;
272
+ contact?: string;
273
+ governance?: string;
274
+ federation_protocol?: string;
275
+ }
276
+ interface RCANResolveResult {
277
+ rrn: string;
278
+ resolved_by: string;
279
+ namespace: string;
280
+ record: Record<string, unknown>;
281
+ cache_status: "HIT" | "MISS" | "STALE";
282
+ resolved_at: string;
283
+ }
259
284
 
260
285
  /**
261
286
  * RCAN Validation — validate messages, configs, and URIs.
@@ -298,6 +323,22 @@ declare class RCANSignatureError extends RCANError {
298
323
  declare class RCANRegistryError extends RCANError {
299
324
  constructor(message: string);
300
325
  }
326
+ declare class RCANNodeError extends RCANError {
327
+ readonly nodeUrl?: string | undefined;
328
+ constructor(message: string, nodeUrl?: string | undefined);
329
+ }
330
+ declare class RCANNodeNotFoundError extends RCANNodeError {
331
+ readonly rrn: string;
332
+ constructor(rrn: string, nodeUrl?: string);
333
+ }
334
+ declare class RCANNodeSyncError extends RCANNodeError {
335
+ readonly cause?: Error | undefined;
336
+ constructor(message: string, nodeUrl?: string, cause?: Error | undefined);
337
+ }
338
+ declare class RCANNodeTrustError extends RCANNodeError {
339
+ readonly reason: "invalid_signature" | "expired_cert" | "unknown_issuer" | "missing_pubkey";
340
+ constructor(reason: RCANNodeTrustError["reason"], nodeUrl?: string);
341
+ }
301
342
 
302
343
  /**
303
344
  * rcan-ts — RegistryClient
@@ -367,6 +408,68 @@ declare class RegistryClient {
367
408
  }): Promise<Robot[]>;
368
409
  }
369
410
 
411
+ /**
412
+ * rcan-ts — NodeClient
413
+ * Federation-aware node discovery and RRN resolution.
414
+ *
415
+ * Zero runtime dependencies — uses globalThis.fetch (Node 18+, browsers, CF Workers).
416
+ *
417
+ * @see https://rcan.dev/spec#section-federation
418
+ */
419
+
420
+ declare class NodeClient {
421
+ private readonly rootUrl;
422
+ private readonly timeoutMs;
423
+ constructor(rootUrl?: string, timeoutMs?: number);
424
+ private _fetch;
425
+ /**
426
+ * Fetch /.well-known/rcan-node.json from any node URL.
427
+ * Throws RCANNodeTrustError if the manifest is malformed.
428
+ * Throws RCANNodeSyncError on network failure.
429
+ */
430
+ getNodeManifest(nodeUrl: string): Promise<RCANRegistryNode>;
431
+ /**
432
+ * Get list of known registry nodes from root /api/v1/nodes.
433
+ * Optionally filter by namespace prefix (e.g. "BD").
434
+ */
435
+ listNodes(prefix?: string): Promise<RCANRegistryNode[]>;
436
+ /**
437
+ * Find the authoritative node for an RRN.
438
+ * - Delegated RRN (RRN-BD-00000001): GET /api/v1/nodes?prefix=BD, return first match.
439
+ * - Root RRN (RRN-00000042): return root node manifest.
440
+ * - Unknown format: throws RCANNodeNotFoundError.
441
+ */
442
+ discover(rrn: string): Promise<RCANRegistryNode>;
443
+ /**
444
+ * Resolve an RRN across the federation.
445
+ * First tries {rootUrl}/api/v1/resolve/{rrn}.
446
+ * On 404, discovers the authoritative node and tries {node.api_base}/robots/{rrn}.
447
+ */
448
+ resolve(rrn: string): Promise<RCANResolveResult>;
449
+ /**
450
+ * Verify a node manifest is well-formed.
451
+ * Checks required fields, ed25519: public_key prefix, valid node_type, https:// api_base.
452
+ */
453
+ verifyNode(manifest: unknown): manifest is RCANRegistryNode;
454
+ }
455
+
456
+ declare function fetchCanonicalSchema(schemaName: string): Promise<object | null>;
457
+ /**
458
+ * Validate a config object against the canonical rcan-config schema from rcan.dev.
459
+ * Returns { valid: true } or { valid: false, errors: string[] }.
460
+ * Gracefully returns { valid: true, skipped: true } if schema unreachable.
461
+ */
462
+ declare function validateConfigAgainstSchema(config: unknown): Promise<{
463
+ valid: boolean;
464
+ errors?: string[];
465
+ skipped?: boolean;
466
+ }>;
467
+ declare function validateNodeAgainstSchema(manifest: unknown): Promise<{
468
+ valid: boolean;
469
+ errors?: string[];
470
+ skipped?: boolean;
471
+ }>;
472
+
370
473
  /**
371
474
  * rcan-ts — Official TypeScript SDK for RCAN v1.2
372
475
  * Robot Communication and Accountability Network
@@ -375,7 +478,9 @@ declare class RegistryClient {
375
478
  * @see https://github.com/continuonai/rcan-ts
376
479
  */
377
480
 
378
- declare const VERSION = "0.1.0";
481
+ declare const VERSION = "0.3.0";
482
+ declare const SPEC_VERSION = "1.2";
483
+ /** @deprecated Use SPEC_VERSION instead */
379
484
  declare const RCAN_VERSION = "1.2";
380
485
 
381
- export { type ApprovalStatus, AuditChain, AuditError, type ChainVerifyResult, CommitmentRecord, type CommitmentRecordData, type CommitmentRecordJSON, ConfidenceGate, GateError, HiTLGate, type ListResult, type PendingApproval, RCANAddressError, type RCANAgentConfig, type RCANConfig, RCANError, RCANGateError, RCANMessage, type RCANMessageData, type RCANMessageEnvelope, RCANMessageError, type RCANMetadata, RCANRegistryError, RCANSignatureError, RCANValidationError, RCAN_VERSION, type RegistrationResult, RegistryClient, type Robot, type RobotRegistration, RobotURI, RobotURIError, type RobotURIOptions, type SignatureBlock, VERSION, type ValidationResult, validateConfig, validateMessage, validateURI };
486
+ export { type ApprovalStatus, AuditChain, AuditError, type ChainVerifyResult, CommitmentRecord, type CommitmentRecordData, type CommitmentRecordJSON, ConfidenceGate, GateError, HiTLGate, type ListResult, NodeClient, type PendingApproval, RCANAddressError, type RCANAgentConfig, type RCANConfig, RCANError, RCANGateError, RCANMessage, type RCANMessageData, type RCANMessageEnvelope, RCANMessageError, type RCANMetadata, RCANNodeError, RCANNodeNotFoundError, RCANNodeSyncError, RCANNodeTrustError, RCANRegistryError, type RCANRegistryNode, type RCANResolveResult, RCANSignatureError, RCANValidationError, RCAN_VERSION, type RegistrationResult, RegistryClient, type Robot, type RobotRegistration, RobotURI, RobotURIError, type RobotURIOptions, SPEC_VERSION, type SignatureBlock, VERSION, type ValidationResult, fetchCanonicalSchema, validateConfig, validateConfigAgainstSchema, validateMessage, validateNodeAgainstSchema, validateURI };
package/dist/browser.mjs CHANGED
@@ -747,6 +747,39 @@ var RCANRegistryError = class extends RCANError {
747
747
  Object.setPrototypeOf(this, new.target.prototype);
748
748
  }
749
749
  };
750
+ var RCANNodeError = class _RCANNodeError extends RCANError {
751
+ constructor(message, nodeUrl) {
752
+ super(message);
753
+ this.nodeUrl = nodeUrl;
754
+ this.name = "RCANNodeError";
755
+ Object.setPrototypeOf(this, _RCANNodeError.prototype);
756
+ }
757
+ };
758
+ var RCANNodeNotFoundError = class _RCANNodeNotFoundError extends RCANNodeError {
759
+ constructor(rrn, nodeUrl) {
760
+ super(`RRN not found in federation: ${rrn}`, nodeUrl);
761
+ this.rrn = rrn;
762
+ this.name = "RCANNodeNotFoundError";
763
+ Object.setPrototypeOf(this, _RCANNodeNotFoundError.prototype);
764
+ }
765
+ };
766
+ var RCANNodeSyncError = class _RCANNodeSyncError extends RCANNodeError {
767
+ constructor(message, nodeUrl, cause) {
768
+ super(message, nodeUrl);
769
+ this.cause = cause;
770
+ this.name = "RCANNodeSyncError";
771
+ Object.setPrototypeOf(this, _RCANNodeSyncError.prototype);
772
+ }
773
+ };
774
+ var RCANNodeTrustError = class _RCANNodeTrustError extends RCANNodeError {
775
+ constructor(reason, nodeUrl) {
776
+ super(`Node trust verification failed: ${reason}`, nodeUrl);
777
+ __publicField(this, "reason");
778
+ this.name = "RCANNodeTrustError";
779
+ this.reason = reason;
780
+ Object.setPrototypeOf(this, _RCANNodeTrustError.prototype);
781
+ }
782
+ };
750
783
 
751
784
  // src/registry.ts
752
785
  var DEFAULT_BASE_URL = "https://rcan-spec.pages.dev";
@@ -871,8 +904,250 @@ var RegistryClient = class {
871
904
  }
872
905
  };
873
906
 
907
+ // src/node.ts
908
+ var ROOT_NODE_URL = "https://rcan.dev";
909
+ var NODE_MANIFEST_PATH = "/.well-known/rcan-node.json";
910
+ var VALID_NODE_TYPES = /* @__PURE__ */ new Set([
911
+ "root",
912
+ "authoritative",
913
+ "resolver",
914
+ "cache"
915
+ ]);
916
+ function parseRRNNamespace(rrn) {
917
+ const delegated = rrn.match(/^RRN-([A-Z0-9]{2,8})-(\d{8,16})$/);
918
+ if (delegated) return { type: "delegated", prefix: delegated[1], serial: delegated[2] };
919
+ const root = rrn.match(/^RRN-(\d{8,16})$/);
920
+ if (root) return { type: "root", serial: root[1] };
921
+ return null;
922
+ }
923
+ var NodeClient = class {
924
+ constructor(rootUrl = ROOT_NODE_URL, timeoutMs = 1e4) {
925
+ __publicField(this, "rootUrl");
926
+ __publicField(this, "timeoutMs");
927
+ this.rootUrl = rootUrl.replace(/\/$/, "");
928
+ this.timeoutMs = timeoutMs;
929
+ }
930
+ // ── Private helpers ─────────────────────────────────────────────────────────
931
+ async _fetch(url) {
932
+ const controller = new AbortController();
933
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
934
+ try {
935
+ return await globalThis.fetch(url, { signal: controller.signal });
936
+ } catch (err) {
937
+ if (err instanceof Error && err.name === "AbortError") {
938
+ throw new RCANNodeSyncError(`Request timed out: ${url}`, url, err);
939
+ }
940
+ throw new RCANNodeSyncError(
941
+ `Network error fetching ${url}: ${err.message}`,
942
+ url,
943
+ err instanceof Error ? err : void 0
944
+ );
945
+ } finally {
946
+ clearTimeout(timer);
947
+ }
948
+ }
949
+ // ── Public API ───────────────────────────────────────────────────────────────
950
+ /**
951
+ * Fetch /.well-known/rcan-node.json from any node URL.
952
+ * Throws RCANNodeTrustError if the manifest is malformed.
953
+ * Throws RCANNodeSyncError on network failure.
954
+ */
955
+ async getNodeManifest(nodeUrl) {
956
+ const url = `${nodeUrl.replace(/\/$/, "")}${NODE_MANIFEST_PATH}`;
957
+ const resp = await this._fetch(url);
958
+ if (!resp.ok) {
959
+ if (resp.status === 404) {
960
+ throw new RCANNodeNotFoundError(url, nodeUrl);
961
+ }
962
+ throw new RCANNodeSyncError(
963
+ `Failed to fetch node manifest from ${nodeUrl}: HTTP ${resp.status}`,
964
+ nodeUrl
965
+ );
966
+ }
967
+ let data;
968
+ try {
969
+ data = await resp.json();
970
+ } catch (err) {
971
+ throw new RCANNodeSyncError(
972
+ `Invalid JSON in node manifest from ${nodeUrl}`,
973
+ nodeUrl,
974
+ err instanceof Error ? err : void 0
975
+ );
976
+ }
977
+ if (!this.verifyNode(data)) {
978
+ throw new RCANNodeTrustError("missing_pubkey", nodeUrl);
979
+ }
980
+ return data;
981
+ }
982
+ /**
983
+ * Get list of known registry nodes from root /api/v1/nodes.
984
+ * Optionally filter by namespace prefix (e.g. "BD").
985
+ */
986
+ async listNodes(prefix) {
987
+ const qs = prefix ? `?prefix=${encodeURIComponent(prefix)}` : "";
988
+ const url = `${this.rootUrl}/api/v1/nodes${qs}`;
989
+ const resp = await this._fetch(url);
990
+ if (!resp.ok) {
991
+ throw new RCANNodeSyncError(
992
+ `Failed to list nodes from ${url}: HTTP ${resp.status}`,
993
+ url
994
+ );
995
+ }
996
+ let data;
997
+ try {
998
+ data = await resp.json();
999
+ } catch (err) {
1000
+ throw new RCANNodeSyncError(
1001
+ `Invalid JSON in nodes list from ${url}`,
1002
+ url,
1003
+ err instanceof Error ? err : void 0
1004
+ );
1005
+ }
1006
+ if (Array.isArray(data)) return data;
1007
+ if (data && typeof data === "object" && "nodes" in data) {
1008
+ return data.nodes;
1009
+ }
1010
+ return [];
1011
+ }
1012
+ /**
1013
+ * Find the authoritative node for an RRN.
1014
+ * - Delegated RRN (RRN-BD-00000001): GET /api/v1/nodes?prefix=BD, return first match.
1015
+ * - Root RRN (RRN-00000042): return root node manifest.
1016
+ * - Unknown format: throws RCANNodeNotFoundError.
1017
+ */
1018
+ async discover(rrn) {
1019
+ const parsed = parseRRNNamespace(rrn);
1020
+ if (!parsed) {
1021
+ throw new RCANNodeNotFoundError(rrn, this.rootUrl);
1022
+ }
1023
+ if (parsed.type === "root") {
1024
+ return this.getNodeManifest(this.rootUrl);
1025
+ }
1026
+ const nodes = await this.listNodes(parsed.prefix);
1027
+ if (nodes.length === 0) {
1028
+ throw new RCANNodeNotFoundError(rrn, this.rootUrl);
1029
+ }
1030
+ return nodes[0];
1031
+ }
1032
+ /**
1033
+ * Resolve an RRN across the federation.
1034
+ * First tries {rootUrl}/api/v1/resolve/{rrn}.
1035
+ * On 404, discovers the authoritative node and tries {node.api_base}/robots/{rrn}.
1036
+ */
1037
+ async resolve(rrn) {
1038
+ const primaryUrl = `${this.rootUrl}/api/v1/resolve/${encodeURIComponent(rrn)}`;
1039
+ let resp;
1040
+ try {
1041
+ resp = await this._fetch(primaryUrl);
1042
+ } catch (err) {
1043
+ throw err;
1044
+ }
1045
+ if (resp.ok) {
1046
+ try {
1047
+ return await resp.json();
1048
+ } catch (err) {
1049
+ throw new RCANNodeSyncError(
1050
+ `Invalid JSON in resolve response for ${rrn}`,
1051
+ this.rootUrl,
1052
+ err instanceof Error ? err : void 0
1053
+ );
1054
+ }
1055
+ }
1056
+ if (resp.status !== 404) {
1057
+ throw new RCANNodeSyncError(
1058
+ `Unexpected HTTP ${resp.status} resolving ${rrn}`,
1059
+ this.rootUrl
1060
+ );
1061
+ }
1062
+ const node = await this.discover(rrn);
1063
+ const fallbackUrl = `${node.api_base.replace(/\/$/, "")}/robots/${encodeURIComponent(rrn)}`;
1064
+ const fallbackResp = await this._fetch(fallbackUrl);
1065
+ if (!fallbackResp.ok) {
1066
+ if (fallbackResp.status === 404) {
1067
+ throw new RCANNodeNotFoundError(rrn, node.api_base);
1068
+ }
1069
+ throw new RCANNodeSyncError(
1070
+ `HTTP ${fallbackResp.status} from authoritative node for ${rrn}`,
1071
+ node.api_base
1072
+ );
1073
+ }
1074
+ try {
1075
+ return await fallbackResp.json();
1076
+ } catch (err) {
1077
+ throw new RCANNodeSyncError(
1078
+ `Invalid JSON in fallback resolve response for ${rrn}`,
1079
+ node.api_base,
1080
+ err instanceof Error ? err : void 0
1081
+ );
1082
+ }
1083
+ }
1084
+ /**
1085
+ * Verify a node manifest is well-formed.
1086
+ * Checks required fields, ed25519: public_key prefix, valid node_type, https:// api_base.
1087
+ */
1088
+ verifyNode(manifest) {
1089
+ if (!manifest || typeof manifest !== "object") return false;
1090
+ const m = manifest;
1091
+ if (typeof m.rcan_node_version !== "string" || !m.rcan_node_version) return false;
1092
+ if (typeof m.node_type !== "string" || !VALID_NODE_TYPES.has(m.node_type)) return false;
1093
+ if (typeof m.operator !== "string" || !m.operator) return false;
1094
+ if (typeof m.namespace_prefix !== "string" || !m.namespace_prefix) return false;
1095
+ if (typeof m.public_key !== "string" || !m.public_key.startsWith("ed25519:")) return false;
1096
+ if (typeof m.api_base !== "string" || !m.api_base.startsWith("https://")) return false;
1097
+ return true;
1098
+ }
1099
+ };
1100
+
1101
+ // src/schema.ts
1102
+ var SCHEMA_BASE = "https://rcan.dev/schemas";
1103
+ var schemaCache = /* @__PURE__ */ new Map();
1104
+ async function fetchCanonicalSchema(schemaName) {
1105
+ if (schemaCache.has(schemaName)) return schemaCache.get(schemaName);
1106
+ try {
1107
+ const controller = new AbortController();
1108
+ const timer = setTimeout(() => controller.abort(), 5e3);
1109
+ const res = await fetch(`${SCHEMA_BASE}/${schemaName}`, { signal: controller.signal });
1110
+ clearTimeout(timer);
1111
+ if (!res.ok) return null;
1112
+ const schema = await res.json();
1113
+ schemaCache.set(schemaName, schema);
1114
+ return schema;
1115
+ } catch {
1116
+ return null;
1117
+ }
1118
+ }
1119
+ async function validateConfigAgainstSchema(config) {
1120
+ const schema = await fetchCanonicalSchema("rcan-config.schema.json");
1121
+ if (!schema) return { valid: true, skipped: true };
1122
+ const errors = [];
1123
+ if (typeof config !== "object" || config === null) {
1124
+ return { valid: false, errors: ["Config must be an object"] };
1125
+ }
1126
+ const cfg = config;
1127
+ const required = schema.required ?? [];
1128
+ for (const key of required) {
1129
+ if (!(key in cfg)) errors.push(`Missing required field: ${key}`);
1130
+ }
1131
+ return errors.length === 0 ? { valid: true } : { valid: false, errors };
1132
+ }
1133
+ async function validateNodeAgainstSchema(manifest) {
1134
+ const schema = await fetchCanonicalSchema("rcan-node.schema.json");
1135
+ if (!schema) return { valid: true, skipped: true };
1136
+ const errors = [];
1137
+ if (typeof manifest !== "object" || manifest === null) {
1138
+ return { valid: false, errors: ["Manifest must be an object"] };
1139
+ }
1140
+ const m = manifest;
1141
+ const required = schema.required ?? [];
1142
+ for (const key of required) {
1143
+ if (!(key in m)) errors.push(`Missing required field: ${key}`);
1144
+ }
1145
+ return errors.length === 0 ? { valid: true } : { valid: false, errors };
1146
+ }
1147
+
874
1148
  // src/index.ts
875
- var VERSION = "0.1.0";
1149
+ var VERSION = "0.3.0";
1150
+ var SPEC_VERSION = "1.2";
876
1151
  var RCAN_VERSION = "1.2";
877
1152
  export {
878
1153
  AuditChain,
@@ -881,11 +1156,16 @@ export {
881
1156
  ConfidenceGate,
882
1157
  GateError,
883
1158
  HiTLGate,
1159
+ NodeClient,
884
1160
  RCANAddressError,
885
1161
  RCANError,
886
1162
  RCANGateError,
887
1163
  RCANMessage,
888
1164
  RCANMessageError,
1165
+ RCANNodeError,
1166
+ RCANNodeNotFoundError,
1167
+ RCANNodeSyncError,
1168
+ RCANNodeTrustError,
889
1169
  RCANRegistryError,
890
1170
  RCANSignatureError,
891
1171
  RCANValidationError,
@@ -893,9 +1173,13 @@ export {
893
1173
  RegistryClient,
894
1174
  RobotURI,
895
1175
  RobotURIError,
1176
+ SPEC_VERSION,
896
1177
  VERSION,
1178
+ fetchCanonicalSchema,
897
1179
  validateConfig,
1180
+ validateConfigAgainstSchema,
898
1181
  validateMessage,
1182
+ validateNodeAgainstSchema,
899
1183
  validateURI
900
1184
  };
901
1185
  //# sourceMappingURL=browser.mjs.map