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
9 changes: 9 additions & 0 deletions .changeset/geohash-prefix-filters.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
19 changes: 16 additions & 3 deletions src/repositories/event-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -193,8 +193,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)
})
Expand Down
6 changes: 6 additions & 0 deletions src/schemas/base-schema.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
21 changes: 20 additions & 1 deletion src/schemas/event-schema.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
* {
Expand Down Expand Up @@ -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],
})
}
})
})
20 changes: 17 additions & 3 deletions src/schemas/filter-schema.ts
Original file line number Diff line number Diff line change
@@ -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'])

Expand All @@ -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],
})
}
})
}
}
})
16 changes: 14 additions & 2 deletions src/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 (isGeohashPrefixCriterion(key, criterion)) {
return tag[1].startsWith(stripGeohashPrefixWildcard(criterion))
}

return tag[1] === criterion
}

// NIP-01: Basic protocol flow description

Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/utils/filter.ts
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions src/utils/geohash.ts
Original file line number Diff line number Diff line change
@@ -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]+\*?$/
22 changes: 22 additions & 0 deletions test/unit/repositories/event-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': [] }]
Expand Down
36 changes: 36 additions & 0 deletions test/unit/schemas/event-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
44 changes: 44 additions & 0 deletions test/unit/schemas/filter-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})
})
27 changes: 27 additions & 0 deletions test/unit/utils/event.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading