diff --git a/.changeset/uppercase-tag-filters.md b/.changeset/uppercase-tag-filters.md new file mode 100644 index 00000000..2d024281 --- /dev/null +++ b/.changeset/uppercase-tag-filters.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Support uppercase tag filters (#A-Z) in filter schema validation diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 9698e4b1..df63db91 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -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. | @@ -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 `#` 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":[""]}`, 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 `/.nostr/settings.yaml`. If the file is not created and an error is thrown ensure that the `/.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. diff --git a/src/schemas/filter-schema.ts b/src/schemas/filter-schema.ts index dc1baa27..e27817c4 100644 --- a/src/schemas/filter-schema.ts +++ b/src/schemas/filter-schema.ts @@ -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']) @@ -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({ code: z.ZodIssueCode.custom, message: `Unknown key: ${key}`, diff --git a/test/unit/schemas/filter-schema.spec.ts b/test/unit/schemas/filter-schema.spec.ts index 4840e102..92683751 100644 --- a/test/unit/schemas/filter-schema.spec.ts +++ b/test/unit/schemas/filter-schema.spec.ts @@ -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)