From 520643301d2a1a8850a3e4b8d6400b1363192fce Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:42:14 +0200 Subject: [PATCH] Accept a place origin that starts with a digit A leading digit is valid, so 21gifts passes the origin check. Length, lowercase, and the ban on a leading hyphen stay the same. --- docs/handbook/functions.md | 2 +- src/__tests__/map/place.test.ts | 29 +++++++++++++++++ src/__tests__/map/routes.test.ts | 53 ++++++++++++++++++++++++++++++++ src/lib/map/place.ts | 2 +- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 8c004302d..200dc9e2f 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -2,7 +2,7 @@ ## Function: normalizePlaceOrigin -- **Purpose:** Trim a pin origin and accept only `^[a-z][a-z0-9-]{0,31}$`. Uppercase tokens such as SPAR are invalid. +- **Purpose:** Trim a pin origin and accept only `^[a-z0-9][a-z0-9-]{0,31}$`. A leading digit is valid. Uppercase tokens such as SPAR are invalid. - **Inputs:** Unknown value, typically a JSON field or the `origin` query string. - **Returns / side effects:** `{ ok: true, value }` with the trimmed origin, or `{ ok: false, error: "Place origin is invalid" }`. No I/O. - **Used by:** `normalizeMapPlace`, `normalizeMapPlaceKey`, and `mapRoutes` on `GET /map/places`. diff --git a/src/__tests__/map/place.test.ts b/src/__tests__/map/place.test.ts index aa9c22e6c..503edee21 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -23,6 +23,15 @@ describe('normalizePlaceOrigin', () => { expect(normalizePlaceOrigin(' spar ')).toEqual({ ok: true, value: 'spar' }); }); + it('trims an origin that starts with a digit', () => { + expect(normalizePlaceOrigin(' 21gifts ')).toEqual({ ok: true, value: '21gifts' }); + }); + + it('accepts a 32-character origin that starts with a digit', () => { + const origin = '2' + 'a'.repeat(31); + expect(normalizePlaceOrigin(origin)).toEqual({ ok: true, value: origin }); + }); + it('rejects SPAR and any other invalid value', () => { expect(normalizePlaceOrigin('SPAR')).toEqual({ ok: false, @@ -36,6 +45,26 @@ describe('normalizePlaceOrigin', () => { ok: false, error: 'Place origin is invalid', }); + expect(normalizePlaceOrigin('2' + 'a'.repeat(32))).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin('-a')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin('21.gifts')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin('21Gifts')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin(' ')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); }); }); diff --git a/src/__tests__/map/routes.test.ts b/src/__tests__/map/routes.test.ts index 5ec15013d..95fddbb95 100644 --- a/src/__tests__/map/routes.test.ts +++ b/src/__tests__/map/routes.test.ts @@ -86,6 +86,59 @@ describe('map routes', () => { expect(all.places.map((row) => row.origin).sort()).toEqual(['dfx', 'spar']); }); + it('accepts origin 21gifts on PUT and GET and rejects a leading hyphen or uppercase', async () => { + const api = app({ token: 'secret' }); + const created = await api.request('/map/places', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ + origin: '21gifts', + externalId: 'shop-1', + name: 'Shop', + lat: 14.620311, + lon: 120.959626, + category: 'shopping', + paymentMethods: 'lightning', + techProvider: '21.gifts', + }), + }); + expect(created.status).toBe(201); + const createdJson = (await created.json()) as { created: boolean }; + expect(createdJson.created).toBe(true); + const listed = await api.request('/map/places?origin=21gifts'); + expect(listed.status).toBe(200); + const listedJson = (await listed.json()) as { + places: Array<{ origin: string; name: string; category: string; techProvider: string }>; + }; + expect(listedJson.places).toHaveLength(1); + expect(listedJson.places[0]?.origin).toBe('21gifts'); + expect(listedJson.places[0]?.name).toBe('Shop'); + expect(listedJson.places[0]?.category).toBe('shopping'); + expect(listedJson.places[0]?.techProvider).toBe('21.gifts'); + const hyphen = await api.request('/map/places?origin=-21gifts'); + expect(hyphen.status).toBe(400); + expect(await hyphen.json()).toEqual({ error: 'Place origin is invalid' }); + const uppercase = await api.request('/map/places?origin=21Gifts'); + expect(uppercase.status).toBe(400); + expect(await uppercase.json()).toEqual({ error: 'Place origin is invalid' }); + const putHyphen = await api.request('/map/places', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ + origin: '-21gifts', + externalId: 'shop-2', + name: 'Shop', + lat: 14.620311, + lon: 120.959626, + category: 'shopping', + paymentMethods: 'lightning', + techProvider: '21.gifts', + }), + }); + expect(putHyphen.status).toBe(400); + expect(await putHyphen.json()).toEqual({ error: 'Place origin is invalid' }); + }); + it('rejects a bad limit', async () => { const api = app({}); expect((await api.request('/map/places?limit=0')).status).toBe(400); diff --git a/src/lib/map/place.ts b/src/lib/map/place.ts index 253cef245..6c2053b1a 100644 --- a/src/lib/map/place.ts +++ b/src/lib/map/place.ts @@ -409,7 +409,7 @@ function uniqueSortedSupports(items: MapPlaceSupport[]): MapPlaceSupport[] { export function normalizePlaceOrigin( raw: unknown, ): { ok: true; value: string } | { ok: false; error: string } { - if (typeof raw !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(raw.trim())) { + if (typeof raw !== 'string' || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(raw.trim())) { return { ok: false, error: ORIGIN_ERROR }; } return { ok: true, value: raw.trim() };