Skip to content

Commit b66b336

Browse files
committed
refactor: encapsulate geohash helpers and validate schemas
1 parent 4680133 commit b66b336

10 files changed

Lines changed: 150 additions & 13 deletions

File tree

src/constants/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export enum EventTags {
5656
Invoice = 'bolt11',
5757
// NIP-03: target event kind on an OpenTimestamps attestation
5858
Kind = 'k',
59+
// NIP-12: geohash tag for location-based queries
60+
Geohash = 'g',
5961
}
6062

6163
export const ALL_RELAYS = 'ALL_RELAYS'

src/repositories/event-repository.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { DBEvent, Event } from '../@types/event'
4040
import { EventPurgeCounts, EventRetentionOptions, IEventRepository, IQueryResult } from '../@types/repositories'
4141
import { toBuffer, toJSON } from '../utils/transform'
4242
import { createLogger } from '../factories/logger-factory'
43-
import { isGenericTagQuery } from '../utils/filter'
43+
import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from '../utils/filter'
4444
import { SubscriptionFilter } from '../@types/subscription'
4545

4646
const even = pipe(modulo(__, 2), equals(0))
@@ -58,11 +58,6 @@ const groupByLengthSpec = groupBy<string, 'exact' | 'even' | 'odd'>(
5858

5959
const logger = createLogger('event-repository')
6060

61-
const isGeohashPrefixCriterion = (filterName: string, criterion: string): boolean =>
62-
filterName === '#g' && criterion.endsWith('*')
63-
64-
const stripGeohashPrefixWildcard = (criterion: string): string => criterion.slice(0, -1)
65-
6661
export class EventRepository implements IEventRepository {
6762
public constructor(
6863
private readonly masterDbClient: DatabaseClient,

src/schemas/base-schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { z } from 'zod'
22

3+
import { GEOHASH_FILTER_PATTERN, GEOHASH_PATTERN } from '../utils/geohash'
4+
35
const lowerHexRegex = /^[0-9a-f]+$/
46

7+
// NIP-12 geohash schemas
8+
export const geohashSchema = z.string().regex(GEOHASH_PATTERN, 'Invalid geohash')
9+
export const geohashFilterValueSchema = z.string().regex(GEOHASH_FILTER_PATTERN, 'Invalid geohash filter')
10+
511
export const prefixSchema = z.string().regex(lowerHexRegex).min(4).max(64)
612

713
export const idSchema = z.string().regex(lowerHexRegex).length(64)

src/schemas/event-schema.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { z } from 'zod'
22

33
import { EventKinds, EventTags } from '../constants/base'
4-
import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from './base-schema'
4+
import {
5+
createdAtSchema,
6+
geohashSchema,
7+
idSchema,
8+
kindSchema,
9+
pubkeySchema,
10+
signatureSchema,
11+
tagSchema,
12+
} from './base-schema'
513

614
/**
715
* {
@@ -42,4 +50,15 @@ export const eventSchema = z
4250
}
4351
})
4452
}
53+
54+
// Validate geohash tag values (NIP-12 #g)
55+
event.tags.forEach((tag, index) => {
56+
if (tag[0] === EventTags.Geohash && typeof tag[1] === 'string' && !geohashSchema.safeParse(tag[1]).success) {
57+
ctx.addIssue({
58+
code: z.ZodIssueCode.custom,
59+
message: 'Invalid geohash',
60+
path: ['tags', index, 1],
61+
})
62+
}
63+
})
4564
})

src/schemas/filter-schema.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from 'zod'
22

3-
import { createdAtSchema, kindSchema, prefixSchema } from './base-schema'
4-
import { isGenericTagQuery } from '../utils/filter'
3+
import { createdAtSchema, geohashFilterValueSchema, kindSchema, prefixSchema } from './base-schema'
4+
import { isGenericTagQuery, isGeohashTagQuery } from '../utils/filter'
55

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

@@ -16,13 +16,27 @@ export const filterSchema = z
1616
})
1717
.catchall(z.array(z.string().max(1024)))
1818
.superRefine((data, ctx) => {
19-
for (const key of Object.keys(data)) {
19+
for (const [key, value] of Object.entries(data)) {
2020
if (!knownFilterKeys.has(key) && !isGenericTagQuery(key)) {
2121
ctx.addIssue({
2222
code: z.ZodIssueCode.custom,
2323
message: `Unknown key: ${key}`,
2424
path: [key],
2525
})
26+
continue
27+
}
28+
29+
// Validate #g filter values: NIP-12 geohash with optional single trailing '*'
30+
if (isGeohashTagQuery(key) && Array.isArray(value)) {
31+
value.forEach((criterion, index) => {
32+
if (typeof criterion === 'string' && !geohashFilterValueSchema.safeParse(criterion).success) {
33+
ctx.addIssue({
34+
code: z.ZodIssueCode.custom,
35+
message: 'Invalid geohash filter',
36+
path: [key, index],
37+
})
38+
}
39+
})
2640
}
2741
}
2842
})

src/utils/event.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { deriveFromSecret } from './secret'
88
import { EventKindsRange } from '../@types/settings'
99
import { fromBuffer } from './transform'
1010
import { getLeadingZeroBits } from './proof-of-work'
11-
import { isGenericTagQuery } from './filter'
11+
import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from './filter'
1212
import { SubscriptionFilter } from '../@types/subscription'
1313
import { WebSocketServerAdapterEvent } from '../constants/adapter'
1414

@@ -46,8 +46,8 @@ export const isEventMatchingFilter =
4646
return false
4747
}
4848

49-
if (key === '#g' && criterion.endsWith('*')) {
50-
return tag[1].startsWith(criterion.slice(0, -1))
49+
if (isGeohashPrefixCriterion(key, criterion)) {
50+
return tag[1].startsWith(stripGeohashPrefixWildcard(criterion))
5151
}
5252

5353
return tag[1] === criterion

src/utils/filter.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,13 @@
1+
import { EventTags } from '../constants/base'
2+
13
export const isGenericTagQuery = (key: string) => /^#[a-zA-Z]$/.test(key)
4+
5+
// NIP-12 geohash filter helpers
6+
export const geohashTagQuery = `#${EventTags.Geohash}`
7+
8+
export const isGeohashTagQuery = (key: string): boolean => key === geohashTagQuery
9+
10+
export const isGeohashPrefixCriterion = (key: string, criterion: string): boolean =>
11+
isGeohashTagQuery(key) && criterion.endsWith('*')
12+
13+
export const stripGeohashPrefixWildcard = (criterion: string): string => criterion.slice(0, -1)

src/utils/geohash.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Geohash base32 alphabet (excludes 'a', 'i', 'l', 'o')
2+
export const GEOHASH_BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'
3+
4+
// Matches a complete geohash (one or more base32 chars)
5+
export const GEOHASH_PATTERN = /^[0123456789bcdefghjkmnpqrstuvwxyz]+$/
6+
7+
// Matches a geohash filter criterion: one or more base32 chars, with an
8+
// optional single trailing '*' wildcard (NIP-12 prefix matching)
9+
export const GEOHASH_FILTER_PATTERN = /^[0123456789bcdefghjkmnpqrstuvwxyz]+\*?$/

test/unit/schemas/event-schema.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,42 @@ describe('NIP-65', () => {
163163
})
164164
})
165165

166+
describe('NIP-12', () => {
167+
const geohashBase: Event = {
168+
id: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5',
169+
pubkey: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29',
170+
created_at: 1660306803,
171+
kind: EventKinds.TEXT_NOTE,
172+
tags: [],
173+
content: '',
174+
sig: '313a9b8cd68267a51da84e292c0937d1f3686c6757c4584f50fcedad2b13fad755e6226924f79880fb5aa9de95c04231a4823981513ac9e7092bad7488282a96',
175+
}
176+
177+
it('accepts event with valid base32 geohash tag', () => {
178+
const event = { ...geohashBase, tags: [[EventTags.Geohash, 'u4pruydqqvj']] }
179+
const result = validateSchema(eventSchema)(event)
180+
expect(result.error).to.be.undefined
181+
})
182+
183+
it('rejects event with non-base32 geohash characters', () => {
184+
const event = { ...geohashBase, tags: [[EventTags.Geohash, 'u4pruyda']] }
185+
const result = validateSchema(eventSchema)(event)
186+
expect(result.error).to.not.be.undefined
187+
})
188+
189+
it('rejects event with empty geohash', () => {
190+
const event = { ...geohashBase, tags: [[EventTags.Geohash, '']] }
191+
const result = validateSchema(eventSchema)(event)
192+
expect(result.error).to.not.be.undefined
193+
})
194+
195+
it('rejects event with uppercase geohash', () => {
196+
const event = { ...geohashBase, tags: [[EventTags.Geohash, 'U4PRUYDQQVJ']] }
197+
const result = validateSchema(eventSchema)(event)
198+
expect(result.error).to.not.be.undefined
199+
})
200+
})
201+
166202
describe('NIP-14', () => {
167203
it('accepts subject tag on text note events', () => {
168204
const event: Event = {

test/unit/schemas/filter-schema.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,47 @@ describe('NIP-01', () => {
141141
}
142142
})
143143
})
144+
145+
describe('NIP-12', () => {
146+
describe('#g filter validation', () => {
147+
it('accepts a valid base32 geohash', () => {
148+
const result = validateSchema(filterSchema)({ '#g': ['u4pruydqqvj'] })
149+
expect(result.error).to.be.undefined
150+
})
151+
152+
it('accepts a valid geohash prefix with trailing wildcard', () => {
153+
const result = validateSchema(filterSchema)({ '#g': ['u4pruyd*'] })
154+
expect(result.error).to.be.undefined
155+
})
156+
157+
it('rejects an empty criterion', () => {
158+
const result = validateSchema(filterSchema)({ '#g': [''] })
159+
expect(result.error).to.not.be.undefined
160+
})
161+
162+
it('rejects a bare wildcard', () => {
163+
const result = validateSchema(filterSchema)({ '#g': ['*'] })
164+
expect(result.error).to.not.be.undefined
165+
})
166+
167+
it('rejects non-base32 characters', () => {
168+
const result = validateSchema(filterSchema)({ '#g': ['u4pruyda'] })
169+
expect(result.error).to.not.be.undefined
170+
})
171+
172+
it('rejects uppercase characters', () => {
173+
const result = validateSchema(filterSchema)({ '#g': ['U4PRUYDQQVJ'] })
174+
expect(result.error).to.not.be.undefined
175+
})
176+
177+
it('rejects wildcard not at the end', () => {
178+
const result = validateSchema(filterSchema)({ '#g': ['u4*pruyd'] })
179+
expect(result.error).to.not.be.undefined
180+
})
181+
182+
it('rejects multiple wildcards', () => {
183+
const result = validateSchema(filterSchema)({ '#g': ['u4pruyd**'] })
184+
expect(result.error).to.not.be.undefined
185+
})
186+
})
187+
})

0 commit comments

Comments
 (0)