Merge branch 'main' into fix/overpass-proxy-forwarded-origin
This commit is contained in:
commit
2f5ce0e80e
10 changed files with 171 additions and 7 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { buildQuery, parseResponse, deduplicateById, type Poi } from "./overpass.ts";
|
import { buildQuery, parseResponse, deduplicateById, quantizeBbox, type Poi } from "./overpass.ts";
|
||||||
import { poiCategories } from "@trails-cool/map-core";
|
import { poiCategories } from "@trails-cool/map-core";
|
||||||
|
|
||||||
describe("buildQuery", () => {
|
describe("buildQuery", () => {
|
||||||
|
|
@ -9,7 +9,7 @@ describe("buildQuery", () => {
|
||||||
const query = buildQuery(bbox, categories);
|
const query = buildQuery(bbox, categories);
|
||||||
|
|
||||||
expect(query).toContain("[out:json]");
|
expect(query).toContain("[out:json]");
|
||||||
expect(query).toContain("[bbox:52.5,13.3,52.6,13.5]");
|
expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]");
|
||||||
expect(query).toContain('amenity"="drinking_water"');
|
expect(query).toContain('amenity"="drinking_water"');
|
||||||
expect(query).toContain("out center qt 100");
|
expect(query).toContain("out center qt 100");
|
||||||
});
|
});
|
||||||
|
|
@ -22,6 +22,45 @@ describe("buildQuery", () => {
|
||||||
expect(query).toContain("drinking_water");
|
expect(query).toContain("drinking_water");
|
||||||
expect(query).toContain("camp_site");
|
expect(query).toContain("camp_site");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("produces identical queries for near-identical viewports within one grid cell", () => {
|
||||||
|
const categories = poiCategories.filter((c) => c.id === "drinking_water");
|
||||||
|
const a = buildQuery(
|
||||||
|
{ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 },
|
||||||
|
categories,
|
||||||
|
);
|
||||||
|
const b = buildQuery(
|
||||||
|
{ south: 52.5039, west: 13.3078, north: 52.5925, east: 13.4921 },
|
||||||
|
categories,
|
||||||
|
);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands the bbox outward so the original viewport is always covered", () => {
|
||||||
|
const categories = poiCategories.filter((c) => c.id === "drinking_water");
|
||||||
|
const query = buildQuery(
|
||||||
|
{ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 },
|
||||||
|
categories,
|
||||||
|
);
|
||||||
|
expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quantizeBbox", () => {
|
||||||
|
it("snaps south and west down, north and east up", () => {
|
||||||
|
const q = quantizeBbox({ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, 0.01);
|
||||||
|
expect(q.south).toBeCloseTo(52.5, 10);
|
||||||
|
expect(q.west).toBeCloseTo(13.3, 10);
|
||||||
|
expect(q.north).toBeCloseTo(52.6, 10);
|
||||||
|
expect(q.east).toBeCloseTo(13.5, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent on already-aligned coordinates", () => {
|
||||||
|
const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 };
|
||||||
|
const q = quantizeBbox(bbox, 0.01);
|
||||||
|
expect(q.south).toBeCloseTo(52.5, 10);
|
||||||
|
expect(q.north).toBeCloseTo(52.6, 10);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseResponse", () => {
|
describe("parseResponse", () => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,18 @@ import type { PoiCategory } from "@trails-cool/map-core";
|
||||||
|
|
||||||
const OVERPASS_PROXY = "/api/overpass";
|
const OVERPASS_PROXY = "/api/overpass";
|
||||||
|
|
||||||
|
// Snap bbox coordinates to this grid before building the query so that two
|
||||||
|
// users looking at nearly-identical viewports produce byte-identical queries
|
||||||
|
// and therefore share a server-side cache entry. Expansion is outward
|
||||||
|
// (south/west floor, north/east ceil) so the user's viewport is always
|
||||||
|
// covered. ~0.01° ≈ 1 km at mid-latitudes.
|
||||||
|
const BBOX_GRID_STEP = 0.01;
|
||||||
|
|
||||||
|
// Decimals used when formatting the quantized bbox into the query string.
|
||||||
|
// 3 decimals → 0.001° precision (~111 m) which is well below BBOX_GRID_STEP,
|
||||||
|
// so the string is a stable representation of the quantized cell.
|
||||||
|
const BBOX_DECIMALS = 3;
|
||||||
|
|
||||||
export interface Poi {
|
export interface Poi {
|
||||||
id: number;
|
id: number;
|
||||||
lat: number;
|
lat: number;
|
||||||
|
|
@ -18,11 +30,31 @@ export interface BBox {
|
||||||
east: number;
|
east: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quantize a bbox to the grid defined by `BBOX_GRID_STEP`, expanding outward
|
||||||
|
* so the original bbox is fully contained. Returns a new BBox whose
|
||||||
|
* coordinates are cell-aligned.
|
||||||
|
*/
|
||||||
|
export function quantizeBbox(bbox: BBox, step: number = BBOX_GRID_STEP): BBox {
|
||||||
|
return {
|
||||||
|
south: Math.floor(bbox.south / step) * step,
|
||||||
|
west: Math.floor(bbox.west / step) * step,
|
||||||
|
north: Math.ceil(bbox.north / step) * step,
|
||||||
|
east: Math.ceil(bbox.east / step) * step,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an Overpass QL query combining all enabled categories into a union.
|
* Build an Overpass QL query combining all enabled categories into a union.
|
||||||
|
* The bbox is quantized to a fixed grid and formatted with a fixed number of
|
||||||
|
* decimals so near-identical viewports produce a byte-identical query — which
|
||||||
|
* the `/api/overpass` server-side cache keys on.
|
||||||
*/
|
*/
|
||||||
export function buildQuery(bbox: BBox, categories: PoiCategory[]): string {
|
export function buildQuery(bbox: BBox, categories: PoiCategory[]): string {
|
||||||
const bboxStr = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
|
const q = quantizeBbox(bbox);
|
||||||
|
const bboxStr = [q.south, q.west, q.north, q.east]
|
||||||
|
.map((n) => n.toFixed(BBOX_DECIMALS))
|
||||||
|
.join(",");
|
||||||
const unions = categories.map((c) => c.query).join("");
|
const unions = categories.map((c) => c.query).join("");
|
||||||
return `[out:json][timeout:10][maxsize:1048576][bbox:${bboxStr}];(${unions});out center qt 100;`;
|
return `[out:json][timeout:10][maxsize:1048576][bbox:${bboxStr}];(${unions});out center qt 100;`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
|
|
||||||
## 7. Review & Publish
|
## 7. Review & Publish
|
||||||
|
|
||||||
- [ ] 7.1 Get the drafted German and English legal text reviewed by a German IT lawyer before treating as final
|
- [x] 7.1 Get the drafted German and English legal text reviewed by a German IT lawyer before treating as final
|
||||||
- [ ] 7.2 Deploy and verify all three legal pages render correctly at their URLs
|
- [x] 7.2 Deploy and verify all three legal pages render correctly at their URLs
|
||||||
- [ ] 7.3 Verify registration blocks without acknowledgement and records the timestamp on success
|
- [x] 7.3 Verify registration blocks without acknowledgement and records the timestamp on success
|
||||||
- [ ] 7.4 Verify the alpha banner appears and dismisses correctly
|
- [x] 7.4 Verify the alpha banner appears and dismisses correctly
|
||||||
|
|
@ -11,3 +11,19 @@ The journal auth system SHALL store OAuth tokens for external services alongside
|
||||||
- **WHEN** a user connects their Wahoo account
|
- **WHEN** a user connects their Wahoo account
|
||||||
- **THEN** access token, refresh token, expiry time, and Wahoo user ID are stored in the `wahoo_tokens` table
|
- **THEN** access token, refresh token, expiry time, and Wahoo user ID are stored in the `wahoo_tokens` table
|
||||||
- **AND** tokens are associated with the journal user ID
|
- **AND** tokens are associated with the journal user ID
|
||||||
|
|
||||||
|
### Requirement: Terms acknowledgement at signup
|
||||||
|
The registration form SHALL require explicit acknowledgement of the Terms of Service before an account can be created.
|
||||||
|
|
||||||
|
#### Scenario: Checkbox required
|
||||||
|
- **WHEN** a user views the registration form
|
||||||
|
- **THEN** they see a required checkbox labeled "I have read and agree to the Terms of Service, including that trails.cool is in alpha and my data may be reset"
|
||||||
|
- **AND** the checkbox label links to the Terms page
|
||||||
|
|
||||||
|
#### Scenario: Cannot submit without acknowledgement
|
||||||
|
- **WHEN** a user attempts to register without checking the acknowledgement box
|
||||||
|
- **THEN** the form blocks submission and shows a validation message
|
||||||
|
|
||||||
|
#### Scenario: Acknowledgement recorded
|
||||||
|
- **WHEN** a user successfully registers
|
||||||
|
- **THEN** the current timestamp is stored in `users.terms_accepted_at`
|
||||||
|
|
|
||||||
77
openspec/specs/legal-disclaimers/spec.md
Normal file
77
openspec/specs/legal-disclaimers/spec.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Legal pages, signup acknowledgement, and alpha-state disclosures required for operating trails.cool under German law (TMG, MStV, GDPR).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Impressum page
|
||||||
|
The Journal SHALL serve an Impressum page at `/legal/imprint` containing the operator's legal information per §5 TMG and §18 MStV.
|
||||||
|
|
||||||
|
#### Scenario: Impressum is accessible
|
||||||
|
- **WHEN** a user navigates to `/legal/imprint`
|
||||||
|
- **THEN** they see a page with the operator's full name, postal address, email address, and the responsible person per §18 Abs. 2 MStV
|
||||||
|
|
||||||
|
#### Scenario: Impressum is linked from footer
|
||||||
|
- **WHEN** a user views any page on the Journal or Planner
|
||||||
|
- **THEN** the footer contains a link to the Impressum
|
||||||
|
|
||||||
|
### Requirement: Terms of Service page
|
||||||
|
The Journal SHALL serve a Terms of Service page at `/legal/terms` with clear disclaimers about the service's alpha state.
|
||||||
|
|
||||||
|
#### Scenario: Alpha status disclosure
|
||||||
|
- **WHEN** a user reads the Terms page
|
||||||
|
- **THEN** the Terms clearly state that the service is in early development, untested, and may change without notice
|
||||||
|
|
||||||
|
#### Scenario: Database reset disclosure
|
||||||
|
- **WHEN** a user reads the Terms page
|
||||||
|
- **THEN** the Terms explicitly state that the operator may delete all user data at any time without prior notice
|
||||||
|
|
||||||
|
#### Scenario: Warranty disclaimer
|
||||||
|
- **WHEN** a user reads the Terms page
|
||||||
|
- **THEN** the Terms disclaim all warranties to the extent permitted by German consumer law
|
||||||
|
|
||||||
|
#### Scenario: Data export responsibility
|
||||||
|
- **WHEN** a user reads the Terms page
|
||||||
|
- **THEN** the Terms state that the user is responsible for exporting their own data and link to the data export functionality
|
||||||
|
|
||||||
|
### Requirement: Datenschutzerklärung
|
||||||
|
The Journal SHALL serve a GDPR-compliant privacy policy at `/legal/privacy`, replacing the existing `/privacy` route.
|
||||||
|
|
||||||
|
#### Scenario: Controller contact
|
||||||
|
- **WHEN** a user reads the Datenschutzerklärung
|
||||||
|
- **THEN** they see the name, address, and email of the data controller (Verantwortlicher per Art. 4 Nr. 7 DSGVO)
|
||||||
|
|
||||||
|
#### Scenario: Legal basis for processing
|
||||||
|
- **WHEN** a user reads the Datenschutzerklärung
|
||||||
|
- **THEN** each category of data processing lists its legal basis (e.g., Art. 6 Abs. 1 lit. b for contract, lit. f for legitimate interest)
|
||||||
|
|
||||||
|
#### Scenario: Data subject rights
|
||||||
|
- **WHEN** a user reads the Datenschutzerklärung
|
||||||
|
- **THEN** they see their rights under GDPR: access (Art. 15), rectification (Art. 16), erasure (Art. 17), data portability (Art. 20), objection (Art. 21), and right to lodge a complaint with a supervisory authority
|
||||||
|
|
||||||
|
#### Scenario: Redirect from old privacy URL
|
||||||
|
- **WHEN** a user navigates to `/privacy`
|
||||||
|
- **THEN** they are redirected to `/legal/privacy` with a 301 status
|
||||||
|
|
||||||
|
### Requirement: Alpha banner
|
||||||
|
The Journal SHALL display a persistent banner notifying users that the service is in alpha, dismissible for the browser session.
|
||||||
|
|
||||||
|
#### Scenario: Banner visible on first visit
|
||||||
|
- **WHEN** a user visits any Journal page for the first time in a session
|
||||||
|
- **THEN** a banner is shown with text stating trails.cool is in early development and data may be reset, with a link to the Terms
|
||||||
|
|
||||||
|
#### Scenario: Banner dismissible
|
||||||
|
- **WHEN** a user clicks the dismiss button on the alpha banner
|
||||||
|
- **THEN** the banner is hidden for the remainder of the browser session (via sessionStorage)
|
||||||
|
- **AND** the banner reappears in a new session
|
||||||
|
|
||||||
|
### Requirement: Footer legal links
|
||||||
|
Both the Journal and Planner SHALL render a footer with links to Impressum, Datenschutzerklärung, and Terms.
|
||||||
|
|
||||||
|
#### Scenario: Footer on Journal
|
||||||
|
- **WHEN** a user views any Journal page
|
||||||
|
- **THEN** the footer contains links labeled "Impressum", "Privacy", and "Terms" pointing to the respective legal pages
|
||||||
|
|
||||||
|
#### Scenario: Footer on Planner
|
||||||
|
- **WHEN** a user views any Planner page
|
||||||
|
- **THEN** the footer contains the same three legal links, pointing to the Journal instance (since the Planner is stateless and legal pages live on the Journal)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue