From 468013308a2c6ffaf309156db787e71def616f1f Mon Sep 17 00:00:00 2001 From: Kanishka Date: Mon, 27 Apr 2026 18:39:42 +0530 Subject: [PATCH 1/2] feat: support geohash prefix matching for #g filters --- .changeset/geohash-prefix-filters.md | 9 +++++++ src/repositories/event-repository.ts | 22 +++++++++++++-- src/utils/event.ts | 14 +++++++++- .../repositories/event-repository.spec.ts | 22 +++++++++++++++ test/unit/utils/event.spec.ts | 27 +++++++++++++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 .changeset/geohash-prefix-filters.md diff --git a/.changeset/geohash-prefix-filters.md b/.changeset/geohash-prefix-filters.md new file mode 100644 index 00000000..7f14c99a --- /dev/null +++ b/.changeset/geohash-prefix-filters.md @@ -0,0 +1,9 @@ +--- +"nostream": patch +--- + +Implement geohash wildcard/prefix behavior for `#g` filters (closes #265): a +criterion ending in `*` matches any event `g` tag whose value starts with the +prefix before `*`; exact matching (no `*`) is unchanged. Only normal geohash +prefixes are intended as input. This is a Nostream extension, not part of +NIP-12. diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 56da579d..fb3e135a 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -58,6 +58,11 @@ const groupByLengthSpec = groupBy( const logger = createLogger('event-repository') +const isGeohashPrefixCriterion = (filterName: string, criterion: string): boolean => + filterName === '#g' && criterion.endsWith('*') + +const stripGeohashPrefixWildcard = (criterion: string): string => criterion.slice(0, -1) + export class EventRepository implements IEventRepository { public constructor( private readonly masterDbClient: DatabaseClient, @@ -193,8 +198,21 @@ export class EventRepository implements IEventRepository { isEmpty, () => andWhereRaw('1 = 0', bd), forEach( - (criterion: string) => - void orWhereRaw('event_tags.tag_name = ? AND event_tags.tag_value = ?', [filterName[1], criterion], bd), + (criterion: string) => { + if (isGeohashPrefixCriterion(filterName, criterion)) { + return void orWhereRaw( + 'event_tags.tag_name = ? AND event_tags.tag_value LIKE ?', + [filterName[1], `${stripGeohashPrefixWildcard(criterion)}%`], + bd, + ) + } + + return void orWhereRaw( + 'event_tags.tag_name = ? AND event_tags.tag_value = ?', + [filterName[1], criterion], + bd, + ) + }, ), )(criteria) }) diff --git a/src/utils/event.ts b/src/utils/event.ts index 18bad057..b621d389 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -40,6 +40,18 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => { const startsWith = (input: string) => (prefix: string) => input.startsWith(prefix) + const isMatchingGenericTagCriterion = (key: string, criterion: string) => (tag: Tag): boolean => { + const [, tagName] = key + if (tag[0] !== tagName) { + return false + } + + if (key === '#g' && criterion.endsWith('*')) { + return tag[1].startsWith(criterion.slice(0, -1)) + } + + return tag[1] === criterion + } // NIP-01: Basic protocol flow description @@ -84,7 +96,7 @@ export const isEventMatchingFilter = Object.entries(filter) .filter(([key, criteria]) => isGenericTagQuery(key) && Array.isArray(criteria)) .some(([key, criteria]) => { - return !event.tags.some((tag) => tag[0] === key[1] && criteria.includes(tag[1])) + return !event.tags.some((tag) => criteria.some((criterion) => isMatchingGenericTagCriterion(key, criterion)(tag))) }) ) { return false diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index 6a9ffbfa..f9a90cc8 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -320,6 +320,28 @@ describe('EventRepository', () => { }) }) + describe('#g', () => { + it('selects geohash tags by prefix when criterion ends with wildcard', () => { + const filters = [{ '#g': ['u4pruyd*'] }] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + 'select "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'g\' AND event_tags.tag_value LIKE \'u4pruyd%\') order by "event_created_at" asc, "event_id" asc limit 500', + ) + }) + + it('keeps geohash tags exact when criterion has no wildcard', () => { + const filters = [{ '#g': ['u4pruyd'] }] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + 'select "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'g\' AND event_tags.tag_value = \'u4pruyd\') order by "event_created_at" asc, "event_id" asc limit 500', + ) + }) + }) + describe('#p', () => { it('selects no events given empty list of #p tags', () => { const filters = [{ '#p': [] }] diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index f059f940..b1514b25 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -302,6 +302,33 @@ describe('NIP-12', () => { expect(isEventMatchingFilter({ '#r': ['something else'] })(event)).to.be.false }) }) + + describe('#g filter', () => { + beforeEach(() => { + event = { + id: 'cf8de9db67a1d7203512d1d81e6190f5e53abfdc0ac90275f67172b65a5b09a0', + pubkey: 'e8b487c079b0f67c695ae6c4c2552a47f38adfa2533cc5926bd2c102942fdcb7', + created_at: 1645030752, + kind: 1, + tags: [['g', 'u4pruydqqvj']], + content: 'g', + sig: '53d12018d036092794366283eca36df4e0cabd014b6e91bbf684c8bb9bbbe9dedafa77b6b928587e11e05e036227598dded8713e8da17d55076e12242b361542', + } + }) + + it('returns true if #g filter contains a matching geohash prefix wildcard', () => { + expect(isEventMatchingFilter({ '#g': ['u4pruyd*'] })(event)).to.be.true + }) + + it('returns false if #g filter contains a non-matching geohash prefix wildcard', () => { + expect(isEventMatchingFilter({ '#g': ['u4pruz*'] })(event)).to.be.false + }) + + it('keeps #g filter exact when criterion has no wildcard', () => { + expect(isEventMatchingFilter({ '#g': ['u4pruyd'] })(event)).to.be.false + expect(isEventMatchingFilter({ '#g': ['u4pruydqqvj'] })(event)).to.be.true + }) + }) }) describe('NIP-16', () => { From b66b33636f20b091a7b9d79e95ce69b752adc0ab Mon Sep 17 00:00:00 2001 From: Kanishka Date: Thu, 30 Apr 2026 18:56:03 +0530 Subject: [PATCH 2/2] refactor: encapsulate geohash helpers and validate schemas --- src/constants/base.ts | 2 ++ src/repositories/event-repository.ts | 7 +--- src/schemas/base-schema.ts | 6 ++++ src/schemas/event-schema.ts | 21 +++++++++++- src/schemas/filter-schema.ts | 20 +++++++++-- src/utils/event.ts | 6 ++-- src/utils/filter.ts | 12 +++++++ src/utils/geohash.ts | 9 +++++ test/unit/schemas/event-schema.spec.ts | 36 ++++++++++++++++++++ test/unit/schemas/filter-schema.spec.ts | 44 +++++++++++++++++++++++++ 10 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 src/utils/geohash.ts diff --git a/src/constants/base.ts b/src/constants/base.ts index 3212efa2..141f1175 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -56,6 +56,8 @@ export enum EventTags { Invoice = 'bolt11', // NIP-03: target event kind on an OpenTimestamps attestation Kind = 'k', + // NIP-12: geohash tag for location-based queries + Geohash = 'g', } export const ALL_RELAYS = 'ALL_RELAYS' diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index fb3e135a..6c53fd0a 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -40,7 +40,7 @@ import { DBEvent, Event } from '../@types/event' import { EventPurgeCounts, EventRetentionOptions, IEventRepository, IQueryResult } from '../@types/repositories' import { toBuffer, toJSON } from '../utils/transform' import { createLogger } from '../factories/logger-factory' -import { isGenericTagQuery } from '../utils/filter' +import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from '../utils/filter' import { SubscriptionFilter } from '../@types/subscription' const even = pipe(modulo(__, 2), equals(0)) @@ -58,11 +58,6 @@ const groupByLengthSpec = groupBy( const logger = createLogger('event-repository') -const isGeohashPrefixCriterion = (filterName: string, criterion: string): boolean => - filterName === '#g' && criterion.endsWith('*') - -const stripGeohashPrefixWildcard = (criterion: string): string => criterion.slice(0, -1) - export class EventRepository implements IEventRepository { public constructor( private readonly masterDbClient: DatabaseClient, diff --git a/src/schemas/base-schema.ts b/src/schemas/base-schema.ts index 5af6c308..f03461d7 100644 --- a/src/schemas/base-schema.ts +++ b/src/schemas/base-schema.ts @@ -1,7 +1,13 @@ import { z } from 'zod' +import { GEOHASH_FILTER_PATTERN, GEOHASH_PATTERN } from '../utils/geohash' + const lowerHexRegex = /^[0-9a-f]+$/ +// NIP-12 geohash schemas +export const geohashSchema = z.string().regex(GEOHASH_PATTERN, 'Invalid geohash') +export const geohashFilterValueSchema = z.string().regex(GEOHASH_FILTER_PATTERN, 'Invalid geohash filter') + export const prefixSchema = z.string().regex(lowerHexRegex).min(4).max(64) export const idSchema = z.string().regex(lowerHexRegex).length(64) diff --git a/src/schemas/event-schema.ts b/src/schemas/event-schema.ts index 83be81ab..389f76f8 100644 --- a/src/schemas/event-schema.ts +++ b/src/schemas/event-schema.ts @@ -1,7 +1,15 @@ import { z } from 'zod' import { EventKinds, EventTags } from '../constants/base' -import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from './base-schema' +import { + createdAtSchema, + geohashSchema, + idSchema, + kindSchema, + pubkeySchema, + signatureSchema, + tagSchema, +} from './base-schema' /** * { @@ -42,4 +50,15 @@ export const eventSchema = z } }) } + + // Validate geohash tag values (NIP-12 #g) + event.tags.forEach((tag, index) => { + if (tag[0] === EventTags.Geohash && typeof tag[1] === 'string' && !geohashSchema.safeParse(tag[1]).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid geohash', + path: ['tags', index, 1], + }) + } + }) }) diff --git a/src/schemas/filter-schema.ts b/src/schemas/filter-schema.ts index 1aa41897..e64c9f55 100644 --- a/src/schemas/filter-schema.ts +++ b/src/schemas/filter-schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import { createdAtSchema, kindSchema, prefixSchema } from './base-schema' -import { isGenericTagQuery } from '../utils/filter' +import { createdAtSchema, geohashFilterValueSchema, kindSchema, prefixSchema } from './base-schema' +import { isGenericTagQuery, isGeohashTagQuery } from '../utils/filter' const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit']) @@ -16,13 +16,27 @@ export const filterSchema = z }) .catchall(z.array(z.string().max(1024))) .superRefine((data, ctx) => { - for (const key of Object.keys(data)) { + for (const [key, value] of Object.entries(data)) { if (!knownFilterKeys.has(key) && !isGenericTagQuery(key)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Unknown key: ${key}`, path: [key], }) + continue + } + + // Validate #g filter values: NIP-12 geohash with optional single trailing '*' + if (isGeohashTagQuery(key) && Array.isArray(value)) { + value.forEach((criterion, index) => { + if (typeof criterion === 'string' && !geohashFilterValueSchema.safeParse(criterion).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid geohash filter', + path: [key, index], + }) + } + }) } } }) diff --git a/src/utils/event.ts b/src/utils/event.ts index b621d389..914772b8 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -8,7 +8,7 @@ import { deriveFromSecret } from './secret' import { EventKindsRange } from '../@types/settings' import { fromBuffer } from './transform' import { getLeadingZeroBits } from './proof-of-work' -import { isGenericTagQuery } from './filter' +import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from './filter' import { SubscriptionFilter } from '../@types/subscription' import { WebSocketServerAdapterEvent } from '../constants/adapter' @@ -46,8 +46,8 @@ export const isEventMatchingFilter = return false } - if (key === '#g' && criterion.endsWith('*')) { - return tag[1].startsWith(criterion.slice(0, -1)) + if (isGeohashPrefixCriterion(key, criterion)) { + return tag[1].startsWith(stripGeohashPrefixWildcard(criterion)) } return tag[1] === criterion diff --git a/src/utils/filter.ts b/src/utils/filter.ts index 2e1d5c94..243a5e49 100644 --- a/src/utils/filter.ts +++ b/src/utils/filter.ts @@ -1 +1,13 @@ +import { EventTags } from '../constants/base' + export const isGenericTagQuery = (key: string) => /^#[a-zA-Z]$/.test(key) + +// NIP-12 geohash filter helpers +export const geohashTagQuery = `#${EventTags.Geohash}` + +export const isGeohashTagQuery = (key: string): boolean => key === geohashTagQuery + +export const isGeohashPrefixCriterion = (key: string, criterion: string): boolean => + isGeohashTagQuery(key) && criterion.endsWith('*') + +export const stripGeohashPrefixWildcard = (criterion: string): string => criterion.slice(0, -1) diff --git a/src/utils/geohash.ts b/src/utils/geohash.ts new file mode 100644 index 00000000..a6e39d3f --- /dev/null +++ b/src/utils/geohash.ts @@ -0,0 +1,9 @@ +// Geohash base32 alphabet (excludes 'a', 'i', 'l', 'o') +export const GEOHASH_BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz' + +// Matches a complete geohash (one or more base32 chars) +export const GEOHASH_PATTERN = /^[0123456789bcdefghjkmnpqrstuvwxyz]+$/ + +// Matches a geohash filter criterion: one or more base32 chars, with an +// optional single trailing '*' wildcard (NIP-12 prefix matching) +export const GEOHASH_FILTER_PATTERN = /^[0123456789bcdefghjkmnpqrstuvwxyz]+\*?$/ diff --git a/test/unit/schemas/event-schema.spec.ts b/test/unit/schemas/event-schema.spec.ts index dbdbfe50..b0ad0c62 100644 --- a/test/unit/schemas/event-schema.spec.ts +++ b/test/unit/schemas/event-schema.spec.ts @@ -163,6 +163,42 @@ describe('NIP-65', () => { }) }) +describe('NIP-12', () => { + const geohashBase: Event = { + id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5', + pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + created_at: 1660306803, + kind: EventKinds.TEXT_NOTE, + tags: [], + content: '', + sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96', + } + + it('accepts event with valid base32 geohash tag', () => { + const event = { ...geohashBase, tags: [[EventTags.Geohash, 'u4pruydqqvj']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.be.undefined + }) + + it('rejects event with non-base32 geohash characters', () => { + const event = { ...geohashBase, tags: [[EventTags.Geohash, 'u4pruyda']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('rejects event with empty geohash', () => { + const event = { ...geohashBase, tags: [[EventTags.Geohash, '']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) + + it('rejects event with uppercase geohash', () => { + const event = { ...geohashBase, tags: [[EventTags.Geohash, 'U4PRUYDQQVJ']] } + const result = validateSchema(eventSchema)(event) + expect(result.error).to.not.be.undefined + }) +}) + describe('NIP-14', () => { it('accepts subject tag on text note events', () => { const event: Event = { diff --git a/test/unit/schemas/filter-schema.spec.ts b/test/unit/schemas/filter-schema.spec.ts index d6720008..e45a7899 100644 --- a/test/unit/schemas/filter-schema.spec.ts +++ b/test/unit/schemas/filter-schema.spec.ts @@ -141,3 +141,47 @@ describe('NIP-01', () => { } }) }) + +describe('NIP-12', () => { + describe('#g filter validation', () => { + it('accepts a valid base32 geohash', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4pruydqqvj'] }) + expect(result.error).to.be.undefined + }) + + it('accepts a valid geohash prefix with trailing wildcard', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4pruyd*'] }) + expect(result.error).to.be.undefined + }) + + it('rejects an empty criterion', () => { + const result = validateSchema(filterSchema)({ '#g': [''] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects a bare wildcard', () => { + const result = validateSchema(filterSchema)({ '#g': ['*'] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects non-base32 characters', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4pruyda'] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects uppercase characters', () => { + const result = validateSchema(filterSchema)({ '#g': ['U4PRUYDQQVJ'] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects wildcard not at the end', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4*pruyd'] }) + expect(result.error).to.not.be.undefined + }) + + it('rejects multiple wildcards', () => { + const result = validateSchema(filterSchema)({ '#g': ['u4pruyd**'] }) + expect(result.error).to.not.be.undefined + }) + }) +})