@nostrify/policies 0.36.2
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/.turbo/daemon/7bb8240f68f7ad88-turbo.log.2025-07-29 +0 -0
- package/.turbo/turbo-build.log +5 -0
- package/AntiDuplicationPolicy.test.ts +50 -0
- package/AntiDuplicationPolicy.ts +78 -0
- package/AnyPolicy.test.ts +57 -0
- package/AnyPolicy.ts +21 -0
- package/AuthorPolicy.test.ts +23 -0
- package/AuthorPolicy.ts +23 -0
- package/CHANGELOG.md +66 -0
- package/DomainPolicy.test.ts +246 -0
- package/DomainPolicy.ts +96 -0
- package/FiltersPolicy.test.ts +14 -0
- package/FiltersPolicy.ts +27 -0
- package/HashtagPolicy.test.ts +34 -0
- package/HashtagPolicy.ts +25 -0
- package/HellthreadPolicy.test.ts +23 -0
- package/HellthreadPolicy.ts +27 -0
- package/InvertPolicy.test.ts +19 -0
- package/InvertPolicy.ts +17 -0
- package/KeywordPolicy.test.ts +28 -0
- package/KeywordPolicy.ts +25 -0
- package/LICENSE +21 -0
- package/NoOpPolicy.test.ts +18 -0
- package/NoOpPolicy.ts +9 -0
- package/OpenAIPolicy.test.ts +42 -0
- package/OpenAIPolicy.ts +113 -0
- package/PipePolicy.test.ts +58 -0
- package/PipePolicy.ts +36 -0
- package/PowPolicy.test.ts +27 -0
- package/PowPolicy.ts +41 -0
- package/PubkeyBanPolicy.test.ts +17 -0
- package/PubkeyBanPolicy.ts +24 -0
- package/README.md +34 -0
- package/ReadOnlyPolicy.test.ts +19 -0
- package/ReadOnlyPolicy.ts +9 -0
- package/RegexPolicy.test.ts +17 -0
- package/RegexPolicy.ts +22 -0
- package/ReplyBotPolicy.test.ts +50 -0
- package/ReplyBotPolicy.ts +59 -0
- package/SizePolicy.test.ts +21 -0
- package/SizePolicy.ts +35 -0
- package/WhitelistPolicy.test.ts +17 -0
- package/WhitelistPolicy.ts +32 -0
- package/WoTPolicy.test.ts +35 -0
- package/WoTPolicy.ts +61 -0
- package/dist/AntiDuplicationPolicy.d.ts +43 -0
- package/dist/AntiDuplicationPolicy.d.ts.map +1 -0
- package/dist/AntiDuplicationPolicy.js +63 -0
- package/dist/AntiDuplicationPolicy.js.map +1 -0
- package/dist/AnyPolicy.d.ts +8 -0
- package/dist/AnyPolicy.d.ts.map +1 -0
- package/dist/AnyPolicy.js +23 -0
- package/dist/AnyPolicy.js.map +1 -0
- package/dist/AuthorPolicy.d.ts +9 -0
- package/dist/AuthorPolicy.d.ts.map +1 -0
- package/dist/AuthorPolicy.js +27 -0
- package/dist/AuthorPolicy.js.map +1 -0
- package/dist/DomainPolicy.d.ts +21 -0
- package/dist/DomainPolicy.d.ts.map +1 -0
- package/dist/DomainPolicy.js +68 -0
- package/dist/DomainPolicy.js.map +1 -0
- package/dist/FiltersPolicy.d.ts +18 -0
- package/dist/FiltersPolicy.d.ts.map +1 -0
- package/dist/FiltersPolicy.js +30 -0
- package/dist/FiltersPolicy.js.map +1 -0
- package/dist/HashtagPolicy.d.ts +16 -0
- package/dist/HashtagPolicy.d.ts.map +1 -0
- package/dist/HashtagPolicy.js +29 -0
- package/dist/HashtagPolicy.js.map +1 -0
- package/dist/HellthreadPolicy.d.ts +14 -0
- package/dist/HellthreadPolicy.d.ts.map +1 -0
- package/dist/HellthreadPolicy.js +23 -0
- package/dist/HellthreadPolicy.js.map +1 -0
- package/dist/InvertPolicy.d.ts +9 -0
- package/dist/InvertPolicy.d.ts.map +1 -0
- package/dist/InvertPolicy.js +24 -0
- package/dist/InvertPolicy.js.map +1 -0
- package/dist/KeywordPolicy.d.ts +16 -0
- package/dist/KeywordPolicy.d.ts.map +1 -0
- package/dist/KeywordPolicy.js +29 -0
- package/dist/KeywordPolicy.js.map +1 -0
- package/dist/NoOpPolicy.d.ts +6 -0
- package/dist/NoOpPolicy.d.ts.map +1 -0
- package/dist/NoOpPolicy.js +12 -0
- package/dist/NoOpPolicy.js.map +1 -0
- package/dist/OpenAIPolicy.d.ts +80 -0
- package/dist/OpenAIPolicy.d.ts.map +1 -0
- package/dist/OpenAIPolicy.js +62 -0
- package/dist/OpenAIPolicy.js.map +1 -0
- package/dist/PipePolicy.d.ts +26 -0
- package/dist/PipePolicy.d.ts.map +1 -0
- package/dist/PipePolicy.js +39 -0
- package/dist/PipePolicy.js.map +1 -0
- package/dist/PowPolicy.d.ts +21 -0
- package/dist/PowPolicy.d.ts.map +1 -0
- package/dist/PowPolicy.js +36 -0
- package/dist/PowPolicy.js.map +1 -0
- package/dist/PubkeyBanPolicy.d.ts +15 -0
- package/dist/PubkeyBanPolicy.d.ts.map +1 -0
- package/dist/PubkeyBanPolicy.js +28 -0
- package/dist/PubkeyBanPolicy.js.map +1 -0
- package/dist/ReadOnlyPolicy.d.ts +6 -0
- package/dist/ReadOnlyPolicy.d.ts.map +1 -0
- package/dist/ReadOnlyPolicy.js +12 -0
- package/dist/ReadOnlyPolicy.js.map +1 -0
- package/dist/RegexPolicy.d.ts +15 -0
- package/dist/RegexPolicy.d.ts.map +1 -0
- package/dist/RegexPolicy.js +26 -0
- package/dist/RegexPolicy.js.map +1 -0
- package/dist/ReplyBotPolicy.d.ts +25 -0
- package/dist/ReplyBotPolicy.d.ts.map +1 -0
- package/dist/ReplyBotPolicy.js +45 -0
- package/dist/ReplyBotPolicy.js.map +1 -0
- package/dist/SizePolicy.d.ts +23 -0
- package/dist/SizePolicy.d.ts.map +1 -0
- package/dist/SizePolicy.js +31 -0
- package/dist/SizePolicy.js.map +1 -0
- package/dist/WhitelistPolicy.d.ts +16 -0
- package/dist/WhitelistPolicy.d.ts.map +1 -0
- package/dist/WhitelistPolicy.js +35 -0
- package/dist/WhitelistPolicy.js.map +1 -0
- package/dist/WoTPolicy.d.ts +26 -0
- package/dist/WoTPolicy.d.ts.map +1 -0
- package/dist/WoTPolicy.js +42 -0
- package/dist/WoTPolicy.js.map +1 -0
- package/dist/mod.d.ts +21 -0
- package/dist/mod.d.ts.map +1 -0
- package/dist/mod.js +44 -0
- package/dist/mod.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/mod.ts +20 -0
- package/package.json +20 -0
- package/tsconfig.json +14 -0
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { genEvent } from '@nostrify/nostrify/test';
|
|
2
|
+
import { assertEquals } from '@std/assert';
|
|
3
|
+
|
|
4
|
+
import { AntiDuplicationPolicy } from './AntiDuplicationPolicy.ts';
|
|
5
|
+
|
|
6
|
+
Deno.test('AntiDuplicationPolicy', async () => {
|
|
7
|
+
using kv = await Deno.openKv(':memory:');
|
|
8
|
+
|
|
9
|
+
const policy = new AntiDuplicationPolicy({ kv });
|
|
10
|
+
const content = 'Spicy peppermint apricot mediterranean ginger carrot spiced juice edamame hummus';
|
|
11
|
+
|
|
12
|
+
const event1 = genEvent({ kind: 1, content });
|
|
13
|
+
|
|
14
|
+
assertEquals((await policy.call(event1))[2], true);
|
|
15
|
+
assertEquals((await policy.call(event1))[2], false);
|
|
16
|
+
assertEquals((await policy.call(event1))[2], false);
|
|
17
|
+
|
|
18
|
+
const event2 = genEvent({ kind: 1, content: 'a' });
|
|
19
|
+
|
|
20
|
+
assertEquals((await policy.call(event2))[2], true);
|
|
21
|
+
assertEquals((await policy.call(event2))[2], true);
|
|
22
|
+
assertEquals((await policy.call(event2))[2], true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
Deno.test('AntiDuplicationPolicy with deobfuscate', async () => {
|
|
26
|
+
using kv = await Deno.openKv(':memory:');
|
|
27
|
+
|
|
28
|
+
const policy = new AntiDuplicationPolicy({
|
|
29
|
+
kv,
|
|
30
|
+
deobfuscate: ({ content }) => (
|
|
31
|
+
content
|
|
32
|
+
.replace(/[\p{Emoji}\p{Emoji_Modifier}\p{Emoji_Component}\p{Emoji_Modifier_Base}\p{Emoji_Presentation}]/gu, '')
|
|
33
|
+
.replace(/\s/g, '')
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const event1 = genEvent({
|
|
39
|
+
kind: 1,
|
|
40
|
+
content: 'Spicy peppermint apricot mediterranean ginger carrot spiced juice edamame hummus',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const event2 = genEvent({
|
|
44
|
+
kind: 1,
|
|
45
|
+
content: 'Spicy 🌶️ peppermint apricot 🍑 mediterranean 🌊 ginger carrot 🥕 spiced 🌶️ juice 🥤 edamame 🌱 hummus ',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assertEquals((await policy.call(event1))[2], true);
|
|
49
|
+
assertEquals((await policy.call(event2))[2], false);
|
|
50
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { NostrEvent, NostrRelayOK, NPolicy } from '@nostrify/types';
|
|
2
|
+
import { Kv, KvKey } from '@deno/kv';
|
|
3
|
+
|
|
4
|
+
/** Policy options for `AntiDuplicationPolicy`. */
|
|
5
|
+
interface AntiDuplicationPolicyOpts {
|
|
6
|
+
/** Deno.Kv implementation to use. */
|
|
7
|
+
kv: Pick<Kv, 'get' | 'set'>;
|
|
8
|
+
/** Time in ms until a message with this content may be posted again. Default: `60000` (1 minute). */
|
|
9
|
+
expireIn?: number;
|
|
10
|
+
/** Note text under this limit will be skipped by the policy. Default: `50`. */
|
|
11
|
+
minLength?: number;
|
|
12
|
+
/** Normalize the event's content before a hash is taken, to prevent the attacker from making small changes. Should return the normalized content. */
|
|
13
|
+
deobfuscate?(event: NostrEvent): string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Prevent messages with the exact same content from being submitted repeatedly.
|
|
18
|
+
*
|
|
19
|
+
* It stores a hashcode for each content in a Deno.Kv database and rate-limits them. Only messages that meet the minimum length criteria are selected.
|
|
20
|
+
* Each time a matching message is submitted, the timer will reset, so spammers sending the same message will only ever get the first one through.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* // Open a Deno.KV instance.
|
|
24
|
+
* const kv = await Deno.openKv();
|
|
25
|
+
*
|
|
26
|
+
* // Prevent the same message from being posted within 60 seconds.
|
|
27
|
+
* new AntiDuplicationPolicy({ kv, expireIn: 60000 });
|
|
28
|
+
*
|
|
29
|
+
* // Only enforce the policy on messages with at least 50 characters.
|
|
30
|
+
* new AntiDuplicationPolicy({ kv, expireIn: 60000, minLength: 50 });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class AntiDuplicationPolicy implements NPolicy {
|
|
34
|
+
constructor(private opts: AntiDuplicationPolicyOpts) {}
|
|
35
|
+
|
|
36
|
+
async call(event: NostrEvent): Promise<NostrRelayOK> {
|
|
37
|
+
const { id, kind } = event;
|
|
38
|
+
const { kv, expireIn = 60000, minLength = 50, deobfuscate } = this.opts;
|
|
39
|
+
|
|
40
|
+
const content = deobfuscate?.(event) ?? event.content;
|
|
41
|
+
|
|
42
|
+
if (kind === 1 && content.length >= minLength) {
|
|
43
|
+
const hash = AntiDuplicationPolicy.hashCode(content);
|
|
44
|
+
const key: KvKey = ['nostrify', 'policies', 'antiduplication', hash];
|
|
45
|
+
|
|
46
|
+
const { value } = await kv.get(key);
|
|
47
|
+
|
|
48
|
+
if (value) {
|
|
49
|
+
await kv.set(key, true, { expireIn });
|
|
50
|
+
return [
|
|
51
|
+
'OK',
|
|
52
|
+
id,
|
|
53
|
+
false,
|
|
54
|
+
'blocked: the same message has been repeated too many times',
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await kv.set(key, true, { expireIn });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return ['OK', id, true, ''];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get a "good enough" unique identifier for this content.
|
|
66
|
+
* This algorithm was chosen because it's very fast with a low chance of collisions.
|
|
67
|
+
* https://stackoverflow.com/a/8831937
|
|
68
|
+
*/
|
|
69
|
+
private static hashCode(str: string): number {
|
|
70
|
+
let hash = 0;
|
|
71
|
+
for (let i = 0, len = str.length; i < len; i++) {
|
|
72
|
+
const chr = str.charCodeAt(i);
|
|
73
|
+
hash = (hash << 5) - hash + chr;
|
|
74
|
+
hash |= 0; // Convert to 32bit integer
|
|
75
|
+
}
|
|
76
|
+
return hash;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { assertEquals } from '@std/assert';
|
|
2
|
+
import { finalizeEvent, generateSecretKey } from 'nostr-tools';
|
|
3
|
+
|
|
4
|
+
import { AnyPolicy } from './AnyPolicy.ts';
|
|
5
|
+
import { NoOpPolicy } from './NoOpPolicy.ts';
|
|
6
|
+
import { ReadOnlyPolicy } from './ReadOnlyPolicy.ts';
|
|
7
|
+
|
|
8
|
+
Deno.test('accepts when all policies accept', async () => {
|
|
9
|
+
const policy = new AnyPolicy([
|
|
10
|
+
new NoOpPolicy(),
|
|
11
|
+
new NoOpPolicy(),
|
|
12
|
+
new NoOpPolicy(),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const event = finalizeEvent(
|
|
16
|
+
{ kind: 1, content: '', tags: [], created_at: 0 },
|
|
17
|
+
generateSecretKey(),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const [_, _eventId, ok] = await policy.call(event);
|
|
21
|
+
|
|
22
|
+
assertEquals(ok, true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
Deno.test('accepts when some policies reject', async () => {
|
|
26
|
+
const policy = new AnyPolicy([
|
|
27
|
+
new NoOpPolicy(),
|
|
28
|
+
new ReadOnlyPolicy(),
|
|
29
|
+
new NoOpPolicy(),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const event = finalizeEvent(
|
|
33
|
+
{ kind: 1, content: '', tags: [], created_at: 0 },
|
|
34
|
+
generateSecretKey(),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const [_, _eventId, ok] = await policy.call(event);
|
|
38
|
+
|
|
39
|
+
assertEquals(ok, true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
Deno.test('rejects when all policies reject', async () => {
|
|
43
|
+
const policy = new AnyPolicy([
|
|
44
|
+
new ReadOnlyPolicy(),
|
|
45
|
+
new ReadOnlyPolicy(),
|
|
46
|
+
new ReadOnlyPolicy(),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const event = finalizeEvent(
|
|
50
|
+
{ kind: 1, content: '', tags: [], created_at: 0 },
|
|
51
|
+
generateSecretKey(),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const [_, _eventId, ok] = await policy.call(event);
|
|
55
|
+
|
|
56
|
+
assertEquals(ok, false);
|
|
57
|
+
});
|
package/AnyPolicy.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NostrEvent, NostrRelayOK, NPolicy } from '@nostrify/types';
|
|
2
|
+
|
|
3
|
+
/** Similar to `PipePolicy`, but passes if at least one policy passes. */
|
|
4
|
+
export class AnyPolicy implements NPolicy {
|
|
5
|
+
constructor(private policies: NPolicy[]) {}
|
|
6
|
+
|
|
7
|
+
async call(event: NostrEvent, signal?: AbortSignal): Promise<NostrRelayOK> {
|
|
8
|
+
let result: NostrRelayOK = ['OK', event.id, false, 'blocked: no policy passed'];
|
|
9
|
+
|
|
10
|
+
for (const policy of this.policies) {
|
|
11
|
+
result = await policy.call(event, signal);
|
|
12
|
+
|
|
13
|
+
const ok = result[2];
|
|
14
|
+
if (ok) {
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { genEvent, MockRelay } from '@nostrify/nostrify/test';
|
|
2
|
+
import { assertEquals } from '@std/assert';
|
|
3
|
+
import { generateSecretKey } from 'nostr-tools';
|
|
4
|
+
|
|
5
|
+
import { AuthorPolicy } from './AuthorPolicy.ts';
|
|
6
|
+
|
|
7
|
+
Deno.test('AuthorPolicy', async () => {
|
|
8
|
+
const store = new MockRelay();
|
|
9
|
+
const policy = new AuthorPolicy(store);
|
|
10
|
+
|
|
11
|
+
const sk = generateSecretKey();
|
|
12
|
+
const event = genEvent({ kind: 1 }, sk);
|
|
13
|
+
|
|
14
|
+
const [, , ok1] = await policy.call(event);
|
|
15
|
+
|
|
16
|
+
assertEquals(ok1, false);
|
|
17
|
+
|
|
18
|
+
await store.event(genEvent({ kind: 0 }, sk));
|
|
19
|
+
|
|
20
|
+
const [, , ok2] = await policy.call(event);
|
|
21
|
+
|
|
22
|
+
assertEquals(ok2, true);
|
|
23
|
+
});
|
package/AuthorPolicy.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NostrEvent, NostrRelayOK, NPolicy, NStore } from '@nostrify/types';
|
|
2
|
+
|
|
3
|
+
/** Rejects events by authors without a kind 0, then optionally applies another policy to the kind 0. */
|
|
4
|
+
export class AuthorPolicy implements NPolicy {
|
|
5
|
+
constructor(private store: NStore, private policy?: NPolicy) {}
|
|
6
|
+
|
|
7
|
+
async call(event: NostrEvent, signal?: AbortSignal): Promise<NostrRelayOK> {
|
|
8
|
+
const author: NostrEvent | undefined = event.kind === 0 ? event : await this.store
|
|
9
|
+
.query([{ kinds: [0], authors: [event.pubkey], limit: 1 }], { signal })
|
|
10
|
+
.then(([event]) => event);
|
|
11
|
+
|
|
12
|
+
if (!author) {
|
|
13
|
+
return ['OK', event.id, false, 'blocked: author is missing a kind 0 event'];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (this.policy) {
|
|
17
|
+
const [, , ok, reason] = await this.policy.call(author, signal);
|
|
18
|
+
return ['OK', event.id, ok, reason];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return ['OK', event.id, true, ''];
|
|
22
|
+
}
|
|
23
|
+
}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## 0.36.2 - 2025-06-06
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- DomainPolicy blacklist now includes subdomains.
|
|
13
|
+
|
|
14
|
+
## 0.36.1 - 2024-10-09
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- WoTPolicy: fix unhandled promise rejection by delaying getPubkeys until the first call.
|
|
19
|
+
|
|
20
|
+
## 0.36.0 - 2024-09-25
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Added WoTPolicy to whitelist follows of follows.
|
|
25
|
+
|
|
26
|
+
## 0.35.0 - 2024-09-23
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- AntiDuplicationPolicy: added `deobfuscate` option to pre-process event content before taking the hash.
|
|
31
|
+
|
|
32
|
+
## 0.34.0 - 2024-09-21
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- Added DomainPolicy to filter events by NIP-05 domain.
|
|
37
|
+
|
|
38
|
+
## 0.33.1 - 2024-09-11
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- AuthorPolicy: treat kind 0s as its own author
|
|
43
|
+
|
|
44
|
+
## 0.33.0 - 2024-09-09
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Exported `ReplyBotPolicy` and `AuthorPolicy`.
|
|
49
|
+
|
|
50
|
+
## 0.32.0 - 2024-09-09
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- ReplyBotPolicy: accept a `signal`.
|
|
55
|
+
- PipePolicy: accept a `signal`.
|
|
56
|
+
- AnyPolicy: accept a `signal`.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- BREAKING: OpenAIPolicy: Removed `timeout` opt. It now accept a `signal` in its `call` method.
|
|
61
|
+
|
|
62
|
+
## 0.31.0 - 2024-09-09
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- Initial release of `@nostrify/policies`, moved from `@nostrify/nostrify/policies`.
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { genEvent, MockRelay } from '@nostrify/nostrify/test';
|
|
2
|
+
import { NostrMetadata } from '@nostrify/types';
|
|
3
|
+
import { assertEquals } from '@std/assert';
|
|
4
|
+
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
|
5
|
+
|
|
6
|
+
import { DomainPolicy } from './DomainPolicy.ts';
|
|
7
|
+
|
|
8
|
+
Deno.test('DomainPolicy allows events from authors with a valid nip05', async () => {
|
|
9
|
+
const sk = generateSecretKey();
|
|
10
|
+
const pubkey = getPublicKey(sk);
|
|
11
|
+
|
|
12
|
+
const store = new MockRelay();
|
|
13
|
+
const policy = new DomainPolicy(store, {
|
|
14
|
+
// deno-lint-ignore require-await
|
|
15
|
+
async lookup(nip05: string) {
|
|
16
|
+
if (nip05 === 'alex@gleasonator.dev') {
|
|
17
|
+
return { pubkey };
|
|
18
|
+
} else {
|
|
19
|
+
throw new Error('not found');
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const metadata: NostrMetadata = { nip05: 'alex@gleasonator.dev' };
|
|
25
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
26
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
27
|
+
|
|
28
|
+
const result = await policy.call(event);
|
|
29
|
+
|
|
30
|
+
assertEquals(result, ['OK', event.id, true, '']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
Deno.test('DomainPolicy rejects events from authors without a kind 0', async () => {
|
|
34
|
+
const store = new MockRelay();
|
|
35
|
+
const policy = new DomainPolicy(store);
|
|
36
|
+
const event = genEvent({ kind: 1, content: 'hello world' });
|
|
37
|
+
|
|
38
|
+
const result = await policy.call(event);
|
|
39
|
+
|
|
40
|
+
assertEquals(result, ['OK', event.id, false, 'blocked: author is missing a kind 0 event']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
Deno.test('DomainPolicy rejects events from authors with a missing nip05', async () => {
|
|
44
|
+
const store = new MockRelay();
|
|
45
|
+
const policy = new DomainPolicy(store);
|
|
46
|
+
|
|
47
|
+
const sk = generateSecretKey();
|
|
48
|
+
const metadata: NostrMetadata = {};
|
|
49
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
50
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
51
|
+
|
|
52
|
+
const result = await policy.call(event);
|
|
53
|
+
|
|
54
|
+
assertEquals(result, ['OK', event.id, false, 'blocked: missing nip05']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
Deno.test('DomainPolicy rejects events from authors with a malformed nip05', async () => {
|
|
58
|
+
const store = new MockRelay();
|
|
59
|
+
const policy = new DomainPolicy(store);
|
|
60
|
+
|
|
61
|
+
const sk = generateSecretKey();
|
|
62
|
+
const metadata: NostrMetadata = { nip05: 'asdf' };
|
|
63
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
64
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
65
|
+
|
|
66
|
+
const result = await policy.call(event);
|
|
67
|
+
|
|
68
|
+
assertEquals(result, ['OK', event.id, false, 'blocked: missing nip05']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
Deno.test('DomainPolicy rejects events from authors with an invalid nip05', async () => {
|
|
72
|
+
const store = new MockRelay();
|
|
73
|
+
|
|
74
|
+
const policy = new DomainPolicy(store, {
|
|
75
|
+
// deno-lint-ignore require-await
|
|
76
|
+
async lookup(_nip05: string) {
|
|
77
|
+
const pubkey = getPublicKey(generateSecretKey());
|
|
78
|
+
return { pubkey };
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const metadata: NostrMetadata = { nip05: 'alex@gleasonator.dev' };
|
|
83
|
+
const sk = generateSecretKey();
|
|
84
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
85
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
86
|
+
|
|
87
|
+
const result = await policy.call(event);
|
|
88
|
+
|
|
89
|
+
assertEquals(result, ['OK', event.id, false, 'blocked: mismatched nip05 pubkey']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
Deno.test('DomainPolicy rejects events from authors with a blacklisted nip05 domain', async () => {
|
|
93
|
+
const sk = generateSecretKey();
|
|
94
|
+
const pubkey = getPublicKey(sk);
|
|
95
|
+
|
|
96
|
+
const store = new MockRelay();
|
|
97
|
+
const policy = new DomainPolicy(store, {
|
|
98
|
+
// deno-lint-ignore require-await
|
|
99
|
+
async lookup(nip05: string) {
|
|
100
|
+
if (nip05 === 'bot@replyguy.dev') {
|
|
101
|
+
return { pubkey };
|
|
102
|
+
} else {
|
|
103
|
+
throw new Error('not found');
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
blacklist: ['replyguy.dev'],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const metadata: NostrMetadata = { nip05: 'bot@replyguy.dev' };
|
|
110
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
111
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
112
|
+
|
|
113
|
+
const result = await policy.call(event);
|
|
114
|
+
|
|
115
|
+
assertEquals(result, ['OK', event.id, false, 'blocked: blacklisted nip05 domain']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
Deno.test("DomainPolicy rejects events from authors who aren't on a whitelisted domain", async () => {
|
|
119
|
+
const sk = generateSecretKey();
|
|
120
|
+
const pubkey = getPublicKey(sk);
|
|
121
|
+
|
|
122
|
+
const store = new MockRelay();
|
|
123
|
+
const policy = new DomainPolicy(store, {
|
|
124
|
+
// deno-lint-ignore require-await
|
|
125
|
+
async lookup(nip05: string) {
|
|
126
|
+
if (nip05 === 'bot@replyguy.dev') {
|
|
127
|
+
return { pubkey };
|
|
128
|
+
} else {
|
|
129
|
+
throw new Error('not found');
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
whitelist: ['gleasonator.dev'],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const metadata: NostrMetadata = { nip05: 'bot@replyguy.dev' };
|
|
136
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
137
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
138
|
+
|
|
139
|
+
const result = await policy.call(event);
|
|
140
|
+
|
|
141
|
+
assertEquals(result, ['OK', event.id, false, 'blocked: nip05 domain not in whitelist']);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
Deno.test('DomainPolicy allows events from authors who are on a whitelisted domain', async () => {
|
|
145
|
+
const sk = generateSecretKey();
|
|
146
|
+
const pubkey = getPublicKey(sk);
|
|
147
|
+
|
|
148
|
+
const store = new MockRelay();
|
|
149
|
+
const policy = new DomainPolicy(store, {
|
|
150
|
+
// deno-lint-ignore require-await
|
|
151
|
+
async lookup(nip05: string) {
|
|
152
|
+
if (nip05 === 'alex@gleasonator.dev') {
|
|
153
|
+
return { pubkey };
|
|
154
|
+
} else {
|
|
155
|
+
throw new Error('not found');
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
whitelist: ['gleasonator.dev'],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const metadata: NostrMetadata = { nip05: 'alex@gleasonator.dev' };
|
|
162
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
163
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
164
|
+
|
|
165
|
+
const result = await policy.call(event);
|
|
166
|
+
|
|
167
|
+
assertEquals(result, ['OK', event.id, true, '']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
Deno.test('DomainPolicy rejects events from authors with a subdomain of a blacklisted domain', async () => {
|
|
171
|
+
const sk = generateSecretKey();
|
|
172
|
+
const pubkey = getPublicKey(sk);
|
|
173
|
+
|
|
174
|
+
const store = new MockRelay();
|
|
175
|
+
const policy = new DomainPolicy(store, {
|
|
176
|
+
// deno-lint-ignore require-await
|
|
177
|
+
async lookup(nip05: string) {
|
|
178
|
+
if (nip05 === 'bot@spam.replyguy.dev') {
|
|
179
|
+
return { pubkey };
|
|
180
|
+
} else {
|
|
181
|
+
throw new Error('not found');
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
blacklist: ['replyguy.dev'],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const metadata: NostrMetadata = { nip05: 'bot@spam.replyguy.dev' };
|
|
188
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
189
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
190
|
+
|
|
191
|
+
const result = await policy.call(event);
|
|
192
|
+
|
|
193
|
+
assertEquals(result, ['OK', event.id, false, 'blocked: blacklisted nip05 domain']);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
Deno.test('DomainPolicy rejects events from authors with a deeply nested subdomain of a blacklisted domain', async () => {
|
|
197
|
+
const sk = generateSecretKey();
|
|
198
|
+
const pubkey = getPublicKey(sk);
|
|
199
|
+
|
|
200
|
+
const store = new MockRelay();
|
|
201
|
+
const policy = new DomainPolicy(store, {
|
|
202
|
+
// deno-lint-ignore require-await
|
|
203
|
+
async lookup(nip05: string) {
|
|
204
|
+
if (nip05 === 'user@deep.nested.spam.replyguy.dev') {
|
|
205
|
+
return { pubkey };
|
|
206
|
+
} else {
|
|
207
|
+
throw new Error('not found');
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
blacklist: ['replyguy.dev'],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const metadata: NostrMetadata = { nip05: 'user@deep.nested.spam.replyguy.dev' };
|
|
214
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
215
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
216
|
+
|
|
217
|
+
const result = await policy.call(event);
|
|
218
|
+
|
|
219
|
+
assertEquals(result, ['OK', event.id, false, 'blocked: blacklisted nip05 domain']);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
Deno.test('DomainPolicy allows events from authors with similar but not subdomain of blacklisted domain', async () => {
|
|
223
|
+
const sk = generateSecretKey();
|
|
224
|
+
const pubkey = getPublicKey(sk);
|
|
225
|
+
|
|
226
|
+
const store = new MockRelay();
|
|
227
|
+
const policy = new DomainPolicy(store, {
|
|
228
|
+
// deno-lint-ignore require-await
|
|
229
|
+
async lookup(nip05: string) {
|
|
230
|
+
if (nip05 === 'user@notreplyguy.dev') {
|
|
231
|
+
return { pubkey };
|
|
232
|
+
} else {
|
|
233
|
+
throw new Error('not found');
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
blacklist: ['replyguy.dev'],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const metadata: NostrMetadata = { nip05: 'user@notreplyguy.dev' };
|
|
240
|
+
await store.event(genEvent({ kind: 0, content: JSON.stringify(metadata) }, sk));
|
|
241
|
+
const event = genEvent({ kind: 1, content: 'hello world' }, sk);
|
|
242
|
+
|
|
243
|
+
const result = await policy.call(event);
|
|
244
|
+
|
|
245
|
+
assertEquals(result, ['OK', event.id, true, '']);
|
|
246
|
+
});
|
package/DomainPolicy.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { NIP05, NSchema as n } from '@nostrify/nostrify';
|
|
2
|
+
import { NPolicy, NProfilePointer, NStore } from '@nostrify/types';
|
|
3
|
+
|
|
4
|
+
import { AuthorPolicy } from './AuthorPolicy';
|
|
5
|
+
|
|
6
|
+
/** Options for `DomainPolicy`. */
|
|
7
|
+
interface DomainPolicyOpts {
|
|
8
|
+
/** Custom NIP-05 lookup function. */
|
|
9
|
+
lookup?(nip05: string, signal?: AbortSignal): Promise<NProfilePointer>;
|
|
10
|
+
/** List of domains to blacklist. Reject events from users with a NIP-05 matching any of these domains. */
|
|
11
|
+
blacklist?: string[];
|
|
12
|
+
/** List of domains to whitelist. If provided, only events from users with a valid NIP-05 on the given domains will be accepted. */
|
|
13
|
+
whitelist?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Ban events unless their author has a valid NIP-05 name. Domains can also be whitelisted or blacklisted. */
|
|
17
|
+
export class DomainPolicy extends AuthorPolicy implements NPolicy {
|
|
18
|
+
constructor(store: NStore, opts: DomainPolicyOpts = {}) {
|
|
19
|
+
super(store, {
|
|
20
|
+
async call(event, signal) {
|
|
21
|
+
const { blacklist = [], whitelist, lookup = DomainPolicy.lookup } = opts;
|
|
22
|
+
|
|
23
|
+
const metadata = n.json().pipe(n.metadata()).safeParse(event.content);
|
|
24
|
+
|
|
25
|
+
if (!metadata.success) {
|
|
26
|
+
return ['OK', event.id, false, 'blocked: invalid kind 0 metadata'];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { nip05 } = metadata.data;
|
|
30
|
+
|
|
31
|
+
if (!nip05) {
|
|
32
|
+
return ['OK', event.id, false, 'blocked: missing nip05'];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const domain = nip05.split('@').pop();
|
|
36
|
+
|
|
37
|
+
if (!domain) {
|
|
38
|
+
return ['OK', event.id, false, 'blocked: invalid nip05'];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (DomainPolicy.isDomainBlacklisted(domain, blacklist)) {
|
|
42
|
+
return ['OK', event.id, false, 'blocked: blacklisted nip05 domain'];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const { pubkey } = await lookup(nip05, signal);
|
|
47
|
+
|
|
48
|
+
if (pubkey !== event.pubkey) {
|
|
49
|
+
return ['OK', event.id, false, 'blocked: mismatched nip05 pubkey'];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (whitelist && !whitelist.includes(domain)) {
|
|
53
|
+
return [
|
|
54
|
+
'OK',
|
|
55
|
+
event.id,
|
|
56
|
+
false,
|
|
57
|
+
'blocked: nip05 domain not in whitelist',
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return ['OK', event.id, true, ''];
|
|
62
|
+
} catch {
|
|
63
|
+
return ['OK', event.id, false, 'blocked: failed to lookup nip05'];
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Check if a domain is blacklisted, including subdomains of blacklisted domains. */
|
|
70
|
+
private static isDomainBlacklisted(
|
|
71
|
+
domain: string,
|
|
72
|
+
blacklist: string[],
|
|
73
|
+
): boolean {
|
|
74
|
+
// Check for exact match
|
|
75
|
+
if (blacklist.includes(domain)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check if domain is a subdomain of any blacklisted domain
|
|
80
|
+
for (const blacklistedDomain of blacklist) {
|
|
81
|
+
if (domain.endsWith('.' + blacklistedDomain)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Default NIP-05 lookup method if one isn't provided by the caller. */
|
|
90
|
+
private static lookup(
|
|
91
|
+
nip05: string,
|
|
92
|
+
signal?: AbortSignal,
|
|
93
|
+
): Promise<NProfilePointer> {
|
|
94
|
+
return NIP05.lookup(nip05, { signal });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { assertEquals } from '@std/assert';
|
|
2
|
+
import { finalizeEvent, generateSecretKey } from 'nostr-tools';
|
|
3
|
+
|
|
4
|
+
import { FiltersPolicy } from './FiltersPolicy.ts';
|
|
5
|
+
|
|
6
|
+
Deno.test('FiltersPolicy', async () => {
|
|
7
|
+
const event = finalizeEvent(
|
|
8
|
+
{ kind: 1, content: '', tags: [], created_at: 0 },
|
|
9
|
+
generateSecretKey(),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
assertEquals((await new FiltersPolicy([{ kinds: [1] }]).call(event))[2], true);
|
|
13
|
+
assertEquals((await new FiltersPolicy([{ kinds: [1], authors: [] }]).call(event))[2], false);
|
|
14
|
+
});
|