Policy
Composable event checks. Each policy answers one question about one event; apps combine them with pipe instead of growing a bespoke shouldAccept() function. Useful anywhere untrusted events arrive - relays, bots, client-side feed filtering, moderation pipelines.
Import
Namespaced, or on its own subpath, so generic names like pipe and not stay out of your flat import scope.
import { policy } from 'nostr-core'
// or, as a subpath
import * as policy from 'nostr-core/policy'The Policy Interface
type Policy = {
readonly name: string
check(event: NostrEvent): PolicyResult | Promise<PolicyResult>
}
type PolicyResult = {
accepted: boolean
policy?: string // which policy decided
reason?: string // NIP-01 OK-style, e.g. 'blocked: spam'
}const p = policy.pipe([
policy.noDuplicates(),
policy.blockKeywords(['spam']),
policy.requirePow(20),
])
const result = await p.check(event)
if (!result.accepted) {
console.log(result.policy, result.reason)
// 'noDuplicates' 'duplicate: event already seen'
}TIP
Several policies keep state (noDuplicates, rateLimit). Build them once and reuse the instance - rebuilding per event throws the state away.
Combinators
pipe
function pipe(policies: Policy[], name?: string): PolicyRuns policies in order and stops at the first rejection, returning that policy's result. Order matters - put cheap checks first so an event is discarded before it reaches an expensive one.
anyOf
function anyOf(policies: Policy[], name?: string): PolicyAccepts when at least one policy accepts.
not
function not(policy: Policy, name?: string): PolicycustomPolicy
function customPolicy(
name: string,
predicate: (event: NostrEvent) => boolean | Promise<boolean>,
reason?: string,
): Policyconst followsOnly = policy.customPolicy(
'followsOnly',
event => followSet.has(event.pubkey),
'blocked: not in your follow list',
)The predicate may be async, so a policy can hit a database or a remote list.
Built-in Policies
noDuplicates
function noDuplicates(opts?: { max?: number }): PolicyRejects events already seen. Keeps an in-memory set of the most recent max ids (default 10000), evicting oldest first.
requirePow
function requirePow(difficulty: number, opts?: { requireCommitment?: boolean }): PolicyRequires NIP-13 proof of work. By default the nonce tag must also commit to the target, so an event cannot get credit for accidental leading zeroes. Pass { requireCommitment: false } to check the hash alone.
blockKeywords
function blockKeywords(
keywords: (string | RegExp)[],
opts?: { caseSensitive?: boolean; includeTags?: boolean },
): PolicyPlain strings match case-insensitively as substrings unless caseSensitive is set; regular expressions are used as given. includeTags extends the search to tag values.
policy.blockKeywords(['spam', /\bfree\s+bitcoin\b/i])filterPolicy / rejectFilterPolicy
function filterPolicy(filters: Filter[]): Policy
function rejectFilterPolicy(filters: Filter[]): PolicyAccept only events matching at least one filter, or reject events matching any. Reuses the same matcher the relay client uses.
kindAllowList / kindDenyList
function kindAllowList(kinds: number[]): Policy
function kindDenyList(kinds: number[]): PolicypubkeyAllowList / pubkeyDenyList
function pubkeyAllowList(pubkeys: string[]): Policy
function pubkeyDenyList(pubkeys: string[]): PolicysizeLimit
function sizeLimit(opts: {
maxContentLength?: number
maxTags?: number
maxTagLength?: number
}): PolicyrateLimit
function rateLimit(opts: {
max: number
windowMs: number
key?: (event: NostrEvent) => string
}): PolicyPer-author by default; pass key to bucket by something else (an IP, a connection id).
policy.rateLimit({ max: 10, windowMs: 60_000 })requireValidSignature
function requireValidSignature(): PolicyRejects events whose id or signature does not verify. Always verifies from scratch, ignoring any cached verification flag on the event - a policy exists to judge untrusted events, so it must not take the event's word for it.
createdAtPolicy
function createdAtPolicy(opts: {
maxPastSeconds?: number
maxFutureSeconds?: number
}): PolicyRejects timestamps too far from now. Omit a bound to leave that side unbounded.
notExpired
function notExpired(): PolicyRejects events past their NIP-40 expiration tag.
Example: a relay ingress pipeline
import { policy } from 'nostr-core'
// Built once, at startup.
const ingress = policy.pipe([
policy.requireValidSignature(),
policy.createdAtPolicy({ maxFutureSeconds: 900, maxPastSeconds: 60 * 60 * 24 * 365 }),
policy.notExpired(),
policy.sizeLimit({ maxContentLength: 64_000, maxTags: 2000 }),
policy.noDuplicates(),
policy.kindDenyList([4]), // legacy DMs not accepted here
policy.rateLimit({ max: 20, windowMs: 60_000 }),
policy.blockKeywords(['spam', /free\s+bitcoin/i]),
])
async function onEvent(event) {
const result = await ingress.check(event)
if (!result.accepted) {
return send(['OK', event.id, false, result.reason])
}
await store(event)
send(['OK', event.id, true, ''])
}Example: a client-side feed filter
const feed = policy.pipe([
policy.notExpired(),
policy.anyOf([
policy.pubkeyAllowList(followList), // always show people I follow
policy.requirePow(16), // otherwise make them pay for attention
]),
policy.blockKeywords(mutedWords),
])
pool.subscribe(relays, { kinds: [1] }, {
onevent: async (event) => {
if ((await feed.check(event)).accepted) render(event)
},
})