ncc-02-js 0.2.5 → 0.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 CHANGED
@@ -6,7 +6,7 @@ This library provides tools for service owners to publish records and for client
6
6
 
7
7
  ## Features
8
8
 
9
- - **Service Discovery**: Resolve Kind 30059 service records.
9
+ - **Service Discovery**: Resolve Kind 30059 service records for both public and private services.
10
10
  - **Verification**: Built-in signature and expiry validation.
11
11
  - **Trust Policy**: Support for third-party attestations (Kind 30060) and revocations (Kind 30061).
12
12
  - **Security**: Cross-validation of subject and service identifiers to prevent impersonation.
@@ -36,68 +36,33 @@ try {
36
36
  requireAttestation: true,
37
37
  minLevel: 'verified' // 'self', 'verified', 'hardened'
38
38
  });
39
- console.log('Resolved endpoint:', service.endpoint);
39
+ if(service.endpoint) {
40
+ console.log('Resolved endpoint:', service.endpoint);
41
+ } else {
42
+ console.log('Resolved private service, use NCC-05 for endpoint discovery.');
43
+ }
40
44
  } catch (err) {
41
45
  console.error('Resolution failed:', err.code, err.message);
46
+ } finally {
47
+ resolver.close(); // Clean up WebSocket connections
42
48
  }
43
49
  ```
44
50
 
45
51
  ### 2. Publish a Service Record
46
-
47
- ```javascript
48
- import { NCC02Builder } from 'ncc-02-js';
49
-
50
- // Initialize with private key (hex)
51
- const builder = new NCC02Builder(privateKey);
52
-
53
- // Example 1: IP-based Service
54
- const event = builder.createServiceRecord({
55
- serviceId: 'media',
56
- endpoint: 'https://203.0.113.45:8443',
57
- fingerprint: 'sha256:fingerprint',
58
- expiryDays: 14
59
- });
60
-
61
- // Example 2: Tor Onion Service
62
- const onionEvent = builder.createServiceRecord({
63
- serviceId: 'wallet',
64
- endpoint: 'tcp://vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd.onion:80',
65
- fingerprint: 'sha256:fingerprint',
66
- expiryDays: 7
67
- });
68
- // publish events to relays...
69
- ```
70
-
71
- ### 3. Issue an Attestation (CA)
72
-
73
- ```javascript
74
- const caBuilder = new NCC02Builder(caPrivateKey);
75
- const attestation = caBuilder.createAttestation({
76
- subjectPubkey: 'npub1...', // The service owner being certified
77
- serviceId: 'media',
78
- serviceEventId: serviceRecordEventId,
79
- level: 'verified',
80
- validDays: 30
81
- });
82
- ```
83
-
84
- ## Trust Model & Security
52
+ ...
53
+ ### Trust Model & Security
85
54
 
86
55
  ### Trust Levels
87
56
  - `self`: Asserted by the service owner (default if no attestation).
88
57
  - `verified`: Attested by a trusted third party.
89
58
  - `hardened`: Attested by a third party with stricter verification (e.g., physical proof or long-term history).
90
59
 
91
- ### Threat Model
92
- - **Endpoint Impersonation**: Prevented by binding the endpoint URI to a public key fingerprint (`k` tag).
93
- - **Man-in-the-Middle (MITM)**: Mitigated via cryptographic pinning of transport-level keys.
94
- - **Stale Records**: Limited by required expiry (`exp`) and support for revocations.
95
- - **Relay Censorship**: Mitigated by querying multiple relays (implemented via `SimplePool`).
60
+ ### Resolution Optimization
61
+ The resolver is designed to be network-efficient. It will only query for attestations (Kind 30060) and revocations (Kind 30061) if the provided policy requires them (e.g., when `requireAttestation` is set to `true` or a `minLevel` higher than `self` is requested).
96
62
 
97
- ### Fail-Closed Design
98
- The library follows a fail-closed principle. If a policy requirement is not met (e.g., `requireAttestation: true` but no valid attestation is found), it throws an `NCC02Error` rather than returning a partially verified record.
99
-
100
- ## API Reference
63
+ ### Threat Model
64
+ ...
65
+ ### API Reference
101
66
 
102
67
  ### `NCC02Resolver(relays, options)`
103
68
  - `relays`: Array of relay URLs.
@@ -108,8 +73,12 @@ The library follows a fail-closed principle. If a policy requirement is not met
108
73
  - `options.requireAttestation`: Fails if no trusted attestation is found.
109
74
  - `options.minLevel`: Minimum trust level required.
110
75
 
76
+ #### `close()`
77
+ Closes WebSocket connections to all relays. If the resolver was initialized with an external `pool`, this will *not* close the pool; it only untracks the relays for this instance. If the resolver created its own internal pool, it will close it entirely.
78
+
79
+
111
80
  ### `NCC02Builder(privateKey)`
112
- - `createServiceRecord({ serviceId, endpoint, fingerprint, expiryDays })`
81
+ - `createServiceRecord({ serviceId, endpoint?, fingerprint?, expiryDays? })`
113
82
  - `createAttestation({ subjectPubkey, serviceId, serviceEventId, level, validDays })`
114
83
  - `createRevocation({ attestationId, reason })`
115
84
 
package/dist/index.cjs CHANGED
@@ -2509,25 +2509,24 @@ var NCC02Builder = class {
2509
2509
  * Creates a signed Service Record (Kind 30059).
2510
2510
  * @param {Object} options
2511
2511
  * @param {string} options.serviceId - The 'd' tag identifier.
2512
- * @param {string} options.endpoint - The 'u' tag URI.
2513
- * @param {string} options.fingerprint - The 'k' tag fingerprint.
2512
+ * @param {string} [options.endpoint] - The 'u' tag URI.
2513
+ * @param {string} [options.fingerprint] - The 'k' tag fingerprint.
2514
2514
  * @param {number} [options.expiryDays=14] - Expiry in days.
2515
2515
  */
2516
2516
  createServiceRecord(options) {
2517
2517
  const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
2518
2518
  if (!serviceId) throw new Error("serviceId (d tag) is required");
2519
- if (!endpoint) throw new Error("endpoint (u tag) is required");
2520
- if (!fingerprint) throw new Error("fingerprint (k tag) is required");
2521
2519
  const expiry = Math.floor(Date.now() / 1e3) + expiryDays * 24 * 60 * 60;
2520
+ const tags = [
2521
+ ["d", serviceId],
2522
+ ["exp", expiry.toString()]
2523
+ ];
2524
+ if (endpoint) tags.push(["u", endpoint]);
2525
+ if (fingerprint) tags.push(["k", fingerprint]);
2522
2526
  const event = {
2523
2527
  kind: KINDS.SERVICE_RECORD,
2524
2528
  created_at: Math.floor(Date.now() / 1e3),
2525
- tags: [
2526
- ["d", serviceId],
2527
- ["u", endpoint],
2528
- ["k", fingerprint],
2529
- ["exp", expiry.toString()]
2530
- ],
2529
+ tags,
2531
2530
  content: `NCC-02 Service Record for ${serviceId}`,
2532
2531
  pubkey: this.pk
2533
2532
  };
@@ -7693,6 +7692,14 @@ var NCC02Resolver = class {
7693
7692
  this.ownsPool = !options.pool;
7694
7693
  this.trustedCAPubkeys = new Set(options.trustedCAPubkeys || []);
7695
7694
  }
7695
+ /**
7696
+ * Closes the connection to the relays if the pool is owned by this resolver.
7697
+ */
7698
+ close() {
7699
+ if (this.ownsPool && this.pool) {
7700
+ this.pool.close(this.relays);
7701
+ }
7702
+ }
7696
7703
  /**
7697
7704
  * Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
7698
7705
  * @param {import('nostr-tools').Filter} filter
@@ -7752,8 +7759,11 @@ var NCC02Resolver = class {
7752
7759
  }
7753
7760
  const serviceTags = Object.fromEntries(serviceEvent.tags);
7754
7761
  const now2 = Math.floor(Date.now() / 1e3);
7755
- if (!serviceTags.u || !serviceTags.k || !serviceTags.exp) {
7756
- throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tags (u, k, or exp)");
7762
+ if (!serviceTags.exp) {
7763
+ throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tag (exp)");
7764
+ }
7765
+ if (serviceTags.u && (serviceTags.u.startsWith("wss://") || serviceTags.u.startsWith("https://")) && !serviceTags.k) {
7766
+ throw new NCC02Error("MALFORMED_RECORD", "Service record with 'https' or 'wss' endpoint must have a 'k' tag");
7757
7767
  }
7758
7768
  const exp = parseInt(serviceTags.exp);
7759
7769
  if (isNaN(exp)) {
@@ -7762,35 +7772,37 @@ var NCC02Resolver = class {
7762
7772
  if (exp < now2) {
7763
7773
  throw new NCC02Error("EXPIRED", "Service record has expired");
7764
7774
  }
7765
- let attestations;
7766
- let revocations;
7767
- try {
7768
- [attestations, revocations] = await Promise.all([
7769
- this._query({ kinds: [KINDS.ATTESTATION], "#e": [serviceEvent.id] }),
7770
- this._query({ kinds: [KINDS.REVOCATION] })
7771
- ]);
7772
- } catch (err) {
7773
- throw new NCC02Error("RELAY_ERROR", "Failed to query relay for attestations/revocations", err);
7774
- }
7775
7775
  const validAttestations = [];
7776
- for (const att of attestations) {
7777
- if (this.trustedCAPubkeys.has(att.pubkey)) {
7778
- const attTags = Object.fromEntries(att.tags);
7779
- if (attTags.subj !== pubkey) continue;
7780
- if (attTags.srv !== serviceId) continue;
7781
- if (standard && attTags.std !== standard) continue;
7782
- if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
7783
- if (this._isAttestationValid(att, attTags, revocations)) {
7784
- validAttestations.push({
7785
- pubkey: att.pubkey,
7786
- level: attTags.lvl,
7787
- eventId: att.id
7788
- });
7776
+ if (requireAttestation || minLevel === "verified" || minLevel === "hardened") {
7777
+ let attestations;
7778
+ let revocations;
7779
+ try {
7780
+ [attestations, revocations] = await Promise.all([
7781
+ this._query({ kinds: [KINDS.ATTESTATION], "#e": [serviceEvent.id] }),
7782
+ this._query({ kinds: [KINDS.REVOCATION] })
7783
+ ]);
7784
+ } catch (err) {
7785
+ throw new NCC02Error("RELAY_ERROR", "Failed to query relay for attestations/revocations", err);
7786
+ }
7787
+ for (const att of attestations) {
7788
+ if (this.trustedCAPubkeys.has(att.pubkey)) {
7789
+ const attTags = Object.fromEntries(att.tags);
7790
+ if (attTags.subj !== pubkey) continue;
7791
+ if (attTags.srv !== serviceId) continue;
7792
+ if (standard && attTags.std !== standard) continue;
7793
+ if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
7794
+ if (this._isAttestationValid(att, attTags, revocations)) {
7795
+ validAttestations.push({
7796
+ pubkey: att.pubkey,
7797
+ level: attTags.lvl,
7798
+ eventId: att.id
7799
+ });
7800
+ }
7789
7801
  }
7790
7802
  }
7791
- }
7792
- if (requireAttestation && validAttestations.length === 0) {
7793
- throw new NCC02Error("POLICY_FAILURE", `No trusted attestations meet the required policy for ${serviceId}`);
7803
+ if (requireAttestation && validAttestations.length === 0) {
7804
+ throw new NCC02Error("POLICY_FAILURE", `No trusted attestations meet the required policy for ${serviceId}`);
7805
+ }
7794
7806
  }
7795
7807
  return {
7796
7808
  endpoint: serviceTags.u,
package/dist/index.mjs CHANGED
@@ -2475,25 +2475,24 @@ var NCC02Builder = class {
2475
2475
  * Creates a signed Service Record (Kind 30059).
2476
2476
  * @param {Object} options
2477
2477
  * @param {string} options.serviceId - The 'd' tag identifier.
2478
- * @param {string} options.endpoint - The 'u' tag URI.
2479
- * @param {string} options.fingerprint - The 'k' tag fingerprint.
2478
+ * @param {string} [options.endpoint] - The 'u' tag URI.
2479
+ * @param {string} [options.fingerprint] - The 'k' tag fingerprint.
2480
2480
  * @param {number} [options.expiryDays=14] - Expiry in days.
2481
2481
  */
2482
2482
  createServiceRecord(options) {
2483
2483
  const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
2484
2484
  if (!serviceId) throw new Error("serviceId (d tag) is required");
2485
- if (!endpoint) throw new Error("endpoint (u tag) is required");
2486
- if (!fingerprint) throw new Error("fingerprint (k tag) is required");
2487
2485
  const expiry = Math.floor(Date.now() / 1e3) + expiryDays * 24 * 60 * 60;
2486
+ const tags = [
2487
+ ["d", serviceId],
2488
+ ["exp", expiry.toString()]
2489
+ ];
2490
+ if (endpoint) tags.push(["u", endpoint]);
2491
+ if (fingerprint) tags.push(["k", fingerprint]);
2488
2492
  const event = {
2489
2493
  kind: KINDS.SERVICE_RECORD,
2490
2494
  created_at: Math.floor(Date.now() / 1e3),
2491
- tags: [
2492
- ["d", serviceId],
2493
- ["u", endpoint],
2494
- ["k", fingerprint],
2495
- ["exp", expiry.toString()]
2496
- ],
2495
+ tags,
2497
2496
  content: `NCC-02 Service Record for ${serviceId}`,
2498
2497
  pubkey: this.pk
2499
2498
  };
@@ -7659,6 +7658,14 @@ var NCC02Resolver = class {
7659
7658
  this.ownsPool = !options.pool;
7660
7659
  this.trustedCAPubkeys = new Set(options.trustedCAPubkeys || []);
7661
7660
  }
7661
+ /**
7662
+ * Closes the connection to the relays if the pool is owned by this resolver.
7663
+ */
7664
+ close() {
7665
+ if (this.ownsPool && this.pool) {
7666
+ this.pool.close(this.relays);
7667
+ }
7668
+ }
7662
7669
  /**
7663
7670
  * Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
7664
7671
  * @param {import('nostr-tools').Filter} filter
@@ -7718,8 +7725,11 @@ var NCC02Resolver = class {
7718
7725
  }
7719
7726
  const serviceTags = Object.fromEntries(serviceEvent.tags);
7720
7727
  const now2 = Math.floor(Date.now() / 1e3);
7721
- if (!serviceTags.u || !serviceTags.k || !serviceTags.exp) {
7722
- throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tags (u, k, or exp)");
7728
+ if (!serviceTags.exp) {
7729
+ throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tag (exp)");
7730
+ }
7731
+ if (serviceTags.u && (serviceTags.u.startsWith("wss://") || serviceTags.u.startsWith("https://")) && !serviceTags.k) {
7732
+ throw new NCC02Error("MALFORMED_RECORD", "Service record with 'https' or 'wss' endpoint must have a 'k' tag");
7723
7733
  }
7724
7734
  const exp = parseInt(serviceTags.exp);
7725
7735
  if (isNaN(exp)) {
@@ -7728,35 +7738,37 @@ var NCC02Resolver = class {
7728
7738
  if (exp < now2) {
7729
7739
  throw new NCC02Error("EXPIRED", "Service record has expired");
7730
7740
  }
7731
- let attestations;
7732
- let revocations;
7733
- try {
7734
- [attestations, revocations] = await Promise.all([
7735
- this._query({ kinds: [KINDS.ATTESTATION], "#e": [serviceEvent.id] }),
7736
- this._query({ kinds: [KINDS.REVOCATION] })
7737
- ]);
7738
- } catch (err) {
7739
- throw new NCC02Error("RELAY_ERROR", "Failed to query relay for attestations/revocations", err);
7740
- }
7741
7741
  const validAttestations = [];
7742
- for (const att of attestations) {
7743
- if (this.trustedCAPubkeys.has(att.pubkey)) {
7744
- const attTags = Object.fromEntries(att.tags);
7745
- if (attTags.subj !== pubkey) continue;
7746
- if (attTags.srv !== serviceId) continue;
7747
- if (standard && attTags.std !== standard) continue;
7748
- if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
7749
- if (this._isAttestationValid(att, attTags, revocations)) {
7750
- validAttestations.push({
7751
- pubkey: att.pubkey,
7752
- level: attTags.lvl,
7753
- eventId: att.id
7754
- });
7742
+ if (requireAttestation || minLevel === "verified" || minLevel === "hardened") {
7743
+ let attestations;
7744
+ let revocations;
7745
+ try {
7746
+ [attestations, revocations] = await Promise.all([
7747
+ this._query({ kinds: [KINDS.ATTESTATION], "#e": [serviceEvent.id] }),
7748
+ this._query({ kinds: [KINDS.REVOCATION] })
7749
+ ]);
7750
+ } catch (err) {
7751
+ throw new NCC02Error("RELAY_ERROR", "Failed to query relay for attestations/revocations", err);
7752
+ }
7753
+ for (const att of attestations) {
7754
+ if (this.trustedCAPubkeys.has(att.pubkey)) {
7755
+ const attTags = Object.fromEntries(att.tags);
7756
+ if (attTags.subj !== pubkey) continue;
7757
+ if (attTags.srv !== serviceId) continue;
7758
+ if (standard && attTags.std !== standard) continue;
7759
+ if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
7760
+ if (this._isAttestationValid(att, attTags, revocations)) {
7761
+ validAttestations.push({
7762
+ pubkey: att.pubkey,
7763
+ level: attTags.lvl,
7764
+ eventId: att.id
7765
+ });
7766
+ }
7755
7767
  }
7756
7768
  }
7757
- }
7758
- if (requireAttestation && validAttestations.length === 0) {
7759
- throw new NCC02Error("POLICY_FAILURE", `No trusted attestations meet the required policy for ${serviceId}`);
7769
+ if (requireAttestation && validAttestations.length === 0) {
7770
+ throw new NCC02Error("POLICY_FAILURE", `No trusted attestations meet the required policy for ${serviceId}`);
7771
+ }
7760
7772
  }
7761
7773
  return {
7762
7774
  endpoint: serviceTags.u,
package/dist/models.d.ts CHANGED
@@ -22,14 +22,14 @@ export class NCC02Builder {
22
22
  * Creates a signed Service Record (Kind 30059).
23
23
  * @param {Object} options
24
24
  * @param {string} options.serviceId - The 'd' tag identifier.
25
- * @param {string} options.endpoint - The 'u' tag URI.
26
- * @param {string} options.fingerprint - The 'k' tag fingerprint.
25
+ * @param {string} [options.endpoint] - The 'u' tag URI.
26
+ * @param {string} [options.fingerprint] - The 'k' tag fingerprint.
27
27
  * @param {number} [options.expiryDays=14] - Expiry in days.
28
28
  */
29
29
  createServiceRecord(options: {
30
30
  serviceId: string;
31
- endpoint: string;
32
- fingerprint: string;
31
+ endpoint?: string;
32
+ fingerprint?: string;
33
33
  expiryDays?: number;
34
34
  }): import("nostr-tools/core").VerifiedEvent;
35
35
  /**
@@ -13,8 +13,8 @@ export class NCC02Error extends Error {
13
13
  }
14
14
  /**
15
15
  * @typedef {Object} ResolvedService
16
- * @property {string} endpoint
17
- * @property {string} fingerprint
16
+ * @property {string|undefined} endpoint
17
+ * @property {string|undefined} fingerprint
18
18
  * @property {number} expiry
19
19
  * @property {any[]} attestations
20
20
  * @property {string} eventId
@@ -42,6 +42,10 @@ export class NCC02Resolver {
42
42
  pool: SimplePool;
43
43
  ownsPool: boolean;
44
44
  trustedCAPubkeys: Set<string>;
45
+ /**
46
+ * Closes the connection to the relays if the pool is owned by this resolver.
47
+ */
48
+ close(): void;
45
49
  /**
46
50
  * Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
47
51
  * @param {import('nostr-tools').Filter} filter
@@ -87,8 +91,8 @@ export class NCC02Resolver {
87
91
  verifyEndpoint(resolved: ResolvedService, actualFingerprint: string): boolean;
88
92
  }
89
93
  export type ResolvedService = {
90
- endpoint: string;
91
- fingerprint: string;
94
+ endpoint: string | undefined;
95
+ fingerprint: string | undefined;
92
96
  expiry: number;
93
97
  attestations: any[];
94
98
  eventId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncc-02-js",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "Nostr-native service discovery and trust implementation (NCC-02)",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",
package/src/models.js CHANGED
@@ -27,26 +27,26 @@ export class NCC02Builder {
27
27
  * Creates a signed Service Record (Kind 30059).
28
28
  * @param {Object} options
29
29
  * @param {string} options.serviceId - The 'd' tag identifier.
30
- * @param {string} options.endpoint - The 'u' tag URI.
31
- * @param {string} options.fingerprint - The 'k' tag fingerprint.
30
+ * @param {string} [options.endpoint] - The 'u' tag URI.
31
+ * @param {string} [options.fingerprint] - The 'k' tag fingerprint.
32
32
  * @param {number} [options.expiryDays=14] - Expiry in days.
33
33
  */
34
34
  createServiceRecord(options) {
35
35
  const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
36
36
  if (!serviceId) throw new Error('serviceId (d tag) is required');
37
- if (!endpoint) throw new Error('endpoint (u tag) is required');
38
- if (!fingerprint) throw new Error('fingerprint (k tag) is required');
39
37
 
40
38
  const expiry = Math.floor(Date.now() / 1000) + (expiryDays * 24 * 60 * 60);
39
+ const tags = [
40
+ ['d', serviceId],
41
+ ['exp', expiry.toString()]
42
+ ];
43
+ if(endpoint) tags.push(['u', endpoint]);
44
+ if(fingerprint) tags.push(['k', fingerprint]);
45
+
41
46
  const event = {
42
47
  kind: KINDS.SERVICE_RECORD,
43
48
  created_at: Math.floor(Date.now() / 1000),
44
- tags: [
45
- ['d', serviceId],
46
- ['u', endpoint],
47
- ['k', fingerprint],
48
- ['exp', expiry.toString()]
49
- ],
49
+ tags: tags,
50
50
  content: `NCC-02 Service Record for ${serviceId}`,
51
51
  pubkey: this.pk
52
52
  };
package/src/resolver.js CHANGED
@@ -19,8 +19,8 @@ export class NCC02Error extends Error {
19
19
 
20
20
  /**
21
21
  * @typedef {Object} ResolvedService
22
- * @property {string} endpoint
23
- * @property {string} fingerprint
22
+ * @property {string|undefined} endpoint
23
+ * @property {string|undefined} fingerprint
24
24
  * @property {number} expiry
25
25
  * @property {any[]} attestations
26
26
  * @property {string} eventId
@@ -43,7 +43,7 @@ export class NCC02Resolver {
43
43
  */
44
44
  constructor(relays, options = {}) {
45
45
  if (!Array.isArray(relays)) {
46
- throw new Error("NCC02Resolver expects an array of relay URLs.");
46
+ throw new Error('NCC02Resolver expects an array of relay URLs.');
47
47
  }
48
48
  this.relays = relays;
49
49
  this.pool = options.pool || new SimplePool();
@@ -51,22 +51,31 @@ export class NCC02Resolver {
51
51
  this.trustedCAPubkeys = new Set(options.trustedCAPubkeys || []);
52
52
  }
53
53
 
54
+ /**
55
+ * Closes the connection to the relays if the pool is owned by this resolver.
56
+ */
57
+ close() {
58
+ if (this.ownsPool && this.pool) {
59
+ this.pool.close(this.relays);
60
+ }
61
+ }
62
+
54
63
  /**
55
64
  * Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
56
65
  * @param {import('nostr-tools').Filter} filter
57
66
  * @returns {Promise<import('nostr-tools').Event[]>}
58
67
  */
59
68
  async _query(filter) {
60
- return new Promise((resolve) => {
61
- /** @type {import('nostr-tools').Event[]} */
62
- const events = [];
63
- // subscribeMany(relays, filters, callbacks)
64
- // @ts-ignore - subscribeMany filters parameter type mismatch with simple Object
65
- const sub = this.pool.subscribeMany(this.relays, [filter], {
66
- onevent(e) { events.push(e); },
67
- oneose() { sub.close(); resolve(events); }
68
- });
69
+ return new Promise((resolve) => {
70
+ /** @type {import('nostr-tools').Event[]} */
71
+ const events = [];
72
+ // subscribeMany(relays, filters, callbacks)
73
+ // @ts-ignore - subscribeMany filters parameter type mismatch with simple Object
74
+ const sub = this.pool.subscribeMany(this.relays, [filter], {
75
+ onevent(e) { events.push(e); },
76
+ oneose() { sub.close(); resolve(events); }
69
77
  });
78
+ });
70
79
  }
71
80
 
72
81
  /**
@@ -117,8 +126,13 @@ export class NCC02Resolver {
117
126
  const now = Math.floor(Date.now() / 1000);
118
127
 
119
128
  // Security Fix: exp is REQUIRED by NCC-02 spec
120
- if (!serviceTags.u || !serviceTags.k || !serviceTags.exp) {
121
- throw new NCC02Error('MALFORMED_RECORD', 'Service record is missing required tags (u, k, or exp)');
129
+ if (!serviceTags.exp) {
130
+ throw new NCC02Error('MALFORMED_RECORD', 'Service record is missing required tag (exp)');
131
+ }
132
+
133
+ // 'k' is required for TLS-based endpoints
134
+ if (serviceTags.u && (serviceTags.u.startsWith('wss://') || serviceTags.u.startsWith('https://')) && !serviceTags.k) {
135
+ throw new NCC02Error('MALFORMED_RECORD', 'Service record with \'https\' or \'wss\' endpoint must have a \'k\' tag');
122
136
  }
123
137
 
124
138
  const exp = parseInt(serviceTags.exp);
@@ -129,42 +143,46 @@ export class NCC02Resolver {
129
143
  throw new NCC02Error('EXPIRED', 'Service record has expired');
130
144
  }
131
145
 
132
- let attestations;
133
- let revocations;
134
- try {
135
- [attestations, revocations] = await Promise.all([
136
- this._query({ kinds: [KINDS.ATTESTATION], '#e': [serviceEvent.id] }),
137
- this._query({ kinds: [KINDS.REVOCATION] })
138
- ]);
139
- } catch (err) {
140
- throw new NCC02Error('RELAY_ERROR', 'Failed to query relay for attestations/revocations', err);
141
- }
142
-
143
146
  const validAttestations = [];
144
- for (const att of attestations) {
145
- if (this.trustedCAPubkeys.has(att.pubkey)) {
146
- const attTags = Object.fromEntries(att.tags);
147
-
148
- // Cross-validate subject, service ID, and standard
149
- if (attTags.subj !== pubkey) continue;
150
- if (attTags.srv !== serviceId) continue;
151
- if (standard && attTags.std !== standard) continue;
152
-
153
- // Trust Level Filtering
154
- if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
155
-
156
- if (this._isAttestationValid(att, attTags, revocations)) {
157
- validAttestations.push({
158
- pubkey: att.pubkey,
159
- level: attTags.lvl,
160
- eventId: att.id
161
- });
147
+
148
+ // Optimization: Only fetch attestations if policy requires it
149
+ if (requireAttestation || minLevel === 'verified' || minLevel === 'hardened') {
150
+ let attestations;
151
+ let revocations;
152
+ try {
153
+ [attestations, revocations] = await Promise.all([
154
+ this._query({ kinds: [KINDS.ATTESTATION], '#e': [serviceEvent.id] }),
155
+ this._query({ kinds: [KINDS.REVOCATION] })
156
+ ]);
157
+ } catch (err) {
158
+ throw new NCC02Error('RELAY_ERROR', 'Failed to query relay for attestations/revocations', err);
159
+ }
160
+
161
+ for (const att of attestations) {
162
+ if (this.trustedCAPubkeys.has(att.pubkey)) {
163
+ const attTags = Object.fromEntries(att.tags);
164
+
165
+ // Cross-validate subject, service ID, and standard
166
+ if (attTags.subj !== pubkey) continue;
167
+ if (attTags.srv !== serviceId) continue;
168
+ if (standard && attTags.std !== standard) continue;
169
+
170
+ // Trust Level Filtering
171
+ if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
172
+
173
+ if (this._isAttestationValid(att, attTags, revocations)) {
174
+ validAttestations.push({
175
+ pubkey: att.pubkey,
176
+ level: attTags.lvl,
177
+ eventId: att.id
178
+ });
179
+ }
162
180
  }
163
181
  }
164
- }
165
182
 
166
- if (requireAttestation && validAttestations.length === 0) {
167
- throw new NCC02Error('POLICY_FAILURE', `No trusted attestations meet the required policy for ${serviceId}`);
183
+ if (requireAttestation && validAttestations.length === 0) {
184
+ throw new NCC02Error('POLICY_FAILURE', `No trusted attestations meet the required policy for ${serviceId}`);
185
+ }
168
186
  }
169
187
 
170
188
  return {