fix(places): a website without a scheme no longer blocks saving a place

A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.

Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.

placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.

Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
This commit is contained in:
Maurice
2026-09-23 21:03:57 +02:00
parent 362b1ee8d1
commit 2350c3ae14
34 changed files with 718 additions and 42 deletions
@@ -1271,3 +1271,62 @@ describe('journey hooks on the MCP delete paths', () => {
expect(skeletonFor(journey.id, foreign.id)).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// A website without a scheme (#2483): the tools parse it the way REST does
// ---------------------------------------------------------------------------
describe('place tools and a website without a scheme (#2483)', () => {
const SITE = 'fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët';
const websiteOf = (placeId: number) =>
(testDb.prepare('SELECT website FROM places WHERE id = ?').get(placeId) as { website: string | null }).website;
it('MCP-PLACES-2483-01: create_place, create_and_assign_place, update_place and bulk_update_places store https', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({ name: 'create_place', arguments: { tripId: trip.id, name: 'Chapelle', website: SITE } })) as { place: { id: number } };
expect(websiteOf(created.place.id)).toBe(`https://${SITE}`);
const assigned = parseToolResult(await h.client.callTool({
name: 'create_and_assign_place',
arguments: { tripId: trip.id, dayId: day.id, name: 'Halles', website: 'www.example.fr/halles' },
})) as { place: { id: number } };
expect(websiteOf(assigned.place.id)).toBe('https://www.example.fr/halles');
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: created.place.id, website: '//www.example.fr' } });
expect(websiteOf(created.place.id)).toBe('https://www.example.fr');
await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [created.place.id, assigned.place.id], website: 'example.fr:8080/x' } });
expect([websiteOf(created.place.id), websiteOf(assigned.place.id)]).toEqual(['https://example.fr:8080/x', 'https://example.fr:8080/x']);
});
});
it('MCP-PLACES-2483-02: a script link or another scheme is still refused and stores nothing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
for (const website of ['javascript:alert(1)', 'mailto:mairie@example.fr', 'Chapelle']) {
const result = await h.client.callTool({ name: 'create_place', arguments: { tripId: trip.id, name: 'Hostile', website } });
expect(result.isError, website).toBe(true);
expect((result.content as { text: string }[])[0].text).toMatch(/Invalid arguments/);
}
});
expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 });
});
it('MCP-PLACES-2483-03: tools/list still describes the field as a plain capped string', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const { tools } = await h.client.listTools();
for (const name of ['create_place', 'create_and_assign_place', 'update_place', 'bulk_update_places']) {
const tool = tools.find((t) => t.name === name);
expect((tool?.inputSchema.properties as Record<string, unknown>).website, name).toEqual({ type: 'string', maxLength: 500 });
}
});
});
});