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:
- Leak A (primary): the post-redirect landing URL carries
sid(the scan event id). Anyone opening a shared copy gets the silent geolocation prompt, andrecordScanLocationaccepts a re-write of the same event’s coordinates at any time — a friend on another continent overwrites the object’s “Senast sedd” position and re-seeds place anchors. - Leak B (secondary): sharing the raw tag URL itself (copied from the camera preview, or a photo of the QR code). Every open creates a brand-new ScanEvent. A physical scan and a shared-link click are identical HTTP requests; the server can never reliably distinguish them.
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
- ADR (new, in the API PR): shared-link opens count as scans; the share URL is
the clean
/o/{oid};sidis single-use with a 15-minute TTL. docs/core-systems/s-plings-io/url-structure.md: document thesidsingle-use + TTL semantics and the landing-page URL cleanup.Plings-API/ROADMAP.mdBacklog: the future conflict-handling ideas (geo-plausibility filtering; nearby-known-tag device-trust challenge).- Flow doc: the Skanna flow doc (where present) is updated to describe the clean-URL behaviour and the share path.
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
- Detecting shared-link clicks server-side (heuristics rejected as unreliable).
- Any change to Gateway routing or ScanEvent creation semantics (Leak B accepted).
- Interaction-gated scanning (“I am at the object” confirmation) — rejected: friction on every genuine scan contradicts the fewest-actions north star.