javascript-solid-server 0.0.158 → 0.0.159

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/clock-updater.mjs CHANGED
@@ -3,8 +3,7 @@
3
3
  * Usage: node clock-updater.mjs
4
4
  */
5
5
 
6
- import { getPublicKey, finalizeEvent } from 'nostr-tools';
7
- import { getToken } from 'nostr-tools/nip98';
6
+ import { getPublicKey, nip98Token } from './src/nostr/event.js';
8
7
 
9
8
  // Nostr keypair (in production, load from env/file)
10
9
  const SK_HEX = '3f188544fb81bd324ead7be9697fd9503d18345e233a7b0182915b0b582ddd70';
@@ -26,7 +25,11 @@ async function updateClock() {
26
25
  };
27
26
 
28
27
  try {
29
- const token = await getToken(CLOCK_URL, 'PUT', (e) => finalizeEvent(e, sk));
28
+ // Serialize once: the same bytes feed both the NIP-98 payload hash
29
+ // and the fetch body. nip98Token requires bytes (not an object) so
30
+ // the `payload` tag matches what the server actually receives.
31
+ const bodyBytes = JSON.stringify(clockData);
32
+ const token = nip98Token(CLOCK_URL, 'PUT', sk, bodyBytes);
30
33
 
31
34
  const res = await fetch(CLOCK_URL, {
32
35
  method: 'PUT',
@@ -34,7 +37,7 @@ async function updateClock() {
34
37
  'Content-Type': 'application/ld+json',
35
38
  'Authorization': 'Nostr ' + token
36
39
  },
37
- body: JSON.stringify(clockData)
40
+ body: bodyBytes
38
41
  });
39
42
 
40
43
  const time = isoDate.split('T')[1].replace('Z', '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.158",
3
+ "version": "0.0.159",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@noble/curves": "^1.2.0",
28
+ "@noble/hashes": "^1.3.2",
28
29
  "@fastify/middie": "^8.3.3",
29
30
  "@fastify/rate-limit": "^9.1.0",
30
31
  "@fastify/websocket": "^8.3.1",
@@ -36,7 +37,6 @@
36
37
  "jose": "^6.1.3",
37
38
  "microfed": "^0.0.14",
38
39
  "n3": "^1.26.0",
39
- "nostr-tools": "^2.19.4",
40
40
  "oidc-provider": "^9.6.0",
41
41
  "sql.js": "^1.13.0"
42
42
  },
package/src/auth/nostr.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * did:nostr:<64-char-hex-pubkey>
12
12
  */
13
13
 
14
- import { verifyEvent } from 'nostr-tools';
14
+ import { verifyEvent, getEventHash } from '../nostr/event.js';
15
15
  import crypto from 'crypto';
16
16
  import { resolveDidNostrToWebId } from './did-nostr.js';
17
17
 
@@ -216,17 +216,11 @@ export async function verifyNostrAuth(request) {
216
216
  return { webId: null, error: 'Invalid or missing pubkey' };
217
217
  }
218
218
 
219
- // Compute event id if missing (lenient mode for nosdav compatibility)
219
+ // Compute event id if missing (lenient mode for nosdav compatibility).
220
+ // Uses the same canonical serialization as `verifyEvent` below so we
221
+ // can't drift out of sync with how the verifier hashes events.
220
222
  if (!event.id) {
221
- const serialized = JSON.stringify([
222
- 0,
223
- event.pubkey,
224
- event.created_at,
225
- event.kind,
226
- event.tags,
227
- event.content
228
- ]);
229
- event.id = crypto.createHash('sha256').update(serialized).digest('hex');
223
+ event.id = getEventHash(event);
230
224
  }
231
225
 
232
226
  // Verify Schnorr signature
@@ -0,0 +1,206 @@
1
+ /**
2
+ * NIP-01 / NIP-98 event utilities — verifier and signer.
3
+ *
4
+ * Node-targeted: uses `node:crypto` (`createHash`) and `Buffer` for
5
+ * SHA-256 and base64. The crypto primitive (`@noble/curves` Schnorr)
6
+ * is itself runtime-portable, but this module is wired for Node and
7
+ * is what JSS runs on. Don't claim Workers/Deno portability without
8
+ * swapping `node:crypto` for `crypto.subtle` and `Buffer` for
9
+ * `btoa`/`TextEncoder`.
10
+ *
11
+ * Replaces the `nostr-tools` dependency tree we previously pulled just
12
+ * for a few functions (#135).
13
+ *
14
+ * Verifier surface (used by production):
15
+ * - `getEventHash`, `validateEvent`, `verifyEvent`
16
+ * - consumed by `src/auth/nostr.js` (NIP-98 HTTP auth) and
17
+ * `src/nostr/relay.js` (in-process relay).
18
+ *
19
+ * Signer surface (used by integration tests + dev scripts):
20
+ * - `generateSecretKey`, `getPublicKey`, `finalizeEvent`, `nip98Token`
21
+ * - consumed by `test/*.js` and the repo-root `*.mjs`/`test-*.js`
22
+ * dev scripts. Living in `src/` rather than `test/helpers/` so
23
+ * non-test consumers don't reach into test-only code paths.
24
+ */
25
+
26
+ import { schnorr, secp256k1 } from '@noble/curves/secp256k1';
27
+ import { createHash } from 'node:crypto';
28
+
29
+ // NIP-01 and BIP-340 specify hex fields in lowercase. We require it
30
+ // strictly — accepting uppercase here would let an event verify but
31
+ // then fail downstream case-sensitive lookups (e.g. relay filters
32
+ // matching `event.id` exactly), so callers would have to remember to
33
+ // normalize. Strict at the gate avoids that whole class of bug.
34
+ const HEX_64 = /^[a-f0-9]{64}$/;
35
+ const HEX_128 = /^[a-f0-9]{128}$/;
36
+
37
+ /**
38
+ * Compute the canonical NIP-01 event id.
39
+ * Per NIP-01 the id is `sha256(JSON.stringify([0, pubkey, created_at,
40
+ * kind, tags, content]))` with no whitespace, hex-encoded lowercase.
41
+ *
42
+ * @param {object} event - Event with pubkey/created_at/kind/tags/content
43
+ * @returns {string} 64-char lowercase hex sha256 digest
44
+ */
45
+ export function getEventHash(event) {
46
+ const serialized = JSON.stringify([
47
+ 0,
48
+ event.pubkey,
49
+ event.created_at,
50
+ event.kind,
51
+ event.tags,
52
+ event.content
53
+ ]);
54
+ return createHash('sha256').update(serialized, 'utf8').digest('hex');
55
+ }
56
+
57
+ /**
58
+ * Structural validation — does the object have the shape required of a
59
+ * NIP-01 event? Doesn't compute the hash or verify the signature.
60
+ */
61
+ export function validateEvent(event) {
62
+ if (!event || typeof event !== 'object' || Array.isArray(event)) return false;
63
+ if (typeof event.id !== 'string' || !HEX_64.test(event.id)) return false;
64
+ if (typeof event.pubkey !== 'string' || !HEX_64.test(event.pubkey)) return false;
65
+ if (typeof event.sig !== 'string' || !HEX_128.test(event.sig)) return false;
66
+ // NIP-01 doesn't cap kinds at 16 bits — many real kinds (10002, 30023,
67
+ // etc.) are above 65535. Accept any non-negative safe integer.
68
+ if (!Number.isSafeInteger(event.kind) || event.kind < 0) return false;
69
+ // Use isSafeInteger for parity with kind — non-safe ints can't round-trip
70
+ // through JSON without precision loss, which would corrupt the canonical hash.
71
+ if (!Number.isSafeInteger(event.created_at) || event.created_at < 0) return false;
72
+ if (typeof event.content !== 'string') return false;
73
+ if (!Array.isArray(event.tags)) return false;
74
+ for (const tag of event.tags) {
75
+ if (!Array.isArray(tag)) return false;
76
+ for (const v of tag) if (typeof v !== 'string') return false;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * Full event verification: passes structural validation AND the
83
+ * declared `id` matches the recomputed hash AND the Schnorr signature
84
+ * (BIP-340) is valid for that id under `pubkey`.
85
+ *
86
+ * Returns `true` only on full success; any failure or thrown crypto
87
+ * error becomes `false` so callers don't have to wrap in try/catch.
88
+ */
89
+ export function verifyEvent(event) {
90
+ if (!validateEvent(event)) return false;
91
+ if (event.id !== getEventHash(event)) return false;
92
+ try {
93
+ return schnorr.verify(event.sig, event.id, event.pubkey);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------
100
+ // Signer-side helpers
101
+ // ---------------------------------------------------------------------
102
+
103
+ /**
104
+ * Generate a random 32-byte secp256k1 private key.
105
+ *
106
+ * Always uses `secp256k1.utils.randomPrivateKey()` so the result is
107
+ * guaranteed to be in [1, n-1] (a valid secp256k1 scalar). A naive
108
+ * `crypto.randomBytes(32)` fallback would have a vanishing-but-nonzero
109
+ * chance of producing 0 or a value >= curve order, which would manifest
110
+ * as flaky signing/verification.
111
+ */
112
+ export function generateSecretKey() {
113
+ if (!secp256k1.utils?.randomPrivateKey) {
114
+ throw new Error('secp256k1.utils.randomPrivateKey is unavailable');
115
+ }
116
+ return secp256k1.utils.randomPrivateKey();
117
+ }
118
+
119
+ /**
120
+ * Derive the BIP-340 x-only public key as 64-char lowercase hex.
121
+ */
122
+ export function getPublicKey(secretKey) {
123
+ return Buffer.from(schnorr.getPublicKey(secretKey)).toString('hex');
124
+ }
125
+
126
+ /**
127
+ * Take a partial Nostr event, compute its NIP-01 id, sign it with the
128
+ * given secret key, and return the finalized event.
129
+ *
130
+ * Validates the template up-front so we can't produce events that would
131
+ * later fail `verifyEvent` (e.g. `kind: undefined` would JSON-serialize
132
+ * as `null` and the resulting id would round-trip as garbage).
133
+ *
134
+ * @param {object} template - {kind, tags?, content?, created_at?}
135
+ * @param {Uint8Array|string} secretKey
136
+ */
137
+ export function finalizeEvent(template, secretKey) {
138
+ if (!template || typeof template !== 'object') {
139
+ throw new TypeError('finalizeEvent: template must be an object');
140
+ }
141
+ if (!Number.isSafeInteger(template.kind) || template.kind < 0) {
142
+ throw new TypeError('finalizeEvent: template.kind must be a non-negative safe integer');
143
+ }
144
+ if (template.created_at !== undefined &&
145
+ (!Number.isSafeInteger(template.created_at) || template.created_at < 0)) {
146
+ throw new TypeError('finalizeEvent: template.created_at must be a non-negative safe integer');
147
+ }
148
+ if (template.tags !== undefined && !Array.isArray(template.tags)) {
149
+ throw new TypeError('finalizeEvent: template.tags must be an array');
150
+ }
151
+ if (template.content !== undefined && typeof template.content !== 'string') {
152
+ throw new TypeError('finalizeEvent: template.content must be a string');
153
+ }
154
+ const pubkey = getPublicKey(secretKey);
155
+ const event = {
156
+ pubkey,
157
+ created_at: template.created_at ?? Math.floor(Date.now() / 1000),
158
+ kind: template.kind,
159
+ tags: template.tags ?? [],
160
+ content: template.content ?? ''
161
+ };
162
+ event.id = getEventHash(event);
163
+ event.sig = Buffer.from(schnorr.sign(event.id, secretKey)).toString('hex');
164
+ return event;
165
+ }
166
+
167
+ /**
168
+ * Build a NIP-98 HTTP auth header value (the part after `Nostr `):
169
+ * a kind-27235 event signed with `secretKey`, base64-encoded.
170
+ *
171
+ * NIP-98's `payload` tag is the SHA-256 of the *exact bytes* the client
172
+ * will send on the wire. So `body` must be a `string`, `Uint8Array`, or
173
+ * `Buffer` representing those bytes — passing a plain JS object would
174
+ * force us to re-serialize via `JSON.stringify`, which is unlikely to
175
+ * match the bytes `fetch()` would actually send (whitespace, key order,
176
+ * non-JSON payloads). Callers serialize once and pass the bytes here.
177
+ *
178
+ * @param {string} url - Full request URL (becomes the `u` tag)
179
+ * @param {string} method - HTTP method (becomes the `method` tag, uppercased)
180
+ * @param {Uint8Array|string} secretKey - 32-byte secret key
181
+ * @param {string|Uint8Array|Buffer|null} [body] - Optional request body
182
+ * *bytes*; if present the SHA-256 hex hash is added as a `payload` tag
183
+ * per NIP-98.
184
+ * @returns {string} base64-encoded signed event
185
+ */
186
+ export function nip98Token(url, method, secretKey, body = null) {
187
+ const tags = [
188
+ ['u', url],
189
+ ['method', method.toUpperCase()]
190
+ ];
191
+ if (body !== null && body !== undefined) {
192
+ if (typeof body !== 'string' && !(body instanceof Uint8Array)) {
193
+ throw new TypeError(
194
+ 'nip98Token: body must be a string, Uint8Array, or Buffer — ' +
195
+ 'pass the exact bytes that will be sent on the wire'
196
+ );
197
+ }
198
+ const bytes = typeof body === 'string'
199
+ ? Buffer.from(body, 'utf8')
200
+ : body;
201
+ const hash = createHash('sha256').update(bytes).digest('hex');
202
+ tags.push(['payload', hash]);
203
+ }
204
+ const event = finalizeEvent({ kind: 27235, tags, content: '' }, secretKey);
205
+ return Buffer.from(JSON.stringify(event)).toString('base64');
206
+ }
@@ -8,7 +8,7 @@
8
8
  * Endpoint: wss://your.pod/relay
9
9
  */
10
10
 
11
- import { validateEvent, verifyEvent } from 'nostr-tools';
11
+ import { validateEvent, verifyEvent } from './event.js';
12
12
  import websocket from '@fastify/websocket';
13
13
 
14
14
  // Default max events to prevent memory exhaustion
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, it, before, after, mock } from 'node:test';
6
6
  import assert from 'node:assert';
7
- import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
7
+ import { generateSecretKey, getPublicKey, finalizeEvent } from '../src/nostr/event.js';
8
8
  import {
9
9
  startTestServer,
10
10
  stopTestServer,
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Unit tests for src/nostr/event.js — the minimal NIP-01 event verifier
3
+ * that replaced nostr-tools (#135).
4
+ *
5
+ * Coverage focuses on the security-critical paths:
6
+ * - Round-trip: a freshly-signed event verifies.
7
+ * - Tamper rejection: any byte mutation makes it fail.
8
+ * - Wrong-key rejection: a sig from a different key fails.
9
+ * - Structural rejection: malformed events fail validateEvent.
10
+ * - getEventHash matches NIP-01's canonical serialization.
11
+ */
12
+
13
+ import { describe, it } from 'node:test';
14
+ import assert from 'node:assert';
15
+ import { schnorr } from '@noble/curves/secp256k1';
16
+
17
+ import {
18
+ getEventHash,
19
+ validateEvent,
20
+ verifyEvent,
21
+ generateSecretKey,
22
+ getPublicKey,
23
+ finalizeEvent,
24
+ nip98Token
25
+ } from '../src/nostr/event.js';
26
+
27
+ describe('nostr event utilities (#135)', () => {
28
+ describe('getEventHash', () => {
29
+ it('matches NIP-01 canonical serialization (sha256 of [0,pubkey,...])', () => {
30
+ // Pin a deterministic input AND a precomputed digest so both the
31
+ // canonical serialization and the SHA-256 step are locked in.
32
+ // Recompute via:
33
+ // echo -n '[0,"00...01",1000000,1,[],"hello"]' | sha256sum
34
+ const event = {
35
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
36
+ created_at: 1000000,
37
+ kind: 1,
38
+ tags: [],
39
+ content: 'hello'
40
+ };
41
+ const baseline = getEventHash(event);
42
+ assert.strictEqual(
43
+ baseline,
44
+ '01f1ec62e464146177ccfe8580ae050847b3cc48c7eca3e0678fc7b92cedfef0',
45
+ 'NIP-01 canonical hash regression — change here means serialization or SHA-256 has shifted'
46
+ );
47
+ assert.match(baseline, /^[a-f0-9]{64}$/);
48
+
49
+ // Any change to any canonical field must yield a different hash.
50
+ assert.notStrictEqual(getEventHash({ ...event, content: 'hello!' }), baseline);
51
+ assert.notStrictEqual(getEventHash({ ...event, kind: 2 }), baseline);
52
+ assert.notStrictEqual(getEventHash({ ...event, created_at: 1000001 }), baseline);
53
+ assert.notStrictEqual(getEventHash({ ...event, tags: [['t']] }), baseline);
54
+ });
55
+
56
+ it('serializes pubkey verbatim (case-strict per NIP-01)', () => {
57
+ // NIP-01 specifies lowercase hex throughout. We don't normalize
58
+ // inside getEventHash — uppercase pubkey would produce a different
59
+ // hash, but validateEvent/verifyEvent reject uppercase before we
60
+ // get here, so callers never see the divergence in practice.
61
+ // Use a pubkey with hex letters so upper- and lower-case differ.
62
+ const lower = {
63
+ pubkey: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
64
+ created_at: 1, kind: 1, tags: [], content: ''
65
+ };
66
+ const upper = { ...lower, pubkey: lower.pubkey.toUpperCase() };
67
+ assert.notStrictEqual(
68
+ getEventHash(lower), getEventHash(upper),
69
+ 'getEventHash is canonical: caller must supply lowercase'
70
+ );
71
+ });
72
+ });
73
+
74
+ describe('verifyEvent — round-trip and tamper rejection', () => {
75
+ it('verifies a freshly-signed event', () => {
76
+ const sk = generateSecretKey();
77
+ const event = finalizeEvent({
78
+ kind: 27235,
79
+ tags: [['u', 'https://example.test/foo'], ['method', 'GET']],
80
+ content: ''
81
+ }, sk);
82
+ assert.strictEqual(verifyEvent(event), true);
83
+ });
84
+
85
+ it('rejects an event with a flipped content byte', () => {
86
+ const sk = generateSecretKey();
87
+ const event = finalizeEvent({ kind: 1, tags: [], content: 'original' }, sk);
88
+ // Mutate content but keep id/sig — recomputed hash won't match.
89
+ event.content = 'mutated';
90
+ assert.strictEqual(verifyEvent(event), false);
91
+ });
92
+
93
+ it('rejects an event whose declared id is wrong', () => {
94
+ const sk = generateSecretKey();
95
+ const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk);
96
+ event.id = '0'.repeat(64); // wrong id
97
+ assert.strictEqual(verifyEvent(event), false);
98
+ });
99
+
100
+ it('rejects an event signed by a different key', () => {
101
+ const sk1 = generateSecretKey();
102
+ const sk2 = generateSecretKey();
103
+ const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk1);
104
+ // Replace pubkey with sk2's pubkey but keep sk1's signature.
105
+ event.pubkey = getPublicKey(sk2);
106
+ // Recompute id since pubkey is part of the canonical hash, then
107
+ // the sig won't match the new id.
108
+ event.id = getEventHash(event);
109
+ assert.strictEqual(verifyEvent(event), false);
110
+ });
111
+
112
+ it('rejects an event with a tampered signature', () => {
113
+ const sk = generateSecretKey();
114
+ const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk);
115
+ // Flip the last hex char of the signature.
116
+ const last = event.sig.slice(-1);
117
+ event.sig = event.sig.slice(0, -1) + (last === '0' ? '1' : '0');
118
+ assert.strictEqual(verifyEvent(event), false);
119
+ });
120
+ });
121
+
122
+ describe('validateEvent — structural rejection', () => {
123
+ function valid() {
124
+ return finalizeEvent({ kind: 1, tags: [], content: 'x' }, generateSecretKey());
125
+ }
126
+
127
+ it('accepts a well-formed event', () => {
128
+ assert.strictEqual(validateEvent(valid()), true);
129
+ });
130
+
131
+ const cases = [
132
+ ['null', null],
133
+ ['undefined', undefined],
134
+ ['array', []],
135
+ ['missing id', () => { const e = valid(); delete e.id; return e; }],
136
+ ['short id', () => { const e = valid(); e.id = 'abcd'; return e; }],
137
+ ['non-hex id', () => { const e = valid(); e.id = 'g'.repeat(64); return e; }],
138
+ ['short pubkey', () => { const e = valid(); e.pubkey = 'abcd'; return e; }],
139
+ ['short sig', () => { const e = valid(); e.sig = 'abcd'; return e; }],
140
+ ['kind not integer', () => { const e = valid(); e.kind = 'one'; return e; }],
141
+ ['kind negative', () => { const e = valid(); e.kind = -1; return e; }],
142
+ ['negative created_at', () => { const e = valid(); e.created_at = -1; return e; }],
143
+ ['content not string', () => { const e = valid(); e.content = 123; return e; }],
144
+ ['tags not array', () => { const e = valid(); e.tags = 'oops'; return e; }],
145
+ ['nested tag not array', () => { const e = valid(); e.tags = ['oops']; return e; }],
146
+ ['tag value not string', () => { const e = valid(); e.tags = [[1, 2]]; return e; }]
147
+ ];
148
+
149
+ for (const [label, input] of cases) {
150
+ it(`rejects: ${label}`, () => {
151
+ const e = typeof input === 'function' ? input() : input;
152
+ assert.strictEqual(validateEvent(e), false);
153
+ });
154
+ }
155
+ });
156
+
157
+ describe('lenient input — round 2 fixes (#341 review)', () => {
158
+ it('rejects uppercase hex in id/pubkey/sig (NIP-01 is case-strict)', () => {
159
+ // We deliberately do NOT lenient-accept uppercase here — accepting
160
+ // would let an event verify but then miss case-sensitive downstream
161
+ // lookups (relay filter ID match, dedupe by pubkey, etc.). Strict
162
+ // at the gate keeps the rest of the codebase from having to remember
163
+ // to normalize.
164
+ const sk = generateSecretKey();
165
+ const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk);
166
+ assert.strictEqual(verifyEvent({ ...event, id: event.id.toUpperCase() }), false);
167
+ assert.strictEqual(verifyEvent({ ...event, pubkey: event.pubkey.toUpperCase() }), false);
168
+ assert.strictEqual(verifyEvent({ ...event, sig: event.sig.toUpperCase() }), false);
169
+ });
170
+
171
+ it('accepts kinds above 65535 (NIP-01 has no 16-bit cap)', () => {
172
+ const sk = generateSecretKey();
173
+ const event = finalizeEvent({ kind: 30023, tags: [], content: 'long-form' }, sk);
174
+ assert.strictEqual(verifyEvent(event), true,
175
+ 'kind 30023 (NIP-23 long-form content) must verify');
176
+ });
177
+
178
+ it('still rejects negative kinds', () => {
179
+ const sk = generateSecretKey();
180
+ const event = finalizeEvent({ kind: 1, tags: [], content: 'x' }, sk);
181
+ event.kind = -1;
182
+ assert.strictEqual(validateEvent(event), false);
183
+ });
184
+ });
185
+
186
+ describe('finalizeEvent input validation (#341 review)', () => {
187
+ it('throws when kind is missing', () => {
188
+ const sk = generateSecretKey();
189
+ assert.throws(() => finalizeEvent({ tags: [], content: 'x' }, sk), TypeError);
190
+ });
191
+
192
+ it('throws when kind is not a non-negative safe integer', () => {
193
+ const sk = generateSecretKey();
194
+ assert.throws(() => finalizeEvent({ kind: -1 }, sk), TypeError);
195
+ assert.throws(() => finalizeEvent({ kind: 'one' }, sk), TypeError);
196
+ assert.throws(() => finalizeEvent({ kind: 1.5 }, sk), TypeError);
197
+ assert.throws(() => finalizeEvent({ kind: Number.MAX_SAFE_INTEGER + 1 }, sk), TypeError);
198
+ });
199
+
200
+ it('throws when created_at is invalid', () => {
201
+ const sk = generateSecretKey();
202
+ assert.throws(() => finalizeEvent({ kind: 1, created_at: -1 }, sk), TypeError);
203
+ assert.throws(() => finalizeEvent({ kind: 1, created_at: 'now' }, sk), TypeError);
204
+ });
205
+
206
+ it('throws when tags is not an array', () => {
207
+ const sk = generateSecretKey();
208
+ assert.throws(() => finalizeEvent({ kind: 1, tags: 'oops' }, sk), TypeError);
209
+ });
210
+
211
+ it('throws when content is not a string', () => {
212
+ const sk = generateSecretKey();
213
+ assert.throws(() => finalizeEvent({ kind: 1, content: 123 }, sk), TypeError);
214
+ });
215
+ });
216
+
217
+ describe('nip98Token body must be bytes (#341 review)', () => {
218
+ it('accepts a string body (JSON or text)', () => {
219
+ const sk = generateSecretKey();
220
+ const tok = nip98Token('https://x.test/', 'POST', sk, '{"a":1}');
221
+ assert.ok(typeof tok === 'string' && tok.length > 0);
222
+ });
223
+
224
+ it('accepts a Uint8Array body', () => {
225
+ const sk = generateSecretKey();
226
+ const bytes = new TextEncoder().encode('{"a":1}');
227
+ const tok = nip98Token('https://x.test/', 'POST', sk, bytes);
228
+ assert.ok(typeof tok === 'string' && tok.length > 0);
229
+ });
230
+
231
+ it('accepts no body', () => {
232
+ const sk = generateSecretKey();
233
+ const tok = nip98Token('https://x.test/', 'GET', sk);
234
+ assert.ok(typeof tok === 'string' && tok.length > 0);
235
+ });
236
+
237
+ it('throws on a plain object (would re-serialize and not match wire bytes)', () => {
238
+ const sk = generateSecretKey();
239
+ assert.throws(
240
+ () => nip98Token('https://x.test/', 'POST', sk, { a: 1 }),
241
+ TypeError,
242
+ 'Object body must be rejected — caller must pass exact wire bytes'
243
+ );
244
+ });
245
+ });
246
+
247
+ describe('validateEvent — created_at (#341 review)', () => {
248
+ it('rejects created_at beyond Number.MAX_SAFE_INTEGER', () => {
249
+ const sk = generateSecretKey();
250
+ const event = finalizeEvent({ kind: 1 }, sk);
251
+ // Manually inject a non-safe-integer to test validateEvent — we
252
+ // can't construct it via finalizeEvent (which now rejects too).
253
+ event.created_at = Number.MAX_SAFE_INTEGER + 1;
254
+ // Recompute id so the structural check is the only one that fires.
255
+ assert.strictEqual(validateEvent(event), false);
256
+ });
257
+ });
258
+
259
+ describe('test helper parity with @noble/curves', () => {
260
+ it('getPublicKey returns 32-byte (64 hex) x-only pubkey', () => {
261
+ const sk = generateSecretKey();
262
+ const pk = getPublicKey(sk);
263
+ assert.match(pk, /^[a-f0-9]{64}$/);
264
+ // schnorr.getPublicKey returns 32 bytes for x-only.
265
+ assert.strictEqual(Buffer.from(pk, 'hex').length, 32);
266
+ });
267
+
268
+ it('schnorr.verify (via verifyEvent) rejects events whose id was not signed', () => {
269
+ const sk = generateSecretKey();
270
+ const event = finalizeEvent({ kind: 1, tags: [], content: 'a' }, sk);
271
+ // Sanity: a separate raw schnorr.verify of the same event should agree.
272
+ assert.strictEqual(schnorr.verify(event.sig, event.id, event.pubkey), true);
273
+ // And our wrapper should agree.
274
+ assert.strictEqual(verifyEvent(event), true);
275
+ });
276
+ });
277
+ });
@@ -18,8 +18,11 @@
18
18
  * 6. Cleans up
19
19
  */
20
20
 
21
- import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
22
- import { bytesToHex } from '@noble/hashes/utils';
21
+ import { generateSecretKey, getPublicKey } from './src/nostr/event.js';
22
+
23
+ // Avoid the @noble/hashes import here — Buffer does hex conversion natively
24
+ // and keeps the script's import surface minimal.
25
+ const bytesToHex = (bytes) => Buffer.from(bytes).toString('hex');
23
26
  import { execSync, spawn } from 'child_process';
24
27
  import fs from 'fs-extra';
25
28
  import path from 'path';
@@ -10,8 +10,7 @@
10
10
  * 4. Verifies the did:nostr identity is recognized
11
11
  */
12
12
 
13
- import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
14
- import { getToken } from 'nostr-tools/nip98';
13
+ import { generateSecretKey, getPublicKey, nip98Token } from './src/nostr/event.js';
15
14
 
16
15
  const BASE_URL = process.env.TEST_URL || 'http://localhost:4000';
17
16
 
@@ -32,7 +31,7 @@ async function main() {
32
31
 
33
32
  console.log(`2. Creating NIP-98 token for ${method} ${testUrl}`);
34
33
 
35
- const token = await getToken(testUrl, method, (event) => finalizeEvent(event, sk));
34
+ const token = nip98Token(testUrl, method, sk);
36
35
 
37
36
  console.log(` Token length: ${token.length} chars\n`);
38
37
 
@@ -74,7 +73,7 @@ async function main() {
74
73
  const containerUrl = `${BASE_URL}/demo/public/`;
75
74
 
76
75
  try {
77
- const containerToken = await getToken(containerUrl, 'GET', (event) => finalizeEvent(event, sk));
76
+ const containerToken = nip98Token(containerUrl, 'GET', sk);
78
77
 
79
78
  const response = await fetch(containerUrl, {
80
79
  headers: {