Share-Safe Scan URL — Design

Created: Sun 19 Jul 2026 10:11:31 CEST Document Version: 1.0 - Initial approved design Security Classification: Public Technical Documentation Target Audience: Frontend Developers, Backend Developers, iOS Developers Author: Paul Wisén

Problem

A Plings tag encodes a scan URL (s.plings.io?t=…&i=…&p=…). Scanning it creates a ScanEvent and redirects to the public landing page with scan parameters still in the address bar (plings.io/o/{oid}?ikey=…&sid=…&src=scan). Users who want to show an object to a friend or promote it naturally share the URL they have — and today every URL they can reach is scan-coupled:

Decision (policy)

Possession of the tag URL counts as a scan — accepted for now. The Hittat (lost & found) flow requires that a stranger without an account can scan and be treated as standing at the tag. Position is only ever written after the person actively grants the browser geolocation prompt. Instead of trying to detect shared links (unreliable Referer/Sec-Fetch heuristics, or adding friction to every genuine scan), we make the clean URL the path of least resistance for sharing, so the tag URL effectively stops circulating.

Future hardening (parked in Plings-API/ROADMAP.md Backlog, not in scope): conflict handling that filters/downweights scan events from devices provably not at the physical tag — geo-plausibility evaluation, and device trust established by challenging the scanner to scan a nearby known tag that a counterfeiter on the other side of the world cannot reach.

An ADR recording this policy ships in the same PR as the code (per the decision register rules).

Design

1. Plings-Web — clean the URL on landing (fixes Leak A)

ObjectLanding.tsx reads sid once on mount, then immediately calls history.replaceState(null, '', '/o/{oid}') — before the geolocation prompt is answered. The address bar (and therefore the mobile share sheet and any copy-paste) always holds the clean share URL, never a scan URL. The page already loads all data via publicObject(oid) from the path segment, so a reload of the clean URL renders identically; the captured sid lives in component state for the geo back-fill.

/welcome (unknown-tag onboarding) gets the same treatment for sid only: ikey/path remain in the URL because the claim flow needs them. Sharing an unclaimed tag’s URL is semantically the same as letting someone scan the unclaimed tag — out of scope here.

2. Plings-API — sid becomes single-use with a TTL (belt and braces for Leak A)

recordScanLocation only accepts the write when the ScanEvent (a) has no coordinates yet (e.latitude IS NULL) and (b) was created less than 15 minutes ago (e.timestamp > datetime() - duration('PT15M')).

Both guards are WHERE conditions in the existing Cypher MATCH; a non-matching sid returns success: false exactly as an unknown id does today. This closes both the stale-shared-URL overwrite and blind sid-guessing spam. The genuine flow is unaffected — the geolocation prompt is answered seconds after the scan. Existing rate limiting stays as-is.

3. Plings-Web — a visible “Dela” button on the landing card

The object landing card gets a share button using the Web Share API (navigator.share), with copy-to-clipboard as fallback, always sharing the clean https://plings.io/o/{oid}. Right-sharing becomes the easiest action.

4. Plings-iOS — “Dela” in the object view

A ShareLink in the object detail view sharing https://plings.io/o/{oid}. No scan machinery involved: the recipient lands on the public page with no ScanEvent created and no geolocation prompt (no sid).

Documentation & decisions shipping with the code

Scope, order, and verification

Three small PRs; merge API first (the sid guard is backward-compatible), then Web; iOS is independent.

Repo Change Verify
Plings-API recordScanLocation single-use + TTL guard; ROADMAP Backlog entries; ADR poetry run pytest (new tests: already-has-coords rejected; older-than-TTL rejected; fresh event accepted)
Plings-Web replaceState cleanup in ObjectLanding + /welcome; Dela button; url-structure doc npx tsc --noEmit + npm run build; manual: scan → address bar is clean → reload works → share sheet gives clean URL
Plings-iOS ShareLink in object detail swift test + build; on-device check

Non-goals