Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/uppercase-tag-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": patch
---

Support uppercase tag filters (#A-Z) in filter schema validation
13 changes: 12 additions & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ The schema ships with a small, query-driven set of indexes. The most important o
| `events_active_pubkey_kind_created_at_idx` | `REQ` with `authors`+`kinds` ordered by `created_at DESC, event_id ASC`; `hasActiveRequestToVanish`; by-pubkey deletes. Composite key `(event_pubkey, event_kind, event_created_at DESC, event_id)` so the ORDER BY tie-breaker is satisfied from the index without a sort step. |
| `events_deleted_at_partial_idx` | Retention purge over soft-deleted rows. Partial on `deleted_at IS NOT NULL`. |
| `invoices_pending_created_at_idx` | `findPendingInvoices` poll (`ORDER BY created_at ASC`). Partial on `status = 'pending'`. |
| `event_tags (tag_name, tag_value)` | NIP-01 generic tag filters (`#e`, `#p`, …) via the normalized `event_tags` table. |
| `event_tags (tag_name, tag_value)` | NIP-01 generic tag filters (`#e`, `#p`, `#K`, `#I`, …) via the normalized `event_tags` table. Both lowercase and uppercase single-letter tag filters are supported. |
| `events_event_created_at_index` | Time-range scans (`since` / `until`). |
| `events_event_kind_index` | Kind-only filters and purge kind-whitelist logic. |

Expand All @@ -108,6 +108,17 @@ npm run db:verify-index-impact

The hot-path index migration (`20260420_120000_add_hot_path_indexes.js`) uses `CREATE INDEX CONCURRENTLY`, so it can be applied to a running relay without taking `ACCESS EXCLUSIVE` locks on the `events` or `invoices` tables.

## Tag filter scope

Subscription filters support single-letter tag filters using the `#<letter>` key syntax (NIP-01). Both lowercase (`#a`–`#z`) and uppercase (`#A`–`#Z`) variants are accepted.

| Scope | Examples | Usage |
|-------|---------|-------|
| Lowercase (`#a`–`#z`) | `#e`, `#p`, `#a`, `#k` | Standard NIP-01 tag queries; parent-level references in NIP-22 comment threading |
| Uppercase (`#A`–`#Z`) | `#E`, `#P`, `#A`, `#K`, `#I` | Root-level references in NIP-22 comment threading and other NIPs that use uppercase to distinguish root vs. parent scope |

**NIP-22 comment threading (kind 1111):** NIP-22 comment events use lowercase tags (`#e`, `#a`, `#i`, `#k`) to reference the immediate parent and uppercase tags (`#E`, `#A`, `#I`, `#K`) to reference the root item. Filters must therefore accept both cases to allow clients to query the full comment thread hierarchy. For example, to find all comments on a root event: `{"kinds":[1111],"#E":["<root-event-id>"]}`, or to find comments of a specific root kind: `{"kinds":[1111],"#K":["1"]}`.

# Settings

Running `nostream` for the first time creates the settings file in `<project_root>/.nostr/settings.yaml`. If the file is not created and an error is thrown ensure that the `<project_root>/.nostr` folder exists. The configuration directory can be changed by setting the `NOSTR_CONFIG_DIR` environment variable. `nostream` will pick up any changes to this settings file without needing to restart.
Expand Down
3 changes: 2 additions & 1 deletion src/schemas/filter-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from 'zod'

import { createdAtSchema, kindSchema, prefixSchema } from './base-schema'
import { isGenericTagQuery } from '../utils/filter'

const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit'])

Expand All @@ -16,7 +17,7 @@ export const filterSchema = z
.catchall(z.array(z.string().min(1).max(1024)))
.superRefine((data, ctx) => {
for (const key of Object.keys(data)) {
if (!knownFilterKeys.has(key) && !/^#[a-z]$/.test(key)) {
if (!knownFilterKeys.has(key) && !isGenericTagQuery(key)) {
ctx.addIssue({
Comment thread
CKodidela marked this conversation as resolved.
code: z.ZodIssueCode.custom,
message: `Unknown key: ${key}`,
Expand Down
26 changes: 26 additions & 0 deletions test/unit/schemas/filter-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,32 @@ describe('NIP-01', () => {
}
})

it('accepts NIP-22 comment threading filters for kind 1111', () => {
const nip22Filter = {
kinds: [1111],
'#E': ['aaaa'],
'#K': ['1'],
'#I': ['identifier1'],
'#A': ['10000:pubkey:dtag'],
}
const result = validateSchema(filterSchema)(nip22Filter)
expect(result.error).to.be.undefined
expect(result.value).to.deep.equal(nip22Filter)
})

it('accepts uppercase tag filters (#A-Z)', () => {
const filterWithUppercase = {
...filter,
'#I': ['identifier1', 'identifier2'],
'#K': ['1111'],
'#E': ['aa', 'bb'],
'#A': ['10000:pubkey:dtag'],
}
const result = validateSchema(filterSchema)(filterWithUppercase)
expect(result.error).to.be.undefined
expect(result.value).to.deep.equal(filterWithUppercase)
})

it('returns same filter if filter is valid', () => {
const result = validateSchema(filterSchema)(filter)

Expand Down
Loading